Files
where_woof/plans/billing-tag-lifecycle.md
Sam Rolfe aabaa750f4 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
2026-08-28 13:19:32 +10:00

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:

  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.