@kilnjs/payments
v0.1.1
Published
The payments contract for KilnJS: one shape for a purchase whoever took the money, with each provider as a package of its own
Readme
@kilnjs/payments
One shape for a purchase, whoever took the money. The contract and the registry live here; each provider is a package of its own, the way the storage drivers are.
yarn add @kilnjs/payments @kilnjs/payments-stripe// app.config.ts
import { payments } from "@kilnjs/payments";
import "@kilnjs/payments-stripe";
export default {
modules: [payments()],
} satisfies AppConfig;// src/payments.ts
import type { PaymentDelegate } from "@kilnjs/payments";
export default {
handle: async (event) => {
if (event.kind === "purchased") await grant(event);
if (event.kind === "refunded") await revoke(event);
},
} satisfies PaymentDelegate;Importing a provider package is what installs it. Declaring the module mounts one notification route per installed provider, verifies what arrives, and hands the delegate a normalised event.
| | |
| --- | --- |
| @kilnjs/payments-stripe | /payments/stripe |
| @kilnjs/payments-play | /payments/play |
| @kilnjs/payments-appstore | /payments/appstore |
Why normalise at all
Because the three disagree about everything. A renewal is
invoice.payment_succeeded, SUBSCRIPTION_RENEWED as the integer 2, and
DID_RENEW. A cancellation is a deleted subscription, a 3, and a type whose
meaning depends on its subtype. An application that branches on those directly
has the same logic three times, and the third copy is the one with the bug in
it.
So a provider answers two questions in one vocabulary:
What just happened is a PaymentEvent: a kind out of nine - purchased,
renewed, cancelled, expired, refunded, paused, recovered,
billing_failed, other - with the identifier the provider uses, what was
bought, who bought it as far as the provider knows, and the provider's own
event kept whole in raw.
What is true now is an Entitlement, asked of the provider rather than
worked out from events. That distinction is the one worth keeping: events are
missed, delivered twice and delivered out of order, and the provider is the
only thing that knows the current answer.
const held = await Payments.stripe.entitlement("sub_1");
if (held.state === "active") serve();What this contract deliberately does not cover
Creating a checkout session, refunding, changing a plan, reading an invoice. Each of those is one provider's own surface, reached through its own package: a contract spanning all three would be the lowest common denominator of three unrelated businesses, and the first thing anybody needed would be underneath it.
Two decisions worth knowing about
A notification is answered only once the delegate is done. All three
providers retry on a failure, which is what makes an acknowledgement sent
before the work a quiet way to lose events. Raising inside handle is
therefore how an application says "try me again"; returning is how it says "I
have this, even if I did nothing with it".
The body is verified as it arrived. A signature covers bytes, so these routes keep the raw body while every other route in the application keeps the parsed one. Verifying against a re-serialised object fails for reasons nobody can debug: whitespace, key order and number formatting all differ.
Verifying
yarn verify # ts:check, lint, build, exports:check, testNo provider is reached. What each provider's own package proves about verification is in its own suite, and those are the tests worth reading: every way a forged notification can be wrong has a case.
