Skip to content

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.

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.

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.

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_TEAM
  • STRIPE_PRICE_BUSINESS
  • STRIPE_PRICE_GROWTH
  • STRIPE_PRICE_ENTERPRISE (the first-deal per-contract Price; subsequent contracts override per-tenant via quotas_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_ADDON is 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.

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

Trigger the reporter manually against a single tenant before the monthly fire:

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

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_...).

  • Tenant has no stripe_customer_id: usage reporter skips with reason no_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_name and 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 time 0 1 1 * * fires (1 hour after midnight UTC on the 1st).