@birtalanrobert/billing
v7.1.1
Published
Our own revenue: plans, subscriptions, seat and usage metering, invoicing and the plan-feature gate
Readme
@birtalanrobert/billing
What a customer pays us: a business for its plan, or a player for a pack.
Not to be confused with @birtalanrobert/commerce, which is the
other direction: a business taking money from its own customers through Stripe
Connect, with the funds never touching us. They share a vendor and almost
nothing else — different account, different liability, different answer when one
fails. Conflating them is the mistake that makes both hard to reason about.
Pricing a plan, without a database
import { priceFor, withinLimit, isEntitled } from '@birtalanrobert/billing';
// Five staff included, then graduated bands.
priceFor(plan, 7); // 14_900 + 2 × 2_000
withinLimit(plan, 'locations', 1); // false — one is the ceiling
isEntitled('past_due'); // true, and deliberately sopast_due entitles, because a card expires on a Sunday and nobody reads the
email until Tuesday. A salon whose diary stopped in between has lost bookings it
cannot recover, over an amount it fully intended to pay. How long that is
tolerated is dunningStage, and suspend withholds the service without
deleting anything.
Putting a business on a plan without a payment page
await billing.assign(tenantId, { planCode: 'chain', quantity: 9 });A chain is sold to rather than checked out, a pilot runs for three months on somebody's word, and a business migrating from a competitor is put on the plan it agreed to before a card is ever entered. It is also the only way a deployment with no provider configured can have a subscription at all — which is every deployment before the Stripe account exists.
It refuses to touch a subscription the provider owns: once a card is being
charged on a schedule, a plan code written beside it makes our screen and their
invoice disagree. Changing how many is setQuantity, which tells the
provider. Who assigned it and why is the caller's to record — this package knows
nothing about operators.
Selling a pack, rather than a plan
A premium currency in packs, a ticket, a wedding: one payment, through the same hosted checkout, priced by its amount rather than by a price kept in the provider's dashboard.
await provider.checkout({
mode: 'payment',
customer: 'cus_…',
price: {
amount: 999, // €9.99, the tax inside it
currency: 'EUR',
name: 'A purse: 260 Silver',
taxBehavior: 'inclusive',
taxCode: 'txcd_…',
},
subject: 'account:…',
metadata: { purchase: '…' },
locale: 'ro',
reference: '…', // the provider's idempotency key
successUrl: '…',
cancelUrl: '…',
});
await provider.refund({ payment: 'pi_…', reference: 'refund-…' });
await provider.deleteCustomer('cus_…'); // for an erasure: the payments stay- The amount is the one the product shows. A pack whose price an operator edits in the product's settings is charged as written; a price id in the dashboard would be a second copy of the figure, and the two drift the first time somebody edits one of them.
- Metadata rides on the payment as well as the session, because a refund or a dispute names the payment and never the session.
- A completed checkout is not always a paid one. A bank debit completes
the session days before the money arrives;
paidsays which, and its own event says when it did. - Every refund is heard, ours or not.
charge.refundedarrives for one made in the provider's dashboard too, with how much has gone back altogether, and a dispute is read by the money it moves — withdrawn, or reinstated when it is won. eventIdis the same on every delivery, which is what a product keys on to act on a webhook delivered twice once.- An erasure deletes the customer, and with it the address and name the
provider was given; the payments stay, as accounting law requires. One
already deleted is forgotten all the same, so an erasure retried after a
timeout does not fail on its first attempt.
NoBillingrefuses, as it refuses everything: a customer made by a provider the deployment no longer has cannot be said to be forgotten.
Taking the payment in the product's own page
When buyers should not leave the product to pay, the card form is Stripe's Payment Element, mounted in the product's page with no intent yet. On submit the page makes a ConfirmationToken of what the buyer gave and hands it to the server, which decides the amount and confirms:
const paid = await provider.pay({
customer: 'cus_…',
price: {
amount: 999, // €9.99, the tax inside it
currency: 'EUR',
name: 'A purse: 260 Silver',
taxBehavior: 'inclusive',
taxCode: 'txcd_…',
},
confirmationToken: 'ctoken_…', // from stripe.createConfirmationToken in the page
reference: '…', // the payment's idempotency key
metadata: { purchase: '…' },
});
// paid.status: 'succeeded' | 'processing' | 'requires_action' | 'failed' | 'canceled'
await provider.payment('pi_…'); // asked again when the page says it is done- The tax is still Stripe's.
payworks out the VAT from the billing address in the token — a country is all VAT asks of a buyer outside the United States and Canada — and attaches the calculation to the PaymentIntent, so Stripe records the tax transaction when the payment succeeds and reverses it on a refund, made here or in its dashboard. An address it cannot place is thrown asPaymentLocationError, before anything is charged. The tax worked out rides on the payment, so the payment read back and its events say it too — for a webhook that arrives beforepayhas answered. - A card refused is an answer, not an error. It comes back
failedwith Stripe's code, the issuer's reason and Stripe's sentence; the buyer tries another card, as a payment of its own under a reference of its own. Anything else is thrown. - One reference is one payment. Asked again under it — a retry after a
timeout, or after Stripe failed —
payis answered with the payment already made, never a second; asked with another card while the first may have been made, Stripe refuses it rather than charging twice. A failure thrown is not a payment refused: what was asked may have been done all the same, which the payment's own event then says — so the caller retries under the same reference, never a new one. An address corrected afterPaymentLocationErrorkeeps it, nothing having been asked under it yet: the tax calculation is keyed by what the buyer gave as well. requires_actioncarries a client secret, for the page to finish a 3-D Secure check withstripe.handleNextAction— and is the only status that does.- A dispute's tax is the product's to reverse. Stripe reverses a
refund's tax by itself, never a dispute's:
reverseTaxreverses the tax of the money a dispute withdrew — a partial reversal, by that amount — andrestoreTaxundoes it when the dispute is won, by reversing that reversal in full, as Stripe undoes a reversal. A payment that recorded no tax has nothing to reverse. - A payment's own events —
payment_intent.succeeded,processing,payment_failed,canceled— read askind: 'payment',paidonly on success andfailedon a refusal or a cancellation, with the metadata the payment carried.
Using it in a NestJS application
import { NoBilling } from '@birtalanrobert/billing';
import { StripeBilling } from '@birtalanrobert/billing/stripe';
import {
BILLING_PROVIDER,
BillingService,
billingEntities,
billingMigrations,
} from '@birtalanrobert/billing/nestjs';
@Module({
providers: [
{
provide: BILLING_PROVIDER,
inject: [ConfigModule.token()],
// A deployment with no key still has to start. Constructing the vendor's
// client with an empty string throws inside its own constructor.
useFactory: (config: AppConfig) =>
config.BILLING_SECRET_KEY
? new StripeBilling({ secretKey: config.BILLING_SECRET_KEY })
: new NoBilling(),
},
BillingService,
],
})
export class SubscriptionModule {}Register billingEntities and billingMigrations with the data source, as with
every other package here.
Three entry points, and the split is deliberate. The root is pure — what a
plan costs, what it permits, what to do about an unpaid invoice — because a
console decides all three while somebody is still choosing. /nestjs holds the
service, the entities and the migrations. /stripe holds the vendor's client on
its own, because importing it pulls in the whole SDK.
The parts worth knowing before using it
- A plan is a row, and a price change is a new row. A business that bought
"Salon, 149 lei, five staff included" in March keeps those terms in April. A
subscription stores the plan's
code, never a key into something editable. - Tiers are graduated, not volume. "Five included, then twenty each" charges twenty for the sixth, not twenty for all six. Past the last band, the last band's rate keeps applying — the alternative is an invoice that has been wrong in the customer's favour for a year.
- Absence is the gate. No deny list: a feature nobody added to a plan is one nobody can use, which is noticed at once.
- Usage is counted here and reported in a batch. A provider call on the path of every text message sent is a provider outage that stops the product.
- Two ways to count, and the product picks by what it is counting.
recordadds, for an event as it happens — a message sent, a pack bought.recountsets, for a table read on a schedule — tickets issued yesterday — where adding would double the day on a second run and could never take a refunded ticket off again.usageBetweenreads a period back, because the table has a policy on it and a product summing it for itself would read nothing and report success. - The subscription row is written when the provider says money moved, not on the way to the payment page — that is a subscription for somebody who closed the tab.
- A webhook about a subscription we have no record of is ignored, never inserted: it belongs to another environment sharing the provider account.
