Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ui-layer.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"require": {
"whilesmart/entitlements-cashier": "^0.1.1"
"predis/predis": "^3.0",
"whilesmart/entitlements-cashier": "^0.2.1"
}
}
55 changes: 27 additions & 28 deletions config/cloudplans.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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'),
],
],

Expand All @@ -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',
Expand All @@ -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'),
Expand All @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
6 changes: 6 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
45 changes: 45 additions & 0 deletions src/CloudIntegration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Trakli\Cloud;

use App\Contracts\IntegrationUi;

class CloudIntegration implements IntegrationUi
{
public function key(): string
{
return 'billing';
}
public function name(): string
{
return 'Premium';
}
public function description(): ?string
{
return 'Manage your plan, usage and billing.';
}
public function category(): string
{
return 'billing';
}
public function icon(): ?string
{
return 'credit-card';
}
public function featureKey(): ?string
{
return null;
}
public function isConfigured(): bool
{
return filled(config('cashier.secret'));
}
public function ui(): array
{
return [
'slots' => ['settings.integrations', 'sidebar.nav'],
'card' => ['title' => 'Premium', 'cta' => 'Manage plan', 'href' => '/billing'],
'show_when_unconfigured' => true,
];
}
}
8 changes: 7 additions & 1 deletion src/CloudServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,23 @@ class CloudServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->mergeConfigFrom(__DIR__.'/../config/cloudplans.php', 'cloudplans');
}

/**
* Bootstrap any application services.
*/
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.
Expand Down
57 changes: 54 additions & 3 deletions src/Http/Controllers/CloudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading