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/README.md b/README.md
index 8d092c1..d38449a 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 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/composer.json b/composer.json
index b1a0476..b6c63bf 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,6 @@
{
"require": {
- "whilesmart/entitlements-cashier": "^0.1.1"
+ "predis/predis": "^3.0",
+ "whilesmart/entitlements-cashier": "^0.2.1"
}
}
diff --git a/config/cloudplans.php b/config/cloudplans.php
index f40ba72..dff278d 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,37 +31,37 @@
],
[
'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' => [
'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'),
],
],
@@ -73,15 +73,15 @@
'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 categories',
- 'Community support',
+ 'Up to 5 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,15 +112,15 @@
'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%)',
+ 'Save 20% with yearly billing',
'Premium support',
'Early feature access',
'Priority voting on roadmap',
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..1979625
--- /dev/null
+++ b/src/CloudIntegration.php
@@ -0,0 +1,45 @@
+ ['settings.integrations', 'sidebar.nav'],
+ 'card' => ['title' => 'Premium', 'cta' => 'Manage plan', 'href' => '/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..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),
@@ -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..0806d60 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' => 5],
+ ['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');
}
}
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 @@
+
+ {{ t('Subscription status') }}: {{ status.status }}
+ {{ t('Paid through') }}: {{ formatDate(status.paid_through) }}
+
+ {{ t('Payment confirmation is pending. Refresh your status before connecting a bank.') }}
+
+ {{ t('Checkout cancelled. Your current plan is unchanged.') }}
+ {{ t('Wallet limit') }}: {{ limit(status.limits.wallets) }}
+ {{ t('New custom category limit') }}:
+ {{ limit(status.limits.categories) }}
+
+ {{ t('Bank connection limit') }}:
+ {{ limit(status.limits.bank_connections) }}
+
+ {{ status.ai_usage.used.toLocaleString() }} /
+ {{ status.ai_usage.allowance.toLocaleString() }} {{ t('tokens') }}
+ {{ t('Resets') }}: {{ formatDate(status.ai_usage.period_end) }} (UTC)
+ {{
+ t(
+ 'Upgrade for unlimited wallets, categories and bank connections, plus 100,000 model tokens each month.'
+ )
+ }}
+ {{ t('A three-day trial is available once per account.') }}
+ {{ selectedPrice }} /
+ {{ plan === 'monthly' ? t('Month') : t('Year') }}
+ {{ t('Your plan') }}
+
+ {{ status.paid_access ? t('Paid access active') : t('Free plan') }}
+
+ {{ t('Monthly model usage') }}
+