Build a billing page
Show the plans, the current plan, usage and the price of a change, then collect it.
A billing page answers four questions for a customer: what can I buy, what am I on, how much have I used, and what does a change cost? Paykit supplies a read for each one, and the same catalog your deploy validated.
Read the plans for sale
The browser reads the catalog the deploy recorded. It is validated on arrival, so a pricing page cannot show a price the deployment does not sell.
import { CatalogService, PaykitCatalogClient } from "@potion.run/paykit";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as Layer from "effect/Layer";
const LiveCatalog = PaykitCatalogClient("/api/billing/catalog").pipe(
Layer.provide(FetchHttpClient.layer),
);
export const Plans = Effect.gen(function* () {
const catalog = yield* CatalogService;
return catalog.tiers;
}).pipe(Effect.provide(LiveCatalog));
Render tier.name, tier.price and the plan’s grants. A plan with no price
is the free tier.
Show the plan the customer holds
Customers.get() returns the plans on the account, their status, and any plan
scheduled to replace them.
const customers = yield* Paykit.Customers(account);
const customer = yield* customers.get();
customer.plans;
// [{ planId: "pro", version: 1, status: "active", scheduledPlanId: "scale" }]
customer.billingControls?.spendLimits;
A status of trialing, active, grace, canceled or expired is the same
value a check uses, so the badge and the gate agree.
Show usage
A balance is what the plan granted, what the account used, and what is left. Draw
a meter from granted and remaining, and use nextResetAt for the reset line.
const balances = yield* Paykit.Balances(account);
const { balances: all } = yield* balances.list();
for (const balance of all) {
balance.featureId; // "messages"
balance.granted; // 2_000
balance.remaining; // 1_240
balance.nextResetAt; // when it returns to 2_000
}
remaining is null for an unlimited item, which the page should show as
unlimited instead of a full bar.
Quote a change before charging
Preview runs the same decision a command would, and records nothing. Show the
answer first, then ask the customer to confirm.
const preview = yield* Paykit.Preview(account);
const quote = yield* preview({ command: { kind: "changeTier", tier: "scale" } });
if (quote._tag === "accepted" && quote.proration !== undefined) {
quote.proration.due; // minor units. Negative means the account is owed.
}
A refusal comes back as _tag: "rejected" with the reason, not as a failure, so
the page can explain why the plan cannot move.
Make the change
ChangeTier records the move and the proration. The provider collects the money
in a separate call, so a failed charge does not undo the plan change.
const changeTier = yield* Paykit.ChangeTier(account);
const moved = yield* changeTier({ tier: "scale", dedupeKey: requestId });
if ((moved.proration?.due ?? 0) > 0) {
const charge = yield* Paykit.ChargeProration(account);
yield* charge();
}
The dedupeKey makes a retried click one move. A downgrade’s credit is recorded
on the standing. Paykit does not pay money out for it.
Sell a plan the account does not hold
For a first purchase or a change of plan, Subscribe walks the provider’s
purchase path and tells you where to send the customer.
const subscribe = yield* Paykit.Subscribe(account);
const result = yield* subscribe({ tier: "pro" });
if ("url" in result) redirect(result.url); // hosted checkout
if ("invoiceId" in result) showInvoice(result.invoiceId);
if ("activated" in result) reload(); // a free plan, active now
The plan activates when the provider confirms the payment, not when the customer leaves the checkout page.
Send them to the provider for card and invoices
The card form, the invoices and the cancellation flow belong to the provider.
Portal returns its hosted page:
const portal = yield* Paykit.Portal(account);
const { url } = yield* portal();
A provider without a portal refuses with ProviderCapabilityMissing. Manual
is one, so a page using an invoiced provider needs its own route for invoices
instead of a portal button.
What the page does not do
It does not compute a price. The catalog owns prices, the model owns proration, and the page renders their answers. If the page multiplies a price by a quantity, it will disagree with the invoice.
See Check for the gate behind the page, and Providers for the capabilities each provider declares.
The atom client’s page is make.