# 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.