From 45a26f1f1e87eb5a675dd6429c070d35f01b75bd Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 6 Oct 2026 17:46:17 +0100 Subject: [PATCH 1/4] feat: Add billing and bank connection screens --- components/premium/ExtensionCard.vue | 16 ++ i18n/locales/en.json | 53 +++- pages/banks.vue | 350 +++++++++++++++++++++++++++ pages/billing.vue | 187 ++++++++++++++ plugins/premium.ts | 6 + tests/pages/banks.test.ts | 105 ++++++++ tests/pages/billing.test.ts | 97 ++++++++ types/premium.ts | 63 +++++ 8 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 components/premium/ExtensionCard.vue create mode 100644 pages/banks.vue create mode 100644 pages/billing.vue create mode 100644 plugins/premium.ts create mode 100644 tests/pages/banks.test.ts create mode 100644 tests/pages/billing.test.ts create mode 100644 types/premium.ts diff --git a/components/premium/ExtensionCard.vue b/components/premium/ExtensionCard.vue new file mode 100644 index 0000000..3d42a1b --- /dev/null +++ b/components/premium/ExtensionCard.vue @@ -0,0 +1,16 @@ + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 25e7d6f..03cf997 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1386,5 +1386,56 @@ "No outreach sent yet": "No outreach sent yet", "Write to your users from here and every send is listed, so you can see what was said and when.": "Write to your users from here and every send is listed, so you can see what was said and when.", "{groups} are measured for the whole product, so they are not shown while one client is selected.": "{groups} are measured for the whole product, so they are not shown while one client is selected.", - "{start} to {end}": "{start} to {end}" + "{start} to {end}": "{start} to {end}", + "A three-day trial is available once per account.": "A three-day trial is available once per account.", + "Account selection saved. Synchronization queued.": "Account selection saved. Synchronization queued.", + "Bank": "Bank", + "Bank connection limit": "Bank connection limit", + "Bank connections": "Bank connections", + "Billing period": "Billing period", + "Check connection status": "Check connection status", + "Checkout cancelled. Your current plan is unchanged.": "Checkout cancelled. Your current plan is unchanged.", + "Choose accounts": "Choose accounts", + "Choose accounts to import": "Choose accounts to import", + "Connect a bank": "Connect a bank", + "Connect a bank, then choose which accounts to import into your wallets.": "Connect a bank, then choose which accounts to import into your wallets.", + "Connect your banks with a paid plan": "Connect your banks with a paid plan", + "Connection session": "Connection session", + "Continue bank connection": "Continue bank connection", + "Create a wallet in this currency": "Create a wallet in this currency", + "Disconnect": "Disconnect", + "Disconnect bank?": "Disconnect bank?", + "Do not import": "Do not import", + "Free plan": "Free plan", + "Last synchronized": "Last synchronized", + "Manage banks": "Manage banks", + "Manage billing": "Manage billing", + "Month": "Month", + "Monthly model usage": "Monthly model usage", + "New custom category limit": "New custom category limit", + "No banks connected": "No banks connected", + "Only selected accounts are imported. Each wallet must use the account currency.": "Only selected accounts are imported. Each wallet must use the account currency.", + "Paid access active": "Paid access active", + "Paid through": "Paid through", + "Payment confirmation is pending. Refresh your status before connecting a bank.": "Payment confirmation is pending. Refresh your status before connecting a bank.", + "Pricing region": "Pricing region", + "Reconnect": "Reconnect", + "Refresh status": "Refresh status", + "Resets": "Resets", + "Save and synchronize": "Save and synchronize", + "Subscription status": "Subscription status", + "Synchronization queued. Imported transactions will appear in your transaction list.": "Synchronization queued. Imported transactions will appear in your transaction list.", + "Synchronize": "Synchronize", + "This bank has no available accounts.": "This bank has no available accounts.", + "This stops imports from this bank. Your imported transactions stay available.": "This stops imports from this bank. Your imported transactions stay available.", + "Unlimited": "Unlimited", + "Upgrade": "Upgrade", + "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 imported transactions stay available when paid access ends.": "Your imported transactions stay available when paid access ends.", + "Your plan": "Your plan", + "tokens": "tokens", + "This bank connection could not be confirmed. Start a new connection session. Banks already connected remain available.": "This bank connection could not be confirmed. Start a new connection session. Banks already connected remain available.", + "Start a new connection session": "Start a new connection session" } diff --git a/pages/banks.vue b/pages/banks.vue new file mode 100644 index 0000000..d00dedc --- /dev/null +++ b/pages/banks.vue @@ -0,0 +1,350 @@ + + + diff --git a/pages/billing.vue b/pages/billing.vue new file mode 100644 index 0000000..d5edbb4 --- /dev/null +++ b/pages/billing.vue @@ -0,0 +1,187 @@ + + + diff --git a/plugins/premium.ts b/plugins/premium.ts new file mode 100644 index 0000000..cd4fd36 --- /dev/null +++ b/plugins/premium.ts @@ -0,0 +1,6 @@ +import ExtensionCard from '~/components/premium/ExtensionCard.vue'; +export default defineNuxtPlugin(() => { + const { registerComponent } = useExtensionSlots(); + registerComponent('premium.billing', ExtensionCard); + registerComponent('premium.banks', ExtensionCard); +}); diff --git a/tests/pages/banks.test.ts b/tests/pages/banks.test.ts new file mode 100644 index 0000000..384a6cb --- /dev/null +++ b/tests/pages/banks.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import BankPage from '~/pages/banks.vue'; +const request = vi.hoisted(() => vi.fn()); +vi.mock('~/composables/useApi', () => ({ useApi: () => request })); +vi.mock('~/composables/useWallets', () => ({ + useWallets: () => ({ wallets: ref([]), fetchWallets: async () => [] }) +})); +vi.stubGlobal('definePageMeta', vi.fn()); +vi.stubGlobal('navigateTo', vi.fn()); +const connection = { + id: 8, + institution_name: 'Selected bank', + status: 'connected', + is_active: true, + disconnected_at: null, + last_synced_at: null +}; +beforeEach(() => { + request.mockReset(); + vi.stubGlobal('useRoute', () => ({ query: {} })); + request.mockImplementation((path: string) => + Promise.resolve({ + success: true, + message: 'Success', + data: + path === '/cloud/billing/status' + ? { paid_access: false } + : { data: [connection], current_page: 1, last_page: 1, per_page: 100, total: 1 } + }) + ); +}); +function render() { + return mount(BankPage, { + global: { + stubs: { + TCard: { template: '
' }, + ComponentLoader: { template: '
' }, + TButton: { + props: ['text', 'disabled'], + template: '' + }, + NuxtLink: { template: '' } + } + } + }); +} +describe('expired bank access', () => { + it('offers a new session after a partial connection outcome and retains connected banks', async () => { + vi.stubGlobal('useRoute', () => ({ query: { session: '21' } })); + request.mockImplementation((path: string) => + Promise.resolve({ + success: true, + message: 'Success', + data: + path === '/cloud/billing/status' + ? { paid_access: true } + : path === '/plaid/link-sessions/21' + ? { id: 21, status: 'partial_relink' } + : path === '/plaid/link-sessions' + ? { id: 22, status: 'pending', url: 'https://example.invalid/bank' } + : { data: [connection], current_page: 1, last_page: 1, per_page: 100, total: 1 } + }) + ); + const page = render(); + await flushPromises(); + expect(page.text()).toContain('Selected bank'); + expect(page.text()).toContain('Banks already connected remain available.'); + await page + .findAll('button') + .find((button) => button.text() === 'Start a new connection session')! + .trigger('click'); + await flushPromises(); + expect(request).toHaveBeenCalledWith('/plaid/link-sessions', { method: 'POST' }); + expect(page.text()).toContain('Selected bank'); + }); + it('keeps bank history and disconnect available while hiding paid actions', async () => { + const page = render(); + await flushPromises(); + expect(page.text()).toContain('Selected bank'); + expect(page.text()).toContain('Disconnect'); + expect(page.text()).not.toContain('Choose accounts'); + expect(page.text()).not.toContain('Synchronize'); + expect(page.text()).not.toContain('Reconnect'); + expect(page.text()).toContain('Upgrade'); + }); + it('confirms and disconnects only the selected bank', async () => { + const page = render(); + await flushPromises(); + await page + .findAll('button') + .find((button) => button.text() === 'Disconnect')! + .trigger('click'); + expect(page.text()).toContain('Your imported transactions stay available.'); + await page + .findAll('button') + .filter((button) => button.text() === 'Disconnect') + .at(-1)! + .trigger('click'); + await flushPromises(); + expect(request).toHaveBeenCalledWith('/plaid/connections/8', { method: 'DELETE' }); + expect(request).not.toHaveBeenCalledWith('/plaid/connections', { method: 'DELETE' }); + }); +}); diff --git a/tests/pages/billing.test.ts b/tests/pages/billing.test.ts new file mode 100644 index 0000000..f9c5446 --- /dev/null +++ b/tests/pages/billing.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import BillingPage from '~/pages/billing.vue'; + +const request = vi.hoisted(() => vi.fn()); +vi.mock('~/composables/useApi', () => ({ useApi: () => request })); +vi.stubGlobal('definePageMeta', vi.fn()); +vi.stubGlobal('navigateTo', vi.fn()); +const current = { + paid_access: false, + has_billing_account: false, + status: 'free', + paid_through: null, + limits: { wallets: 3, categories: 10, bank_connections: 0 }, + ai_usage: { + used: 0, + allowance: 0, + remaining: 0, + period_start: '2026-10-01T00:00:00Z', + period_end: '2026-11-01T00:00:00Z' + } +}; +function render() { + return mount(BillingPage, { + global: { + stubs: { + TCard: { template: '
' }, + ComponentLoader: { template: '
' }, + TButton: { + props: ['text', 'disabled'], + template: '' + } + } + } + }); +} +beforeEach(() => { + request.mockReset(); + vi.stubGlobal('useRoute', () => ({ query: { checkout: 'success' } })); + request.mockImplementation((path: string) => + Promise.resolve({ + success: true, + message: 'Success', + data: + path === '/cloud/plans' + ? { + trial_days: 3, + regions: { + us: { + name: 'United States', + currency: 'USD', + prices: { + monthly: { price: 5, price_formatted: '$5.00' }, + yearly: { price: 50, price_formatted: '$50.00' } + } + } + } + } + : current + }) + ); +}); +describe('billing purchase return', () => { + it('keeps free access until the server confirms payment and shows the price', async () => { + const page = render(); + await flushPromises(); + expect(page.text()).toContain('Free plan'); + expect(page.text()).toContain('Payment confirmation is pending'); + expect(page.text()).toContain('$5.00'); + expect(page.text()).not.toContain('Paid access active'); + expect(page.text()).not.toContain('Manage billing'); + }); + 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 } + } + }) + ); + const page = 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'); + }); +}); diff --git a/types/premium.ts b/types/premium.ts new file mode 100644 index 0000000..8f1012b --- /dev/null +++ b/types/premium.ts @@ -0,0 +1,63 @@ +export interface BillingStatus { + paid_access: boolean; + has_billing_account: boolean; + status: string; + paid_through: string | null; + limits: { wallets: number | null; categories: number | null; bank_connections: number | null }; + ai_usage: { + used: number; + allowance: number; + remaining: number; + period_start: string; + period_end: string; + }; +} +export interface BankConnection { + id: number; + institution_name: string | null; + status: string; + is_active: boolean; + disconnected_at: string | null; + last_synced_at: string | null; +} +export interface BankAccount { + account_id: string; + name: string; + mask: string | null; + balances: { iso_currency_code: string | null }; +} +export interface BankMapping { + plaid_account_id: string; + wallet_id: number; +} +export interface LinkSession { + id: number; + url: string; + status: string; + expires_at: string; +} +export interface PremiumResponse { + success: boolean; + message: string; + data: T; +} + +export interface CloudPlans { + trial_days: number; + regions: Record< + string, + { + name: string; + currency: string; + prices: Record; + } + >; +} + +export interface PremiumPage { + data: T[]; + current_page: number; + last_page: number; + per_page: number; + total: number; +} From fe1ae72306357b7ecd1fa3490995fe5c150457d3 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 20 Oct 2026 17:22:57 +0100 Subject: [PATCH 2/4] test: Use current plus prices in billing coverage --- tests/pages/billing.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/pages/billing.test.ts b/tests/pages/billing.test.ts index f9c5446..285b2d9 100644 --- a/tests/pages/billing.test.ts +++ b/tests/pages/billing.test.ts @@ -50,8 +50,8 @@ beforeEach(() => { name: 'United States', currency: 'USD', prices: { - monthly: { price: 5, price_formatted: '$5.00' }, - yearly: { price: 50, price_formatted: '$50.00' } + monthly: { price: 8.99, price_formatted: '$8.99' }, + yearly: { price: 86.28, price_formatted: '$86.28' } } } } @@ -66,7 +66,7 @@ describe('billing purchase return', () => { await flushPromises(); expect(page.text()).toContain('Free plan'); expect(page.text()).toContain('Payment confirmation is pending'); - expect(page.text()).toContain('$5.00'); + expect(page.text()).toContain('$8.99'); expect(page.text()).not.toContain('Paid access active'); expect(page.text()).not.toContain('Manage billing'); }); From 8a196b44aa7eb22792b3763e26e91e21a129dbf1 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 20 Oct 2026 18:16:50 +0100 Subject: [PATCH 3/4] fix(billing): Default checkout to yearly billing --- pages/billing.vue | 4 ++-- tests/pages/billing.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/pages/billing.vue b/pages/billing.vue index d5edbb4..aae470c 100644 --- a/pages/billing.vue +++ b/pages/billing.vue @@ -59,8 +59,8 @@ @@ -108,7 +108,7 @@ const loading = ref(true); const busy = ref(false); const error = ref(null); const region = ref('us'); -const plan = ref('monthly'); +const plan = ref('yearly'); const pricing = ref(null); const selectedPrice = computed( () => pricing.value?.regions[region.value]?.prices[plan.value]?.price_formatted diff --git a/tests/pages/billing.test.ts b/tests/pages/billing.test.ts index 285b2d9..e99cb33 100644 --- a/tests/pages/billing.test.ts +++ b/tests/pages/billing.test.ts @@ -66,10 +66,34 @@ describe('billing purchase return', () => { await flushPromises(); expect(page.text()).toContain('Free plan'); expect(page.text()).toContain('Payment confirmation is pending'); - expect(page.text()).toContain('$8.99'); + expect(page.text()).toContain('$86.28'); expect(page.text()).not.toContain('Paid access active'); expect(page.text()).not.toContain('Manage billing'); }); + it('starts checkout with yearly billing and allows switching to monthly', async () => { + const page = render(); + 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'); + expect(request).toHaveBeenCalledWith('/cloud/billing/checkout', { + method: 'POST', + body: { region: 'us', plan: 'monthly' } + }); + }); it('shows only server-confirmed access and monthly usage', async () => { request.mockImplementation((path: string) => Promise.resolve({ From 312126dfbe7d864184637b6bd193ab776e403bde Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 20 Oct 2026 21:04:21 +0100 Subject: [PATCH 4/4] refactor(ui): Load optional pages from plugin layers --- PLUGINS.md | 18 + components/premium/ExtensionCard.vue | 16 - composables/useExtensionSlots.ts | 6 + i18n/locales/en.json | 53 +-- nuxt.config.ts | 7 +- package.json | 3 +- pages/banks.vue | 350 -------------------- pages/billing.vue | 187 ----------- plugins/premium.ts | 6 - scripts/check-plugin-layers.mjs | 49 +++ tests/composables/useExtensionSlots.test.ts | 11 + tests/pages/banks.test.ts | 105 ------ tests/pages/billing.test.ts | 121 ------- types/premium.ts | 63 ---- vitest.config.ts | 14 +- 15 files changed, 106 insertions(+), 903 deletions(-) create mode 100644 PLUGINS.md delete mode 100644 components/premium/ExtensionCard.vue delete mode 100644 pages/banks.vue delete mode 100644 pages/billing.vue delete mode 100644 plugins/premium.ts create mode 100644 scripts/check-plugin-layers.mjs delete mode 100644 tests/pages/banks.test.ts delete mode 100644 tests/pages/billing.test.ts delete mode 100644 types/premium.ts diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..9e64c90 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,18 @@ +# Trakli UI plugins + +Set `TRAKLI_UI_LAYERS` to a comma-separated list of installed Nuxt layer paths before development or build. Paths are relative to the web UI checkout, or absolute. + +```bash +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run dev +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run build +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run test:run +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run check:plugins +``` + +Without this setting, the app loads no optional plugin layers. A layer supplies its pages, translations and optional components. Keep the matching backend plugin enabled. Installing or removing a layer requires restarting development or rebuilding the app. + +Backend integration descriptors insert links into the existing sidebar and settings slots. Links to local routes are hidden when the route is absent. Custom controls can still register through `useExtensionSlots().registerComponent()`. + +Layer pages use the host's authentication middleware, layouts, components and API client. Keep provider return URLs on the same browser hostname as the UI to preserve login cookies. + +Plugin tests live under the layer's `tests` directory. The host test runner includes tests from the configured layers and supplies its aliases and dependencies. diff --git a/components/premium/ExtensionCard.vue b/components/premium/ExtensionCard.vue deleted file mode 100644 index 3d42a1b..0000000 --- a/components/premium/ExtensionCard.vue +++ /dev/null @@ -1,16 +0,0 @@ - - diff --git a/composables/useExtensionSlots.ts b/composables/useExtensionSlots.ts index 45e9d13..94a8616 100644 --- a/composables/useExtensionSlots.ts +++ b/composables/useExtensionSlots.ts @@ -16,6 +16,7 @@ const componentRegistry = ref>({}); */ export const useExtensionSlots = () => { const { integrations } = useIntegrations(); + const router = useRouter(); const contributionsFor = (slot: ExtensionSlot) => computed(() => { @@ -39,6 +40,11 @@ export const useExtensionSlots = () => { continue; } + const href = ui.onboarding?.href || ui.card?.href; + if (href?.startsWith('/') && !router.resolve(href).matched.length) { + continue; + } + items.push({ key: integration.key, slot, diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 03cf997..25e7d6f 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1386,56 +1386,5 @@ "No outreach sent yet": "No outreach sent yet", "Write to your users from here and every send is listed, so you can see what was said and when.": "Write to your users from here and every send is listed, so you can see what was said and when.", "{groups} are measured for the whole product, so they are not shown while one client is selected.": "{groups} are measured for the whole product, so they are not shown while one client is selected.", - "{start} to {end}": "{start} to {end}", - "A three-day trial is available once per account.": "A three-day trial is available once per account.", - "Account selection saved. Synchronization queued.": "Account selection saved. Synchronization queued.", - "Bank": "Bank", - "Bank connection limit": "Bank connection limit", - "Bank connections": "Bank connections", - "Billing period": "Billing period", - "Check connection status": "Check connection status", - "Checkout cancelled. Your current plan is unchanged.": "Checkout cancelled. Your current plan is unchanged.", - "Choose accounts": "Choose accounts", - "Choose accounts to import": "Choose accounts to import", - "Connect a bank": "Connect a bank", - "Connect a bank, then choose which accounts to import into your wallets.": "Connect a bank, then choose which accounts to import into your wallets.", - "Connect your banks with a paid plan": "Connect your banks with a paid plan", - "Connection session": "Connection session", - "Continue bank connection": "Continue bank connection", - "Create a wallet in this currency": "Create a wallet in this currency", - "Disconnect": "Disconnect", - "Disconnect bank?": "Disconnect bank?", - "Do not import": "Do not import", - "Free plan": "Free plan", - "Last synchronized": "Last synchronized", - "Manage banks": "Manage banks", - "Manage billing": "Manage billing", - "Month": "Month", - "Monthly model usage": "Monthly model usage", - "New custom category limit": "New custom category limit", - "No banks connected": "No banks connected", - "Only selected accounts are imported. Each wallet must use the account currency.": "Only selected accounts are imported. Each wallet must use the account currency.", - "Paid access active": "Paid access active", - "Paid through": "Paid through", - "Payment confirmation is pending. Refresh your status before connecting a bank.": "Payment confirmation is pending. Refresh your status before connecting a bank.", - "Pricing region": "Pricing region", - "Reconnect": "Reconnect", - "Refresh status": "Refresh status", - "Resets": "Resets", - "Save and synchronize": "Save and synchronize", - "Subscription status": "Subscription status", - "Synchronization queued. Imported transactions will appear in your transaction list.": "Synchronization queued. Imported transactions will appear in your transaction list.", - "Synchronize": "Synchronize", - "This bank has no available accounts.": "This bank has no available accounts.", - "This stops imports from this bank. Your imported transactions stay available.": "This stops imports from this bank. Your imported transactions stay available.", - "Unlimited": "Unlimited", - "Upgrade": "Upgrade", - "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 imported transactions stay available when paid access ends.": "Your imported transactions stay available when paid access ends.", - "Your plan": "Your plan", - "tokens": "tokens", - "This bank connection could not be confirmed. Start a new connection session. Banks already connected remain available.": "This bank connection could not be confirmed. Start a new connection session. Banks already connected remain available.", - "Start a new connection session": "Start a new connection session" + "{start} to {end}": "{start} to {end}" } diff --git a/nuxt.config.ts b/nuxt.config.ts index ca54dc5..7c5949f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,11 @@ import { defineNuxtConfig } from 'nuxt/config'; import Icons from 'unplugin-icons/vite'; +const pluginLayers = (process.env.TRAKLI_UI_LAYERS || '') + .split(',') + .map((layer) => layer.trim()) + .filter(Boolean); + // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ vite: { @@ -12,7 +17,7 @@ export default defineNuxtConfig({ // A layer registers a component against a slot key with // useExtensionSlots().registerComponent(), and DescriptorRenderer resolves it when a // descriptor sets `ui.component`. That is how plugin UI reaches the app. - extends: ['@whilesmart/eloquent-admin-ui'], + extends: ['@whilesmart/eloquent-admin-ui', ...pluginLayers], routeRules: { '/ai-insights': { redirect: '/assistant' } }, diff --git a/package.json b/package.json index b6b5d9b..9187f6a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "format:check": "prettier --check ./", "test": "vitest", "test:run": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "check:plugins": "node scripts/check-plugin-layers.mjs" }, "dependencies": { "@whilesmart/eloquent-admin-ui": "^0.1.0", diff --git a/pages/banks.vue b/pages/banks.vue deleted file mode 100644 index d00dedc..0000000 --- a/pages/banks.vue +++ /dev/null @@ -1,350 +0,0 @@ - - - diff --git a/pages/billing.vue b/pages/billing.vue deleted file mode 100644 index aae470c..0000000 --- a/pages/billing.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - diff --git a/plugins/premium.ts b/plugins/premium.ts deleted file mode 100644 index cd4fd36..0000000 --- a/plugins/premium.ts +++ /dev/null @@ -1,6 +0,0 @@ -import ExtensionCard from '~/components/premium/ExtensionCard.vue'; -export default defineNuxtPlugin(() => { - const { registerComponent } = useExtensionSlots(); - registerComponent('premium.billing', ExtensionCard); - registerComponent('premium.banks', ExtensionCard); -}); diff --git a/scripts/check-plugin-layers.mjs b/scripts/check-plugin-layers.mjs new file mode 100644 index 0000000..1fbe2b9 --- /dev/null +++ b/scripts/check-plugin-layers.mjs @@ -0,0 +1,49 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, extname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import * as prettier from 'prettier'; + +const host = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const layers = (process.env.TRAKLI_UI_LAYERS || '') + .split(',') + .map((layer) => layer.trim()) + .filter(Boolean) + .map((layer) => resolve(host, layer)); +const extensions = new Set(['.ts', '.js', '.mjs', '.vue', '.json', '.md']); +const excluded = new Set(['node_modules', '.nuxt', '.output', '.git']); + +async function sources(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (excluded.has(entry.name)) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await sources(path))); + else if (extensions.has(extname(path))) files.push(path); + } + return files; +} + +if (layers.length) { + let common = host; + for (const layer of layers) { + while (relative(common, layer).startsWith('..')) common = dirname(common); + } + const eslint = new ESLint({ cwd: common, overrideConfigFile: join(host, 'eslint.config.mjs') }); + const results = await eslint.lintFiles(layers.map((layer) => `${layer}/**/*.{ts,js,mjs,vue}`)); + console.log(await (await eslint.loadFormatter('stylish')).format(results)); + if (results.some((result) => result.errorCount)) process.exitCode = 1; + + const options = await prettier.resolveConfig(join(host, 'nuxt.config.ts')); + for (const layer of layers) { + for (const file of await sources(layer)) { + if (!(await prettier.check(await readFile(file, 'utf8'), { ...options, filepath: file }))) { + console.error(`Formatting failed: ${relative(host, file)}`); + process.exitCode = 1; + } + } + } + if (!process.exitCode) console.log('Plugin layer lint and formatting passed.'); +} else { + console.log('No plugin layers configured.'); +} diff --git a/tests/composables/useExtensionSlots.test.ts b/tests/composables/useExtensionSlots.test.ts index ecdca0f..0b8ab5b 100644 --- a/tests/composables/useExtensionSlots.test.ts +++ b/tests/composables/useExtensionSlots.test.ts @@ -27,6 +27,17 @@ function makeIntegration(overrides: Partial): Integration { describe('useExtensionSlots', () => { beforeEach(() => { mockIntegrations.value = []; + vi.stubGlobal('useRouter', () => ({ resolve: () => ({ matched: [{}] }) })); + }); + + it('hides local links when their plugin route is not installed', () => { + vi.stubGlobal('useRouter', () => ({ resolve: () => ({ matched: [] }) })); + mockIntegrations.value = [ + makeIntegration({ + ui: { slots: ['sidebar.nav'], card: { title: 'Optional plugin', href: '/optional' } } + }) + ]; + expect(useExtensionSlots().contributionsFor('sidebar.nav').value).toEqual([]); }); it('collects only contributions that declare the slot', () => { diff --git a/tests/pages/banks.test.ts b/tests/pages/banks.test.ts deleted file mode 100644 index 384a6cb..0000000 --- a/tests/pages/banks.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { flushPromises, mount } from '@vue/test-utils'; -import { ref } from 'vue'; -import BankPage from '~/pages/banks.vue'; -const request = vi.hoisted(() => vi.fn()); -vi.mock('~/composables/useApi', () => ({ useApi: () => request })); -vi.mock('~/composables/useWallets', () => ({ - useWallets: () => ({ wallets: ref([]), fetchWallets: async () => [] }) -})); -vi.stubGlobal('definePageMeta', vi.fn()); -vi.stubGlobal('navigateTo', vi.fn()); -const connection = { - id: 8, - institution_name: 'Selected bank', - status: 'connected', - is_active: true, - disconnected_at: null, - last_synced_at: null -}; -beforeEach(() => { - request.mockReset(); - vi.stubGlobal('useRoute', () => ({ query: {} })); - request.mockImplementation((path: string) => - Promise.resolve({ - success: true, - message: 'Success', - data: - path === '/cloud/billing/status' - ? { paid_access: false } - : { data: [connection], current_page: 1, last_page: 1, per_page: 100, total: 1 } - }) - ); -}); -function render() { - return mount(BankPage, { - global: { - stubs: { - TCard: { template: '
' }, - ComponentLoader: { template: '
' }, - TButton: { - props: ['text', 'disabled'], - template: '' - }, - NuxtLink: { template: '' } - } - } - }); -} -describe('expired bank access', () => { - it('offers a new session after a partial connection outcome and retains connected banks', async () => { - vi.stubGlobal('useRoute', () => ({ query: { session: '21' } })); - request.mockImplementation((path: string) => - Promise.resolve({ - success: true, - message: 'Success', - data: - path === '/cloud/billing/status' - ? { paid_access: true } - : path === '/plaid/link-sessions/21' - ? { id: 21, status: 'partial_relink' } - : path === '/plaid/link-sessions' - ? { id: 22, status: 'pending', url: 'https://example.invalid/bank' } - : { data: [connection], current_page: 1, last_page: 1, per_page: 100, total: 1 } - }) - ); - const page = render(); - await flushPromises(); - expect(page.text()).toContain('Selected bank'); - expect(page.text()).toContain('Banks already connected remain available.'); - await page - .findAll('button') - .find((button) => button.text() === 'Start a new connection session')! - .trigger('click'); - await flushPromises(); - expect(request).toHaveBeenCalledWith('/plaid/link-sessions', { method: 'POST' }); - expect(page.text()).toContain('Selected bank'); - }); - it('keeps bank history and disconnect available while hiding paid actions', async () => { - const page = render(); - await flushPromises(); - expect(page.text()).toContain('Selected bank'); - expect(page.text()).toContain('Disconnect'); - expect(page.text()).not.toContain('Choose accounts'); - expect(page.text()).not.toContain('Synchronize'); - expect(page.text()).not.toContain('Reconnect'); - expect(page.text()).toContain('Upgrade'); - }); - it('confirms and disconnects only the selected bank', async () => { - const page = render(); - await flushPromises(); - await page - .findAll('button') - .find((button) => button.text() === 'Disconnect')! - .trigger('click'); - expect(page.text()).toContain('Your imported transactions stay available.'); - await page - .findAll('button') - .filter((button) => button.text() === 'Disconnect') - .at(-1)! - .trigger('click'); - await flushPromises(); - expect(request).toHaveBeenCalledWith('/plaid/connections/8', { method: 'DELETE' }); - expect(request).not.toHaveBeenCalledWith('/plaid/connections', { method: 'DELETE' }); - }); -}); diff --git a/tests/pages/billing.test.ts b/tests/pages/billing.test.ts deleted file mode 100644 index e99cb33..0000000 --- a/tests/pages/billing.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { flushPromises, mount } from '@vue/test-utils'; -import BillingPage from '~/pages/billing.vue'; - -const request = vi.hoisted(() => vi.fn()); -vi.mock('~/composables/useApi', () => ({ useApi: () => request })); -vi.stubGlobal('definePageMeta', vi.fn()); -vi.stubGlobal('navigateTo', vi.fn()); -const current = { - paid_access: false, - has_billing_account: false, - status: 'free', - paid_through: null, - limits: { wallets: 3, categories: 10, bank_connections: 0 }, - ai_usage: { - used: 0, - allowance: 0, - remaining: 0, - period_start: '2026-10-01T00:00:00Z', - period_end: '2026-11-01T00:00:00Z' - } -}; -function render() { - return mount(BillingPage, { - global: { - stubs: { - TCard: { template: '
' }, - ComponentLoader: { template: '
' }, - TButton: { - props: ['text', 'disabled'], - template: '' - } - } - } - }); -} -beforeEach(() => { - request.mockReset(); - vi.stubGlobal('useRoute', () => ({ query: { checkout: 'success' } })); - request.mockImplementation((path: 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 - }) - ); -}); -describe('billing purchase return', () => { - it('keeps free access until the server confirms payment and shows the price', async () => { - const page = 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'); - }); - it('starts checkout with yearly billing and allows switching to monthly', async () => { - const page = render(); - 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'); - expect(request).toHaveBeenCalledWith('/cloud/billing/checkout', { - method: 'POST', - body: { region: 'us', plan: 'monthly' } - }); - }); - 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 } - } - }) - ); - const page = 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'); - }); -}); diff --git a/types/premium.ts b/types/premium.ts deleted file mode 100644 index 8f1012b..0000000 --- a/types/premium.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface BillingStatus { - paid_access: boolean; - has_billing_account: boolean; - status: string; - paid_through: string | null; - limits: { wallets: number | null; categories: number | null; bank_connections: number | null }; - ai_usage: { - used: number; - allowance: number; - remaining: number; - period_start: string; - period_end: string; - }; -} -export interface BankConnection { - id: number; - institution_name: string | null; - status: string; - is_active: boolean; - disconnected_at: string | null; - last_synced_at: string | null; -} -export interface BankAccount { - account_id: string; - name: string; - mask: string | null; - balances: { iso_currency_code: string | null }; -} -export interface BankMapping { - plaid_account_id: string; - wallet_id: number; -} -export interface LinkSession { - id: number; - url: string; - status: string; - expires_at: string; -} -export interface PremiumResponse { - success: boolean; - message: string; - data: T; -} - -export interface CloudPlans { - trial_days: number; - regions: Record< - string, - { - name: string; - currency: string; - prices: Record; - } - >; -} - -export interface PremiumPage { - data: T[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} diff --git a/vitest.config.ts b/vitest.config.ts index b7ddf25..7887f42 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,13 +3,24 @@ import vue from '@vitejs/plugin-vue'; import Icons from 'unplugin-icons/vite'; import { resolve } from 'path'; +const pluginLayers = (process.env.TRAKLI_UI_LAYERS || '') + .split(',') + .map((layer) => layer.trim()) + .filter(Boolean); + export default defineConfig({ plugins: [Icons({ compiler: 'vue3', autoInstall: false }), vue()], + server: { + fs: { allow: [resolve(__dirname), ...pluginLayers.map((layer) => resolve(__dirname, layer))] } + }, test: { globals: true, environment: 'happy-dom', setupFiles: ['./tests/setup.ts'], - include: ['tests/**/*.{test,spec}.{js,ts}'], + include: [ + 'tests/**/*.{test,spec}.{js,ts}', + ...pluginLayers.map((layer) => `${layer}/tests/**/*.{test,spec}.{js,ts}`) + ], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], @@ -17,6 +28,7 @@ export default defineConfig({ } }, resolve: { + dedupe: ['vue', '@vue/test-utils', 'vitest'], alias: { '@': resolve(__dirname, './'), '~': resolve(__dirname, './')