# The product was the easy part

What a SaaS needs before it can charge anyone: credit billing in Stripe, invoicing and sales tax, email unsubscribe law, terms and privacy, and an admin panel.

By Elson Tan (https://elsontan.com)
Published: 2026-08-08
Reading time: 17 min
Tags: SaaS, Architecture, Cost
Canonical: https://elsontan.com/blog/saas-billing-tax-email-legal-setup/

> Four things have to work before a SaaS can charge anyone, and none of them is the product. Credits, which either expire or do not. Invoicing and sales tax, where Stripe makes you the legal seller. Email, where a message that sells something needs an unsubscribe link. And an admin panel that logs what staff do. Plan all four as features.

---
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](/blog/metering-llm-tokens-for-billing/). 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](https://docs.stripe.com/billing/subscriptions/usage-based/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:

1. **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.
2. **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.
3. **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](https://www.hasil.gov.my/en/e-invoice/reference-for-the-implementation-of-e-invoice/guidelines/)
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](https://stripe.com/tax) calculates what to charge and applies the
[reverse charge](https://docs.stripe.com/tax/zero-tax) 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](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business)
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.

| Email | 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](https://www.rfc-editor.org/rfc/rfc8058.html)
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](/blog/signing-and-hardening-webhooks/), 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:

```ts
// 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](https://clerk.com/docs/guides/billing/overview) 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](https://docs.stripe.com/billing/subscriptions/usage-based/billing-credits), Stripe documentation
- [Zero tax amounts and reverse charges](https://docs.stripe.com/tax/zero-tax), Stripe documentation
- [Stripe Tax](https://stripe.com/tax), Stripe
- [CAN-SPAM Act: A Compliance Guide for Business](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business), Federal Trade Commission
- [RFC 8058: Signaling One-Click Functionality for List Email Headers](https://www.rfc-editor.org/rfc/rfc8058.html), IETF
- [Email sender guidelines](https://support.google.com/mail/answer/81126), Google
- [e-Invoice guidelines](https://www.hasil.gov.my/en/e-invoice/reference-for-the-implementation-of-e-invoice/guidelines/), Lembaga Hasil Dalam Negeri Malaysia
- [MyInvois SDK](https://sdk.myinvois.hasil.gov.my/start/), Lembaga Hasil Dalam Negeri Malaysia
- [Art. 13 GDPR: Information to be provided where personal data are collected from the data subject](https://gdpr-info.eu/art-13-gdpr/), GDPR
- [Clerk Billing](https://clerk.com/docs/guides/billing/overview), Clerk documentation
