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
This commit is contained in:
2
openspec/changes/billing-tag-lifecycle/.openspec.yaml
Normal file
2
openspec/changes/billing-tag-lifecycle/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-26
|
||||
90
openspec/changes/billing-tag-lifecycle/design.md
Normal file
90
openspec/changes/billing-tag-lifecycle/design.md
Normal file
@@ -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.)
|
||||
37
openspec/changes/billing-tag-lifecycle/proposal.md
Normal file
37
openspec/changes/billing-tag-lifecycle/proposal.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
62
openspec/changes/billing-tag-lifecycle/tasks.md
Normal file
62
openspec/changes/billing-tag-lifecycle/tasks.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user