Bill by a count you own

A coaching academy pays per active student. Thirty students in March, forty-two in April. Your code knows how many students an academy has, because the students are rows in your database. The store does not, and payments does not either.

So payments asks your code how many, and tells the store. It never counts your members. This guide is that one loop: a product billed by a reported quantity, a resolver that answers it, and the Workflow that runs the pass.

Tiers, quotas and features is the other half of a tiered product: what a plan includes, and the kit’s own meters, which refuse at a limit and never bill.

Declare the product

payments({
  billingSubject: "organization",
  resolveSubject,
  entitlements: { pro: { name: "Pro", quotas: { seats: null } } },
  rails: { stripe: true },
  stripe: { successUrl, cancelUrl, portalReturnUrl },
  products: {
    academy_seats: {
      type: "subscription",
      name: "Academy",
      entitlements: ["pro"],
      stripe: { priceId: ACADEMY_METERED_PRICE },
      billing: {
        kind: "reported",
        unit: "student",
        cadence: "period-end", // or "daily-peak"
        min: 1,
        max: 500,
        rails: ["stripe"],
        stripe: { meterEventName: "academy_students" },
      },
    },
  },
  resolveQuantity: async (c, subject) => countActiveStudents(c.env, subject.subjectId),
});

resolveQuantity sits on the options, beside resolveSubject, and never in config. The config crosses into the Worker as JSON, and a function does not survive that. A reported product with no resolver is refused at boot, naming the product.

min and max bound what can become money. max is required. A count outside them is skipped and named in the run’s tally, never clamped, because a clamped value is a wrong number billed in silence.

Pick a cadence

cadenceWhat is sent
period-end (the default)One count per subscription per billing period, in the period’s last day
daily-peakEach day’s high-water mark is kept, and the period’s highest day is sent once, in its last day

Why one report, even for a daily peak? Stripe and Paddle both add up what they are sent. A report a day would bill the sum of the days, so thirty days of forty students would bill twelve hundred. One report bills the peak.

That report goes out at the first run inside the period’s last day, and bills the peak up to then. Sampling carries on until the period ends, so a higher answer later that day is recorded in its day row and not billed. The day rows stay as the audit, and a row above the reported count on the last day is that gap. Under-billing by a few hours is the right way round.

What is never reported

  • An answer of undefined. That means nothing for that subject this period: it is logged and counted as skipped, never sent as 0.
  • A fraction, a negative, or a count outside min and max. Skipped and named in the tally.
  • A period already reported. There is one row per rail, subscription and period, claimed before the store is called, and its id is sent as the store’s idempotency key. A replayed run finds the row and sends nothing.

A resolver that throws for one subject does not stop the run for the others.

How each store hears it

  • Stripe gets one Billing Meter event per report, under the meterEventName you declared. The meter’s own aggregation decides what the invoice bills. The checkout sends the metered line with no quantity, which Stripe requires.
  • Paddle gets a one-time charge of billing.paddle.priceId at the reported quantity, on the subscription’s next bill. Nothing rewrites the subscription’s items.
  • Apple and Google have no seats, and a reported product on either is refused at boot.
  • Lemon Squeezy is deferred, and refused at boot with the reason: its usage records attach to a subscription item the projection does not store.

Export the Workflow from your Worker

The pass calls your resolveQuantity, which lives only in your Worker, so the Workflow class is exported from your own entry. It is five lines, and the body is the kit’s:

// apps/api/src/index.ts, beside createEntrypoint
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
import { reportPaymentsUsage } from "@pithy-sh/payments/src/workflows/usage";

export class PaymentsUsageWorkflow extends WorkflowEntrypoint<Record<string, unknown>> {
  override run(event: WorkflowEvent<unknown>, step: WorkflowStep) {
    return reportPaymentsUsage(this.env, event, step, NonRetryableError);
  }
}
// apps/api/wrangler.jsonc, in each environment's stanza
"workflows": [
  { "binding": "PAYMENTS_USAGE", "name": "acme-prod-payments-usage", "class_name": "PaymentsUsageWorkflow" }
],
"triggers": { "crons": ["15 * * * *"] }

Hourly, on purpose. A daily-peak day is sampled through the day, and every period’s report lands in its last day whatever hour the period ends at. createEntrypoint fires the Workflow on the cron, and c.var.workflows.trigger("payments/usage", {}) runs it on demand.

The entry and the cron are yours, and the CLI leaves them be. pithy worker sync keeps them where you wrote them, and pithy doctor and pithy deploy do not count them against your app’s own Workflows. Write the cron exactly as 15 * * * *: the scheduled handler starts a job only when the cron that fired is its schedule, character for character.

Read what it did

Every run is kept in pithy_payments_usage_runs, with its tally and the subjects it did not report and why. GET /payments/admin/usage-runs reads them behind payments:usage:read, its own scope because its findings name customers. That read is the answer to why was this customer not billed.

GET /payments/subscription carries reportedQuantity and reportedAt for a product billed this way: the last count sent, and when. Both are absent from a Worker older than reported billing.

Check it worked

  • The reported product has a billing block, and payments(...) has a resolveQuantity
  • max is a number somebody chose on purpose
  • The Stripe price is metered and reads the meter named in meterEventName, or the Paddle priceId is a one-time price
  • PaymentsUsageWorkflow is exported from the Worker’s entry, and each environment’s wrangler.jsonc binds PAYMENTS_USAGE with an hourly cron
  • After a period ends, the usage-run log shows the subject reported once, and GET /payments/subscription shows the count
ESC