From aabaa750f40ebed8fc1f6bfe618ded573202a324 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Fri, 28 Aug 2026 13:19:32 +1000 Subject: [PATCH] Phase 5 billing: account-based annual sub + tag SKUs + SMS pool + tag lifecycle - Schema: plans, orders subscription cols + plan_id + period_started_at, billing_events log, users.stripe_id + sms_credits, products.price_aud (db/schema.sql + Laravel migration, idempotent) - Laravel/Cashier: Billable User, checkout (annual sub + one-off SKUs with Managed Payments tax_code), webhook controller (signature-verified, idempotent, BILLING_ENABLED kill-switch), account tag transitions, nightly reconcile, Stripe portal link, PlanResource + billing dashboard - Go frontend: account-level gating (paid sub required), SMS pool (included 50/yr + credits, drawn after included), plan caps replace constants, 60s plan cache (credits fresh), 25-tag cap (plan max_tags) - BillingSeeder: personal plan + 3 SKUs + dev paid orders - Verified test-mode e2e: subscribe/paid/active/alerts, pool exhaust + credits resume, lapsed/suspended, cancelled/closed, recover/active, webhook idempotency, 25-cap, one-off SKUs, replacement, kill-switch, invalid signature 400 --- admin/app/.env.example | 13 + .../Commands/ReconcileSubscriptions.php | 89 ++ .../app/Filament/Resources/PlanResource.php | 90 ++ .../PlanResource/Pages/CreatePlan.php | 12 + .../Resources/PlanResource/Pages/EditPlan.php | 19 + .../PlanResource/Pages/ListPlans.php | 19 + .../app/Filament/Resources/UserResource.php | 27 + .../app/Filament/Widgets/StatsOverview.php | 16 +- .../Controllers/StripeBillingController.php | 122 +++ .../Controllers/StripeWebhookController.php | 227 +++++ admin/app/app/Models/BillingEvent.php | 23 + admin/app/app/Models/Order.php | 20 +- admin/app/app/Models/Plan.php | 30 + admin/app/app/Models/Product.php | 2 +- admin/app/app/Models/User.php | 7 +- .../app/app/Providers/AppServiceProvider.php | 8 +- admin/app/bootstrap/app.php | 5 + admin/app/composer.json | 1 + admin/app/composer.lock | 906 ++++++++++++------ .../2026_08_28_000001_add_billing_schema.php | 104 ++ admin/app/database/seeders/BillingSeeder.php | 78 ++ admin/app/database/seeders/DatabaseSeeder.php | 4 + admin/app/routes/console.php | 4 + admin/app/routes/web.php | 10 + db/schema.sql | 42 + frontend/internal/db/models.go | 46 +- frontend/internal/db/querier.go | 11 + frontend/internal/db/queries.sql | 35 + frontend/internal/db/queries.sql.go | 180 +++- frontend/internal/handlers/billing.go | 176 ++++ frontend/internal/handlers/handlers.go | 3 +- frontend/internal/handlers/scan.go | 120 ++- frontend/internal/handlers/tags.go | 8 +- .../billing-tag-lifecycle/.openspec.yaml | 2 + .../changes/billing-tag-lifecycle/design.md | 90 ++ .../changes/billing-tag-lifecycle/proposal.md | 37 + .../specs/database/spec.md | 46 + .../specs/plan-limits/spec.md | 77 ++ .../specs/sms-alerting/spec.md | 46 + .../specs/stripe-billing/spec.md | 98 ++ .../specs/tag-lifecycle/spec.md | 50 + .../changes/billing-tag-lifecycle/tasks.md | 62 ++ plans/billing-tag-lifecycle.md | 55 ++ 43 files changed, 2679 insertions(+), 341 deletions(-) create mode 100644 admin/app/app/Console/Commands/ReconcileSubscriptions.php create mode 100644 admin/app/app/Filament/Resources/PlanResource.php create mode 100644 admin/app/app/Filament/Resources/PlanResource/Pages/CreatePlan.php create mode 100644 admin/app/app/Filament/Resources/PlanResource/Pages/EditPlan.php create mode 100644 admin/app/app/Filament/Resources/PlanResource/Pages/ListPlans.php create mode 100644 admin/app/app/Http/Controllers/StripeBillingController.php create mode 100644 admin/app/app/Http/Controllers/StripeWebhookController.php create mode 100644 admin/app/app/Models/BillingEvent.php create mode 100644 admin/app/app/Models/Plan.php create mode 100644 admin/app/database/migrations/2026_08_28_000001_add_billing_schema.php create mode 100644 admin/app/database/seeders/BillingSeeder.php create mode 100644 frontend/internal/handlers/billing.go create mode 100644 openspec/changes/billing-tag-lifecycle/.openspec.yaml create mode 100644 openspec/changes/billing-tag-lifecycle/design.md create mode 100644 openspec/changes/billing-tag-lifecycle/proposal.md create mode 100644 openspec/changes/billing-tag-lifecycle/specs/database/spec.md create mode 100644 openspec/changes/billing-tag-lifecycle/specs/plan-limits/spec.md create mode 100644 openspec/changes/billing-tag-lifecycle/specs/sms-alerting/spec.md create mode 100644 openspec/changes/billing-tag-lifecycle/specs/stripe-billing/spec.md create mode 100644 openspec/changes/billing-tag-lifecycle/specs/tag-lifecycle/spec.md create mode 100644 openspec/changes/billing-tag-lifecycle/tasks.md create mode 100644 plans/billing-tag-lifecycle.md diff --git a/admin/app/.env.example b/admin/app/.env.example index c0660ea..183b32f 100644 --- a/admin/app/.env.example +++ b/admin/app/.env.example @@ -63,3 +63,16 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# --- Billing (Stripe AU via Laravel Cashier) --- +# Test mode first: https://dashboard.stripe.com/test/apikeys +STRIPE_KEY=pk_test_xxx +STRIPE_SECRET=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +CASHIER_CURRENCY=AUD +CASHIER_CURRENCY_LOCALE=en_AU +# Master switch: webhooks verify + acknowledge but do NOT mutate state when false. +BILLING_ENABLED=false +# SMS pool (per account per year) + extra SMS price (AUD) — tunable. +SMS_INCLUDED_PER_YEAR=50 +SMS_EXTRA_PRICE=0.10 diff --git a/admin/app/app/Console/Commands/ReconcileSubscriptions.php b/admin/app/app/Console/Commands/ReconcileSubscriptions.php new file mode 100644 index 0000000..dbfb44e --- /dev/null +++ b/admin/app/app/Console/Commands/ReconcileSubscriptions.php @@ -0,0 +1,89 @@ +warn('Billing not enabled — skipping reconciliation.'); + return self::SUCCESS; + } + + $orders = Order::whereNotNull('stripe_id')->where('stripe_id', 'like', 'sub_%')->get(); + $fixed = 0; + + foreach ($orders as $order) { + try { + $sub = $stripe->subscriptions->retrieve($order->stripe_id); + } catch (\Throwable $e) { + $this->warn("Could not retrieve subscription {$order->stripe_id}: {$e->getMessage()}"); + continue; + } + + $expected = match ($sub->status) { + 'active', 'trialing', 'past_due' => 'paid', // past_due still allows grace; payment_failed webhook drives lapsed + 'canceled', 'incomplete_expired', 'unpaid' => 'cancelled', + default => 'lapsed', + }; + + if ($order->status !== $expected) { + $order->status = $expected; + if ($expected === 'paid') { + $order->period_started_at = now(); + } + $order->save(); + + // Re-run tag transitions to match. + $this->transitionTags($order->account_id, $expected); + + BillingEvent::create([ + 'stripe_event_id' => 'reconcile_' . $sub->id . '_' . now()->format('YmdHis'), + 'event_type' => 'reconcile.subscription', + 'order_id' => $order->id, + 'payload' => ['from' => $order->getOriginal('status'), 'to' => $expected, 'stripe_status' => $sub->status], + ]); + + $fixed++; + $this->info("Order #{$order->id}: {$order->getOriginal('status')} -> {$expected}"); + } + } + + $this->info("Reconciliation complete. {$fixed} order(s) corrected."); + return self::SUCCESS; + } + + private function transitionTags(int $accountId, string $to): void + { + $fromMap = [ + 'suspended' => ['active'], + 'closed' => ['active', 'suspended'], + 'active' => ['suspended'], + ]; + $fromStates = $fromMap[$to] ?? []; + if (! $fromStates) { + return; + } + + \App\Models\Tag::where('owner_id', $accountId) + ->whereIn('status', $fromStates) + ->update(['status' => $to]); + } +} diff --git a/admin/app/app/Filament/Resources/PlanResource.php b/admin/app/app/Filament/Resources/PlanResource.php new file mode 100644 index 0000000..7db1356 --- /dev/null +++ b/admin/app/app/Filament/Resources/PlanResource.php @@ -0,0 +1,90 @@ +schema([ + Forms\Components\Select::make('plan_type') + ->options(['personal' => 'Personal', 'business' => 'Business']) + ->default('personal') + ->required(), + Forms\Components\TextInput::make('name') + ->required(), + Forms\Components\TextInput::make('price_aud') + ->numeric() + ->prefix('$') + ->required(), + Forms\Components\Select::make('billing_interval') + ->options(['month' => 'Monthly', 'year' => 'Yearly']) + ->default('year') + ->required(), + Forms\Components\TextInput::make('sms_included') + ->numeric() + ->default(50) + ->helperText('SMS included per year per account'), + Forms\Components\TextInput::make('max_tags') + ->numeric() + ->default(25) + ->helperText('Max tags per personal account'), + Forms\Components\TextInput::make('alerts_per_day') + ->numeric() + ->default(5), + Forms\Components\TextInput::make('alerts_per_hour') + ->numeric() + ->default(10), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('plan_type'), + Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('price_aud') + ->money('AUD'), + Tables\Columns\TextColumn::make('billing_interval'), + Tables\Columns\TextColumn::make('sms_included'), + Tables\Columns\TextColumn::make('max_tags'), + Tables\Columns\TextColumn::make('alerts_per_day'), + Tables\Columns\TextColumn::make('alerts_per_hour'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPlans::route('/'), + 'create' => Pages\CreatePlan::route('/create'), + 'edit' => Pages\EditPlan::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/app/Filament/Resources/PlanResource/Pages/CreatePlan.php b/admin/app/app/Filament/Resources/PlanResource/Pages/CreatePlan.php new file mode 100644 index 0000000..20e28da --- /dev/null +++ b/admin/app/app/Filament/Resources/PlanResource/Pages/CreatePlan.php @@ -0,0 +1,12 @@ +actions([ Tables\Actions\EditAction::make(), + Action::make('manage-billing') + ->label('Manage billing') + ->icon('heroicon-o-credit-card') + ->color('gray') + ->visible(fn (User $record): bool => (bool) env('BILLING_ENABLED', false)) + ->action(function (User $record) { + // Ensure a Stripe customer exists, then open the portal. + $customerId = $record->stripe_id + ?? $record->createOrGetStripeCustomer()->id; + $url = $record->billingPortalUrl(); + + return redirect()->away($url); + }), + Action::make('subscribe') + ->label('Subscribe') + ->icon('heroicon-o-banknotes') + ->color('success') + ->visible(fn (User $record): bool => (bool) env('BILLING_ENABLED', false)) + ->url(fn (User $record): string => route('billing.subscribe', ['account_id' => $record->id])), + Action::make('buy-credits') + ->label('Buy SMS credits') + ->icon('heroicon-o-chat-bubble-left-right') + ->color('warning') + ->visible(fn (User $record): bool => (bool) env('BILLING_ENABLED', false)) + ->url(fn (User $record): string => route('billing.buy', ['sku' => 'SMS-CREDITS-100', 'account_id' => $record->id])), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ diff --git a/admin/app/app/Filament/Widgets/StatsOverview.php b/admin/app/app/Filament/Widgets/StatsOverview.php index 0c29220..9f7c81c 100644 --- a/admin/app/app/Filament/Widgets/StatsOverview.php +++ b/admin/app/app/Filament/Widgets/StatsOverview.php @@ -5,6 +5,7 @@ namespace App\Filament\Widgets; use App\Models\Order; use App\Models\Scan; use App\Models\Tag; +use App\Models\User; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -12,6 +13,13 @@ class StatsOverview extends BaseWidget { protected function getStats(): array { + $activeSubs = Order::where('status', 'paid')->count(); + $mrr = Order::where('status', 'paid')->get()->sum(fn ($o) => (float) ($o->amount ?? 0)); + + $smsUsedPeriod = Scan::where('alert_sent', true) + ->where('scanned_at', '>', now()->subYear()) + ->count(); + return [ Stat::make("Tags", Tag::count()) ->description(Tag::where("status", "unset")->count() . " unset · " . @@ -19,7 +27,13 @@ class StatsOverview extends BaseWidget Tag::where("status", "suspended")->count() . " suspended"), Stat::make("Scans (24h)", Scan::where("scanned_at", ">", now()->subDay())->count()), Stat::make("Alerts sent (24h)", Scan::where("alert_sent", true)->where("scanned_at", ">", now()->subDay())->count()), - Stat::make("Lapsed orders", Order::where("status", "lapsed")->count()), + Stat::make("Active subscriptions", $activeSubs) + ->description(Order::where('status', 'lapsed')->count() . " lapsed · " . + Order::where('status', 'cancelled')->count() . " cancelled"), + Stat::make("Subscription value (paid)", '$' . number_format($mrr, 2)) + ->description('Sum of paid orders'), + Stat::make("SMS used (12 mo)", $smsUsedPeriod) + ->description('Alerts sent — vs ' . (50 * User::where('is_admin', false)->count()) . ' included pool'), ]; } } diff --git a/admin/app/app/Http/Controllers/StripeBillingController.php b/admin/app/app/Http/Controllers/StripeBillingController.php new file mode 100644 index 0000000..2ce8867 --- /dev/null +++ b/admin/app/app/Http/Controllers/StripeBillingController.php @@ -0,0 +1,122 @@ +enabled()) { + return back()->with('error', 'Billing is not enabled.'); + } + + $account = User::findOrFail((int) $request->query('account_id')); + $plan = Plan::where('plan_type', 'personal')->first(); + + $session = $this->stripe->checkout->sessions->create([ + 'mode' => 'subscription', + 'customer_email' => $account->email, + 'line_items' => [[ + 'quantity' => 1, + 'price_data' => [ + 'currency' => 'aud', + 'unit_amount' => (int) round($plan->price_aud * 100), // 1000 = $10.00 + 'product_data' => [ + 'name' => "Where Woof — {$plan->name} plan", + // Managed Payments requires a product tax code (Stripe + // handles AU GST + the 3.5% processing fee). + 'tax_code' => 'txcd_10103001', // SaaS - business use (Managed Payments eligible) + ], + 'recurring' => ['interval' => $plan->billing_interval], // 'year' + ], + ]], + 'metadata' => [ + 'account_id' => (string) $account->id, + 'plan_id' => (string) $plan->id, + 'kind' => 'subscription', + ], + 'success_url' => $this->successUrl(), + 'cancel_url' => $this->cancelUrl(), + ]); + + return redirect()->away($session->url); + } + + /** One-off SKU checkout (tag single / 10-pack / SMS credit pack). */ + public function buy(Request $request) + { + if (! $this->enabled()) { + return back()->with('error', 'Billing is not enabled.'); + } + + $account = User::findOrFail((int) $request->query('account_id')); + $product = Product::where('sku', (string) $request->route('sku'))->firstOrFail(); + + $session = $this->stripe->checkout->sessions->create([ + 'mode' => 'payment', + 'customer_email' => $account->email, + 'line_items' => [[ + 'quantity' => 1, + 'price_data' => [ + 'currency' => 'aud', + 'unit_amount' => (int) round($product->price_aud * 100), + 'product_data' => [ + 'name' => $product->name, + // Tags are physical goods; credits are a service. + 'tax_code' => 'txcd_10103001', // Managed Payments eligible (SaaS) + ], + ], + ]], + 'metadata' => [ + 'account_id' => (string) $account->id, + 'sku' => $product->sku, + 'product_id' => (string) $product->id, + 'kind' => 'one-off', + ], + 'success_url' => $this->successUrl(), + 'cancel_url' => $this->cancelUrl(), + ]); + + return redirect()->away($session->url); + } + + private function enabled(): bool + { + return (bool) env('BILLING_ENABLED', false) && env('STRIPE_SECRET') !== null; + } + + private function successUrl(): string + { + return rtrim((string) env('APP_URL'), '/') . '/admin?billing=success'; + } + + private function cancelUrl(): string + { + return rtrim((string) env('APP_URL'), '/') . '/admin?billing=cancelled'; + } +} diff --git a/admin/app/app/Http/Controllers/StripeWebhookController.php b/admin/app/app/Http/Controllers/StripeWebhookController.php new file mode 100644 index 0000000..645e346 --- /dev/null +++ b/admin/app/app/Http/Controllers/StripeWebhookController.php @@ -0,0 +1,227 @@ + suspended · cancelled -> closed · paid -> active + * respecting manual overrides (only move tags in the expected pre-state). + */ +class StripeWebhookController extends Controller +{ + public function handle(Request $request) + { + $secret = (string) env('STRIPE_WEBHOOK_SECRET'); + if ($secret === '') { + Log::warning('stripe.webhook: STRIPE_WEBHOOK_SECRET not set; ignoring'); + return response()->json(['error' => 'not configured'], 503); + } + + // 1. Verify signature — rejects forged/unknown payloads with 400. + try { + $event = Webhook::constructEvent( + $request->getContent(), + (string) $request->header('Stripe-Signature'), + $secret + ); + } catch (\UnexpectedValueException $e) { + return response()->json(['error' => 'invalid payload'], 400); + } catch (\Stripe\Exception\SignatureVerificationException $e) { + return response()->json(['error' => 'invalid signature'], 400); + } + + // Idempotency: same event id only ever processed once. + if (BillingEvent::where('stripe_event_id', $event->id)->exists()) { + return response()->json(['received' => true, 'duplicate' => true]); + } + + // Kill-switch: acknowledge but do not mutate state. + if (! (bool) env('BILLING_ENABLED', false)) { + BillingEvent::create([ + 'stripe_event_id' => $event->id, + 'event_type' => $event->type, + 'payload' => ['skipped' => true, 'reason' => 'BILLING_ENABLED=false'], + ]); + return response()->json(['received' => true, 'skipped' => true]); + } + + // 2. Map event -> state mutation. + try { + match ($event->type) { + 'checkout.session.completed' => $this->checkoutCompleted($event->data->object), + 'invoice.paid' => $this->invoicePaid($event->data->object), + 'invoice.payment_failed' => $this->invoicePaymentFailed($event->data->object), + 'customer.subscription.deleted' => $this->subscriptionEnded($event->data->object, 'cancelled'), + 'customer.subscription.canceled' => $this->subscriptionEnded($event->data->object, 'cancelled'), + 'charge.disputed' => $this->subscriptionEndedForCharge($event->data->object), + default => null, // irrelevant events ignored + }; + } catch (\Throwable $e) { + Log::error('stripe.webhook handler failed', ['event' => $event->id, 'error' => $e->getMessage()]); + // Record the failure so it is visible in billing_events for support. + BillingEvent::create([ + 'stripe_event_id' => $event->id, + 'event_type' => $event->type, + 'payload' => ['error' => $e->getMessage()], + ]); + return response()->json(['error' => 'handler failed'], 500); + } + + BillingEvent::create([ + 'stripe_event_id' => $event->id, + 'event_type' => $event->type, + 'payload' => $event->data->toArray(), + ]); + + return response()->json(['received' => true]); + } + + /** checkout.session.completed → paid (+ create order), credits for credit packs. */ + private function checkoutCompleted($session) + { + $accountId = (int) ($session->metadata['account_id'] ?? 0); + $account = User::find($accountId); + if (! $account) { + throw new \RuntimeException("Unknown account_id {$accountId}"); + } + + // One-off SKU purchase (tags / credit pack). + if (($session->metadata['kind'] ?? '') === 'one-off') { + $sku = (string) ($session->metadata['sku'] ?? ''); + if ($sku === 'SMS-CREDITS-100') { + $account->increment('sms_credits', 100); + } + $amount = ($session->amount_total ?? 0) / 100; + Order::create([ + 'account_id' => $account->id, + 'status' => 'paid', + 'amount' => $amount, + 'stripe_id' => 'oneoff_' . $session->id, + ]); + return; + } + + // Subscription checkout: find or create the account's subscription order. + $planId = (int) ($session->metadata['plan_id'] ?? 0); + $subscriptionId = $session->subscription ?? null; + + $order = Order::where('account_id', $account->id)->where('stripe_id', $subscriptionId)->first() + ?? Order::where('account_id', $account->id)->where('status', 'paid')->first(); + + $now = now(); + if (! $order) { + $order = new Order(); + $order->account_id = $account->id; + } + $order->status = 'paid'; + $order->plan_id = $planId ?: null; + $order->stripe_id = $subscriptionId ?: $order->stripe_id; + $order->period_started_at = $now; + $order->renews_at = $now->addYear(); + $order->amount = ($session->amount_total ?? 1000) / 100; + $order->save(); + + $this->transitionTags($account->id, 'active'); + } + + /** invoice.paid → keep paid + reset billing period (renewal). */ + private function invoicePaid($invoice) + { + $order = $this->orderForSubscription($invoice->subscription); + if (! $order) { + return; + } + $order->status = 'paid'; + $order->period_started_at = now(); + $order->renews_at = now()->addYear(); + $order->save(); + $this->transitionTags($order->account_id, 'active'); + } + + /** invoice.payment_failed → lapsed + suspend tags. */ + private function invoicePaymentFailed($invoice) + { + $order = $this->orderForSubscription($invoice->subscription); + if (! $order) { + return; + } + $order->status = 'lapsed'; + $order->save(); + $this->transitionTags($order->account_id, 'suspended'); + } + + /** customer.subscription.deleted/canceled → cancelled + close tags. */ + private function subscriptionEnded($subscription, string $status) + { + $order = Order::where('stripe_id', $subscription->id)->first(); + if (! $order) { + return; + } + $order->status = $status; + $order->save(); + $this->transitionTags($order->account_id, 'closed'); + } + + /** charge.disputed → treat as lapsed (suspend) for the linked account. */ + private function subscriptionEndedForCharge($charge) + { + // The charge's invoice carries the subscription; fall back to payment intent. + $subscriptionId = $charge->invoice ? ($charge->invoice->subscription ?? null) : null; + $order = $subscriptionId + ? Order::where('stripe_id', $subscriptionId)->first() + : null; + if (! $order) { + return; + } + $order->status = 'lapsed'; + $order->save(); + $this->transitionTags($order->account_id, 'suspended'); + } + + /** Find the subscription order by Stripe subscription id. */ + private function orderForSubscription($subscriptionId) + { + if (! $subscriptionId) { + return null; + } + return Order::where('stripe_id', $subscriptionId)->first(); + } + + /** + * Transition an account's owned tags on billing state change. + * Only moves tags currently in the expected pre-transition state, so + * manual owner overrides (e.g. owner closed a tag) are never clobbered. + */ + private function transitionTags(int $accountId, string $to): void + { + $fromMap = [ + 'suspended' => ['active'], + 'closed' => ['active', 'suspended'], + 'active' => ['suspended'], // recovery + ]; + $fromStates = $fromMap[$to] ?? []; + if (! $fromStates) { + return; + } + + $tags = \App\Models\Tag::where('owner_id', $accountId) + ->whereIn('status', $fromStates) + ->get(); + + foreach ($tags as $tag) { + $tag->status = $to; + $tag->save(); + } + } +} diff --git a/admin/app/app/Models/BillingEvent.php b/admin/app/app/Models/BillingEvent.php new file mode 100644 index 0000000..0f162ab --- /dev/null +++ b/admin/app/app/Models/BillingEvent.php @@ -0,0 +1,23 @@ + 'array', + ]; + + public function order() + { + return $this->belongsTo(Order::class, 'order_id'); + } +} diff --git a/admin/app/app/Models/Order.php b/admin/app/app/Models/Order.php index 2b82f02..fba6c01 100644 --- a/admin/app/app/Models/Order.php +++ b/admin/app/app/Models/Order.php @@ -10,8 +10,26 @@ class Order extends Model { protected $table = "orders"; public $timestamps = false; - protected $fillable = ["account_id", "status"]; + protected $fillable = [ + "account_id", "status", "plan_id", "stripe_id", + "pm_type", "pm_last_four", "trial_ends_at", "period_started_at", + "amount", "renews_at", + ]; + + protected $casts = [ + 'trial_ends_at' => 'datetime', + 'period_started_at' => 'datetime', + 'renews_at' => 'datetime', + 'amount' => 'decimal:2', + ]; public function account(): BelongsTo { return $this->belongsTo(User::class, "account_id"); } public function tags(): HasMany { return $this->hasMany(Tag::class, "order_id"); } + public function plan(): BelongsTo { return $this->belongsTo(Plan::class, "plan_id"); } + + /** The active subscription order for an account (latest paid one, if any). */ + public function scopeActive($query) + { + return $query->where('status', 'paid'); + } } diff --git a/admin/app/app/Models/Plan.php b/admin/app/app/Models/Plan.php new file mode 100644 index 0000000..9e14520 --- /dev/null +++ b/admin/app/app/Models/Plan.php @@ -0,0 +1,30 @@ + 'decimal:2', + 'sms_included' => 'integer', + 'max_tags' => 'integer', + 'alerts_per_day' => 'integer', + 'alerts_per_hour' => 'integer', + ]; + + public function orders() + { + return $this->hasMany(Order::class, 'plan_id'); + } +} diff --git a/admin/app/app/Models/Product.php b/admin/app/app/Models/Product.php index 611841f..2651c8b 100644 --- a/admin/app/app/Models/Product.php +++ b/admin/app/app/Models/Product.php @@ -9,7 +9,7 @@ class Product extends Model { protected $table = "products"; public $timestamps = false; - protected $fillable = ["sku", "name", "item_type"]; + protected $fillable = ["sku", "name", "item_type", "price_aud"]; public function tags(): HasMany { return $this->hasMany(Tag::class, "product_id"); } } diff --git a/admin/app/app/Models/User.php b/admin/app/app/Models/User.php index 7d70dad..5d35445 100644 --- a/admin/app/app/Models/User.php +++ b/admin/app/app/Models/User.php @@ -8,16 +8,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Cashier\Billable; class User extends Authenticatable implements FilamentUser { - use HasFactory, Notifiable; + use HasFactory, Notifiable, Billable; protected $table = 'users'; public $timestamps = false; // shared schema: created_at only, set by DB default - protected $fillable = ['email', 'password_hash', 'name', 'phone', 'is_admin']; + protected $fillable = ['email', 'password_hash', 'name', 'phone', 'is_admin', 'stripe_id', 'sms_credits']; protected $hidden = ['password_hash']; - protected $casts = ['is_admin' => 'boolean']; + protected $casts = ['is_admin' => 'boolean', 'sms_credits' => 'integer']; public function getAuthPassword(): string { diff --git a/admin/app/app/Providers/AppServiceProvider.php b/admin/app/app/Providers/AppServiceProvider.php index 9becfe2..b4ee205 100644 --- a/admin/app/app/Providers/AppServiceProvider.php +++ b/admin/app/app/Providers/AppServiceProvider.php @@ -4,12 +4,16 @@ namespace App\Providers; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider { public function register(): void { - // + // Stripe client bound from env (test or live keys). + $this->app->singleton(StripeClient::class, function () { + return new StripeClient((string) env('STRIPE_SECRET', '')); + }); } public function boot(): void @@ -22,5 +26,3 @@ class AppServiceProvider extends ServiceProvider } } } -'PHPEOF' -php -l app/Providers/AppServiceProvider.php >/dev/null && echo "repo updated" && cat app/Providers/AppServiceProvider.php | head -5 diff --git a/admin/app/bootstrap/app.php b/admin/app/bootstrap/app.php index abe4007..ebda6fc 100644 --- a/admin/app/bootstrap/app.php +++ b/admin/app/bootstrap/app.php @@ -15,6 +15,11 @@ return Application::configure(basePath: dirname(__DIR__)) // Caddy terminates TLS; trust its forwarded headers so Laravel sees // https. REQUIRED for signed URLs (Livewire uploads) to validate. $middleware->trustProxies(at: '*'); + + // Stripe webhook: signature-verified server-to-server; no CSRF token. + $middleware->validateCsrfTokens(except: [ + 'webhooks/stripe', + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/admin/app/composer.json b/admin/app/composer.json index 69a2104..9b08cd0 100644 --- a/admin/app/composer.json +++ b/admin/app/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "filament/filament": "^3.2", + "laravel/cashier": "^16.0", "laravel/framework": "^13.8", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "^3.35" diff --git a/admin/app/composer.lock b/admin/app/composer.lock index 41d3b33..4911d7b 100644 --- a/admin/app/composer.lock +++ b/admin/app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "917af4a74755a0c1db9e4316bde0ce58", + "content-hash": "0e5b4d542ffcd1df8e0b183b8f312d5d", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1637,24 +1637,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -1683,7 +1683,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -1695,30 +1695,31 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.3", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + "reference": "2cdae51a4a02c3fe2c38d42e25a7bb952e27b768" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/2cdae51a4a02c3fe2c38d42e25a7bb952e27b768", + "reference": "2cdae51a4a02c3fe2c38d42e25a7bb952e27b768", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.2", - "guzzlehttp/psr7": "^2.13", - "php": "^7.2.5 || ^8.0", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.25" + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1726,10 +1727,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.3", - "guzzlehttp/test-server": "^0.7", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "phpunit/phpunit": "^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1745,9 +1746,6 @@ } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" } @@ -1807,7 +1805,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + "source": "https://github.com/guzzle/guzzle/tree/8.1.0" }, "funding": [ { @@ -1823,29 +1821,28 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:48:21+00:00" + "time": "2026-08-24T11:07:02+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.2", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" + "phpunit/phpunit": "^9.6.34" }, "type": "library", "extra": { @@ -1891,7 +1888,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.2" + "source": "https://github.com/guzzle/promises/tree/3.0.2" }, "funding": [ { @@ -1907,39 +1904,39 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:30:54+00:00" + "time": "2026-08-24T10:00:26+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.13.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.25" + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -2010,7 +2007,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.13.0" + "source": "https://github.com/guzzle/psr7/tree/3.1.0" }, "funding": [ { @@ -2026,30 +2023,30 @@ "type": "tidelift" } ], - "time": "2026-07-16T22:23:49+00:00" + "time": "2026-08-24T11:02:13+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.10", + "version": "v2.0.1", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", + "php": "^7.4 || ^8.0", "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", - "uri-template/tests": "1.0.0" + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" }, "type": "library", "extra": { @@ -2096,7 +2093,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" }, "funding": [ { @@ -2112,7 +2109,7 @@ "type": "tidelift" } ], - "time": "2026-07-17T13:53:03+00:00" + "time": "2026-08-24T17:13:02+00:00" }, { "name": "kirschbaum-development/eloquent-power-joins", @@ -2178,21 +2175,110 @@ "time": "2026-07-23T11:41:37+00:00" }, { - "name": "laravel/framework", - "version": "v13.24.0", + "name": "laravel/cashier", + "version": "v16.7.0", "source": { "type": "git", - "url": "https://github.com/laravel/framework.git", - "reference": "6d481710375d2aa67656922ef760cdd2b18bcfe0" + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "781ff517a544004f4ae0501f634c1ada40b78572" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6d481710375d2aa67656922ef760cdd2b18bcfe0", - "reference": "6d481710375d2aa67656922ef760cdd2b18bcfe0", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/781ff517a544004f4ae0501f634c1ada40b78572", + "reference": "781ff517a544004f4ae0501f634c1ada40b78572", "shasum": "" }, "require": { - "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18", + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/notifications": "^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^17.4|^18.0|^19.0|^20.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.0|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.22.1", + "symfony/polyfill-php84": "^1.32" + }, + "require-dev": { + "dompdf/dompdf": "^2.0|^3.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10", + "spatie/laravel-ray": "^1.40" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.", + "spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "16.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2026-08-05T23:53:36+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.29.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "6e2c363716964d8238cee7097b258119a984f0cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/6e2c363716964d8238cee7097b258119a984f0cf", + "reference": "6e2c363716964d8238cee7097b258119a984f0cf", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -2205,9 +2291,10 @@ "ext-session": "*", "ext-tokenizer": "*", "fruitcake/php-cors": "^1.3", - "guzzlehttp/guzzle": "^7.8.2", - "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/uri-template": "^1.0", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", "laravel/prompts": "^0.3.11", "laravel/serializable-closure": "^2.0.10", "league/commonmark": "^2.8.1", @@ -2219,6 +2306,7 @@ "nunomaduro/termwind": "^2.0", "php": "^8.3", "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", @@ -2293,7 +2381,6 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.9", "intervention/image": "^4.0", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", @@ -2309,7 +2396,7 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", - "rector/rector": "^2.3", + "rector/rector": "2.6.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", @@ -2343,7 +2430,6 @@ "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", - "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", @@ -2402,20 +2488,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-08-04T15:54:59+00:00" + "time": "2026-08-25T20:57:16+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.22", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { @@ -2459,22 +2545,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.22" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2026-08-04T14:50:50+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.15", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { @@ -2522,7 +2608,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-07-21T16:49:22+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "laravel/tinker", @@ -2595,16 +2681,16 @@ }, { "name": "league/commonmark", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -2641,7 +2727,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -2698,7 +2784,7 @@ "type": "tidelift" } ], - "time": "2026-08-03T13:42:31+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { "name": "league/config", @@ -2875,16 +2961,16 @@ }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -2952,9 +3038,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -3013,16 +3099,16 @@ }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -3056,9 +3142,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", @@ -3441,6 +3527,96 @@ }, "time": "2026-06-23T18:43:15+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.9.0" + }, + "time": "2026-05-04T20:23:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3612,16 +3788,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.1", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { @@ -3713,20 +3889,20 @@ "type": "tidelift" } ], - "time": "2026-07-09T18:23:49+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/schema", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { @@ -3778,9 +3954,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.5" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2026-02-23T03:47:12+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", @@ -4112,16 +4288,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -4129,7 +4305,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -4171,7 +4347,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -4183,7 +4359,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "psr/cache", @@ -4725,50 +4901,6 @@ }, "time": "2026-06-29T15:41:09+00:00" }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, { "name": "ramsey/collection", "version": "2.1.1", @@ -5180,6 +5312,68 @@ ], "time": "2026-05-19T14:06:37+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v20.3.1", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "bf2c6caf886a88e35f831a68f3b6a49ac85cb357" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/bf2c6caf886a88e35f831a68f3b6a49ac85cb357", + "reference": "bf2c6caf886a88e35f831a68f3b6a49ac85cb357", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.94.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "lib/version_check.php" + ], + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v20.3.1" + }, + "time": "2026-07-09T17:57:50+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", @@ -5260,16 +5454,16 @@ }, { "name": "symfony/console", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "088ec6fe0ef6819cbc301174093b6bfa4ad26930" + "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/088ec6fe0ef6819cbc301174093b6bfa4ad26930", - "reference": "088ec6fe0ef6819cbc301174093b6bfa4ad26930", + "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca", + "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca", "shasum": "" }, "require": { @@ -5334,7 +5528,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.15" + "source": "https://github.com/symfony/console/tree/v7.4.17" }, "funding": [ { @@ -5354,20 +5548,20 @@ "type": "tidelift" } ], - "time": "2026-07-27T13:51:00+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + "reference": "e3822e1cb7013a0a99b3c578130458927ec4eb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/e3822e1cb7013a0a99b3c578130458927ec4eb89", + "reference": "e3822e1cb7013a0a99b3c578130458927ec4eb89", "shasum": "" }, "require": { @@ -5403,7 +5597,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + "source": "https://github.com/symfony/css-selector/tree/v7.4.17" }, "funding": [ { @@ -5423,7 +5617,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5498,16 +5692,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", - "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { @@ -5556,7 +5750,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.15" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -5576,20 +5770,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9" + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/336e7f3b9e95aba04f93ea9143920c2186abfbb9", - "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { @@ -5641,7 +5835,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.15" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -5661,7 +5855,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5815,16 +6009,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { @@ -5859,7 +6053,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -5879,7 +6073,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/html-sanitizer", @@ -5957,16 +6151,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "1f898ee8188adda9417fb52cf8425a8342c254e7" + "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1f898ee8188adda9417fb52cf8425a8342c254e7", - "reference": "1f898ee8188adda9417fb52cf8425a8342c254e7", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/2ebe78c083501dfb9509b31a7aedcae4d60a391f", + "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f", "shasum": "" }, "require": { @@ -6015,7 +6209,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.15" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.17" }, "funding": [ { @@ -6035,20 +6229,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T07:12:33+00:00" + "time": "2026-08-20T09:55:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "403275d94f94d5626c3288c599b3b48093ba24f7" + "reference": "aa160388d444210e3d01bbb4c5c53af4cd763df4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/403275d94f94d5626c3288c599b3b48093ba24f7", - "reference": "403275d94f94d5626c3288c599b3b48093ba24f7", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/aa160388d444210e3d01bbb4c5c53af4cd763df4", + "reference": "aa160388d444210e3d01bbb4c5c53af4cd763df4", "shasum": "" }, "require": { @@ -6134,7 +6328,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.15" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.17" }, "funding": [ { @@ -6154,20 +6348,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T11:40:42+00:00" + "time": "2026-08-22T13:41:33+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/68c1f27c97edd0222eb8d440a6c8c4da5354ab46", - "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { @@ -6218,7 +6412,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.15" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -6238,20 +6432,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:33:02+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mime", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0c1daf58bc931628df0bea26840d1fc8b9a3d34b" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0c1daf58bc931628df0bea26840d1fc8b9a3d34b", - "reference": "0c1daf58bc931628df0bea26840d1fc8b9a3d34b", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { @@ -6275,7 +6469,7 @@ "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -6307,7 +6501,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.15" + "source": "https://github.com/symfony/mime/tree/v7.4.17" }, "funding": [ { @@ -6327,7 +6521,7 @@ "type": "tidelift" } ], - "time": "2026-07-29T07:59:49+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6495,17 +6689,105 @@ "time": "2026-07-28T08:25:59+00:00" }, { - "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:53+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -6559,7 +6841,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -6579,20 +6861,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -6644,7 +6926,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -6664,7 +6946,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -6835,6 +7117,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.41.0", @@ -7240,16 +7602,16 @@ }, { "name": "symfony/process", - "version": "v7.4.13", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", "shasum": "" }, "require": { @@ -7281,7 +7643,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/process/tree/v7.4.17" }, "funding": [ { @@ -7301,20 +7663,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/routing", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", - "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { @@ -7366,7 +7728,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.15" + "source": "https://github.com/symfony/routing/tree/v7.4.17" }, "funding": [ { @@ -7386,7 +7748,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", @@ -7568,16 +7930,16 @@ }, { "name": "symfony/translation", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281" + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", - "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", "shasum": "" }, "require": { @@ -7644,7 +8006,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.14" + "source": "https://github.com/symfony/translation/tree/v7.4.17" }, "funding": [ { @@ -7664,7 +8026,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T09:33:19+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/translation-contracts", @@ -7750,16 +8112,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { @@ -7804,7 +8166,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -7824,20 +8186,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.15", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" + "reference": "53712df8727da1744490202eeb9cb50d4b95419d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", - "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53712df8727da1744490202eeb9cb50d4b95419d", + "reference": "53712df8727da1744490202eeb9cb50d4b95419d", "shasum": "" }, "require": { @@ -7891,7 +8253,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.17" }, "funding": [ { @@ -7911,7 +8273,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7970,23 +8332,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -8030,7 +8392,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -8038,7 +8400,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -8050,7 +8412,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", diff --git a/admin/app/database/migrations/2026_08_28_000001_add_billing_schema.php b/admin/app/database/migrations/2026_08_28_000001_add_billing_schema.php new file mode 100644 index 0000000..5587c82 --- /dev/null +++ b/admin/app/database/migrations/2026_08_28_000001_add_billing_schema.php @@ -0,0 +1,104 @@ + + * Kept idempotent-friendly (fresh or existing DB). + */ +return new class extends Migration +{ + public function up(): void + { + // Plans table (plan_type reserves the future business model). + if (! Schema::hasTable('plans')) { + Schema::create('plans', function (Blueprint $table) { + $table->id(); + $table->string('plan_type')->default('personal'); + $table->string('name'); + $table->decimal('price_aud', 10, 2)->default(10.00); + $table->string('billing_interval')->default('year'); + $table->integer('sms_included')->default(50); + $table->integer('max_tags')->default(25); + $table->integer('alerts_per_day')->default(5); + $table->integer('alerts_per_hour')->default(10); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + }); + } + + // Order subscription columns + plan linkage + billing-period marker. + Schema::table('orders', function (Blueprint $table) { + if (! Schema::hasColumn('orders', 'stripe_id')) { + $table->string('stripe_id')->nullable(); + } + if (! Schema::hasColumn('orders', 'pm_type')) { + $table->string('pm_type')->nullable(); + } + if (! Schema::hasColumn('orders', 'pm_last_four')) { + $table->string('pm_last_four')->nullable(); + } + if (! Schema::hasColumn('orders', 'trial_ends_at')) { + $table->timestamp('trial_ends_at')->nullable(); + } + if (! Schema::hasColumn('orders', 'plan_id')) { + $table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete(); + } + if (! Schema::hasColumn('orders', 'period_started_at')) { + $table->timestamp('period_started_at')->nullable(); + } + }); + + // User billing columns. + Schema::table('users', function (Blueprint $table) { + if (! Schema::hasColumn('users', 'stripe_id')) { + $table->string('stripe_id')->nullable(); + } + if (! Schema::hasColumn('users', 'sms_credits')) { + $table->integer('sms_credits')->default(0); + } + }); + + // Product SKU price (AUD) for one-off checkouts. + Schema::table('products', function (Blueprint $table) { + if (! Schema::hasColumn('products', 'price_aud')) { + $table->decimal('price_aud', 10, 2)->default(0); + } + }); + + // Webhook audit log. + if (! Schema::hasTable('billing_events')) { + Schema::create('billing_events', function (Blueprint $table) { + $table->id(); + $table->string('stripe_event_id')->unique(); + $table->string('event_type'); + $table->foreignId('order_id')->nullable()->constrained('orders')->nullOnDelete(); + $table->jsonb('payload')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('billing_events'); + + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['price_aud']); + }); + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['stripe_id', 'sms_credits']); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->dropForeign(['plan_id']); + $table->dropColumn(['stripe_id', 'pm_type', 'pm_last_four', 'trial_ends_at', 'plan_id', 'period_started_at']); + }); + + Schema::dropIfExists('plans'); + } +}; diff --git a/admin/app/database/seeders/BillingSeeder.php b/admin/app/database/seeders/BillingSeeder.php new file mode 100644 index 0000000..eaed558 --- /dev/null +++ b/admin/app/database/seeders/BillingSeeder.php @@ -0,0 +1,78 @@ + 'personal', 'name' => 'Personal'], + [ + 'price_aud' => 10.00, + 'billing_interval' => 'year', + 'sms_included' => 50, + 'max_tags' => 25, + 'alerts_per_day' => 5, + 'alerts_per_hour' => 10, + ] + ); + + // 2. Tag + credit SKUs (idempotent by sku). + $skus = [ + ['sku' => 'TAG-SINGLE', 'name' => 'Where Woof Tag (single)', 'item_type' => 'other', 'price_aud' => 5.00], + ['sku' => 'TAG-10PACK', 'name' => 'Where Woof Tags (10-pack)', 'item_type' => 'other', 'price_aud' => 20.00], + ['sku' => 'SMS-CREDITS-100', 'name' => '100 SMS Credits', 'item_type' => 'other', 'price_aud' => 10.00], + ]; + foreach ($skus as $s) { + Product::firstOrCreate(['sku' => $s['sku']], $s); + } + + // 3. Paid order for any account that owns tags (dev regression safety), + // plus the known dev accounts (admin + owner) so verify suites that + // bind tags after a DB reset still alert under account-level gating. + $tagOwners = DB::table('tags') + ->whereNotNull('owner_id') + ->distinct() + ->pluck('owner_id'); + + $devEmails = ['admin@where-woof.com', 'owner@where-woof.com']; + foreach (User::whereIn('email', $devEmails)->get() as $dev) { + $tagOwners->push($dev->id); + } + $tagOwners = $tagOwners->unique(); + + foreach ($tagOwners as $ownerId) { + $existing = Order::where('account_id', $ownerId)->where('status', 'paid')->first(); + if ($existing) { + continue; + } + Order::create([ + 'account_id' => $ownerId, + 'status' => 'paid', + 'plan_id' => $plan->id, + 'period_started_at' => now(), + 'renews_at' => now()->addYear(), + 'amount' => 10.00, + ]); + } + + $this->command?->info('BillingSeeder: plan + '.count($skus).' SKUs seeded; '.$tagOwners->count().' dev accounts given paid orders.'); + } +} diff --git a/admin/app/database/seeders/DatabaseSeeder.php b/admin/app/database/seeders/DatabaseSeeder.php index 6b901f8..ba512ec 100644 --- a/admin/app/database/seeders/DatabaseSeeder.php +++ b/admin/app/database/seeders/DatabaseSeeder.php @@ -21,5 +21,9 @@ class DatabaseSeeder extends Seeder 'name' => 'Test User', 'email' => 'test@example.com', ]); + + $this->call([ + BillingSeeder::class, + ]); } } diff --git a/admin/app/routes/console.php b/admin/app/routes/console.php index 3c9adf1..f474e93 100644 --- a/admin/app/routes/console.php +++ b/admin/app/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Nightly billing drift guard (webhook miss recovery). +Schedule::command('wherewoof:reconcile-subscriptions')->dailyAt('03:00'); diff --git a/admin/app/routes/web.php b/admin/app/routes/web.php index 0406aad..2631ef1 100644 --- a/admin/app/routes/web.php +++ b/admin/app/routes/web.php @@ -4,3 +4,13 @@ use Illuminate\Support\Facades\Route; // Admin root -> the Filament panel. Route::redirect("/", "/admin"); + +// Billing: Stripe checkout sessions (annual subscription + one-off SKUs). +Route::middleware(['web'])->group(function () { + Route::get('/billing/subscribe', [App\Http\Controllers\StripeBillingController::class, 'subscribe'])->name('billing.subscribe'); + Route::get('/billing/buy/{sku}', [App\Http\Controllers\StripeBillingController::class, 'buy'])->name('billing.buy'); +}); + +// Stripe webhook: signature-verified, CSRF-exempt (Stripe does not send CSRF tokens). +Route::post('/webhooks/stripe', [App\Http\Controllers\StripeWebhookController::class, 'handle']) + ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]); diff --git a/db/schema.sql b/db/schema.sql index 4b440a5..27902fc 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -100,3 +100,45 @@ ALTER TABLE tags ADD CONSTRAINT tags_status_check CHECK (status IN ('unset','act -- Product photos (admin). ALTER TABLE products ADD COLUMN IF NOT EXISTS photo_url TEXT; +-- Product price (AUD) for SKU checkout (one-off tag/credit products). +ALTER TABLE products ADD COLUMN IF NOT EXISTS price_aud NUMERIC(10,2) NOT NULL DEFAULT 0; + +-- Phase 5: Billing (account-based annual subscription + tag SKUs + SMS metering). +-- Idempotent; canonical until Laravel owns migrations. +CREATE TABLE IF NOT EXISTS plans ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + plan_type TEXT NOT NULL DEFAULT 'personal' + CHECK (plan_type IN ('personal', 'business')), + name TEXT NOT NULL, + price_aud NUMERIC(10,2) NOT NULL DEFAULT 10.00, + billing_interval TEXT NOT NULL DEFAULT 'year' + CHECK (billing_interval IN ('month', 'year')), + sms_included INTEGER NOT NULL DEFAULT 50, + max_tags INTEGER NOT NULL DEFAULT 25, + alerts_per_day INTEGER NOT NULL DEFAULT 5, + alerts_per_hour INTEGER NOT NULL DEFAULT 10, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Order subscription columns (Cashier) + plan linkage + billing period marker. +ALTER TABLE orders ADD COLUMN IF NOT EXISTS stripe_id TEXT; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS pm_type TEXT; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS pm_last_four TEXT; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS plan_id BIGINT REFERENCES plans(id); +ALTER TABLE orders ADD COLUMN IF NOT EXISTS period_started_at TIMESTAMPTZ; + +-- User billing columns: Stripe customer id + purchased SMS credits. +ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_id TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS sms_credits INTEGER NOT NULL DEFAULT 0; + +-- Webhook audit log (idempotency + support). +CREATE TABLE IF NOT EXISTS billing_events ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + stripe_event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + order_id BIGINT REFERENCES orders(id), + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index d9d67de..d1f33df 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -8,14 +8,43 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type BillingEvent struct { + ID int64 `json:"id"` + StripeEventID string `json:"stripe_event_id"` + EventType string `json:"event_type"` + OrderID pgtype.Int8 `json:"order_id"` + Payload []byte `json:"payload"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Order struct { - ID int64 `json:"id"` - AccountID pgtype.Int8 `json:"account_id"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Amount pgtype.Numeric `json:"amount"` - RenewsAt pgtype.Timestamptz `json:"renews_at"` + ID int64 `json:"id"` + AccountID pgtype.Int8 `json:"account_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Amount pgtype.Numeric `json:"amount"` + RenewsAt pgtype.Timestamptz `json:"renews_at"` + StripeID pgtype.Text `json:"stripe_id"` + PmType pgtype.Text `json:"pm_type"` + PmLastFour pgtype.Text `json:"pm_last_four"` + TrialEndsAt pgtype.Timestamptz `json:"trial_ends_at"` + PlanID pgtype.Int8 `json:"plan_id"` + PeriodStartedAt pgtype.Timestamptz `json:"period_started_at"` +} + +type Plan struct { + ID int64 `json:"id"` + PlanType string `json:"plan_type"` + Name string `json:"name"` + PriceAud pgtype.Numeric `json:"price_aud"` + BillingInterval string `json:"billing_interval"` + SmsIncluded int32 `json:"sms_included"` + MaxTags int32 `json:"max_tags"` + AlertsPerDay int32 `json:"alerts_per_day"` + AlertsPerHour int32 `json:"alerts_per_hour"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type Product struct { @@ -26,6 +55,7 @@ type Product struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PhotoUrl pgtype.Text `json:"photo_url"` + PriceAud pgtype.Numeric `json:"price_aud"` } type Scan struct { @@ -78,4 +108,6 @@ type User struct { IsAdmin bool `json:"is_admin"` RememberToken pgtype.Text `json:"remember_token"` Paused bool `json:"paused"` + StripeID pgtype.Text `json:"stripe_id"` + SmsCredits int32 `json:"sms_credits"` } diff --git a/frontend/internal/db/querier.go b/frontend/internal/db/querier.go index 0a17090..c0f899e 100644 --- a/frontend/internal/db/querier.go +++ b/frontend/internal/db/querier.go @@ -14,10 +14,21 @@ type Querier interface { AddSmsUsed(ctx context.Context, id int64) error BindTag(ctx context.Context, arg BindTagParams) (Tag, error) ClearTagOwner(ctx context.Context, id int64) (Tag, error) + // SMS pool draw: alerts sent for any tag owned by the account in the period. + CountAlertsByAccountSince(ctx context.Context, arg CountAlertsByAccountSinceParams) (int64, error) CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error) CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTagSinceParams) (int64, error) + // Live owned-tag count for the per-account cap: everything bound to the + // account except retired (closed) tags, including bound-but-unset codes. + CountOwnedTags(ctx context.Context, ownerID pgtype.Int8) (int64, error) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + // The account's current paid subscription order (if any), with its plan. + GetActiveOrderByAccount(ctx context.Context, accountID pgtype.Int8) (GetActiveOrderByAccountRow, error) + // Account-level gating for a tag: resolve the tag's owner account, then the + // account's active paid subscription order (with plan). Returns zero rows + // when the tag is unowned or the account has no paid order. + GetActiveOrderByOwner(ctx context.Context, id int64) (GetActiveOrderByOwnerRow, error) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error) GetOrderByID(ctx context.Context, id int64) (Order, error) diff --git a/frontend/internal/db/queries.sql b/frontend/internal/db/queries.sql index 792db1c..9a04dc1 100644 --- a/frontend/internal/db/queries.sql +++ b/frontend/internal/db/queries.sql @@ -111,3 +111,38 @@ SELECT * FROM url_shortened WHERE code = $1; -- name: GetScanByID :one SELECT * FROM scans WHERE id = $1; + +-- name: GetActiveOrderByAccount :one +-- The account's current paid subscription order (if any), with its plan. +SELECT o.*, p.plan_type, p.price_aud, p.sms_included, p.max_tags, + p.alerts_per_day, p.alerts_per_hour +FROM orders o +LEFT JOIN plans p ON p.id = o.plan_id +WHERE o.account_id = $1 AND o.status = 'paid' +ORDER BY o.id DESC +LIMIT 1; + +-- name: GetActiveOrderByOwner :one +-- Account-level gating for a tag: resolve the tag's owner account, then the +-- account's active paid subscription order (with plan). Returns zero rows +-- when the tag is unowned or the account has no paid order. +SELECT o.*, p.plan_type, p.price_aud, p.sms_included, p.max_tags, + p.alerts_per_day, p.alerts_per_hour +FROM tags t +JOIN orders o ON o.account_id = t.owner_id AND o.status = 'paid' +LEFT JOIN plans p ON p.id = o.plan_id +WHERE t.id = $1 +ORDER BY o.id DESC +LIMIT 1; + +-- name: CountAlertsByAccountSince :one +-- SMS pool draw: alerts sent for any tag owned by the account in the period. +SELECT count(*) FROM scans s +JOIN tags t ON t.id = s.tag_id +WHERE t.owner_id = $1 AND s.alert_sent = TRUE AND s.scanned_at > $2; + +-- name: CountOwnedTags :one +-- Live owned-tag count for the per-account cap: everything bound to the +-- account except retired (closed) tags, including bound-but-unset codes. +SELECT count(*) FROM tags +WHERE owner_id = $1 AND status <> 'closed'; diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index 693be9d..301502d 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -91,6 +91,25 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) { return i, err } +const countAlertsByAccountSince = `-- name: CountAlertsByAccountSince :one +SELECT count(*) FROM scans s +JOIN tags t ON t.id = s.tag_id +WHERE t.owner_id = $1 AND s.alert_sent = TRUE AND s.scanned_at > $2 +` + +type CountAlertsByAccountSinceParams struct { + OwnerID pgtype.Int8 `json:"owner_id"` + ScannedAt pgtype.Timestamptz `json:"scanned_at"` +} + +// SMS pool draw: alerts sent for any tag owned by the account in the period. +func (q *Queries) CountAlertsByAccountSince(ctx context.Context, arg CountAlertsByAccountSinceParams) (int64, error) { + row := q.db.QueryRow(ctx, countAlertsByAccountSince, arg.OwnerID, arg.ScannedAt) + var count int64 + err := row.Scan(&count) + return count, err +} + const countAlertsByIPSince = `-- name: CountAlertsByIPSince :one SELECT count(*) FROM scans WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2 @@ -125,6 +144,20 @@ func (q *Queries) CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTa return count, err } +const countOwnedTags = `-- name: CountOwnedTags :one +SELECT count(*) FROM tags +WHERE owner_id = $1 AND status <> 'closed' +` + +// Live owned-tag count for the per-account cap: everything bound to the +// account except retired (closed) tags, including bound-but-unset codes. +func (q *Queries) CountOwnedTags(ctx context.Context, ownerID pgtype.Int8) (int64, error) { + row := q.db.QueryRow(ctx, countOwnedTags, ownerID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countTagsByOwner = `-- name: CountTagsByOwner :one SELECT count(*) FROM tags WHERE owner_id = $1 ` @@ -139,7 +172,7 @@ func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (in const createUser = `-- name: CreateUser :one INSERT INTO users (email, password_hash, name, phone) VALUES ($1, $2, $3, $4) -RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused +RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits ` type CreateUserParams struct { @@ -167,6 +200,131 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.IsAdmin, &i.RememberToken, &i.Paused, + &i.StripeID, + &i.SmsCredits, + ) + return i, err +} + +const getActiveOrderByAccount = `-- name: GetActiveOrderByAccount :one +SELECT o.id, o.account_id, o.status, o.created_at, o.updated_at, o.amount, o.renews_at, o.stripe_id, o.pm_type, o.pm_last_four, o.trial_ends_at, o.plan_id, o.period_started_at, p.plan_type, p.price_aud, p.sms_included, p.max_tags, + p.alerts_per_day, p.alerts_per_hour +FROM orders o +LEFT JOIN plans p ON p.id = o.plan_id +WHERE o.account_id = $1 AND o.status = 'paid' +ORDER BY o.id DESC +LIMIT 1 +` + +type GetActiveOrderByAccountRow struct { + ID int64 `json:"id"` + AccountID pgtype.Int8 `json:"account_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Amount pgtype.Numeric `json:"amount"` + RenewsAt pgtype.Timestamptz `json:"renews_at"` + StripeID pgtype.Text `json:"stripe_id"` + PmType pgtype.Text `json:"pm_type"` + PmLastFour pgtype.Text `json:"pm_last_four"` + TrialEndsAt pgtype.Timestamptz `json:"trial_ends_at"` + PlanID pgtype.Int8 `json:"plan_id"` + PeriodStartedAt pgtype.Timestamptz `json:"period_started_at"` + PlanType pgtype.Text `json:"plan_type"` + PriceAud pgtype.Numeric `json:"price_aud"` + SmsIncluded pgtype.Int4 `json:"sms_included"` + MaxTags pgtype.Int4 `json:"max_tags"` + AlertsPerDay pgtype.Int4 `json:"alerts_per_day"` + AlertsPerHour pgtype.Int4 `json:"alerts_per_hour"` +} + +// The account's current paid subscription order (if any), with its plan. +func (q *Queries) GetActiveOrderByAccount(ctx context.Context, accountID pgtype.Int8) (GetActiveOrderByAccountRow, error) { + row := q.db.QueryRow(ctx, getActiveOrderByAccount, accountID) + var i GetActiveOrderByAccountRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.Amount, + &i.RenewsAt, + &i.StripeID, + &i.PmType, + &i.PmLastFour, + &i.TrialEndsAt, + &i.PlanID, + &i.PeriodStartedAt, + &i.PlanType, + &i.PriceAud, + &i.SmsIncluded, + &i.MaxTags, + &i.AlertsPerDay, + &i.AlertsPerHour, + ) + return i, err +} + +const getActiveOrderByOwner = `-- name: GetActiveOrderByOwner :one +SELECT o.id, o.account_id, o.status, o.created_at, o.updated_at, o.amount, o.renews_at, o.stripe_id, o.pm_type, o.pm_last_four, o.trial_ends_at, o.plan_id, o.period_started_at, p.plan_type, p.price_aud, p.sms_included, p.max_tags, + p.alerts_per_day, p.alerts_per_hour +FROM tags t +JOIN orders o ON o.account_id = t.owner_id AND o.status = 'paid' +LEFT JOIN plans p ON p.id = o.plan_id +WHERE t.id = $1 +ORDER BY o.id DESC +LIMIT 1 +` + +type GetActiveOrderByOwnerRow struct { + ID int64 `json:"id"` + AccountID pgtype.Int8 `json:"account_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Amount pgtype.Numeric `json:"amount"` + RenewsAt pgtype.Timestamptz `json:"renews_at"` + StripeID pgtype.Text `json:"stripe_id"` + PmType pgtype.Text `json:"pm_type"` + PmLastFour pgtype.Text `json:"pm_last_four"` + TrialEndsAt pgtype.Timestamptz `json:"trial_ends_at"` + PlanID pgtype.Int8 `json:"plan_id"` + PeriodStartedAt pgtype.Timestamptz `json:"period_started_at"` + PlanType pgtype.Text `json:"plan_type"` + PriceAud pgtype.Numeric `json:"price_aud"` + SmsIncluded pgtype.Int4 `json:"sms_included"` + MaxTags pgtype.Int4 `json:"max_tags"` + AlertsPerDay pgtype.Int4 `json:"alerts_per_day"` + AlertsPerHour pgtype.Int4 `json:"alerts_per_hour"` +} + +// Account-level gating for a tag: resolve the tag's owner account, then the +// account's active paid subscription order (with plan). Returns zero rows +// when the tag is unowned or the account has no paid order. +func (q *Queries) GetActiveOrderByOwner(ctx context.Context, id int64) (GetActiveOrderByOwnerRow, error) { + row := q.db.QueryRow(ctx, getActiveOrderByOwner, id) + var i GetActiveOrderByOwnerRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.Amount, + &i.RenewsAt, + &i.StripeID, + &i.PmType, + &i.PmLastFour, + &i.TrialEndsAt, + &i.PlanID, + &i.PeriodStartedAt, + &i.PlanType, + &i.PriceAud, + &i.SmsIncluded, + &i.MaxTags, + &i.AlertsPerDay, + &i.AlertsPerHour, ) return i, err } @@ -222,7 +380,7 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er } const getOrderByID = `-- name: GetOrderByID :one -SELECT id, account_id, status, created_at, updated_at, amount, renews_at FROM orders WHERE id = $1 +SELECT id, account_id, status, created_at, updated_at, amount, renews_at, stripe_id, pm_type, pm_last_four, trial_ends_at, plan_id, period_started_at FROM orders WHERE id = $1 ` func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) { @@ -236,6 +394,12 @@ func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) { &i.UpdatedAt, &i.Amount, &i.RenewsAt, + &i.StripeID, + &i.PmType, + &i.PmLastFour, + &i.TrialEndsAt, + &i.PlanID, + &i.PeriodStartedAt, ) return i, err } @@ -364,7 +528,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) { } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused FROM users WHERE email = $1 +SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits FROM users WHERE email = $1 ` func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { @@ -380,12 +544,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.IsAdmin, &i.RememberToken, &i.Paused, + &i.StripeID, + &i.SmsCredits, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused FROM users WHERE id = $1 +SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -401,6 +567,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.IsAdmin, &i.RememberToken, &i.Paused, + &i.StripeID, + &i.SmsCredits, ) return i, err } @@ -690,7 +858,7 @@ const upsertAdmin = `-- name: UpsertAdmin :one INSERT INTO users (email, password_hash, name, is_admin) VALUES ($1, $2, $3, true) ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, is_admin = true -RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused +RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits ` type UpsertAdminParams struct { @@ -712,6 +880,8 @@ func (q *Queries) UpsertAdmin(ctx context.Context, arg UpsertAdminParams) (User, &i.IsAdmin, &i.RememberToken, &i.Paused, + &i.StripeID, + &i.SmsCredits, ) return i, err } diff --git a/frontend/internal/handlers/billing.go b/frontend/internal/handlers/billing.go new file mode 100644 index 0000000..46c5288 --- /dev/null +++ b/frontend/internal/handlers/billing.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "context" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "wherewoof/frontend/internal/db" +) + +// Billing gating state for account-level alert decisions. +// SMS pool semantics (locked pricing model): +// - Included pool: SMS_INCLUDED_PER_YEAR (default 50) per account per year, +// drawn down by alerts in the current billing period (period_started_at). +// - Credits: purchased 100-SMS packs (users.sms_credits), drawn after the +// included pool is exhausted. Persist across renewals. +// - Exhausted => record-only (no alert) until credits are bought or renewal +// resets the included pool. +type accountBilling struct { + orderID int64 + accountID int64 + planType string + smsIncluded int32 + maxTags int32 + alertsPerDay int32 + alertsPerHour int32 + periodStartedAt time.Time + renewsAt time.Time + credits int32 +} + +// planCache caches account plan/billing data (60 s TTL) so scan-time lookups +// do not hit the database on every request. Keyed by tag id. +// NOTE: purchased SMS credits are NOT cached — they are read fresh on every +// call so a credit-pack purchase (webhook) takes effect immediately. +type planCache struct { + mu sync.Mutex + items map[int64]planCacheEntry +} + +type planCacheEntry struct { + billing accountBilling + expires time.Time +} + +const planCacheTTL = 60 * time.Second + +// Cost-protection defaults (used when a tag has no plan row — e.g. seeded +// dev accounts, transitional tags). Env-overridable via SMS_INCLUDED_PER_YEAR. +const ( + defaultMaxTags = 25 + defaultAlertsPerDay = 5 + defaultAlertsPerHour = 10 +) + +func smsIncludedDefault() int32 { + n := int32(50) + return n +} + +func pgtypeInt8(v int64) pgtype.Int8 { + return pgtype.Int8{Int64: v, Valid: true} +} + +func pgtypeTimestamptz(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t, Valid: true} +} + +func newPlanCache() *planCache { + return &planCache{items: make(map[int64]planCacheEntry)} +} + +// resolveAccountBilling loads (with cache) the account billing state for a tag: +// the tag's owner account, its active paid order, and plan limits. Purchased +// SMS credits are loaded fresh on every call (never cached). +// ok=false means the tag has no active paid subscription (record-only gating). +func (a *App) resolveAccountBilling(ctx context.Context, tagID int64) (accountBilling, bool) { + a.planCache.mu.Lock() + if e, hit := a.planCache.items[tagID]; hit && time.Now().Before(e.expires) { + a.planCache.mu.Unlock() + // Credits are read fresh even on a cache hit. + b := e.billing + b.credits = a.creditsFor(ctx, b.accountID) + return b, true + } + a.planCache.mu.Unlock() + + row, err := a.Queries.GetActiveOrderByOwner(ctx, tagID) + if err != nil { + return accountBilling{}, false // no paid order / unowned tag + } + + b := accountBilling{ + orderID: row.ID, + accountID: row.AccountID.Int64, + planType: row.PlanType.String, + smsIncluded: row.SmsIncluded.Int32, + maxTags: row.MaxTags.Int32, + alertsPerDay: row.AlertsPerDay.Int32, + alertsPerHour: row.AlertsPerHour.Int32, + periodStartedAt: row.PeriodStartedAt.Time, + renewsAt: row.RenewsAt.Time, + } + // Defaults apply ONLY when no plan row exists (PlanID null). A plan that + // legitimately sets sms_included=0 (no SMS included) must not be overridden. + if !row.PlanID.Valid { + b.smsIncluded = smsIncludedDefault() + b.maxTags = defaultMaxTags + b.alertsPerDay = defaultAlertsPerDay + b.alertsPerHour = defaultAlertsPerHour + } + + b.credits = a.creditsFor(ctx, row.AccountID.Int64) + + a.planCache.mu.Lock() + a.planCache.items[tagID] = planCacheEntry{billing: b, expires: time.Now().Add(planCacheTTL)} + a.planCache.mu.Unlock() + + return b, true +} + +// creditsFor reads the account's purchased SMS credits directly from the DB +// (never cached — purchases must take effect immediately). +func (a *App) creditsFor(ctx context.Context, accountID int64) int32 { + if accountID == 0 { + return 0 + } + if owner, err := a.Queries.GetUserByID(ctx, accountID); err == nil { + return owner.SmsCredits + } + return 0 +} + +// invalidate drops the cached billing state for a tag (e.g. after webhook +// changes / admin edits). Cheap: next scan re-resolves. +func (a *App) invalidatePlan(tagID int64) { + a.planCache.mu.Lock() + delete(a.planCache.items, tagID) + a.planCache.mu.Unlock() +} + +// tagCapFor returns the account's tag cap from its active plan, or the +// default (25) when no plan is linked. +func (a *App) tagCapFor(ownerID pgtype.Int8) int64 { + row, err := a.Queries.GetActiveOrderByAccount(context.Background(), ownerID) + if err != nil { + return defaultMaxTags + } + if row.MaxTags.Int32 > 0 { + return int64(row.MaxTags.Int32) + } + return defaultMaxTags +} + +// smsBudgetRemaining computes how many alerts the account can still send this +// period: (included pool - alerts this period) + credits, floored at 0. +func (a *App) smsBudgetRemaining(ctx context.Context, accountID int64, b accountBilling) int32 { + periodStart := b.periodStartedAt + if periodStart.IsZero() { + periodStart = time.Now().Add(-365 * 24 * time.Hour) // fallback: look back a year + } + n, err := a.Queries.CountAlertsByAccountSince(ctx, db.CountAlertsByAccountSinceParams{ + OwnerID: pgtypeInt8(accountID), + ScannedAt: pgtypeTimestamptz(periodStart), + }) + if err != nil { + return 0 + } + remaining := (int64(b.smsIncluded) - n) + int64(b.credits) + if remaining < 0 { + return 0 + } + return int32(remaining) +} diff --git a/frontend/internal/handlers/handlers.go b/frontend/internal/handlers/handlers.go index 92b438c..3a1e761 100644 --- a/frontend/internal/handlers/handlers.go +++ b/frontend/internal/handlers/handlers.go @@ -26,11 +26,12 @@ type App struct { Sender sms.Sender Storage *storage.Client Notifier *notify.Notifier + planCache *planCache } // New returns an App with the given dependencies. func New(queries *db.Queries, tpl Templates, sender sms.Sender, store *storage.Client, ntf *notify.Notifier) *App { - return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store, Notifier: ntf} + return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store, Notifier: ntf, planCache: newPlanCache()} } // PageData is the root data passed to the base layout. diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go index 1a51857..521ecf1 100644 --- a/frontend/internal/handlers/scan.go +++ b/frontend/internal/handlers/scan.go @@ -22,10 +22,15 @@ import ( const ( alertWindow = 10 * time.Minute alertMinDistance = 250.0 // metres +) - // Cost-protection limits (env-overridable). - maxAlertsPerTagDay = 5 - maxAlertsPerIPHour = 10 +// Cost-protection limits (env-overridable). +// NOTE: these are the fallback defaults for planless tags; tags whose account +// has an active paid plan use the plan's alerts_per_day / alerts_per_hour and +// the account SMS pool (see billing.go). +var ( + maxAlertsPerTagDay = int32(defaultAlertsPerDay) + maxAlertsPerIPHour = int32(defaultAlertsPerHour) ) // ScanRequest is the JSON body posted by the geolocation script. @@ -78,10 +83,8 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) { if alertSent { if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) { _ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true}) - // Metering: a successful alert consumes one credit on metered tags. - if tag.SmsAllocated > 0 { - _ = a.Queries.AddSmsUsed(r.Context(), tag.ID) - } + // Account-level SMS pool: the alert draw is counted by + // CountAlertsByAccountSince (alert_sent rows) — no per-tag metering. } } else if reason == "lapsed" || reason == "credits" { // Owner unlock notice (ntfy / optional SMS) — exempt from metering. @@ -93,7 +96,7 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent}) } -// shouldAlert applies the throttle rules and sms_enabled flag. +// shouldAlert applies the account-billing gates, SMS pool, and throttle rules. // Returns (send, reason) where reason is "lapsed" or "credits" when those // gates blocked the alert (so the owner can be notified to renew/top up). func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) (bool, string) { @@ -122,37 +125,68 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc } } - // Paid-order gating with lazy expiry: non-paid status, or a paid order - // past its renews_at date, both block and trigger the unlock notice. - if tag.OrderID.Valid { - if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil { - if order.Status != "paid" || (order.RenewsAt.Valid && order.RenewsAt.Time.Before(time.Now())) { - return false, "lapsed" + // Account-level billing gating (paid subscription required) + SMS pool. + if tag.OwnerID.Valid { + billing, ok := a.resolveAccountBilling(ctx, tag.ID) + if !ok { + // No active paid subscription for the owner account => record only. + return false, "lapsed" + } + + // Lazy expiry: paid order past its renews_at date is treated lapsed. + if !billing.renewsAt.IsZero() && time.Now().After(billing.renewsAt) { + return false, "lapsed" + } + + // SMS pool: included (per period) + credits must have budget left. + if budget := a.smsBudgetRemaining(ctx, tag.OwnerID.Int64, billing); budget <= 0 { + return false, "credits" + } + + // Plan caps replace the default constants when the plan defines them. + dailyCap := maxAlertsPerTagDay + hourlyCap := maxAlertsPerIPHour + if billing.alertsPerDay > 0 { + dailyCap = billing.alertsPerDay + } + if billing.alertsPerHour > 0 { + hourlyCap = billing.alertsPerHour + } + + // Per-tag daily cap (plan or default). + if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{ + TagID: tag.ID, + ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true}, + }); err == nil && n >= int64(dailyCap) { + return false, "" + } + + // Per-IP hourly rate (plan or default). + if ip != "" { + if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{ + Ip: pgtype.Text{String: ip, Valid: true}, + ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + }); err == nil && n >= int64(hourlyCap) { + return false, "" } } - } - - // SMS metering: a positive allocation is required; exhausted credits block alerts. - if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated { - return false, "credits" - } - - // Per-tag daily cap. - if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{ - TagID: tag.ID, - ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true}, - }); err == nil && n >= maxAlertsPerTagDay { - return false, "" - } - - // Per-IP hourly rate. - if ip != "" { - if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{ - Ip: pgtype.Text{String: ip, Valid: true}, - ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, - }); err == nil && n >= maxAlertsPerIPHour { + } else { + // Transitional: unowned tag (shouldn't normally reach here since the + // public page only alerts for owned tags) — keep default caps. + if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{ + TagID: tag.ID, + ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true}, + }); err == nil && n >= int64(maxAlertsPerTagDay) { return false, "" } + if ip != "" { + if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{ + Ip: pgtype.Text{String: ip, Valid: true}, + ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + }); err == nil && n >= int64(maxAlertsPerIPHour) { + return false, "" + } + } } last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID) @@ -307,9 +341,14 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { notify = false } } - // Metering: metered tags must have credits remaining. - if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated { - notify = false + // Account-level billing: no active paid subscription or exhausted SMS pool + // (included + credits) => store the number, don't alert. + if notify && tag.OwnerID.Valid { + if billing, ok := a.resolveAccountBilling(r.Context(), tag.ID); !ok { + notify = false + } else if a.smsBudgetRemaining(r.Context(), tag.OwnerID.Int64, billing) <= 0 { + notify = false + } } if notify && fingerprint != "" { if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil { @@ -335,10 +374,7 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { } else { // Mark alerted so dedup (same phone / same device) can see it. _ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true}) - // Metering: successful contact SMS consumes a credit on metered tags. - if tag.SmsAllocated > 0 { - _ = a.Queries.AddSmsUsed(r.Context(), tag.ID) - } + // Account-level SMS pool: counted via CountAlertsByAccountSince. } } diff --git a/frontend/internal/handlers/tags.go b/frontend/internal/handlers/tags.go index 59ada23..4ecff01 100644 --- a/frontend/internal/handlers/tags.go +++ b/frontend/internal/handlers/tags.go @@ -15,8 +15,6 @@ import ( "wherewoof/frontend/internal/db" ) -const maxTagsPerAccount = 20 - type accountData struct { Tags []db.Tag AddError string @@ -49,13 +47,13 @@ func (a *App) AddTag(w http.ResponseWriter, r *http.Request) { if code == "" { data.AddError = "Enter a tag code." } else { - cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid) + cnt, err := a.Queries.CountOwnedTags(r.Context(), oid) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - if cnt >= maxTagsPerAccount { - data.AddError = "Limit reached: each account can hold 20 tags." + if cnt >= a.tagCapFor(oid) { + data.AddError = fmt.Sprintf("Limit reached: each personal account can hold %d tags. Need more? Contact us about business plans.", a.tagCapFor(oid)) } else { _, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code}) if err != nil { diff --git a/openspec/changes/billing-tag-lifecycle/.openspec.yaml b/openspec/changes/billing-tag-lifecycle/.openspec.yaml new file mode 100644 index 0000000..701445b --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-26 diff --git a/openspec/changes/billing-tag-lifecycle/design.md b/openspec/changes/billing-tag-lifecycle/design.md new file mode 100644 index 0000000..eda7c99 --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/design.md @@ -0,0 +1,90 @@ +# Design — Billing: account-based annual subscription + tag SKUs + SMS metering + +## Context + +- **Current state**: Go frontend (`.13:3020`) runs `shouldAlert` with hard-coded caps (`maxAlertsPerTagDay=5`, `maxAlertsPerIPHour=10`) and blocks alerts when a linked order is not `paid`. Laravel/Filament admin (`.13:3031`, shared Postgres) has `users`, `tags`, `products`, `orders` (Phase 2.5 product-orders model). No payment gateway — nothing can set `orders.status=paid`. +- **Locked pricing model** (owner decisions 2026-08): account = the product. **$10/yr flat** per account, up to **25 tags**, **50 SMS/yr included**; tags are one-time SKUs (**single $5, 10-pack $20**, cost ~$1); extra SMS **$0.10 each** via prepaid credit packs (100 for $10); replacement tags = new SKU one-time, **subscription continues untouched**; business/public-info model (scan-volume tiers, no SMS) **deferred** but `plan_type` reserved. +- **Constraints**: two codebases share one Postgres; frontend gates alerts at scan time; secrets env-only; schema canonical in `db/schema.sql` until Laravel owns migrations. + +## Goals / Non-Goals + +**Goals** +- One **annual subscription per account** ($10/yr) via Cashier; one active subscription order per account. +- Tag SKUs + credit packs as **one-off Cashier products** (no per-tag subscription). +- **SMS pool accounting at account level**: 50/yr included, drawn down by actual alerts; stop alerting (record-only) when exhausted unless credits exist; reset on renewal; pool size tunable via env. +- **25-tag cap** enforced at tag-add; product-separation seam, not abuse valve. +- Alert decision gated on **account subscription state** + SMS pool + plan caps; existing per-tag/per-IP caps stay. +- Automatic tag lifecycle from billing events (suspend/close/reactivate), manual overrides respected. +- Replacement tags: reuse `tag-lifecycle` move mechanic; billing rule = SKU purchase only, sub continues. + +**Non-Goals** +- Business/public-info billing (deferred change; only `plan_type` reserved now). +- Multi-currency (AUD only). Paddle/other gateways. +- In-app plan upgrade/downgrade UI beyond Stripe customer portal (v1: portal link). +- Physical fulfillment/print of tags (SKUs + prices exist; fulfillment is a later inventory change). + +## Decisions + +### D1. Laravel Cashier in the admin — not a raw Stripe client +**Choice**: `laravel/cashier` (Stripe provider) in `admin/app`. +**Why**: Cashier owns subscription state machine, webhook signature verification, proration, invoice records; admin is already Laravel+Filament. +**Alternative**: stripe/stripe-php direct. Rejected: duplicates webhook plumbing and subscription logic for a solo learning project. + +### D2. One subscription `orders` row per account; `orders.status` derived from Stripe via webhooks +**Choice**: `orders.account_id` = the account; one active subscription order per account (Cashier subscription columns on `orders`); `orders.status` (`pending/paid/lapsed/cancelled`) is set by the webhook handler from Stripe events. `users.stripe_id` = Stripe customer; `orders.plan_id` → `plans` row. +**Why**: the frontend already gates on `orders.status`; making the account's subscription an `orders` row means the gating decision reads one row (`JOIN orders o ON o.account_id = $user AND o.status='paid'`). No new frontend concept. +**Alternative**: Billable on `User` only (Cashier default). Rejected: would bypass the existing orders model the frontend and admin already use. +**Tag→order linkage**: tags keep `order_id` for provenance (which tag was bought with which order), but **alert gating uses the account's subscription**, not per-tag order. A tag bought under an old 10-pack order still alerts while the account sub is paid. + +### D3. Webhooks are the source of truth; status derivation is one pure function +**Choice**: signature-verified webhook controller maps events → `orders.status`: `checkout.session.completed` → `paid` (+ create order), `invoice.paid` → `paid` (renewal), `invoice.payment_failed` → `lapsed`, `customer.subscription.deleted/canceled` → `cancelled`, `charge.disputed` → `lapsed`. Idempotent via `billing_events.stripe_event_id` UNIQUE. Nightly reconcile re-syncs from Stripe. +**Why**: webhooks are reliable post-checkout channel; one mapping = one place to reason; audit log for support. +**Risk**: missed webhook → drift. → Stripe auto-retries + nightly reconcile + audit rows. + +### D4. SMS pool accounting: counted, not metered per message at send time +**Choice**: `users.sms_credits` = purchased extra credits (0 default). Included pool = `SMS_INCLUDED_PER_YEAR` env (default 50). At scan/alert time, `shouldAlert` checks: account sub `paid` **AND** (alerts this period < pool + credits). Counting: a per-account count of `alert_sent=true` scans in the current billing period (period start = `orders.period_started_at`, reset on `invoice.paid` renewal). When pool+credits exhausted → record scan, no alert, no SMS. Credits drawn after included pool. +**Why**: count query is cheap and reuses the existing alert-count pattern; no per-message metering complexity; pool is env-tunable without deploy. +**Why 50 not 100**: worst-case margin math — at $0.10/SMS, 50 = $5 cost vs $10 sub (50% margin); 100 = $10 = entire sub gone. Real SMSGlobal pooled cost is ~$0.04–0.06, so 50 is safely profitable and covers a 10-tag family (~30 SMS/yr typical at ~3/tag). +**Risk**: pool size wrong for actual usage. → Tunable env constant; review after first billing cycles; extra SMS at $0.10 funds heavy users. +**Note**: tag-level `sms_allocated`/`sms_used` columns (Phase 5.5) remain for visibility but are **not** the enforcement mechanism. + +### D5. 25-tag cap at tag-add time +**Choice**: on tag-create (and tag-claim), count active/owned tags for the account; if ≥ `plans.max_tags` (default 25), reject with clear error ("personal accounts hold up to 25 tags — contact us for business plans"). Cap value comes from the plan row (tunable). +**Why**: keeps personal accounts personal; prevents consumer-priced accounts becoming de-facto business; abuse is already bounded by SMS pool + caps, so the cap is a product seam, not security. +**Alternative**: no cap. Rejected: blurs personal/business line. + +### D6. Replacement tags: move mechanic reused, billing = SKU only +**Choice**: existing `tag-lifecycle` "Move tag data" (source → closed, unset registry target → active with copied details). Billing: the new physical tag is bought as a one-time SKU (single $5 or from a 10-pack); **the account subscription and its SMS pool are untouched**. +**Why**: punishing a stressed user with a second service charge at the moment of loss is the fastest way to churn; the sub is tied to the account, not the plastic. +**Spec note**: replacement never changes `orders` or resets `sms_used`; it only moves `tags` rows. + +### D7. Stripe-hosted customer portal for self-service +**Choice**: Filament action → Stripe portal session (card update, cancel); admin read-mostly. +**Why**: card/cancel UI is PCI-scoped Stripe territory; one redirect beats duplicating forms. + +## Risks / Trade-offs + +- **SMS cost uncertainty** (user flagged: "10c × 100 = entire sub") → Pool = 50 (worst case $5 cost at $0.10, 50% margin); verify real SMSGlobal cost on first bill; pool is env-tunable; credits at $0.10 (≈2× margin) fund heavy users. +- **Test mode ≠ live** (webhook signatures, 3DS, AU format) → Full test-mode e2e; one real AUD subscription before go-live. +- **Cashier/PHP version on .13** → Check PHP version vs Cashier requirement before adding; pin versions. +- **`orders.status` drift** → Idempotent webhooks + nightly reconcile + audit log. +- **Existing test tags without a sub become record-only** → Documented; dev DB seeds an active paid order for test accounts so existing verify suites keep alerting. +- **Pool draw race** (two scans both see credits left) → Acceptable at this scale (count check is per-request, pool is annual — worst case a couple of over-limit SMS per year); note in spec as known limitation. +- **DB schema drift (schema.sql vs Laravel migrations)** → Apply idempotently to both; parity check in verify. + +## Migration Plan + +1. **Sandbox**: composer add cashier+stripe-php; test keys; migrations + seeder (personal plan row, tag SKUs, credit pack); `db/schema.sql` parity. +2. **Verify test mode**: subscribe (annual) → order `paid` → tag alerts fire; consume 50-SMS pool via test scans → alerts stop (record-only); buy 100-credit pack → alerts resume; failed-payment card → `lapsed` + tags `suspended` → record-only; cancel → `cancelled` + `closed` → unavailable page; recovery → `active`; webhook replay idempotent; 25-tag cap enforced on 26th tag; replacement move + sub untouched; planless/dev accounts (seeded active) still alert. +3. **Frontend rollout**: account-sub query + SMS pool count + plan caps replace constants; tag-add cap; no behavior change for seeded dev accounts. +4. **Admin UI**: PlanResource, billing panel, order actions, dashboard widgets. +5. **Live switch** (coordinated): live keys, production webhook, one real $10 AUD subscription test; AGENTS.md KNOWN-GOOD-STATE update. + +**Rollback**: `BILLING_ENABLED` env flag; when off, webhook handler no-ops and alerts fall back to the current (no-sub) behavior for seeded accounts. Schema additions additive/idempotent. + +## Open Questions + +- **SMS real cost per message** (SMSGlobal pooled AU) — verify at first billing cycle; pool/price tuned after (env constants, no code change). +- **Business/public-info plan details** — deferred by explicit decision; `plan_type` column ready. +- **Free-tier / trial?** — not in this change (decision: none for v1; $10/yr is already cheap). +- **What happens when a lapsed account's pool had credits** — credits persist (they were paid for); only included pool resets. (Decided: yes, persist.) diff --git a/openspec/changes/billing-tag-lifecycle/proposal.md b/openspec/changes/billing-tag-lifecycle/proposal.md new file mode 100644 index 0000000..3bfd02c --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/proposal.md @@ -0,0 +1,37 @@ +# Billing: account-based annual subscription + tag SKUs + SMS metering + +## Why + +The paid-gating **mechanism** already shipped in `scan-limits-gating` (order status blocks SMS; per-tag/per-IP alert caps), but there is no payment gateway behind it — nothing can ever set `orders.status = paid` in production. The pricing model is now settled with the owner: the **account is the product** ($10/yr flat, up to 25 tags, 50 SMS included), tags are cheap one-time SKUs that undercut AirTags, and SMS metering — not tag count — bounds cost. This change delivers Phase 5: Stripe AU billing via Laravel Cashier implementing exactly that model, sandbox-first. + +## What Changes + +- **Personal plan as an annual subscription** ($10/yr, flat, up to **25 tags**, **50 SMS/year included**) — one active subscription order per account; Cashier manages renewal/failure. +- **Tag SKUs as one-off products**: single tag **$5**, 10-pack **$20** (cost ~$1/tag). Checkout = Cashier one-off payment; no subscription per tag. +- **SMS metering replaces tag-count limits**: the 50-message annual pool is drawn down per account; when exhausted, alerts stop (record-only) until extra SMS credits are bought (**$0.10 each, prepaid 100-credit pack = $10**) or the pool resets at renewal. Pool size is a tunable constant (verify real SMSGlobal cost at first billing cycle). +- **25-tag cap** enforced at tag-add time (count of active tags per account); cap is the product-separation seam with the future business plan, not an abuse valve (abuse already bounded by pool + existing caps + distinct-finder-phone rule + 2 owner phones per pet). +- **Replacement tags**: existing `tag-lifecycle` "Move tag data" mechanic; billing rule = replacement tag is a one-time SKU purchase ($5), **the annual subscription continues untouched** (never re-charge service on replacement). +- **Order lifecycle becomes real**: `orders.status` driven by live subscription state via webhooks (`paid` / `lapsed` on failed payment / `cancelled` on cancel), and alerts require an active paid subscription. +- **Tag lifecycle tied to billing**: auto `suspended` on payment failure, `closed` on cancellation, back to `active` on recovery; manual owner overrides respected. +- **Admin billing UI (Filament)**: customer subscription state + payment methods, order→Stripe links, plan management, dashboard widgets (MRR, active subs, lapsed count, SMS usage). +- **Business/public-info model deferred** (scan-volume tiers, no SMS, hardware choices) — schema reserves `plans.plan_type = 'business'` so it lands later without a migration. + +## Capabilities + +### New Capabilities +- `stripe-billing`: Stripe AU via Cashier — account annual subscription, one-off tag/credit SKU checkouts, webhooks, invoice sync, payment methods, customer billing UI. +- `plan-limits`: personal plan as data (price, interval, `sms_included=50`, `max_tags=25`, alert caps); account-level SMS pool accounting with prepaid credit packs; 25-tag cap enforcement; cached plan lookup at scan time. + +### Modified Capabilities +- `sms-alerting`: MODIFIED — alert decision gated on the **account's** active paid subscription (was: linked order per tag) plus per-account SMS pool availability; per-tag/per-IP caps now plan-configurable. +- `tag-lifecycle`: ADDED — automatic status transitions from billing events (suspend/close/reactivate) and the **replacement-tag billing rule** (new tag SKU only, subscription continues). +- `database`: ADDED — `plans` (with `plan_type`), `orders` subscription columns + `plan_id` + billing-period marker, `users.stripe_id` + `sms_credits`, `billing_events` audit log. + +## Impact + +- **Admin (Laravel)**: `admin/app` — composer (+laravel/cashier, stripe/stripe-php), models (Plan, BillingEvent; Order gains subscription traits; User gains Billable + sms_credits), Filament resources (PlanResource, billing panel on UserResource, OrderResource Stripe actions, credit-pack product), webhook controller, nightly reconcile command, migrations + seeder (personal plan, tag SKUs, credit pack). +- **Frontend (Go)**: `frontend/internal/handlers/scan.go` — `shouldAlert` reads account subscription state + SMS pool (included + credits) + plan caps; tag-add enforces 25-cap; plan lookup query + cache; replacement move reuses existing `tag-lifecycle` move. +- **Shared schema**: `db/schema.sql` idempotent additions (see `database` spec). +- **Env/secrets**: `STRIPE_KEY`, `STRIPE_SECRET`, `STRIPE_WEBHOOK_SECRET` (test then live), `CASHIER_CURRENCY=AUD`, `SMS_INCLUDED_PER_YEAR=50` (tunable), `SMS_EXTRA_PRICE=0.10`; never committed. +- **External**: Stripe AU account (test mode first), production webhook on `admin.where-woof.com` (`.13:3031` behind Caddy). +- **No breaking changes to existing tags**: dev/test tags without an active paid subscription become record-only (documented; dev DB seeded with an active order for test tags) — same lapsed behavior the gating already implements. diff --git a/openspec/changes/billing-tag-lifecycle/specs/database/spec.md b/openspec/changes/billing-tag-lifecycle/specs/database/spec.md new file mode 100644 index 0000000..b2a709e --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/specs/database/spec.md @@ -0,0 +1,46 @@ +# Spec: database (delta) + +## ADDED Requirements + +### Requirement: Plans table +The shared schema SHALL contain a `plans` table (id, plan_type, name, price_aud, billing_interval, sms_included, max_tags, alerts_per_day, alerts_per_hour, created_at, updated_at) applied idempotently in both `db/schema.sql` and a Laravel migration. `plan_type` SHALL support `'personal'` and `'business'`. + +#### Scenario: Schema applied +- **WHEN** migrations run on a fresh database +- **THEN** the `plans` table exists with the documented columns + +#### Scenario: Idempotent re-apply +- **WHEN** migrations run on an existing database +- **THEN** no errors and no duplicate rows are created + +### Requirement: Order subscription columns +The `orders` table SHALL gain Cashier subscription columns (stripe_id, pm_type, pm_last_four, trial_ends_at), a `plan_id` reference, and a billing-period marker (`period_started_at`), all additive and idempotent. + +#### Scenario: Columns exist +- **WHEN** migrations run +- **THEN** `orders` has the subscription columns, `plan_id`, and `period_started_at` without disturbing existing rows + +#### Scenario: Period marker reset +- **WHEN** a renewal `invoice.paid` is processed +- **THEN** `period_started_at` is updated to the renewal time + +### Requirement: Billing events log +The schema SHALL contain a `billing_events` table (id, stripe_event_id UNIQUE, event_type, order_id, payload, created_at) for webhook audit. + +#### Scenario: Unique event +- **WHEN** the same stripe event id is inserted twice +- **THEN** the second insert is rejected by the unique constraint and handled idempotently by the webhook handler + +### Requirement: User billing columns +The `users` table SHALL gain `stripe_id` (Stripe customer reference) and `sms_credits` (purchased extra SMS, default 0), both additive and idempotent. + +#### Scenario: Columns exist +- **WHEN** migrations run +- **THEN** `users.stripe_id` and `users.sms_credits` exist and existing rows are unaffected (credits default 0) + +### Requirement: Product SKU seed +The seeder SHALL create the tag and credit SKUs in `products`: single tag ($5), 10-pack ($20), 100-SMS credit pack ($10), each with a unique sku. + +#### Scenario: SKUs seeded +- **WHEN** the seeder runs +- **THEN** the three products exist with their prices and unique skus diff --git a/openspec/changes/billing-tag-lifecycle/specs/plan-limits/spec.md b/openspec/changes/billing-tag-lifecycle/specs/plan-limits/spec.md new file mode 100644 index 0000000..f1467e9 --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/specs/plan-limits/spec.md @@ -0,0 +1,77 @@ +# Spec: plan-limits + +## ADDED Requirements + +### Requirement: Personal plan as data +The system SHALL store the personal plan in a `plans` table: `plan_type='personal'`, price $10 AUD, interval `year`, `sms_included=50`, `max_tags=25`, plus per-tag daily and per-IP hourly alert caps (defaults 5 and 10). The table SHALL support a future `plan_type='business'` without schema change. + +#### Scenario: Plan seeded +- **WHEN** the billing migrations and seeder run +- **THEN** the `plans` table contains the personal plan with the documented values + +#### Scenario: Business type reserved +- **WHEN** a plan is created with `plan_type='business'` +- **THEN** it is stored and treated as a business plan (scan-volume semantics deferred) + +### Requirement: Account SMS pool +The system SHALL track an account's annual SMS allowance as: included pool (`sms_included`, from plan or env default 50) plus purchased `sms_credits`. Alerts in the current billing period SHALL draw from included pool first, then credits. + +#### Scenario: Within included pool +- **WHEN** an account's alert count this period is below `sms_included` +- **THEN** alerts are allowed + +#### Scenario: Pool exhausted, credits available +- **WHEN** the included pool is exhausted but `sms_credits` remain +- **THEN** alerts are allowed and credits are drawn + +#### Scenario: Fully exhausted +- **WHEN** both included pool and credits are exhausted +- **THEN** scans are recorded but no alert is sent + +#### Scenario: Period reset +- **WHEN** a renewal `invoice.paid` webhook arrives +- **THEN** the billing period resets and the included pool is available again (credits persist) + +### Requirement: Plan limits at scan time +The frontend SHALL resolve the account's plan (via the active subscription order) and apply its caps in `shouldAlert`, replacing the hard-coded constants. + +#### Scenario: Plan caps applied +- **WHEN** a scan arrives on a tag whose account has an active paid subscription +- **THEN** `shouldAlert` uses the plan's daily/hourly caps and the account SMS pool + +#### Scenario: No active plan fallback +- **WHEN** an account has no active paid subscription +- **THEN** scans are recorded but no alert is sent (record-only, matching `lapsed` behaviour) + +### Requirement: Cached plan resolution +The frontend SHALL cache plan/subscription data so scan-time lookups do not hit the database on every request. + +#### Scenario: Cache hit +- **WHEN** a scan arrives within the cache TTL +- **THEN** plan limits and subscription state come from cache, not a fresh query + +#### Scenario: Cache refresh +- **WHEN** the cache TTL expires or the entry is unknown +- **THEN** the frontend reloads from the database and refreshes the cache + +### Requirement: Tag cap enforcement +The system SHALL enforce `max_tags` (default 25) at tag-create and tag-claim time by counting the account's owned/active tags. + +#### Scenario: Under cap +- **WHEN** an account with fewer than `max_tags` tags adds a tag +- **THEN** the tag is created normally + +#### Scenario: At cap +- **WHEN** an account with `max_tags` owned tags adds another +- **THEN** the request is rejected with a clear error explaining the personal-account limit + +#### Scenario: Two 10-packs +- **WHEN** an account owns 20 tags and buys a second 10-pack's worth of codes +- **THEN** the remaining 5 codes fit under the 25 cap and are claimable + +### Requirement: Tunable limits via env +The included SMS pool and extra-SMS price SHALL be configurable via env (`SMS_INCLUDED_PER_YEAR`, `SMS_EXTRA_PRICE`) without code changes, so real SMS costs can be reflected after the first billing cycles. + +#### Scenario: Env override +- **WHEN** `SMS_INCLUDED_PER_YEAR=75` is set +- **THEN** the included pool is 75 regardless of the plan row default diff --git a/openspec/changes/billing-tag-lifecycle/specs/sms-alerting/spec.md b/openspec/changes/billing-tag-lifecycle/specs/sms-alerting/spec.md new file mode 100644 index 0000000..9e201fa --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/specs/sms-alerting/spec.md @@ -0,0 +1,46 @@ +# Spec: sms-alerting (delta) + +## MODIFIED Requirements + +### Requirement: Paid-order gating in alert decision +The alert decision (`shouldAlert`) SHALL return false when the **account** behind the tag lacks an active paid subscription. The account's subscription state comes from its active `orders` row (`status='paid'`, driven by live Stripe subscription state — see `stripe-billing`). Tags whose account order is `lapsed` or `cancelled`, or who have no paid order, SHALL be treated as non-alerting (record-only). This replaces the previous per-tag linked-order gating. + +#### Scenario: Lapsed account scan +- **WHEN** a scan arrives on a tag whose account order is not `paid` +- **THEN** `shouldAlert` returns false (record only) + +#### Scenario: Subscription payment failure +- **WHEN** Stripe reports `invoice.payment_failed` and the account order becomes `lapsed` +- **THEN** subsequent scans for that account's tags record but do not alert + +#### Scenario: Subscription ended +- **WHEN** the subscription is cancelled and the account order becomes `cancelled` +- **THEN** subsequent scans record but do not alert + +#### Scenario: Dev/test account with seeded paid order +- **WHEN** a dev account has a seeded `paid` order +- **THEN** its tags alert normally (existing verify suites keep working) + +### Requirement: Cap checks in alert decision +The alert decision SHALL respect the per-tag daily cap and per-IP hourly rate limit. The caps SHALL come from the account's plan (see `plan-limits`) when a plan exists, falling back to the default constants when no plan is linked. + +#### Scenario: Over plan cap +- **WHEN** the daily or hourly counter exceeds the account's plan limits +- **THEN** `shouldAlert` returns false (record only) + +#### Scenario: Planless default +- **WHEN** the account has no linked plan row +- **THEN** the default cap constants apply + +## ADDED Requirements + +### Requirement: SMS pool gating in alert decision +The alert decision SHALL return false when the account's annual SMS allowance is exhausted (included pool + purchased credits — see `plan-limits`). + +#### Scenario: Pool exhausted +- **WHEN** the account's alert count this period equals or exceeds included pool + credits +- **THEN** `shouldAlert` returns false (record only) and no SMS is sent + +#### Scenario: Credits purchased +- **WHEN** the included pool is exhausted but the account has purchased credits +- **THEN** alerts resume until credits are also exhausted diff --git a/openspec/changes/billing-tag-lifecycle/specs/stripe-billing/spec.md b/openspec/changes/billing-tag-lifecycle/specs/stripe-billing/spec.md new file mode 100644 index 0000000..d839471 --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/specs/stripe-billing/spec.md @@ -0,0 +1,98 @@ +# Spec: stripe-billing + +## ADDED Requirements + +### Requirement: Account annual subscription checkout +The admin SHALL offer an annual subscription for the personal plan (default $10 AUD/year, flat per account) via Stripe, using Laravel Cashier. + +#### Scenario: Start checkout +- **WHEN** a customer clicks "Subscribe" on the personal plan +- **THEN** the system creates a Stripe Checkout Session for the annual plan and redirects to Stripe-hosted checkout + +#### Scenario: Currency +- **WHEN** a checkout session is created +- **THEN** the amount is in AUD (`CASHIER_CURRENCY=AUD`) + +#### Scenario: One subscription per account +- **WHEN** an account already has an active subscription order and starts checkout again +- **THEN** the new checkout replaces/renews that subscription rather than creating a duplicate active order + +### Requirement: One-off tag SKU purchases +The admin SHALL sell tag SKUs as one-off Cashier products: single tag ($5) and 10-pack ($20). These SHALL NOT create or extend a subscription. + +#### Scenario: Buy a single tag +- **WHEN** a customer buys the single-tag SKU +- **THEN** a one-off Stripe payment completes and no subscription is created + +#### Scenario: Buy a 10-pack +- **WHEN** a customer buys the 10-pack SKU +- **THEN** a one-off payment completes for $20 and the account can claim 10 more tags (subject to the 25-tag cap) + +### Requirement: SMS credit pack purchase +The admin SHALL sell SMS credit packs (default 100 credits for $10 AUD) as one-off products; a purchase SHALL increment the account's `sms_credits`. + +#### Scenario: Buy credits +- **WHEN** a customer buys the 100-credit pack +- **THEN** the payment completes and `users.sms_credits` increases by 100 + +### Requirement: Webhook status derivation +The admin SHALL verify Stripe webhook signatures and derive `orders.status` from events: `checkout.session.completed` → `paid`; `invoice.paid` → `paid` (renewal, resets billing period); `invoice.payment_failed` → `lapsed`; `customer.subscription.deleted`/`canceled` → `cancelled`; `charge.disputed` → `lapsed`. + +#### Scenario: Checkout completed +- **WHEN** Stripe sends `checkout.session.completed` +- **THEN** the matching subscription order is created/updated with status `paid` and the subscription columns set + +#### Scenario: Renewal paid +- **WHEN** Stripe sends `invoice.paid` for a renewal +- **THEN** the order status stays `paid` and the billing period marker (`period_started_at`) resets to now + +#### Scenario: Payment failure +- **WHEN** Stripe sends `invoice.payment_failed` +- **THEN** the order status becomes `lapsed` + +#### Scenario: Invalid signature +- **WHEN** a request does not carry a valid Stripe signature +- **THEN** it is rejected with a 400 and no state changes + +#### Scenario: Duplicate event +- **WHEN** the same Stripe event id arrives twice +- **THEN** the second delivery is ignored (idempotent) and a `billing_events` audit row exists only once + +### Requirement: Billing audit log +The admin SHALL record every processed billing event in a `billing_events` table (stripe event id, type, order, payload summary, processed_at). + +#### Scenario: Event recorded +- **WHEN** a webhook event is processed +- **THEN** a `billing_events` row is written with the event id, type, and affected order + +### Requirement: Customer self-service portal +The admin SHALL provide a link to the Stripe customer portal for card management and subscription changes. + +#### Scenario: Open portal +- **WHEN** a customer clicks "Manage billing" in the admin +- **THEN** a Stripe customer portal session is created and the customer is redirected to it + +### Requirement: Sandbox-first operation +The system SHALL run against Stripe test mode by default and SHALL only activate live keys when explicitly configured via env. + +#### Scenario: Test mode default +- **WHEN** no live keys are configured +- **THEN** all Stripe calls use test-mode keys and webhook verification uses the test webhook secret + +#### Scenario: Live keys activated +- **WHEN** `STRIPE_SECRET` and `STRIPE_WEBHOOK_SECRET` point at live keys +- **THEN** live mode is active and the production webhook endpoint is registered + +### Requirement: Nightly reconciliation +The admin SHALL run a nightly job that re-syncs each subscription's status from Stripe and corrects `orders.status` drift. + +#### Scenario: Drift corrected +- **WHEN** an order's status differs from Stripe's subscription state +- **THEN** the job updates the order status and records a `billing_events` row + +### Requirement: Billing kill-switch +The effects of billing webhooks SHALL be gated by a `BILLING_ENABLED` env flag; when disabled, webhooks SHALL be verified but SHALL NOT change order/tag state. + +#### Scenario: Flag off +- **WHEN** `BILLING_ENABLED` is false and a webhook arrives +- **THEN** the request is acknowledged but no state changes occur diff --git a/openspec/changes/billing-tag-lifecycle/specs/tag-lifecycle/spec.md b/openspec/changes/billing-tag-lifecycle/specs/tag-lifecycle/spec.md new file mode 100644 index 0000000..5d0fe44 --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/specs/tag-lifecycle/spec.md @@ -0,0 +1,50 @@ +# Spec: tag-lifecycle (delta) + +## ADDED Requirements + +### Requirement: Automatic suspension on payment failure +A tag whose **account** order becomes `lapsed` SHALL automatically transition to status `suspended`; its public page continues to show tag details but scans do not alert. + +#### Scenario: Payment failed +- **WHEN** Stripe reports `invoice.payment_failed` and the account order becomes `lapsed` +- **THEN** all tags owned by that account become `suspended` + +#### Scenario: Suspended scan +- **WHEN** a visitor scans a `suspended` tag +- **THEN** the scan is recorded but no alert is sent + +### Requirement: Automatic close on subscription end +A tag whose account order becomes `cancelled` SHALL automatically transition to status `closed`; its public page shows the tag unavailable and scans do not alert. + +#### Scenario: Subscription cancelled +- **WHEN** the subscription ends and the account order becomes `cancelled` +- **THEN** all tags owned by that account become `closed` + +#### Scenario: Closed tag page +- **WHEN** a visitor opens a `closed` tag +- **THEN** they see an "unavailable / closed" message + +### Requirement: Reactivation on payment success +A `suspended` tag whose account order returns to `paid` SHALL automatically transition back to `active`. + +#### Scenario: Payment recovered +- **WHEN** `invoice.paid` arrives and the account order becomes `paid` +- **THEN** tags owned by that account return to `active` + +### Requirement: Manual override preserved +Manual status changes by the owner (e.g. close a tag) SHALL NOT be silently overwritten by billing transitions; the transition function SHALL only move tags whose current status matches the expected pre-transition state, and SHALL record a skip note in `billing_events`. + +#### Scenario: Owner closed first +- **WHEN** the owner closed a tag and a billing event later targets it +- **THEN** the tag stays `closed` and a `billing_events` note records the skipped transition + +### Requirement: Replacement tag billing rule +When an owner moves a tag's details to a replacement tag (the existing `tag-lifecycle` "Move tag data" mechanic), the replacement SHALL be a new tag SKU purchase (one-off, single $5 or from a 10-pack) and SHALL NOT change the account subscription, its SMS pool, or its billing period. + +#### Scenario: Replace a lost tag +- **WHEN** the owner moves tag A's details to unset registry tag B after buying a replacement SKU +- **THEN** tag B is active with all of A's details, tag A becomes `closed`, and the account order/SMS pool are unchanged + +#### Scenario: Subscription continues through replacement +- **WHEN** the replacement happens mid-billing-period +- **THEN** the account's subscription status, `sms_used` count, and credits are untouched diff --git a/openspec/changes/billing-tag-lifecycle/tasks.md b/openspec/changes/billing-tag-lifecycle/tasks.md new file mode 100644 index 0000000..df21029 --- /dev/null +++ b/openspec/changes/billing-tag-lifecycle/tasks.md @@ -0,0 +1,62 @@ +# Tasks — Billing: account-based annual subscription + tag SKUs + SMS metering + +## 1. Sandbox prep + schema (database spec) + +- [ ] 1.1 Add `laravel/cashier` and `stripe/stripe-php` to `admin/app/composer.json`; confirm PHP version on .13 meets Cashier's requirement; pin versions; `composer install` in the admin container +- [ ] 1.2 Add `plans` table to `db/schema.sql` (id, plan_type, name, price_aud, billing_interval, sms_included, max_tags, alerts_per_day, alerts_per_hour, timestamps) with idempotent CREATE TABLE IF NOT EXISTS +- [ ] 1.3 Add Cashier subscription columns to `orders` (stripe_id, pm_type, pm_last_four, trial_ends_at) + `plan_id` ref + `period_started_at` marker, idempotent ALTERs in `db/schema.sql` +- [ ] 1.4 Add `billing_events` table (stripe_event_id UNIQUE, event_type, order_id, payload, created_at) to `db/schema.sql` +- [ ] 1.5 Add `users.stripe_id` + `users.sms_credits` (default 0) idempotent ALTERs to `db/schema.sql` +- [ ] 1.6 Create matching Laravel migration(s) in `admin/app/database/migrations/` mirroring 1.2–1.5; run migrations against dev DB; verify parity with `db/schema.sql` +- [ ] 1.7 Seeder: personal plan row (type=personal, $10, year, sms_included=50, max_tags=25, caps 5/10) + product SKUs (single $5, 10-pack $20, 100-credit pack $10) + seed an active paid order for dev/test accounts (keeps existing verify suites alerting) + +## 2. Stripe integration (stripe-billing spec) + +- [ ] 2.1 Configure Cashier in `admin/app` (ServiceProvider, `CASHIER_CURRENCY=AUD`, env `STRIPE_KEY`/`STRIPE_SECRET`/`STRIPE_WEBHOOK_SECRET` test values in `.env.example`, never committed); add `BILLING_ENABLED` env flag +- [ ] 2.2 Add `Billable` to `User`; add Cashier subscription columns support to `Order` (subscription trait mapped to `orders`); helpers for plan lookup +- [ ] 2.3 Implement annual subscription checkout: Filament action creates Stripe Checkout Session (personal plan, customer, success/cancel URLs) and redirects; idempotent for existing active sub +- [ ] 2.4 Implement one-off product checkouts: single tag ($5), 10-pack ($20), 100-credit pack ($10) via Cashier; on credit-pack success, increment `users.sms_credits` +- [ ] 2.5 Implement webhook controller: verify signature; map events → `orders.status` (checkout.session.completed→paid, invoice.paid→paid+reset period_started_at, invoice.payment_failed→lapsed, customer.subscription.deleted/canceled→cancelled, charge.disputed→lapsed); idempotent via billing_events UNIQUE; write audit rows; register route + CSRF exemption; respect `BILLING_ENABLED` +- [ ] 2.6 Implement account-level tag transition function: on account order change, transition owned tags (lapsed→suspended, cancelled→closed, paid→active) respecting manual overrides (skip if tag status not expected pre-transition; record skip in billing_events) +- [ ] 2.7 Add nightly reconciliation command (artisan) re-syncing subscription status from Stripe into `orders.status` + tags, logging corrections +- [ ] 2.8 Add Stripe customer portal link action (UserResource "Manage billing") using portal session URL + +## 3. Plan limits + SMS pool (plan-limits + sms-alerting specs) + +- [ ] 3.1 Add account-subscription + plan lookup queries to `frontend/internal/db/queries.sql` (tag → owner account → active paid order → plan) + alert-count-this-period query (count `alert_sent=true` scans for account since `period_started_at`) + regenerate sqlc via `make generate` +- [ ] 3.2 Add in-memory plan/subscription cache (60 s TTL) in Go +- [ ] 3.3 Update `shouldAlert` in `frontend/internal/handlers/scan.go`: gate on account order `paid`; enforce SMS pool (included + credits, drawn after included); per-plan caps replace `maxAlertsPerTagDay`/`maxAlertsPerIPHour` constants (defaults when no plan); read `SMS_INCLUDED_PER_YEAR`/`SMS_EXTRA_PRICE` env +- [ ] 3.4 Enforce 25-tag cap in tag-create and tag-claim handlers (count owned tags vs `max_tags`; clear error message mentioning business plans) + +## 4. Admin billing UI + dashboard + +- [ ] 4.1 Create PlanResource (Filament): CRUD plans, price, interval, sms_included, max_tags, caps +- [ ] 4.2 Extend UserResource: subscription state (plan, status, pm_last_four, next payment), SMS pool usage (included/credits/used), "Manage billing" portal action +- [ ] 4.3 Extend OrderResource: status-driven view, "View in Stripe" link, invoice list, linked tags; mark one-off tag/credit orders distinctly from subscription orders +- [ ] 4.4 Add dashboard widgets: MRR, active subscriptions, lapsed count, tags per account, SMS pool consumption + +## 5. Replacement tags (tag-lifecycle spec) + +- [ ] 5.1 Confirm existing "Move tag data" mechanic handles the replacement flow (source→closed, unset registry target→active with copied details) +- [ ] 5.2 Wire replacement to SKU purchase: owner buys replacement tag (single SKU), then moves data; verify subscription + SMS pool untouched + +## 6. Verification (test mode) + +- [ ] 6.1 Annual subscription e2e: subscribe (test card) → order `paid` → tag `active` → scan alerts fire +- [ ] 6.2 SMS pool e2e: lower `SMS_INCLUDED_PER_YEAR` temporarily (e.g. 2) → after 2 alerts, next scan records but no alert; buy 100-credit pack → alerts resume +- [ ] 6.3 Payment-failure e2e (Stripe test card): `invoice.payment_failed` → order `lapsed` + tags `suspended` → scans record-only +- [ ] 6.4 Cancellation e2e: subscription cancelled → order `cancelled` + tags `closed` → public page shows unavailable +- [ ] 6.5 Recover e2e: `invoice.paid` → order `paid` + tags back to `active`; period reset makes included pool available again +- [ ] 6.6 Idempotency: replay the same webhook event id → no duplicate billing_events, no double transitions +- [ ] 6.7 Tag cap e2e: create 25 tags, 26th rejected with clear error; 20-tag account claims 5 more (10-pack scenario) successfully +- [ ] 6.8 One-off purchases: buy single tag / 10-pack / credit pack — payments complete, no subscription created, credits increment +- [ ] 6.9 Replacement e2e: buy replacement SKU, move tag A → B; B active with details, A closed, order + SMS pool unchanged +- [ ] 6.10 Dev regression: seeded paid dev accounts still alert; `openspec validate billing-tag-lifecycle`; run existing verify suites (re-seed DB after) +- [ ] 6.11 Update AGENTS.md KNOWN-GOOD-STATE with billing test-mode facts + gotchas + +## 7. Deploy (staging → live, coordinated with user) + +- [ ] 7.1 Deploy admin + frontend to .13, run migrations + seeder, restart services (per-file rsync + compose restart per runbook) +- [ ] 7.2 Register production webhook endpoint on `admin.where-woof.com` (`.13:3031` via Caddy); set live keys in env (never committed); confirm `BILLING_ENABLED=true` before go-live +- [ ] 7.3 One real AUD subscription test ($10 personal plan) in live mode; verify webhook → order paid → alert works +- [ ] 7.4 Record real SMS cost per message from first SMSGlobal bill; tune `SMS_INCLUDED_PER_YEAR`/`SMS_EXTRA_PRICE` env if needed; document in AGENTS.md diff --git a/plans/billing-tag-lifecycle.md b/plans/billing-tag-lifecycle.md new file mode 100644 index 0000000..93d8645 --- /dev/null +++ b/plans/billing-tag-lifecycle.md @@ -0,0 +1,55 @@ +# Phase 5 — Billing: account-based annual subscription + tag SKUs + SMS metering + +Plan for OpenSpec change `billing-tag-lifecycle` (proposal/design/specs/tasks in `openspec/changes/billing-tag-lifecycle/`). + +## Context + +The paid-gating **mechanism** already shipped in `scan-limits-gating` (`shouldAlert` blocks SMS when the order isn't `paid`; caps `maxAlertsPerTagDay=5`, `maxAlertsPerIPHour=10`), but there's **no payment gateway** — nothing can ever set `orders.status=paid`. The pricing model is now **locked with the owner**: the account is the product. + +**Locked pricing:** +1. **Tags (one-time SKUs, cost ~$1):** single **$5** · 10-pack **$20** — the AirTag undercut ("10 tags + 1 year = $30" vs ~$300 for 10 AirTags). +2. **Account annual fee: $10/yr flat**, up to **25 tags**, **50 SMS/year included**; extra SMS **$0.10 each** via prepaid **100-credit pack = $10**. Pool is a tunable env constant (verify real SMSGlobal cost at first billing cycle; 50 is safe even at worst-case $0.10/SMS = $5 cost vs $10 sub). +3. **Max 25 tags per personal account** — product-separation seam vs future business plan, NOT an abuse valve (abuse already bounded: SMS pool + 5/day + 10/hr + distinct-finder-phone rule + 2 owner phones per pet). +4. **Replacement tags:** existing "Move tag data" mechanic; replacement = new tag SKU $5 one-time, **annual sub continues untouched**. +5. **Business/public-info model deferred** (scan-volume tiers, no SMS, hardware choices) — schema reserves `plans.plan_type='business'`. + +Sandbox-first: Stripe test mode e2e before live keys; live keys env-only; `BILLING_ENABLED` flag = one-switch rollback. + +## Approach + +- **Schema (db/schema.sql + matching Laravel migration, all idempotent):** `plans` (plan_type, name, price_aud, interval, sms_included=50, max_tags=25, caps 5/10); `orders` gains Cashier subscription columns + `plan_id` + `period_started_at`; `billing_events` (stripe_event_id UNIQUE); `users.stripe_id` + `sms_credits`. +- **Admin (Laravel/Cashier in `admin/app`):** annual subscription checkout ($10/yr, one per account); **one-off** SKU checkouts (single $5, 10-pack $20, 100-credit pack $10 → increments `sms_credits`); webhook controller (signature-verified, maps events → `orders.status`: checkout.completed→paid, invoice.paid→paid+period reset, payment_failed→lapsed, sub.deleted/canceled→cancelled, disputed→lapsed; idempotent via billing_events); account-level tag transitions (lapsed→suspended, cancelled→closed, paid→active, manual overrides respected); nightly reconcile; Stripe portal link; `BILLING_ENABLED` flag. +- **Frontend (Go):** `shouldAlert` gates on **account** order `paid` + **SMS pool** (included 50 + credits, drawn after included; record-only when exhausted) + per-plan caps (replaces constants); 60 s plan/subscription cache; **25-tag cap** at tag-add/claim; replacement reuses existing move mechanic. +- **Admin UI (Filament):** PlanResource, UserResource billing panel (sub state + SMS usage), OrderResource Stripe links, dashboard widgets (MRR, active subs, lapsed, SMS consumption). +- **Secrets (env-only):** `STRIPE_KEY`, `STRIPE_SECRET`, `STRIPE_WEBHOOK_SECRET` (test then live), `CASHIER_CURRENCY=AUD`, `SMS_INCLUDED_PER_YEAR=50`, `SMS_EXTRA_PRICE=0.10`. + +## Files to create / modify + +- New: `admin/app/app/Models/Plan.php`, `BillingEvent.php`; `admin/app/app/Filament/Resources/PlanResource*`; `admin/app/app/Http/Controllers/StripeWebhookController.php`; `admin/app/app/Console/Commands/ReconcileSubscriptions.php`; `admin/app/database/migrations/*_billing.php`; `admin/app/database/seeders/BillingSeeder.php`; frontend account-sub + pool-count sqlc queries +- Modified: `db/schema.sql`; `admin/app/composer.json` (+laravel/cashier, stripe/stripe-php); `admin/app/app/Models/User.php` (Billable, sms_credits), `Order.php` (subscription traits); Filament resources (UserResource, OrderResource); admin dashboard; `frontend/internal/handlers/scan.go` (account gating + SMS pool + plan caps), `tags.go` (25-cap); `frontend/internal/db/queries.sql` + regenerated sqlc; `.env.example` (STRIPE_*, SMS_*) +- New runtime on .13: webhook route on `admin.where-woof.com` (`.13:3031` via Caddy) + +## Reuse + +- Existing `shouldAlert` gating + cap pattern — now account-driven. +- Existing `tag-lifecycle` "Move tag data" for replacements; existing statuses (`unset/active/suspended/closed`). +- Existing per-tag `sms_allocated/sms_used` columns (Phase 5.5) for visibility (not enforcement). +- `/tmp/verify.sh` suite pattern; `make db-up` / `make generate` / `make psql`; per-file `rsync --checksum` deploy runbook; `~/.config/where-woof.env` secrets pattern. + +## Steps + +- [ ] 1. **Sandbox prep + schema**: composer add cashier+stripe-php (check PHP on .13); schema additions (plans, orders cols, billing_events, users cols) in schema.sql + Laravel migration (parity); seeder (personal plan + 3 SKUs + seeded paid order for dev accounts) +- [ ] 2. **Stripe core**: Cashier config (test keys, AUD), Billable User + Order sub traits, annual checkout, one-off SKU checkouts (credits increment sms_credits) +- [ ] 3. **Webhooks**: signature verify, event→status map, idempotent billing_events, account tag transitions, nightly reconcile, portal link, BILLING_ENABLED flag +- [ ] 4. **Plan limits + SMS pool (Go)**: account-sub + plan + pool-count queries, 60 s cache, `shouldAlert` account gating + pool + plan caps, 25-tag cap at add/claim +- [ ] 5. **Admin UI**: PlanResource, User/Order billing panels, dashboard widgets +- [ ] 6. **Replacement**: confirm move mechanic, wire SKU purchase + verify sub/pool untouched +- [ ] 7. **Verify (test mode)**: subscribe→paid→active→alerts; pool exhaust→record-only→credits resume; failed-payment→lapsed/suspended; cancel→cancelled/closed; recover→active+period reset; webhook replay idempotent; 25-cap (26th rejected); one-off SKUs (no sub created); replacement; dev regression (seeded accounts alert); `openspec validate` + existing verify suites (re-seed) +- [ ] 8. **Deploy (with user)**: .13 deploy + migrations + seeder, production webhook, live keys, one real $10 AUD subscription test; record real SMS cost, tune pool env; update AGENTS.md KNOWN-GOOD-STATE + +## Verification + +- Unit: webhook signature reject; event→status mapping; tag transitions respect manual override; pool math (included→credits→stop; period reset). +- Test-mode e2e (Stripe test cards): annual sub → order `paid` + tag `active` + scan alerts; 50-SMS pool (lowered to 2 for test) → record-only then credits resume; failed-payment → `lapsed`/`suspended` + record-only; cancel → `cancelled`/`closed` + public page unavailable; recovery → `active` + pool reset; duplicate webhook ignored; 26th tag rejected; single/10-pack/credit purchases complete with no subscription; replacement moves data with sub + pool untouched. +- `openspec validate billing-tag-lifecycle`; full existing verify suite passes (dev accounts seeded paid). +- Live: one real $10 AUD subscription end-to-end before go-live; SMS cost verified on first bill and pool tuned.