From 415375f34dde74a42c365214926ea6ee64c34f5b Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 6 Oct 2026 17:46:17 +0100 Subject: [PATCH 1/5] feat: Add billing status and subscription management --- README.md | 22 ++++++++-- composer.json | 2 +- config/cloudplans.php | 27 ++++++------ plugin.json | 4 +- routes/api.php | 6 +++ src/CloudIntegration.php | 46 ++++++++++++++++++++ src/CloudServiceProvider.php | 8 +++- src/Http/Controllers/CloudController.php | 53 +++++++++++++++++++++++- src/Support/FreePlanFallbackSource.php | 8 +++- src/Support/TokenMeterUsage.php | 20 +++++---- 10 files changed, 164 insertions(+), 32 deletions(-) create mode 100644 src/CloudIntegration.php diff --git a/README.md b/README.md index 8d092c1..ca6b058 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ Set the Stripe credentials and point a Stripe webhook at `/stripe/webhook`: STRIPE_KEY=your-publishable-key-here STRIPE_SECRET=your-secret-key-here STRIPE_WEBHOOK_SECRET=your-webhook-signing-secret-here -ENTITLEMENTS_CASHIER_SUCCESS_URL=https://app.example.com/billing/success -ENTITLEMENTS_CASHIER_CANCEL_URL=https://app.example.com/billing/cancel +ENTITLEMENTS_CASHIER_SUCCESS_URL=https://app.example.com/billing?checkout=success +ENTITLEMENTS_CASHIER_CANCEL_URL=https://app.example.com/billing?checkout=cancelled ``` Then mirror the configured plans into the entitlements tables and create a @@ -237,7 +237,7 @@ from the amount in `config/cloudplans.php`, so no Stripe price id is kept by hand; re-run the command after changing an amount and a new Stripe price is created for it. -`POST /api/v1/cloud/checkout` takes `plan` and `region` and returns the Stripe +`POST /api/v1/cloud/billing/checkout` takes `plan` and `region` and returns the Stripe Checkout URL. Where Stripe returns the customer afterwards is configured above, not sent by the client. @@ -298,3 +298,19 @@ php artisan test ## License This plugin is open-source software licensed under the [MIT License](https://opensource.org/licenses/MIT). + + +## Billing status and management + +Authenticated clients read `GET /api/v1/cloud/billing/status` for subscription state, +paid access, paid-through date, limits, the billing-account flag and monthly AI usage. +`POST /api/v1/cloud/billing/portal` returns the customer portal URL. Checkout return +parameters never grant access. The client waits for billing status to confirm it. + +Paid plans include 100,000 input and output tokens per UTC calendar month. Trial +usage shares the same pool. Converting a trial or changing plans does not reset it. +The app and workers use the same Redis cache for owner usage locks. + +Free owners can create three wallets and ten custom categories. Categories created +before this release and server-seeded defaults are exempt. Downgrades preserve +existing records and their ordinary finance access. diff --git a/composer.json b/composer.json index b1a0476..28c485d 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { "require": { - "whilesmart/entitlements-cashier": "^0.1.1" + "whilesmart/entitlements-cashier": "^0.2.1" } } diff --git a/config/cloudplans.php b/config/cloudplans.php index f40ba72..f2a0242 100644 --- a/config/cloudplans.php +++ b/config/cloudplans.php @@ -4,7 +4,7 @@ 'overview' => [ 'benefits' => [ 'title' => 'Why Create a Trakli Cloud Account?', - 'description' => 'By registering for a Trakli Cloud account, you unlock seamless access across all your devices, secure cloud backups, exclusive feature updates, and priority support — giving you peace of mind and full control of your finances wherever you go.', + 'description' => 'By registering for a Trakli Cloud account, you unlock seamless access across all your devices, secure cloud backups, exclusive feature updates, and priority support, giving you peace of mind and full control of your finances wherever you go.', ], 'plans' => [ 'title' => 'Upgrade to Premium', @@ -15,7 +15,7 @@ 'benefits' => [ [ 'title' => 'Access Anywhere', - 'description' => 'Use Trakli on your phone, tablet, or browser — your data stays synced across all devices.', + 'description' => 'Use Trakli on your phone, tablet, or browser. Your data stays synced across all devices.', ], [ 'title' => 'Secure Cloud Backups', @@ -31,12 +31,12 @@ ], [ 'title' => 'Automatic Updates', - 'description' => 'Stay current with improvements and fixes — no manual updates needed.', + 'description' => 'Stay current with improvements and fixes. No manual updates needed.', ], ], 'trial_days' => 3, - 'free_plan_enabled' => (bool) env('CLOUD_FREE_PLAN_ENABLED', false), + 'free_plan_enabled' => (bool) env('CLOUD_FREE_PLAN_ENABLED', true), 'freemode_enabled' => (bool) env('CLOUD_FREEMODE_ENABLED', false), 'regions' => [ 'us' => [ @@ -80,8 +80,8 @@ // feature_keys, limits and token_allowance above. 'features' => [ 'Up to 3 wallets', - 'Up to 10 categories', - 'Community support', + 'Up to 10 new custom categories', + 'Manual entry, reports and basic imports', ], 'cta' => [ 'text' => 'Current Plan', @@ -92,17 +92,16 @@ 'id' => 'monthly', 'name' => 'Monthly', 'interval' => 'month', - 'feature_keys' => ['plaid'], + 'feature_keys' => ['plaid', 'bank_connections', 'ai'], 'limits' => [ 'max_wallets' => null, 'max_categories' => null, ], - 'token_allowance' => env('CLOUD_PLAN_MONTHLY_TOKEN_ALLOWANCE', 50000), + 'token_allowance' => 100000, 'features' => [ - 'Unlimited categories and wallets', - 'Mobile and web access', - 'CSV exports', - 'Community support', + 'Unlimited wallets and categories', + 'Unlimited bank connections and accounts', + '100,000 AI tokens per UTC calendar month', ], 'cta' => [ 'text' => env('CLOUD_PLAN_CTA_TEXT', 'Start 3-Day Free Trial'), @@ -113,12 +112,12 @@ 'id' => 'yearly', 'name' => 'Yearly', 'interval' => 'year', - 'feature_keys' => ['plaid'], + 'feature_keys' => ['plaid', 'bank_connections', 'ai'], 'limits' => [ 'max_wallets' => null, 'max_categories' => null, ], - 'token_allowance' => env('CLOUD_PLAN_YEARLY_TOKEN_ALLOWANCE', 50000), + 'token_allowance' => 100000, 'features' => [ 'Everything in Monthly', '2 months free (save ~17%)', diff --git a/plugin.json b/plugin.json index c1be5a2..ac9442f 100644 --- a/plugin.json +++ b/plugin.json @@ -7,7 +7,7 @@ "provider": "Trakli\\Cloud\\CloudServiceProvider", "enabled": true, "requires": { - "php": ">=8.1", - "laravel/framework": "^10.0" + "php": "^8.4", + "laravel/framework": "^12.0" } } diff --git a/routes/api.php b/routes/api.php index d642e27..3160005 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,3 +17,9 @@ Route::middleware('auth:sanctum')->post('/checkout', [CloudController::class, 'checkout'])->name('checkout'); + +Route::middleware('auth:sanctum')->group(function () { + Route::get('/billing/status', [CloudController::class, 'billingStatus']); + Route::post('/billing/checkout', [CloudController::class, 'checkout']); + Route::post('/billing/portal', [CloudController::class, 'billingPortal']); +}); diff --git a/src/CloudIntegration.php b/src/CloudIntegration.php new file mode 100644 index 0000000..18a50c7 --- /dev/null +++ b/src/CloudIntegration.php @@ -0,0 +1,46 @@ + ['settings.integrations', 'sidebar.nav'], + 'card' => ['title' => 'Premium', 'cta' => 'Manage plan', 'href' => '/billing'], + 'component' => 'premium.billing', + 'show_when_unconfigured' => true, + ]; + } +} diff --git a/src/CloudServiceProvider.php b/src/CloudServiceProvider.php index 52306dc..624d731 100644 --- a/src/CloudServiceProvider.php +++ b/src/CloudServiceProvider.php @@ -21,7 +21,7 @@ class CloudServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->mergeConfigFrom(__DIR__.'/../config/cloudplans.php', 'cloudplans'); } /** @@ -29,9 +29,15 @@ public function register(): void */ public function boot(): void { + $this->app->make(\App\Services\IntegrationRegistry::class)->register(new CloudIntegration()); $this->registerRoutes(); $this->publishConfig(); $this->enforcePlans(); + app(\Illuminate\Routing\Router::class)->aliasMiddleware('feature', \App\Http\Middleware\RequireFeature::class); + + if (config('entitlements-cashier.trial_days') === null) { + config(['entitlements-cashier.trial_days' => config('cloudplans.trial_days', 3)]); + } // A cancelled Stripe subscription drops the owner back to the free // plan instead of leaving them with none. diff --git a/src/Http/Controllers/CloudController.php b/src/Http/Controllers/CloudController.php index a07d85c..0ef890d 100644 --- a/src/Http/Controllers/CloudController.php +++ b/src/Http/Controllers/CloudController.php @@ -129,7 +129,7 @@ public function getPlans(Request $request): JsonResponse $config = $this->config; if ($config['freemode_enabled'] ?? false) { - return \response()->json([]); + return $this->success([]); } $region = $request->query('region'); @@ -305,6 +305,57 @@ public function checkout(Request $request): JsonResponse /** * Maps plan data to include pricing and currency information. */ + public function billingStatus(Request $request): JsonResponse + { + $owner = $request->user(); + $entitlements = app(\Whilesmart\Entitlements\Contracts\Entitlements::class); + $model = config('entitlements.models.subscription', \Whilesmart\Entitlements\Models\Subscription::class); + $subscription = $model::where('owner_type', $owner->getMorphClass()) + ->where('owner_id', $owner->getKey())->latest('id')->first(); + $used = app(\Trakli\Cloud\Support\TokenMeterUsage::class)->used($owner); + $paid = $entitlements->allows($owner, 'bank_connections'); + $start = now('UTC')->startOfMonth(); + + return $this->success([ + 'has_billing_account' => app(config('entitlements-cashier.billing_profile_model', \Whilesmart\EntitlementsCashier\Models\BillingProfile::class))->newQuery() + ->where('owner_type', $owner->getMorphClass())->where('owner_id', $owner->getKey())->whereNotNull('stripe_id')->exists(), + 'paid_access' => $paid, + 'status' => $subscription?->status?->value ?? 'free', + 'paid_through' => $subscription?->current_period_end?->toIso8601String(), + 'limits' => [ + 'wallets' => $entitlements->limit($owner, 'max_wallets'), + 'categories' => $entitlements->limit($owner, 'max_categories'), + 'bank_connections' => $paid ? null : 0, + ], + 'ai_usage' => [ + 'used' => $used, + 'allowance' => $paid ? 100000 : 0, + 'remaining' => $paid ? max(0, 100000 - $used) : 0, + 'period_start' => $start->toIso8601String(), + 'period_end' => $start->copy()->addMonth()->toIso8601String(), + ], + ]); + } + + public function billingPortal(Request $request): JsonResponse + { + $model = config('entitlements-cashier.billing_profile_model', \Whilesmart\EntitlementsCashier\Models\BillingProfile::class); + $profile = $model::where('owner_type', $request->user()->getMorphClass()) + ->where('owner_id', $request->user()->getKey())->first(); + + if (! $profile?->stripe_id) { + return $this->failure('No billing account exists yet.', 409); + } + + try { + return $this->success(['url' => $profile->billingPortalUrl(config('entitlements-cashier.portal_return_url', config('app.url')))]); + } catch (\Throwable $e) { + report($e); + + return $this->failure('Billing management is unavailable.', 503); + } + } + private function mapPlanData(array $plan, array $regionData, bool $pricesOnly = false): array { $priceKey = $plan['interval'] === 'month' ? 'monthly_price' : 'yearly_price'; diff --git a/src/Support/FreePlanFallbackSource.php b/src/Support/FreePlanFallbackSource.php index f7fbc2a..e4313a1 100644 --- a/src/Support/FreePlanFallbackSource.php +++ b/src/Support/FreePlanFallbackSource.php @@ -30,7 +30,13 @@ private function freePlan(): ?ResolvedPlan $plan = $model::where('key', CloudPlanSync::key('free'))->first(); if ($plan === null) { - return null; + $source = config('cloudplans.plans.free', []); + + return ResolvedPlan::of( + array_fill_keys($source['feature_keys'] ?? [], true), + $source['limits'] ?? ['max_wallets' => 3, 'max_categories' => 10], + ['ai_tokens' => (int) ($source['token_allowance'] ?? 0)], + ); } return ResolvedPlan::of( diff --git a/src/Support/TokenMeterUsage.php b/src/Support/TokenMeterUsage.php index ad4a938..a9abd8a 100644 --- a/src/Support/TokenMeterUsage.php +++ b/src/Support/TokenMeterUsage.php @@ -36,15 +36,17 @@ public function consume(?Model $owner, string $meter, int $amount): void // as well would bill the owner twice for the same tokens. } - /** - * Tokens spent in the current period. Owners that do not record usage at - * all have spent nothing, which keeps a future owner type from failing - * closed before it is metered. - */ - private function used(Model $owner): int + public function used(Model $owner): int { - return method_exists($owner, 'tokensUsed') - ? (int) $owner->tokensUsed(now()->startOfMonth()) - : 0; + if (! method_exists($owner, 'tokenUsages')) { + throw new \RuntimeException('The billing owner does not support token usage accounting.'); + } + + $start = now('UTC')->startOfMonth(); + + return (int) $owner->tokenUsages() + ->where('created_at', '>=', $start) + ->where('created_at', '<', $start->copy()->addMonth()) + ->sum('total_tokens'); } } From 39ec080f07d51ad2b015f8470279be90ffddb9ed Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 20 Oct 2026 16:12:17 +0100 Subject: [PATCH 2/5] fix: Provide a redis client for paid usage locking Paid model requests need a shared lock across workers. Include a PHP client so locking works on runtimes without the Redis extension. --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 28c485d..b6c63bf 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "require": { + "predis/predis": "^3.0", "whilesmart/entitlements-cashier": "^0.2.1" } } From 3ae4a67a609b4a3d63c06ad84bac1e471e3dac69 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 20 Oct 2026 17:22:57 +0100 Subject: [PATCH 3/5] fix: Align cloud pricing with the plus plans --- config/cloudplans.php | 26 ++++++++++++------------ src/Http/Controllers/CloudController.php | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/config/cloudplans.php b/config/cloudplans.php index f2a0242..317dff4 100644 --- a/config/cloudplans.php +++ b/config/cloudplans.php @@ -42,26 +42,26 @@ 'us' => [ 'name' => 'United States', 'currency' => 'USD', - 'monthly_price' => (float) env('CLOUD_PLAN_MONTHLY_PRICE_US', 5.00), - 'yearly_price' => (float) env('CLOUD_PLAN_YEARLY_PRICE_US', 50.00), - 'monthly_price_formatted' => env('CLOUD_PLAN_MONTHLY_PRICE_FORMATTED_US', '$5.00'), - 'yearly_price_formatted' => env('CLOUD_PLAN_YEARLY_PRICE_FORMATTED_US', '$50.00'), + 'monthly_price' => (float) env('CLOUD_PLAN_MONTHLY_PRICE_US', 8.99), + 'yearly_price' => (float) env('CLOUD_PLAN_YEARLY_PRICE_US', 86.28), + 'monthly_price_formatted' => env('CLOUD_PLAN_MONTHLY_PRICE_FORMATTED_US', '$8.99'), + 'yearly_price_formatted' => env('CLOUD_PLAN_YEARLY_PRICE_FORMATTED_US', '$86.28'), ], 'eu' => [ 'name' => 'Europe', 'currency' => 'EUR', - 'monthly_price' => (float) env('CLOUD_PLAN_MONTHLY_PRICE_EU', 5.00), - 'yearly_price' => (float) env('CLOUD_PLAN_YEARLY_PRICE_EU', 50.00), - 'monthly_price_formatted' => env('CLOUD_PLAN_MONTHLY_PRICE_FORMATTED_EU', '€5.00'), - 'yearly_price_formatted' => env('CLOUD_PLAN_YEARLY_PRICE_FORMATTED_EU', '€50.00'), + 'monthly_price' => (float) env('CLOUD_PLAN_MONTHLY_PRICE_EU', 8.99), + 'yearly_price' => (float) env('CLOUD_PLAN_YEARLY_PRICE_EU', 86.28), + 'monthly_price_formatted' => env('CLOUD_PLAN_MONTHLY_PRICE_FORMATTED_EU', '€8.99'), + 'yearly_price_formatted' => env('CLOUD_PLAN_YEARLY_PRICE_FORMATTED_EU', '€86.28'), ], 'uk' => [ 'name' => 'United Kingdom', 'currency' => 'GBP', - 'monthly_price' => (float) env('CLOUD_PLAN_MONTHLY_PRICE_UK', 5.00), - 'yearly_price' => (float) env('CLOUD_PLAN_YEARLY_PRICE_UK', 50.00), - 'monthly_price_formatted' => env('CLOUD_PLAN_MONTHLY_PRICE_FORMATTED_UK', '£5.00'), - 'yearly_price_formatted' => env('CLOUD_PLAN_YEARLY_PRICE_FORMATTED_UK', '£50.00'), + 'monthly_price' => (float) env('CLOUD_PLAN_MONTHLY_PRICE_UK', 8.99), + 'yearly_price' => (float) env('CLOUD_PLAN_YEARLY_PRICE_UK', 86.28), + 'monthly_price_formatted' => env('CLOUD_PLAN_MONTHLY_PRICE_FORMATTED_UK', '£8.99'), + 'yearly_price_formatted' => env('CLOUD_PLAN_YEARLY_PRICE_FORMATTED_UK', '£86.28'), ], ], @@ -120,7 +120,7 @@ 'token_allowance' => 100000, 'features' => [ 'Everything in Monthly', - '2 months free (save ~17%)', + 'Save 20% with yearly billing', 'Premium support', 'Early feature access', 'Priority voting on roadmap', diff --git a/src/Http/Controllers/CloudController.php b/src/Http/Controllers/CloudController.php index 0ef890d..6aff723 100644 --- a/src/Http/Controllers/CloudController.php +++ b/src/Http/Controllers/CloudController.php @@ -42,8 +42,8 @@ properties: [ new OA\Property(property: 'id', type: 'string', example: 'monthly'), new OA\Property(property: 'name', type: 'string', example: 'Monthly'), - new OA\Property(property: 'price', type: 'number', example: 5.00), - new OA\Property(property: 'price_formatted', type: 'string', example: '$5.00'), + new OA\Property(property: 'price', type: 'number', example: 8.99), + new OA\Property(property: 'price_formatted', type: 'string', example: '$8.99'), new OA\Property(property: 'currency', type: 'string', example: 'USD'), new OA\Property(property: 'interval', type: 'string', example: 'month'), new OA\Property(property: 'trial_days', type: 'integer', example: 3), From 77ab9de008b6a1d25d4db2c8ffa16a543a0fc4bd Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 20 Oct 2026 21:04:59 +0100 Subject: [PATCH 4/5] refactor(ui): Supply billing through an optional layer --- .github/workflows/ui-layer.yml | 38 ++++++ src/CloudIntegration.php | 1 - ui/README.md | 7 ++ ui/i18n/locales/en.json | 27 +++++ ui/nuxt.config.ts | 6 + ui/pages/billing.vue | 209 +++++++++++++++++++++++++++++++++ ui/tests/billing.test.ts | 138 ++++++++++++++++++++++ ui/types/api.ts | 34 ++++++ 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ui-layer.yml create mode 100644 ui/README.md create mode 100644 ui/i18n/locales/en.json create mode 100644 ui/nuxt.config.ts create mode 100644 ui/pages/billing.vue create mode 100644 ui/tests/billing.test.ts create mode 100644 ui/types/api.ts diff --git a/.github/workflows/ui-layer.yml b/.github/workflows/ui-layer.yml new file mode 100644 index 0000000..92a9ff9 --- /dev/null +++ b/.github/workflows/ui-layer.yml @@ -0,0 +1,38 @@ +name: UI layer checks + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + ui: + runs-on: ubuntu-latest + env: + TRAKLI_UI_LAYERS: ../ui + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: trakli/webui + ref: eacbb7ec8f04c0444be272671dee127d3c8b48c1 + path: .host-ui + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: .host-ui/package-lock.json + - name: Install host dependencies + working-directory: .host-ui + run: npm ci + - name: Check layer lint and formatting + working-directory: .host-ui + run: npm run check:plugins + - name: Run host and layer tests + working-directory: .host-ui + run: npm run test:run + - name: Build with layer + working-directory: .host-ui + run: npm run build diff --git a/src/CloudIntegration.php b/src/CloudIntegration.php index 18a50c7..1979625 100644 --- a/src/CloudIntegration.php +++ b/src/CloudIntegration.php @@ -39,7 +39,6 @@ public function ui(): array return [ 'slots' => ['settings.integrations', 'sidebar.nav'], 'card' => ['title' => 'Premium', 'cta' => 'Manage plan', 'href' => '/billing'], - 'component' => 'premium.billing', 'show_when_unconfigured' => true, ]; } diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..66303fe --- /dev/null +++ b/ui/README.md @@ -0,0 +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. + +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. + +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/i18n/locales/en.json b/ui/i18n/locales/en.json new file mode 100644 index 0000000..ae845f5 --- /dev/null +++ b/ui/i18n/locales/en.json @@ -0,0 +1,27 @@ +{ + "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", + "Billing period": "Billing period", + "Checkout cancelled. Your current plan is unchanged.": "Checkout cancelled. Your current plan is unchanged.", + "Free plan": "Free plan", + "Manage banks": "Manage banks", + "Manage billing": "Manage billing", + "Month": "Month", + "Monthly model usage": "Monthly model usage", + "New custom category limit": "New custom category limit", + "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", + "Refresh status": "Refresh status", + "Resets": "Resets", + "Subscription status": "Subscription status", + "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 plan": "Your plan", + "tokens": "tokens" +} diff --git a/ui/nuxt.config.ts b/ui/nuxt.config.ts new file mode 100644 index 0000000..b4e7d35 --- /dev/null +++ b/ui/nuxt.config.ts @@ -0,0 +1,6 @@ +export default defineNuxtConfig({ + i18n: { + langDir: 'locales', + locales: [{ code: 'en', file: 'en.json' }] + } +}); diff --git a/ui/pages/billing.vue b/ui/pages/billing.vue new file mode 100644 index 0000000..cbf4634 --- /dev/null +++ b/ui/pages/billing.vue @@ -0,0 +1,209 @@ + + + diff --git a/ui/tests/billing.test.ts b/ui/tests/billing.test.ts new file mode 100644 index 0000000..0f84bd8 --- /dev/null +++ b/ui/tests/billing.test.ts @@ -0,0 +1,138 @@ +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' } })); + vi.stubGlobal('useRouter', () => ({ hasRoute: () => true })); + 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'); + }); + 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(); + await flushPromises(); + expect(page.text()).toContain('Manage billing'); + expect(page.text()).not.toContain('Manage banks'); + }); +}); diff --git a/ui/types/api.ts b/ui/types/api.ts new file mode 100644 index 0000000..e0dd4df --- /dev/null +++ b/ui/types/api.ts @@ -0,0 +1,34 @@ +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 PremiumResponse { + success: boolean; + message: string; + data: T; +} +export interface CloudPlans { + trial_days: number; + regions: Record< + string, + { + name: string; + currency: string; + prices: Record; + } + >; +} From 6eb3ef81d92cf29ffbf277550ae86aca9802d8d0 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 22 Oct 2026 00:05:11 +0100 Subject: [PATCH 5/5] fix: Limit free plans to five custom categories --- README.md | 2 +- config/cloudplans.php | 4 ++-- src/Support/FreePlanFallbackSource.php | 2 +- ui/tests/billing.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ca6b058..d38449a 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,6 @@ Paid plans include 100,000 input and output tokens per UTC calendar month. Trial usage shares the same pool. Converting a trial or changing plans does not reset it. The app and workers use the same Redis cache for owner usage locks. -Free owners can create three wallets and ten custom categories. Categories created +Free owners can create three wallets and five custom categories. Categories created before this release and server-seeded defaults are exempt. Downgrades preserve existing records and their ordinary finance access. diff --git a/config/cloudplans.php b/config/cloudplans.php index 317dff4..dff278d 100644 --- a/config/cloudplans.php +++ b/config/cloudplans.php @@ -73,14 +73,14 @@ 'feature_keys' => [], 'limits' => [ 'max_wallets' => 3, - 'max_categories' => 10, + 'max_categories' => 5, ], 'token_allowance' => env('CLOUD_PLAN_FREE_TOKEN_ALLOWANCE', 0), // The strings below are marketing copy. What the gate reads is // feature_keys, limits and token_allowance above. 'features' => [ 'Up to 3 wallets', - 'Up to 10 new custom categories', + 'Up to 5 new custom categories', 'Manual entry, reports and basic imports', ], 'cta' => [ diff --git a/src/Support/FreePlanFallbackSource.php b/src/Support/FreePlanFallbackSource.php index e4313a1..0806d60 100644 --- a/src/Support/FreePlanFallbackSource.php +++ b/src/Support/FreePlanFallbackSource.php @@ -34,7 +34,7 @@ private function freePlan(): ?ResolvedPlan return ResolvedPlan::of( array_fill_keys($source['feature_keys'] ?? [], true), - $source['limits'] ?? ['max_wallets' => 3, 'max_categories' => 10], + $source['limits'] ?? ['max_wallets' => 3, 'max_categories' => 5], ['ai_tokens' => (int) ($source['token_allowance'] ?? 0)], ); } diff --git a/ui/tests/billing.test.ts b/ui/tests/billing.test.ts index 0f84bd8..ca421d2 100644 --- a/ui/tests/billing.test.ts +++ b/ui/tests/billing.test.ts @@ -11,7 +11,7 @@ const current = { has_billing_account: false, status: 'free', paid_through: null, - limits: { wallets: 3, categories: 10, bank_connections: 0 }, + limits: { wallets: 3, categories: 5, bank_connections: 0 }, ai_usage: { used: 0, allowance: 0,