The feature worked. I could put a document in, get structured output back, and the whole thing ran on a stack I understood. I thought I was close to launching.
I was not close. What stood between me and a first paying customer was a credit ledger, a tax question I could not answer, an email header I had never heard of, a legal document I was not qualified to write, and an admin panel to fix the things that would go wrong in all four. None of that is product work. All of it has to exist before you can charge anyone.
They also arrived in that order, and not by coincidence. Each one was created by the one before it. Deciding how to charge produced a document. The document raised the question of who owed the tax printed on it. The document and the charge both produced email, which turned out to be regulated. The tax details and the email consent were both personal data I was now collecting, which is what the terms and the privacy policy govern. And every one of the four eventually broke in a way only I could fix, which is the admin panel.
So it starts with the charge, because everything downstream inherits its shape.
What does “a credit” mean, exactly?
I had already decided to bill on tokens rather than messages, for reasons I wrote up in metering tokens when the bill is the product. That decides the unit. It does not decide the accounting, and the accounting is where the product decisions hide.
The decision is what the customer is buying when they buy “100 credits a month”, and that phrase can mean at least four different things. Customers assume whichever one suits them.
| Model | Revenue shape | What the customer feels | What it costs you |
|---|---|---|---|
| Monthly allowance, expires | Predictable, recognised monthly | Punished for a quiet month | Support tickets about lost balance |
| Purchased pack, never expires | Lumpy, deferred | Fair, it was paid for | An open-ended liability on your books |
| Auto-reload at a threshold | Smooth, usage-tracking | Invisible until the card statement | Runaway spend, disputes, chargebacks |
| Allowance plus paid packs | Predictable base, usage upside | Understandable | Two balances to draw down in the right order |
I ended up in the last row, which is the awkward one to implement and the only one I could defend in writing. A monthly allowance that expires, plus top-up packs that do not, and a drawdown rule that always spends the expiring balance first. If you spend the non-expiring balance first you have deleted something the customer paid for, and they find out when they go looking for it.
Stripe calls these prepaid credits and models them with
billing credits,
where a credit grant carries an optional expires_at. Leave it unset and the credits never expire,
which is exactly the primitive the two-balance design needs. Two limits matter before you build on
it: a customer can hold at most 100 unused credit grants at a time, and once a grant has
been applied to an invoice you can expire it but not void it.
Auto-reload is the one I would think hardest about. It is the best experience of the four and the only one whose failure is silent while it is happening: an agent in a retry loop can drain a card at machine speed, and the customer finds out from their bank. If you offer it, cap the number of reloads per period rather than only the amount per reload. A cap on the amount limits one mistake. A cap on the count limits a loop.
Then the charge has to become an invoice
Settling on credits and a drawdown order tells you what to charge. It does not produce the artefact the customer’s accounts department asks for two days later, and that artefact is a separate build.
Taking the money is a Stripe call. Producing a document a finance team will accept is a different job, and it is the one that starts generating email.
Three things I did not expect to matter:
- Invoices need to be reissued. A customer will ask for the same invoice with a company name, a registration number or a purchase order number added. Stripe can regenerate a hosted invoice, but only if you kept the customer’s billing details as editable data rather than baking them into the charge.
- A refund and a credit note are different events. A refund moves money. A credit note adjusts what was invoiced. Accountants care which one happened, and picking the wrong one produces books that do not reconcile.
- Failed payments are a state machine, not an error. Dunning, the part of subscription billing everyone defers, is retries, emails, a grace period and eventually a downgrade. That downgrade path touches your entitlement code, so it is product work wearing a billing costume.
What does Malaysia’s e-Invoice require from a SaaS?
Those three assume the invoice is yours to design. In a growing number of countries it is not.
Tax rules are per country, and some of those countries have opinions about the invoice itself rather than only the tax on it. A PDF that satisfies a US customer is not a legal invoice everywhere it lands.
Malaysia is the case I deal with. LHDN’s e-Invoice regime routes invoices through the MyInvois portal for validation before they count, and the rollout is threshold-based: large companies from August 2024, then downward, reaching businesses with revenue between RM1 million and RM5 million on 1 January 2026. An e-Invoice carries 55 data fields, and several of them describe the buyer.
That last part is the bit that reaches into your signup form. You cannot generate a compliant e-Invoice from an email address and a card token, because the buyer’s tax identity is a required field and only the buyer can tell you it.
| Buyer | What you have to collect | Where it comes from |
|---|---|---|
| Malaysian company | TIN, business name, SSM registration number, SST number if registered, address | Their finance team, at checkout |
| Malaysian sole proprietor | NRIC-linked TIN, name, address | The individual, who may not know their TIN |
| Individual consumer | Nothing, if they do not request one | Consolidated submission covers them |
| Foreign customer | Nothing under this regime | Out of scope, different rules apply |
So the schema gets a nullable tax profile per account, the checkout gets an “invoice details” step you can defer without blocking the sale, and the account settings get a place to fill it in later. The part I underestimated is that this is a collection problem before it is a submission problem: the integration with the portal is finite work, and chasing 40 customers for a TIN they have to go and look up is not.
Whatever country you sell from, ask early whether your invoice is a document you generate or a document a tax authority has to accept. The answer changes the signup form, and the signup form is the most expensive thing to change later.
Who actually owes the tax on it?
Collecting a tax ID and printing a tax line raises the obvious question, and I had the answer wrong for a long time. You owe it.
With Stripe you are the merchant of record: the legal seller. Stripe processes payments. Stripe Tax calculates what to charge and applies the reverse charge when a cross-border business customer supplies a valid tax ID. What it does not do is register you in a jurisdiction, file a return, or absorb the liability if you should have been registered and were not.
| Stripe plus Stripe Tax | Merchant of record platform | |
|---|---|---|
| Legal seller | You | The platform |
| Tax calculation | Automatic | Automatic |
| Registration and filing | Yours, per jurisdiction | Theirs |
| Liability if you get it wrong | Yours | Theirs |
| Cost | Processing plus a Tax fee | A noticeably higher percentage |
| Control over billing logic | Complete | Whatever they expose |
The fee gap between the two is what the liability transfer costs. That is the whole trade, and it is a business decision rather than an engineering one. Engineering only inherits it afterwards: if you go the merchant of record route, your entitlement system is now downstream of a webhook you do not control.
Tax ID validation is also not tax ID verification. Stripe validates format synchronously and checks EU VAT, GB VAT and Australian ABN numbers asynchronously against government databases, which means a customer can complete checkout with a number that later comes back invalid. If you granted a reverse charge on that number, the tax was yours to pay. So treat validation as a state on the customer record, not as a checkout gate, and reconcile it later.
Which of those emails needs an unsubscribe link?
By this point the system sends mail for its own reasons: a receipt, a validated invoice, a failed payment, a balance running low. Add the mail you want to send about the product and you have an email system, which is regulated in ways the billing code is not.
The rule is that any message whose primary purpose is commercial needs an unsubscribe link, and the label you attach has nothing to do with which those are.
I assumed there were two clean categories, transactional and marketing, and that the first was exempt. The categories are real. The boundary is not where I thought. Under CAN-SPAM the FTC applies a primary purpose test to the content: if the subject line reads like an advertisement, or the transactional content is buried below promotional content, the message is commercial. Adding “upgrade to Pro” above the reset link in a password reset email reclassifies that email.
The penalty is assessed per message. The FTC’s 2025 penalty adjustment puts the maximum civil penalty at $53,088 per violating email, with no overall cap. A single bad batch is arithmetic you do not want to do.
| Primary purpose | Unsubscribe required | Suppression list applies | |
|---|---|---|---|
| Address verification | Transactional | No | No |
| Password reset | Transactional | No | No |
| Receipt or invoice | Transactional | No | No |
| Payment failed, action needed | Transactional | No | No |
| Usage at 80% of allowance | Arguably either | Treat as yes | Yes, its own category |
| New feature announcement | Commercial | Yes | Yes |
| Onboarding drip after signup | Commercial | Yes | Yes |
Two implementation consequences follow from that table.
First, one global emailOptOut boolean is the wrong shape. A user who unsubscribes from feature
announcements has not asked to stop receiving invoices, and if your flag suppresses both you have
broken billing to satisfy a marketing preference. Preferences belong per category, with the transactional
ones shown as locked and a line explaining that they carry your receipts.
Second, the unsubscribe has to be one click. RFC 8058
defines a List-Unsubscribe-Post: List-Unsubscribe=One-Click header alongside List-Unsubscribe,
which lets Gmail or Yahoo unsubscribe the recipient by POSTing to your endpoint directly. Both
providers have required it since February 2024 for senders above 5,000 messages a day, and the
request must be honoured within 48 hours.
That endpoint is unauthenticated by construction. The mail client POSTs on the recipient’s behalf with no session. So the URL carries a signed, single-purpose token scoped to one subscriber and one list, it must be idempotent, and it must not accept a GET that changes state, because scanners and link previewers will fetch it. This is the same signed-payload discipline as webhook signing, pointed the other way.
Region matters too. CAN-SPAM is opt-out: you may email until told to stop. GDPR is opt-in: you need consent before the first marketing message, and the burden of proving you had it is yours. Canada’s CASL is opt-in as well. If you sell to Europe, the consent record is part of the signup flow, not a compliance task for later.
Where the terms of service and privacy policy come in
Count what the previous three sections added to the signup form without ever being about it: a tax identity, a billing address, a marketing consent, and a record of what the customer agreed to pay for. That is personal data, collected for stated purposes, some of it under a legal obligation to keep. The terms of service and the privacy policy are the documents that describe all of it, which is why they arrive here rather than at the start.
I am not a lawyer and this is not legal advice. There is still an engineering part, and it is the part that gets skipped.
The engineering part is the record of assent. Courts enforce clickwrap agreements because there is evidence the user was shown the terms and took an affirmative action; browsewrap, where the terms sit behind a footer link and consent is implied by use, fails far more often for exactly the lack of that evidence. Which means the schema matters:
// Not enough: proves nothing once the document has been revised.
acceptedTerms: boolean
// What you actually need on the user record.
type TermsAcceptance = {
documentSlug: 'terms-of-service' | 'privacy-policy' | 'dpa'
version: string // the exact published revision
acceptedAt: number
method: 'signup-checkbox' | 'reacceptance-modal'
ipAddress: string
}
Version the documents, keep every published revision addressable, and store which one the user clicked. When you materially change the terms you need re-acceptance, and re-acceptance is a migration: an existing user hits a blocking modal on next login, and you need to know who has and has not passed through it.
The privacy policy carries a maintenance obligation. GDPR Article 13 sets out the disclosures a policy has to make, and “we may share data with trusted partners” does not satisfy the transparency requirement. Your subprocessors have to be named. For an AI product that list is long and it moves: the model provider, the hosting platform, the payment processor, the email sender, the error tracker, the analytics tool. Every time you switch model provider you have changed who processes customer data, which means the policy is now wrong until you update it and, under most business data processing agreements, customers are entitled to notice before the change takes effect.
So the subprocessor list belongs in version control next to the code, not in a document someone edited once. Adding a vendor and updating the list should be the same pull request.
How much should the admin panel be able to do?
Less than you will want it to at 2am, and everything it does should leave a record.
This is the section the other four produce. Each of them fails in a way the customer can see and cannot fix, and none of those failures has a self-service path. A payment succeeded and the entitlement did not apply. A customer bought credits on the wrong account. A TIN was entered wrong and the invoice was rejected. Someone unsubscribed and needs their invoices back. Without an admin panel, the answer to each is a database query written by hand at speed, against production, with no audit trail. That is worse than the panel you did not want to build.
| Action | In the panel | Why |
|---|---|---|
| Read any account’s state and balance | Yes | The first question in every ticket |
| Grant credits, with a reason field | Yes | The most common fix, and it is money |
| Change plan or entitlement | Yes | Webhook failures leave this out of sync |
| Cancel or refund | Link to Stripe | Stripe is the source of truth, do not fork it |
| Resend a transactional email | Yes | Cheap, safe, resolves a lot of tickets |
| Impersonate a user | Yes, but logged and time-boxed | Sometimes the only way to see the bug |
| Edit usage history | No | It is the billing record, adjust with a credit |
| Delete an account | Yes, and it must actually delete | This is a GDPR obligation, not a nice to have |
What the panel should own comes down to which system holds the source of truth. Subscription state, invoices and refunds live in Stripe, and the panel should deep link there rather than reimplement them, because two systems that both think they own a subscription will disagree and the customer will find out first. Credit balances, entitlements and email preferences are mine, so the panel owns those outright.
Two things make the difference between a panel that helps and a panel that becomes its own incident. Every mutating action writes an audit entry naming the admin, the target, the before and after value and a required free-text reason. And impersonation is a session that expires and is visibly logged, not a switch that leaves you inside someone’s account without either of you knowing.
Should you use Clerk?
Clerk answers the authentication and organisation half of this well, and if you are building B2B where accounts have teams and roles, that half is bigger than it looks. Clerk Billing also handles the subscription UI and entitlement checks, and it connects to your own Stripe account rather than sitting in front of it, at roughly 0.7% on top of Stripe’s own fees.
Two facts decide it for a lot of products, and neither is about developer experience. Clerk Billing does not yet support tax or VAT, which is the hardest problem in the whole billing section. And it is unavailable in several countries, including Malaysia, Brazil, India, Mexico, Singapore and Thailand, which rules it out for me on billing regardless of how good it is.
My read: use it for authentication and organisations, where the alternative is months of work you will not do better. Be more careful about the billing layer, because plans, credits and entitlements are where your product logic actually lives, and a percentage on top of every transaction to host that logic elsewhere is a decision you should make deliberately rather than by default.
What this actually costs
Everything above is undifferentiated. SaaS billing, tax, consent and support tooling have roughly the same shape in every product, and no customer chose mine because of the credit drawdown order or the unsubscribe header. It is the licence to charge money at all, and it is the reason the gap between “the demo works” and “I can invoice someone” is measured in weeks.
My mistake was reading that chain as a checklist. Four separate items to work through in whatever order, at the end, once the product was done. They are not four items, they are one sequence, and each link narrows the one after it. The credit model constrained the pricing page. The invoice format constrained the signup form. The subprocessor list constrained which model provider I could switch to. The admin panel needed an audit table that should have been in the first migration.
Four of those land in the schema: the credit ledger, the tax profile, the terms acceptance record and the audit log. That is the part worth pulling forward. Design those on day one, where they cost a migration you have not written yet, and the rest can wait until there is someone to charge.
None of this argues for building it all up front. It argues for knowing it exists, and for putting it on the plan as four systems with real cost rather than as a checklist item called “billing”.
Sources
- Billing credits, Stripe documentation
- Zero tax amounts and reverse charges, Stripe documentation
- Stripe Tax, Stripe
- CAN-SPAM Act: A Compliance Guide for Business, Federal Trade Commission
- RFC 8058: Signaling One-Click Functionality for List Email Headers, IETF
- Email sender guidelines, Google
- e-Invoice guidelines, Lembaga Hasil Dalam Negeri Malaysia
- MyInvois SDK, Lembaga Hasil Dalam Negeri Malaysia
- Art. 13 GDPR: Information to be provided where personal data are collected from the data subject, GDPR
- Clerk Billing, Clerk documentation
Elson Tan