←all writing
1413 Jan 2026/5 min read

One SaaS, many countries: what we keep in data and not in code

How the Happy Pet Tech SaaS handles tax, currency, phone numbers and wording for businesses in different countries, why old invoices never change when a tax rate is edited, and where we still have country checks in code.

SaaSArchitectureTax
Illustration for the post: One SaaS, many countries: what we keep in data and not in code

The Happy Pet Tech SaaS has customers in India, the UAE, Australia, the Philippines and Thailand. A grooming salon in Pune and one in Dubai use the same software, but their bills do not look the same.

We run one codebase for all of them. The differences live in data. This post shows what that data looks like, one bug class it prevents, and where we have not managed to follow our own rule.

A country is a record

Every company in the system points to a country. The country is a document in the database, and it carries what changes from place to place:

  • Currency: the code, the symbol, and how many decimal places to show.
  • Phone: the dial code, and the shortest and longest valid number.
  • Time zone.
  • Words: what the state, city and postcode fields are called in that country.
  • Tax words: what the tax is called and what the business’s tax number is called, like “GST Number”.
  • Payment methods: which ways of paying are offered at the counter, for example UPI in India.

When we add a country, we add a record. The signup form, the invoice and the customer form read from it.

The phone fields do more work than they look. Our WhatsApp messages need the full number with a country code. Staff type numbers without one. So the dial code from the country record is what turns 98765 43210 into a number WhatsApp accepts.

Tax is the hardest part

Each country has its own tax, with its own name and its own rates. We keep a small table of defaults:

const DEFAULT_TAX_PERCENTAGES_BY_COUNTRY_SLUG = {
  india: [0, 5, 12, 18],
  'united-arab-emirates': [0, 5],
  philippines: [0, 12],
  thailand: [0, 7],
  australia: [0, 10],
};

const TAX_TYPE_BY_COUNTRY_SLUG = {
  india: 'GST',
  'united-arab-emirates': 'VAT',
  philippines: 'VAT',
  thailand: 'VAT',
  australia: 'GST',
};

When a business signs up, it gets its own copy of the rates for its country. After that the rates belong to the business. The owner can rename them, add one or remove one. We do not read the default table again for that business.

Copying sounds wasteful. It means each business can adjust its own rates without touching anyone else’s, and we never need code for one business’s exception.

Why an old invoice never changes

Here is the bug this design prevents.

A salon charges 18% tax. In March the owner edits the tax to 12%, because the rule changed. What should a January invoice show when someone opens it in April?

If the invoice stores a link to the tax, it now shows 12%. The total no longer matches what the customer paid. For an accountant this is a disaster.

So an invoice never points at the live tax. Every time a tax is created or edited, we also write a snapshot to a history collection. A snapshot is never changed after it is written. The invoice points at the snapshot that was current when the invoice was made.

Editing a tax creates a new snapshot. New invoices use the new one. Old invoices keep the old one, forever.

The same idea applies to anything on a financial document that a user can edit later: a price, a service name, a discount. Copy the value onto the document, or point to a version that cannot change. Never point to the live record.

India needs more fields

Some countries need things the others do not. India is the clear case. An Indian invoice needs the business’s GST number, and services need an SAC code, which is the tax category of the service.

These are extra fields, shown only when the country needs them. A salon in Australia never sees an SAC code field.

Where we break our own rule

The rule is simple to say: no country names in code. If the code needs to behave differently, the country record gets a new field, and the code reads the field.

We do not fully live by it. If you search our code for 'india', you find it. Most of the hits are the SAC code and the GST number: “show this field if the country is India”. There are around a dozen such checks in the web app and a few in the API.

Each one was the fast choice on the day it was written. Adding a field to the country record means a migration, a change to the admin screen, and a default for every other country. Writing if (country === 'india') takes one line.

The cost comes later. The next country with a similar tax code will need the same fields, and someone will have to find every one of those checks. A field called requires_service_tax_code would have served both.

So the rule I push for now is a softer one. A country check in code is tolerated when there is one country and no time. When a second country needs the same behaviour, the check becomes a field.

Defaults are not the same as supported

Our default tables have entries for more countries than we have customers in. Adding a row for a country takes five minutes. It does not mean the product works there.

Working in a country means someone has checked the invoice against a real one from that country, tried the phone number rules with real numbers, and sent a real WhatsApp message. Those are two different lists: the countries that have data, and the countries where a customer can run a business on us.

← all writing nextHow I went from Shopify developer to CTO →