Stripe setup — Billing Meters for ADR-0038 (+ ADR-0059, ADR-0170)
This is the manual checklist for wiring Puccha’s hybrid pricing model
(platform fee + included resolutions + low overage) into Stripe. The runtime
already records overage to resolutions and api_query_events and a monthly
cron at 01:00Z on the 1st reads them and fires Stripe meter events. None of
that fires real charges until the products and prices below exist in Stripe.
This doc reflects the SKU table after ADR-0059: Growth tier added between Business and Enterprise; Enterprise public floor removed; Team gets a per-seat add-on for seats 6–10.
The cron handler is at
apps/app/src/routes/api/cron/usage-reporter.
It is a no-op while STRIPE_METER_EVENT_* env vars are unset, so this
checklist can be run incrementally without breaking anything.
Order of operations
Section titled “Order of operations”You can do this work in test mode first, validate, then mirror it to live. Stripe products created in test mode are not visible in live mode and vice versa.
1. Create the four Billing Meters
Section titled “1. Create the four Billing Meters”Stripe → Billing → Meters → “Create meter”.
Meter 1 — Resolution overage
| Field | Value |
|---|---|
| Display name | Resolution overage |
| Event name | puccha_resolutions |
| Aggregation | Sum |
| Customer mapping | payload.stripe_customer_id |
| Value field | payload.value |
Meter 2 — API query overage
| Field | Value |
|---|---|
| Display name | API query overage |
| Event name | puccha_api_queries |
| Aggregation | Sum |
| Customer mapping | payload.stripe_customer_id |
| Value field | payload.value |
Meter 3 — Team seat add-on (ADR-0059 §2)
| Field | Value |
|---|---|
| Display name | Team seat add-on |
| Event name | puccha_seat_addon_team |
| Aggregation | Last (use most-recent value, not Sum — quantity is a stock, not a flow) |
| Customer mapping | payload.stripe_customer_id |
| Value field | payload.value |
Aggregation matters: resolution and API meters are flows (sum across the period) but seat add-on is a stock (the count of paid seats as of end of period). Using Sum here would multiply quantity by N reports.
Meter 4 — Guided-flow add-on (ADR-0170)
| Field | Value |
|---|---|
| Display name | Guided-flow add-on |
| Event name | puccha_guided_flow |
| Aggregation | Sum (billed per completed flow — a flow, like resolutions) |
| Customer mapping | payload.stripe_customer_id |
| Value field | payload.value |
Billed per completed flow (= a Draft first delivered, one immutable
flow.completed audit event each). The reporter sends the period’s total as one
value, so aggregation is Sum. Self-gating: a tenant without the add-on has zero
events, so no separate entitlement flag is needed on the Stripe side.
Note all four event names — they go into env vars below.
2. Create the four base Products
Section titled “2. Create the four base Products”Stripe → Products → “Add product”. One per tier (Free has no Stripe product).
| Product | Name | Price (recurring) | Currency | Period |
|---|---|---|---|---|
puccha_team |
Puccha Team | ฿3,900 | THB | monthly |
puccha_business |
Puccha Business | ฿19,900 | THB | monthly |
puccha_growth |
Puccha Growth | ฿34,900 | THB | monthly |
puccha_enterprise |
Puccha Enterprise | per-contract | THB | monthly |
ADR-0059 §1 removed the Enterprise public floor. Don’t create a public Enterprise Price — every Enterprise deal carries a per-contract Price created at signing time. The internal reference floor is ฿149,000/mo; below that, sell Business + Compliance Add-on instead (Compliance Add-on itself ships with ADR-0051 breach automation).
Save the price IDs. They go into:
STRIPE_PRICE_TEAMSTRIPE_PRICE_BUSINESSSTRIPE_PRICE_GROWTHSTRIPE_PRICE_ENTERPRISE(the first-deal per-contract Price; subsequent contracts override per-tenant viaquotas_json)
3. Create nine metered Prices (4 tiers × 2 meters + Team seat add-on)
Section titled “3. Create nine metered Prices (4 tiers × 2 meters + Team seat add-on)”For each base product, add metered prices linked to the meters from step 1. These don’t appear in the public catalog — they’re attached to subscriptions during checkout.
Resolution overage prices (linked to the puccha_resolutions meter):
| Tier | Per-unit | Currency | Notes |
|---|---|---|---|
| Team | ฿4 | THB | recurring, metered, action=set |
| Business | ฿3 | THB | recurring, metered, action=set |
| Growth | ฿2.50 | THB | recurring, metered, action=set |
| Enterprise | ฿2 | THB | recurring, metered, action=set |
API query overage prices (linked to puccha_api_queries meter):
| Tier | Per-unit | Currency |
|---|---|---|
| Team | ฿0.80 | THB |
| Business | ฿0.60 | THB |
| Growth | ฿0.50 | THB |
| Enterprise | ฿0.40 | THB |
Team seat add-on (linked to the puccha_seat_addon_team meter, ADR-0059 §2):
| Tier | Per-unit | Currency | Notes |
|---|---|---|---|
| Team | ฿800 | THB | recurring, metered, max=5 enforced |
Save this price ID under STRIPE_PRICE_TEAM_SEAT_ADDON. The cron uses
the meter event name (STRIPE_METER_EVENT_SEAT_ADDON_TEAM); the Price ID
is needed at checkout/portal time to attach the metered line item to the
Team subscription.
Important: each metered Price must reference the meter event name from
step 1 (Stripe enforces this at create time). Stripe routes incoming meter
events to the right Price based on which subscription the customer holds —
that’s how a customer on Team pays ฿4 and a customer on Business pays ฿3
from the same puccha_resolutions event stream.
4. Checkout flow attaches metered prices automatically
Section titled “4. Checkout flow attaches metered prices automatically”POST /api/billing calls buildCheckoutLineItems() (billing.ts)
which assembles the Checkout Session line items based on which env vars
are populated:
- Base recurring Price — always required (the customer is selecting a tier).
- Per-tier resolution overage Price — attached when
STRIPE_PRICE_OVERAGE_RESOLUTION_<TIER>is set. - Per-tier API overage Price — attached when
STRIPE_PRICE_OVERAGE_API_<TIER>is set. - Team seat add-on Price — attached on Team subs when
STRIPE_PRICE_TEAM_SEAT_ADDONis set, even with zero quantity, so subsequent meter events route correctly.
Unset env vars produce a base-only subscription — same shape the platform shipped before this checklist, and a graceful no-op while you walk through this doc one step at a time. There is no code change to make at this step; populating the env vars in step 5 is the activation point.
The behavior is covered by buildCheckoutLineItems test cases in
billing.test.ts
— Free has no overage line items, Business has no seat add-on, Growth
routes to its own metered Prices, etc.
5. Set the env vars (per environment)
Section titled “5. Set the env vars (per environment)”# In platform/apps/app, run for each environment (dev, prod):wrangler secret put STRIPE_SECRET_KEY # sk_test_... or sk_live_...wrangler secret put STRIPE_WEBHOOK_SECRET # whsec_...wrangler secret put STRIPE_PRICE_TEAM # price_... (base)wrangler secret put STRIPE_PRICE_BUSINESS # price_... (base)wrangler secret put STRIPE_PRICE_GROWTH # price_... (base; ADR-0059 §6)wrangler secret put STRIPE_PRICE_ENTERPRISE # price_... (per-contract first deal)wrangler secret put STRIPE_PRICE_TEAM_SEAT_ADDON # price_... (metered; ADR-0059 §2)wrangler secret put STRIPE_METER_EVENT_RESOLUTION # "puccha_resolutions"wrangler secret put STRIPE_METER_EVENT_API # "puccha_api_queries"wrangler secret put STRIPE_METER_EVENT_SEAT_ADDON_TEAM # "puccha_seat_addon_team"wrangler secret put STRIPE_METER_EVENT_GUIDED_FLOW # "puccha_guided_flow" (ADR-0170)The resolution + API metered Price IDs themselves do not need env vars — they’re attached at Checkout-creation time (step 4) and Stripe figures out which one to charge from the customer’s subscription. The seat-addon Price ID does need an env var because the checkout handler reads it explicitly to attach the line item only on Team subscriptions.
6. Verify in test mode
Section titled “6. Verify in test mode”Trigger the reporter manually against a single tenant before the monthly fire:
# As tenant owner, hit /api/cron/usage-reporter — admin-mode path scopes# to your tenant only. Returns the summary JSON.curl -X GET 'https://puccha-dev.hxlab.io/c/<your-slug>/api/cron/usage-reporter' \ -H 'Cookie: <your session cookie>'Expected output for a tenant within included quota:
{ "scanned": 1, "reported": 0, "skipped": 1, "outcomes": [{ "tenantId": "...", "status": "skipped", "reason": "within_included" }]}Force overage by inserting test rows into resolutions, then rerun. The
summary should show status: "reported" and the meter event should appear in
Stripe → Billing → Meters → (the meter) → Events.
7. Switch to live mode
Section titled “7. Switch to live mode”Repeat steps 1-3 in live mode (or use Stripe’s Copy to Live feature) and
flip the env vars to live values (sk_live_..., price_live_...).
Failure modes worth knowing
Section titled “Failure modes worth knowing”- Tenant has no
stripe_customer_id: usage reporter skips with reasonno_stripe_customer_id. This happens for tenants that never went through Checkout. Fix: trigger Checkout (even for ฿0 trial) or set the customer ID manually. - Meter exists in Stripe but env var missing: reporter logs
cron.usage_reporter.no_meter_event_nameand skips that meter. Add the env var. - Subscription canceled mid-period: reporter skips (filter is
status='active'). The final invoice already includes the previous period’s metered usage Stripe collected. - Cron fires before all conversations close: resolutions still
populating from the 15-min detector. Acceptable — overage is computed
against
billing_period(the calendar month), which is closed by the time0 1 1 * *fires (1 hour after midnight UTC on the 1st).
Related
Section titled “Related”- ADR-0038 — pricing model + SKU spec
- ADR-0059 — Growth tier, Team seat add-on, Sonnet 2× meter
- docs/design/unit-economics.md — cost model
- apps/app/src/lib/billing/billing.ts —
PLAN_META, plan helpers - apps/app/src/routes/api/billing/seat-addon/+server.ts — Team seat add-on endpoint
- apps/app/src/routes/api/cron/usage-reporter/+server.ts — the reporter itself