- 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
6.9 KiB
6.9 KiB
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:
- Tags (one-time SKUs, cost ~$1): single $5 · 10-pack $20 — the AirTag undercut ("10 tags + 1 year = $30" vs ~$300 for 10 AirTags).
- 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).
- 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).
- Replacement tags: existing "Move tag data" mechanic; replacement = new tag SKU $5 one-time, annual sub continues untouched.
- 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);ordersgains 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 → incrementssms_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_ENABLEDflag. - Frontend (Go):
shouldAlertgates on account orderpaid+ 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:3031via Caddy)
Reuse
- Existing
shouldAlertgating + 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_usedcolumns (Phase 5.5) for visibility (not enforcement). /tmp/verify.shsuite pattern;make db-up/make generate/make psql; per-filersync --checksumdeploy runbook;~/.config/where-woof.envsecrets 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,
shouldAlertaccount 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+ tagactive+ 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.