diff --git a/.github/workflows/ui-layer.yml b/.github/workflows/ui-layer.yml index 92a9ff9..0ab6fb2 100644 --- a/.github/workflows/ui-layer.yml +++ b/.github/workflows/ui-layer.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 with: repository: trakli/webui - ref: eacbb7ec8f04c0444be272671dee127d3c8b48c1 + ref: 5caf9d16b0cfb392b2d6ddb42930c98cb4b03caf path: .host-ui - uses: actions/setup-node@v4 with: diff --git a/src/CloudIntegration.php b/src/CloudIntegration.php index 1979625..52fbf08 100644 --- a/src/CloudIntegration.php +++ b/src/CloudIntegration.php @@ -37,8 +37,14 @@ public function isConfigured(): bool public function ui(): array { return [ - 'slots' => ['settings.integrations', 'sidebar.nav'], + 'slots' => ['settings.integrations', 'settings.tabs'], 'card' => ['title' => 'Premium', 'cta' => 'Manage plan', 'href' => '/billing'], + 'tab' => [ + 'key' => 'billing', + 'title' => 'Plan and billing', + 'component' => 'cloud.settings', + 'order' => 10, + ], 'show_when_unconfigured' => true, ]; } diff --git a/ui/README.md b/ui/README.md index 66303fe..02553a7 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,7 +1,7 @@ # Trakli billing UI layer -Include this directory in the host web UI's `TRAKLI_UI_LAYERS` setting and enable the matching backend plugin. The layer supplies the authenticated `/billing` page, English messages and billing flow tests. +Include this directory in the host web UI's `TRAKLI_UI_LAYERS` setting and enable the matching backend plugin. The layer supplies the Plan and billing tab in Settings, English messages and billing flow tests. `/billing` redirects to that tab and keeps its query, so checkout and portal returns still arrive. -Yearly billing is selected by default. Checkout and portal returns retain the existing billing address. Bank management is shown only when a bank UI layer supplies its route. +Yearly billing is selected by default. Checkout and portal returns retain the existing billing address. Bank management is shown only when a bank UI layer is installed. Run the host web UI's lint, format, tests and build with this layer enabled. The host provides authentication, API access, components and layout. diff --git a/ui/components/BillingSettings.vue b/ui/components/BillingSettings.vue new file mode 100644 index 0000000..ee6f619 --- /dev/null +++ b/ui/components/BillingSettings.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/ui/i18n/locales/en.json b/ui/i18n/locales/en.json index ae845f5..2083f4b 100644 --- a/ui/i18n/locales/en.json +++ b/ui/i18n/locales/en.json @@ -1,27 +1,40 @@ { - "A three-day trial is available once per account.": "A three-day trial is available once per account.", - "Bank": "Bank", - "Bank connection limit": "Bank connection limit", + "A {days}-day trial is available once per account.": "A {days}-day trial is available once per account.", + "Active": "Active", + "Bank connections": "Bank connections", "Billing period": "Billing period", + "Cancelled": "Cancelled", "Checkout cancelled. Your current plan is unchanged.": "Checkout cancelled. Your current plan is unchanged.", + "Complimentary": "Complimentary", + "Could not load your plan": "Could not load your plan", + "Custom categories": "Custom categories", + "Dismiss": "Dismiss", + "Free": "Free", "Free plan": "Free plan", + "Loading your plan": "Loading your plan", "Manage banks": "Manage banks", "Manage billing": "Manage billing", - "Month": "Month", + "Monthly": "Monthly", "Monthly model usage": "Monthly model usage", - "New custom category limit": "New custom category limit", - "Paid access active": "Paid access active", - "Paid through": "Paid through", + "Paid plan": "Paid plan", + "Paid through {date}": "Paid through {date}", "Payment confirmation is pending. Refresh your status before connecting a bank.": "Payment confirmation is pending. Refresh your status before connecting a bank.", + "Payment confirmed. Paid access is active.": "Payment confirmed. Paid access is active.", + "Payment due": "Payment due", + "Plan and billing": "Plan and billing", + "Plans are not available in your region yet.": "Plans are not available in your region yet.", "Pricing region": "Pricing region", "Refresh status": "Refresh status", - "Resets": "Resets", - "Subscription status": "Subscription status", + "Resets {date} (UTC)": "Resets {date} (UTC)", + "Trial": "Trial", + "Try again": "Try again", "Unlimited": "Unlimited", "Upgrade": "Upgrade", + "Upgrade for more room": "Upgrade for more room", "Upgrade for unlimited wallets, categories and bank connections, plus 100,000 model tokens each month.": "Upgrade for unlimited wallets, categories and bank connections, plus 100,000 model tokens each month.", - "Wallet limit": "Wallet limit", - "Year": "Year", - "Your plan": "Your plan", + "Wallets": "Wallets", + "Yearly": "Yearly", + "per month": "per month", + "per year": "per year", "tokens": "tokens" } diff --git a/ui/pages/billing.vue b/ui/pages/billing.vue index cbf4634..2160875 100644 --- a/ui/pages/billing.vue +++ b/ui/pages/billing.vue @@ -1,209 +1,9 @@ + - diff --git a/ui/plugins/billing.ts b/ui/plugins/billing.ts new file mode 100644 index 0000000..cc55e5c --- /dev/null +++ b/ui/plugins/billing.ts @@ -0,0 +1,6 @@ +import BillingSettings from '../components/BillingSettings.vue'; +import { useExtensionSlots } from '~/composables/useExtensionSlots'; + +export default defineNuxtPlugin(() => { + useExtensionSlots().registerComponent('cloud.settings', BillingSettings); +}); diff --git a/ui/tests/billing.test.ts b/ui/tests/billing.test.ts index ca421d2..eba3335 100644 --- a/ui/tests/billing.test.ts +++ b/ui/tests/billing.test.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { flushPromises, mount } from '@vue/test-utils'; -import BillingPage from '../pages/billing.vue'; +import BillingSettings from '../components/BillingSettings.vue'; const request = vi.hoisted(() => vi.fn()); +const navigate = vi.hoisted(() => vi.fn()); vi.mock('~/composables/useApi', () => ({ useApi: () => request })); -vi.stubGlobal('definePageMeta', vi.fn()); -vi.stubGlobal('navigateTo', vi.fn()); +vi.stubGlobal('navigateTo', navigate); + const current = { paid_access: false, has_billing_account: false, @@ -20,119 +21,138 @@ const current = { period_end: '2026-11-01T00:00:00Z' } }; -function render() { - return mount(BillingPage, { - global: { - stubs: { - TCard: { template: '
' }, - ComponentLoader: { template: '
' }, - TButton: { - props: ['text', 'disabled'], - template: '' - } + +const plans = { + trial_days: 3, + regions: { + us: { + name: 'United States', + currency: 'USD', + prices: { + monthly: { price: 8.99, price_formatted: '$8.99' }, + yearly: { price: 86.28, price_formatted: '$86.28' } } } - }); -} -beforeEach(() => { - request.mockReset(); - vi.stubGlobal('useRoute', () => ({ query: { checkout: 'success' } })); - vi.stubGlobal('useRouter', () => ({ hasRoute: () => true })); - request.mockImplementation((path: string) => + } +}; + +function respond(status: object, pricing: object = plans) { + request.mockImplementation((path: string, options?: { method?: string }) => Promise.resolve({ success: true, message: 'Success', - data: - path === '/cloud/plans' - ? { - trial_days: 3, - regions: { - us: { - name: 'United States', - currency: 'USD', - prices: { - monthly: { price: 8.99, price_formatted: '$8.99' }, - yearly: { price: 86.28, price_formatted: '$86.28' } - } - } - } - } - : current + data: options?.method + ? { url: 'https://example.invalid/pay' } + : path === '/cloud/plans' + ? pricing + : status }) ); +} + +function render() { + return mount(BillingSettings, { attachTo: document.body }); +} + +const text = () => document.body.textContent ?? ''; +const button = (label: string) => + ([...document.body.querySelectorAll('button')] as HTMLElement[]).find( + (el) => el.textContent?.trim() === label + ); + +beforeEach(() => { + request.mockReset(); + navigate.mockReset(); + document.body.innerHTML = ''; + vi.stubGlobal('useRoute', () => ({ + path: '/settings', + query: { tab: 'billing', checkout: 'success' } + })); + vi.stubGlobal('useRouter', () => ({ hasRoute: () => true })); + respond(current); }); -describe('billing purchase return', () => { + +describe('plan and billing tab', () => { it('keeps free access until the server confirms payment and shows the price', async () => { - const page = render(); + const wrapper = render(); await flushPromises(); - expect(page.text()).toContain('Free plan'); - expect(page.text()).toContain('Payment confirmation is pending'); - expect(page.text()).toContain('$86.28'); - expect(page.text()).not.toContain('Paid access active'); - expect(page.text()).not.toContain('Manage billing'); + expect(text()).toContain('Free plan'); + expect(text()).toContain('Payment confirmation is pending'); + expect(text()).toContain('$86.28'); + expect(text()).not.toContain('Paid plan'); + expect(button('Manage billing')).toBeUndefined(); + wrapper.unmount(); }); + it('starts checkout with yearly billing and allows switching to monthly', async () => { - const page = render(); + const wrapper = render(); + await flushPromises(); + button('Upgrade')!.click(); await flushPromises(); - const period = page.findAll('select')[1]; - expect(period.element.value).toBe('yearly'); - await page - .findAll('button') - .find((button) => button.text() === 'Upgrade')! - .trigger('click'); expect(request).toHaveBeenCalledWith('/cloud/billing/checkout', { method: 'POST', body: { region: 'us', plan: 'yearly' } }); - await period.setValue('monthly'); - expect(page.text()).toContain('$8.99'); - await page - .findAll('button') - .find((button) => button.text() === 'Upgrade')! - .trigger('click'); + (document.body.querySelector('#billing-period') as HTMLElement).click(); + await flushPromises(); + ( + [...document.body.querySelectorAll('[role="option"]')].find( + (option) => option.textContent?.trim() === 'Monthly' + ) as HTMLElement + ).click(); + await flushPromises(); + expect(text()).toContain('$8.99'); + button('Upgrade')!.click(); + await flushPromises(); expect(request).toHaveBeenCalledWith('/cloud/billing/checkout', { method: 'POST', body: { region: 'us', plan: 'monthly' } }); + expect(navigate).toHaveBeenCalledWith('https://example.invalid/pay', { external: true }); + wrapper.unmount(); }); + it('shows only server-confirmed access and monthly usage', async () => { - request.mockImplementation((path: string) => - Promise.resolve({ - success: true, - message: 'Success', - data: - path === '/cloud/plans' - ? { trial_days: 3, regions: {} } - : { - ...current, - paid_access: true, - has_billing_account: true, - status: 'active', - ai_usage: { ...current.ai_usage, used: 420, allowance: 100000 } - } - }) + respond( + { + ...current, + paid_access: true, + has_billing_account: true, + status: 'active', + ai_usage: { ...current.ai_usage, used: 420, allowance: 100000 } + }, + { trial_days: 3, regions: {} } ); - const page = render(); + const wrapper = render(); await flushPromises(); - expect(page.text()).toContain('Paid access active'); - expect(page.text()).toContain('420 / 100,000'); - expect(page.text()).toContain('Manage billing'); - expect(page.text()).not.toContain('Payment confirmation is pending'); + expect(text()).toContain('Paid plan'); + expect(text()).toContain('420 / 100,000'); + expect(text()).toContain('Payment confirmed. Paid access is active.'); + expect(button('Manage billing')).toBeDefined(); + expect(text()).not.toContain('Payment confirmation is pending'); + wrapper.unmount(); }); + it('hides bank management when the bank layer is absent', async () => { vi.stubGlobal('useRouter', () => ({ hasRoute: () => false })); - request.mockImplementation((path: string) => - Promise.resolve({ - success: true, - data: - path === '/cloud/plans' - ? { regions: {} } - : { ...current, paid_access: true, has_billing_account: true } - }) - ); - const page = render(); + respond({ ...current, paid_access: true, has_billing_account: true }, { regions: {} }); + const wrapper = render(); + await flushPromises(); + expect(button('Manage billing')).toBeDefined(); + expect(button('Manage banks')).toBeUndefined(); + wrapper.unmount(); + }); + + it('clears the checkout result from the address when dismissed', async () => { + const wrapper = render(); await flushPromises(); - expect(page.text()).toContain('Manage billing'); - expect(page.text()).not.toContain('Manage banks'); + (document.body.querySelector('[aria-label="Dismiss"]') as HTMLElement).click(); + await flushPromises(); + expect(navigate).toHaveBeenCalledWith( + { path: '/settings', query: { tab: 'billing' } }, + { replace: true } + ); + expect(text()).not.toContain('Payment confirmation is pending'); + wrapper.unmount(); }); }); diff --git a/ui/tests/billingStatus.test.ts b/ui/tests/billingStatus.test.ts new file mode 100644 index 0000000..9fa2576 --- /dev/null +++ b/ui/tests/billingStatus.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import messages from '../i18n/locales/en.json'; +import { defaultRegion, planStatus, regionOptions, usageShare } from '../utils/billing'; + +const plans = { + trial_days: 3, + regions: { + eu: { name: 'Europe', currency: 'EUR', prices: {} }, + uk: { name: 'United Kingdom', currency: 'GBP', prices: {} } + } +}; + +describe('billing logic', () => { + it.each([ + ['free', 'default'], + ['active', 'success'], + ['comped', 'success'], + ['trialing', 'primary'], + ['past_due', 'warning'], + ['canceled', 'default'] + ])('shows %s with a %s chip and a translated label', (status, tone) => { + const result = planStatus(status); + expect(result.tone).toBe(tone); + expect(messages).toHaveProperty([result.label]); + }); + + it('lists each priced region with its currency', () => { + expect(regionOptions(plans)).toEqual([ + { value: 'eu', label: 'Europe (EUR)' }, + { value: 'uk', label: 'United Kingdom (GBP)' } + ]); + }); + + it('keeps the preferred region only when it is priced', () => { + expect(defaultRegion(plans, 'uk')).toBe('uk'); + expect(defaultRegion(plans, 'us')).toBe('eu'); + }); + + it('caps the usage meter at a full bar', () => { + expect(usageShare(420, 100000)).toBe(0); + expect(usageShare(50000, 100000)).toBe(50); + expect(usageShare(120000, 100000)).toBe(100); + expect(usageShare(10, 0)).toBe(0); + }); +}); diff --git a/ui/utils/billing.ts b/ui/utils/billing.ts new file mode 100644 index 0000000..7801f34 --- /dev/null +++ b/ui/utils/billing.ts @@ -0,0 +1,32 @@ +import type { CloudPlans } from '../types/api'; + +export type PlanTone = 'success' | 'warning' | 'primary' | 'default'; + +const statuses: Record = { + active: { tone: 'success', label: 'Active' }, + comped: { tone: 'success', label: 'Complimentary' }, + trialing: { tone: 'primary', label: 'Trial' }, + past_due: { tone: 'warning', label: 'Payment due' }, + canceled: { tone: 'default', label: 'Cancelled' } +}; + +export function planStatus(status: string): { tone: PlanTone; label: string } { + return statuses[status] ?? { tone: 'default', label: 'Free' }; +} + +export function regionOptions(plans: CloudPlans | null): Array<{ value: string; label: string }> { + return Object.entries(plans?.regions ?? {}).map(([value, region]) => ({ + value, + label: `${region.name} (${region.currency})` + })); +} + +export function defaultRegion(plans: CloudPlans | null, preferred = 'us'): string { + const keys = Object.keys(plans?.regions ?? {}); + return keys.includes(preferred) ? preferred : (keys[0] ?? preferred); +} + +export function usageShare(used: number, allowance: number): number { + if (allowance <= 0) return 0; + return Math.min(100, Math.round((used / allowance) * 100)); +}