Introduction
A billing system is the last gate where a company revenue actually turns into cash. No matter how good the product is, if invoices are issued incorrectly, payments are charged twice, or taxes are miscalculated, the damage lands squarely on both the company and its customers.
Billing systems are especially hard to walk back once they hit production. An issued invoice becomes a legal and accounting record, and money a customer has already paid cannot be casually edited. That is why a billing system must be designed from day one so that the money does not leak.
This article covers the whole territory of a billing system from a design perspective: invoice components, billing types, taxes, status flow, data model, payment integration, reconciliation, idempotency, multi-country and multi-currency handling, and audit trails. It also walks through the pitfalls you will meet in practice and how to respond to them.
Here is the scope, laid out up front.
- What an invoice is made of (issuer, recipient, line items, tax, currency, payment terms)
- What billing types exist (flat, usage-based, subscription)
- How to handle taxes, VAT, and electronic tax invoices
- What states an invoice moves through (draft, issued, paid, overdue, void, refunded)
- How to model the data
- How to integrate with payment gateways (PG/PSP) and reconcile receipts
- How to prevent double charging (idempotency)
- How to handle multiple countries and currencies
- How to keep an audit trail
- Practical pitfalls and responses
Invoice Components
An invoice is the formal document by which a seller tells a buyer, please pay this amount. It looks simple on the surface, but because it must carry both legal force and an accounting basis, there are mandatory elements it must contain.
Mandatory Components
| Component | Description | Example |
| --- | --- | --- |
| Invoice number | Unique, sequential identifier | INV-2026-000123 |
| Issue date | Date the invoice was issued | 2026-07-01 |
| Issuer info | Seller name, address, tax number | Acme Corp, tax number 123-45-67890 |
| Recipient info | Buyer name, address, tax number | Beta Inc, address, tax ID |
| Line items | List of goods or services sold | Pro plan 1 month, quantity 10 |
| Subtotal | Sum before tax | USD 100.00 |
| Tax | Applied tax such as VAT | VAT 10 percent, USD 10.00 |
| Total | Final amount charged | USD 110.00 |
| Currency | Billing currency (ISO 4217) | KRW, USD, JPY |
| Payment terms | Due window and method | Net 30, bank transfer |
| Due date | Final payment deadline | 2026-07-31 |
The invoice number matters most. For tax and accounting purposes, invoice numbers must have no gaps or duplicates, and many jurisdictions require them to be sequential and continuous. A gap in the numbering invites the audit suspicion that an invoice was issued and then quietly deleted.
Line Items
The heart of an invoice is its line items. Each line expresses what was sold, how much of it, and at what price.
A line item is usually made of these values.
- Description
- Quantity
- Unit price
- Line subtotal (quantity times unit price)
- Tax rate
- Discount
An important principle here: compute each line subtotal first, round it, and then sum the results. If you sum first and round once at the end, the sum of the individual line amounts will not match the total. We cover this rounding issue in detail in the pitfalls section.
Payment Terms
Payment terms define when and how payment must be made. Common expressions include the following.
| Term | Meaning |
| --- | --- |
| Due on receipt | Pay immediately upon receipt |
| Net 15 | Pay within 15 days of issue |
| Net 30 | Pay within 30 days of issue |
| Net 60 | Pay within 60 days of issue |
| EOM | Pay by the end of the month |
In business-to-business (B2B) deals, Net 30 or Net 60 is common, while consumer (B2C) subscriptions usually charge immediately.
Billing Types
The first thing to decide when designing a billing system is how you will collect money. There are three broad types.
Flat / Fixed Billing
The simplest form. You charge a fixed amount on a fixed cadence.
- Example: a USD 29 monthly plan, an annual license fee
- Pros: simple and predictable to compute
- Cons: fixed regardless of usage, so low flexibility
Usage-Based / Metered Billing
You charge for what the customer actually uses. Common in cloud infrastructure, API calls, and message delivery.
- Example: USD 1 per 1,000 API calls, USD 0.02 per GB of storage per month
- Pros: fair charging proportional to usage
- Cons: accuracy and timeliness of usage metering are hard
The core challenge of usage-based billing is how to aggregate usage events accurately and without duplication. Duplicated events cause overcharging; dropped events cause undercharging. That is why usage events also need idempotency keys.
Subscription / Recurring Billing
You charge automatically on a repeating cadence. This is the standard model for SaaS businesses.
- Example: monthly subscription, annual subscription
- Pros: predictable recurring revenue (MRR/ARR)
- Cons: many exceptions to handle, such as mid-cycle plan changes (proration), renewal failures, and card expiry
In practice these three are often combined. A representative hybrid plan is a fixed USD 50 monthly base fee plus usage-based charging for API calls above the included quota.
Billing Type Comparison
| Item | Flat | Usage-based | Subscription |
| --- | --- | --- | --- |
| Predictability | High | Low | High |
| Compute complexity | Low | High | Medium |
| Metering needed | No | Required | No or partial |
| Main challenge | None | Accurate metering | Proration, renewal failure |
| Typical case | Licenses | Cloud, API | SaaS |
Taxes, VAT, and Electronic Tax Invoices
Taxes are the most error-prone area of a billing system. Rules differ by country, and even within one country the rate can vary by product type or customer type.
VAT Basics
VAT (Value Added Tax) is a consumption tax levied on the value added to goods and services. In Korea, a 10 percent rate applies to most goods and services.
There are two concepts you must always distinguish in tax calculation.
- Tax-exclusive: the displayed price does not include tax, so tax is added on top. Common in B2B.
- Tax-inclusive: the displayed price already includes tax. Common for B2C consumers.
To back out the tax from a tax-inclusive price, compute as follows.
Tax-inclusive price = 110,000 (when it includes VAT 10 percent)
Net (subtotal) = 110,000 / 1.10 = 100,000
Tax (VAT) = 110,000 - 100,000 = 10,000
Cross-Border Trade and Reverse Charge
In the EU and elsewhere, cross-border B2B transactions apply the reverse charge rule. The seller does not charge tax; instead the buyer reports and pays the tax in their own country. In this case the invoice must state reverse charge applies rather than showing a rate of 0 percent.
Also, in the EU you must validate that the buyer VAT ID is valid via the VIES system before you can apply reverse charge. If validation fails, the seller must charge tax.
Electronic Tax Invoices
In Korea, businesses above a certain size are required to issue electronic tax invoices. They are issued through the National Tax Service Hometax or an integrated service, and are transmitted to the tax authority immediately upon issue.
From a billing system perspective, electronic tax invoices imply the following.
- Issuing an invoice and issuing a tax invoice can be separate events.
- Once issued, a tax invoice is hard to change, and correcting it requires a separate corrected-tax-invoice procedure.
- The transmission status to the tax authority (success, failure, resend) must be tracked in the system.
Tax Design Principles
- Do not hard-code rates; manage them in a rate table that can be queried by time, region, and product type.
- Store the rate applied at issue time as a snapshot on the invoice. Even if the rate changes later, past invoices must stay intact.
- Always verify boundary values of the tax calculation logic with unit tests.
Invoice Status Flow
An invoice moves through several states from creation to closure. Designing these transitions as an explicit state machine is important. Blocking disallowed transitions is what preserves data integrity.
Main States
| State | Description | Possible next states |
| --- | --- | --- |
| draft | Not yet finalized, editable | issued, void |
| issued | Charged to the customer, amount fixed | paid, overdue, void |
| paid | Paid in full | refunded |
| overdue | Past due, unpaid | paid, void |
| void | Cancelled, voided for accounting | none (terminal) |
| refunded | Refunded after payment | none (terminal) |
There is one crucial rule here. An issued invoice must never be edited. If the amount is wrong, void it and issue a new one, or issue a credit note. Quietly editing an issued invoice is considered accounting fraud.
Status Flow Diagram
+--------+
| draft |
+--------+
| |
issue | | discard
v v
+--------+ +------+
| issued |-->| void |
+--------+ +------+
| |
pay | | overdue (past due)
v v
+------+ +---------+
| paid | | overdue |
+------+ +---------+
| |
refund| | pay
v v
+----------+ +------+
| refunded | | paid |
+----------+ +------+
Partial Payments
In practice, one invoice is sometimes paid in several installments. Rather than treating the invoice status as a simple paid or unpaid flag, it is safer to model payments as a separate entity and compute the total paid against the invoice.
Invoice total = 110,000
Payment 1 (2026-07-05) = 50,000
Payment 2 (2026-07-10) = 60,000
------------------------------------
Cumulative paid = 110,000 -> status paid
Modeling payments as a separate entity lets you handle complex cases like partial refunds, overpayment, and re-payment consistently.
Data Model
Now let us design the actual data model. The core entities are customer, invoice, line item, and payment.
ASCII ERD
+----------------+ +------------------+
| customers | | invoices |
+----------------+ +------------------+
| id (PK) |1 *| id (PK) |
| name |--------| customer_id (FK) |
| tax_id | | number (unique) |
| country | | status |
| currency | | currency |
| created_at | | subtotal |
+----------------+ | tax_total |
| total |
| issued_at |
| due_at |
| created_at |
+------------------+
|1 |1
| |
|* |*
+----------------+ +------------------+
| line_items | | payments |
+----------------+ +------------------+
| id (PK) | | id (PK) |
| invoice_id (FK)| | invoice_id (FK) |
| description | | amount |
| quantity | | currency |
| unit_price | | status |
| tax_rate | | psp_ref |
| line_subtotal | | idempotency_key |
| line_tax | | created_at |
+----------------+ +------------------+
SQL Schema
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
tax_id TEXT,
country CHAR(2) NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
number TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'draft',
currency CHAR(3) NOT NULL,
subtotal BIGINT NOT NULL DEFAULT 0,
tax_total BIGINT NOT NULL DEFAULT 0,
total BIGINT NOT NULL DEFAULT 0,
issued_at TIMESTAMPTZ,
due_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_status CHECK (
status IN ('draft','issued','paid','overdue','void','refunded')
)
);
CREATE TABLE line_items (
id BIGSERIAL PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES invoices(id),
description TEXT NOT NULL,
quantity NUMERIC(18,4) NOT NULL,
unit_price BIGINT NOT NULL,
tax_rate NUMERIC(5,4) NOT NULL DEFAULT 0,
line_subtotal BIGINT NOT NULL,
line_tax BIGINT NOT NULL
);
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES invoices(id),
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
psp_ref TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
A few points deserve attention here.
- All amounts are stored as integers (BIGINT). Storing in the smallest currency unit (won, cents) eliminates floating-point error at the source.
- A UNIQUE constraint on invoices.number prevents duplicate invoice numbers.
- A UNIQUE constraint on payments.idempotency_key blocks duplicate payments at the database level.
- A CHECK constraint on status keeps disallowed status values out.
Why Store Amounts as Integers
Handling money with floating point (float, double) inevitably produces error. For example, 0.1 plus 0.2 does not become exactly 0.3. So money must be handled with an integer type (smallest unit) or a fixed-point (decimal) type.
Wrong: amount = 10.10 (float) -> error accumulates over operations
Right: amount = 1010 (integer, cents) -> no error
When displaying: 1010 / 100 = 10.10
Payment Integration, Reconciliation, and Idempotency
Once you have issued an invoice, you actually have to collect the money. This is where integration with a payment gateway (PG) or payment service provider (PSP) enters.
Basic PG/PSP Integration Flow
[our system] [PSP] [card / bank]
| | |
| charge request | |
|--------------->| |
| | auth request |
| |------------------>|
| | auth response |
| |<------------------|
| charge result | |
|<---------------| |
| | |
| webhook | |
|<---------------| |
The important thing is that you receive the payment result through two channels.
- Synchronous response: the immediate response to the charge request
- Webhook: an asynchronous status-change notification the PSP sends later
Both channels may deliver the same payment status at different times, so the result must be the same regardless of which arrives first. That is precisely why idempotency is needed.
Idempotency
Idempotency is the property that sending the same request many times yields the same result as sending it once. In payments, idempotency is life or death. When a charge request is retried due to a network error, the actual charge must not happen twice.
The standard way to implement idempotency is the idempotency key.
- The client generates a unique key per charge request and sends it along.
- The server checks whether it has already processed a request with that key.
- If it has, it does not process again and returns the stored result as is.
def charge(idempotency_key, invoice_id, amount):
Check whether this request was already processed
existing = payments.find_by_key(idempotency_key)
if existing is not None:
Duplicate request: return the stored result as is
return existing
The UNIQUE constraint lets only one concurrent request win
try:
payment = payments.insert(
idempotency_key=idempotency_key,
invoice_id=invoice_id,
amount=amount,
status="pending",
)
except UniqueViolation:
Race condition: another request inserted first
return payments.find_by_key(idempotency_key)
result = psp.charge(amount)
payment.status = "succeeded" if result.ok else "failed"
payment.psp_ref = result.reference
payment.save()
return payment
The key is to use the database UNIQUE constraint as the safety net. Application-level check-then-insert logic alone cannot prevent the concurrent-request race condition. Only the UNIQUE constraint guarantees that just one of two simultaneous requests succeeds.
Reconciliation
Reconciliation is the work of matching the money our system recorded as received against the money the PSP or bank actually deposited. These two do not always agree.
Common mismatches found during reconciliation include the following.
| Mismatch type | Cause | Response |
| --- | --- | --- |
| We say success, PSP says failure | Missed webhook, unupdated status | Correct to the PSP status |
| PSP says success, we have nothing | Lost webhook, missed response | Create the payment record |
| Amount mismatch | Fee deduction, partial refund | Reflect fees and refunds |
| Currency mismatch | FX movement, multi-currency settlement | Recompute at the settlement rate |
Reconciliation usually runs as a daily batch. You download the settlement report the PSP provides, compare it line by line with our payment records, and flag mismatches for a human to review.
The most important principle in reconciliation is not to mistake our system record for the source of truth. What actually moved is recorded by the bank and the PSP. Our record is the thing that must be matched to theirs.
Multi-Country and Multi-Currency
A global service has to bill customers in many countries in many currencies. This area is very error-prone.
Currency Codes and Minor Units
Manage currencies with the ISO 4217 standard code, three-letter codes such as KRW, USD, JPY, and EUR.
Note that the number of decimal places (minor unit) differs by currency.
| Currency | Decimals | Minor unit | Meaning of 100 |
| --- | --- | --- | --- |
| USD | 2 | cent | 1.00 dollar |
| EUR | 2 | cent | 1.00 euro |
| KRW | 0 | won | 100 won |
| JPY | 0 | yen | 100 yen |
| BHD | 3 | fils | 0.100 dinar |
So if you apply divide the amount by 100 to display logic uniformly to every currency, KRW and JPY come out 100 times wrong. You must manage the decimal places per currency in a table.
FX and No Currency Mixing
Within a single invoice you must use only one currency. If you mix line items in different currencies in one invoice, you cannot compute the total.
When FX is needed (for example, billed in won but settled in dollars), follow these principles.
- Store the amount in the billing currency on the invoice.
- If conversion is needed, store the FX rate at conversion time and the converted result together as a snapshot.
- Because FX rates change over time, do not recompute later; use the stored values.
Billed: KRW 1,100,000
FX rate: 1 USD = 1,375 KRW (as of 2026-07-01, snapshot)
Settled: 1,100,000 / 1,375 = USD 800.00
Audit Trail
A system that handles money must record who changed what, when, and why. This is the audit trail. It is the basis for accounting audits, dispute resolution, and fraud detection.
Audit Trail Design Principles
- Append-only: audit logs are never edited or deleted, only appended.
- Completeness: record every event that changes state.
- Traceability: record the actor, timestamp, before value, and after value for each event.
+---------------------------------------------------------------+
| audit_log (append-only) |
+---------------------------------------------------------------+
| id | entity_type | entity_id | action | actor | at | detail |
|----|-------------|-----------|---------|-------|----|---------|
| 1 | invoice | 123 | created | u:42 | .. | {...} |
| 2 | invoice | 123 | issued | u:42 | .. | {...} |
| 3 | payment | 987 | charged | sys | .. | {...} |
| 4 | invoice | 123 | paid | sys | .. | {...} |
+---------------------------------------------------------------+
Relationship to Event Sourcing
Going one step further, you can use the event sourcing pattern. Instead of the state itself, the sequence of events that changed the state becomes the source of truth. The current state is computed by replaying the events.
Event sourcing pairs well with billing systems. All change history is naturally preserved, you can reproduce the state at any point in time, and it is favorable for reconciliation and auditing. However, implementation complexity rises, so decide on adoption based on system scale and requirements.
Practical Pitfalls
Finally, here are the representative pitfalls that actually make money leak in a billing system.
The Rounding Pitfall
As noted earlier, whether you round per line then sum, or sum then round, can shift the total by the smallest unit.
Line A: 33.333... -> round to 33
Line B: 33.333... -> round to 33
Line C: 33.333... -> round to 33
Sum of lines: 33 + 33 + 33 = 99
Round after sum: 33.333 times 3 = 99.999 -> round to 100
The two approaches differ by 1!
Decide on a consistent rounding policy (for example, always round per line then sum, use banker rounding), document it, and test it.
The Tax Boundary Pitfall
Mistakes cluster around the moment a rate changes (a tax-law revision), exemption determinations, and whether reverse charge applies. Always snapshot the rate as of issue time, and write dense boundary-value tests for the tax logic.
The Proration Pitfall
When a subscription plan changes mid-month, you must prorate over the remaining period.
Monthly fee 30,000 (based on 30 days)
Upgrade to a higher plan (monthly 60,000) on day 15
Refund of old plan for remaining 15 days: 30,000 times 15 / 30 = 15,000
Charge of new plan for remaining 15 days: 60,000 times 15 / 30 = 30,000
Charge the difference: 30,000 - 15,000 = 15,000
If you do not clearly define how many days to divide by (actual days vs a fixed 30) and which side the change day belongs to, amounts will differ subtly from customer to customer.
The Currency Pitfall
- Ignoring per-currency decimals and processing uniformly
- Mixing multiple currencies in one invoice
- Recomputing FX so past amounts change
Following the principles above (per-currency minor-unit table, single-currency invoices, FX snapshots) prevents most of these.
The Double-Charge Pitfall
This is the most fatal pitfall. It happens when the user clicks pay twice, or a network timeout triggers a retry, and the actual charge happens twice.
Set up defenses in several layers.
- Client: prevent double button clicks, generate an idempotency key
- Server: check the idempotency key
- Database: idempotency-key UNIQUE constraint (last line of defense)
- Reconciliation: a batch that finds duplicate payments after the fact
Pitfall Summary Table
| Pitfall | Symptom | Response |
| --- | --- | --- |
| Rounding | Line sum vs total mismatch | Consistent rounding policy, tests |
| Tax boundary | Wrong rate applied | Rate snapshot, boundary tests |
| Proration | Per-customer amount drift | Clear day-count policy |
| Currency | 100x error, total error | Per-currency minor unit, single currency |
| Double charge | Duplicate payment | Idempotency key, UNIQUE constraint, reconciliation |
Conclusion
A billing system is not flashy, but it is core infrastructure with the company revenue and trust on the line. The principles from this article boil down to the following.
- Store amounts as integers (smallest currency unit) to eliminate floating-point error.
- Do not edit issued invoices; correct them with void, refund, or credit notes.
- Design state transitions as an explicit state machine.
- Block payment duplicates at the source with idempotency keys and UNIQUE constraints.
- Do not mistake our record for the source of truth; reconcile against PSP and bank records.
- Respect per-currency minor units, and use one currency per invoice.
- Store tax rates and FX rates as snapshots at issue time.
- Record every change in the audit trail as append-only.
A system where money does not leak is not finished in one shot. Only when the habits of knowing each pitfall, layering multiple defenses, and verifying after the fact through reconciliation accumulate do you finally get a billing system you can trust.
References
- Stripe Invoicing docs: https://stripe.com/docs/invoicing
- Stripe Billing docs: https://stripe.com/docs/billing
- Stripe Idempotent Requests: https://stripe.com/docs/api/idempotent_requests
- PayPal Developer docs: https://developer.paypal.com/
- ISO 4217 currency codes: https://www.iso.org/iso-4217-currency-codes.html
- Idempotence (Wikipedia): https://en.wikipedia.org/wiki/Idempotence
- Martin Fowler, Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html
- PostgreSQL official docs: https://www.postgresql.org/docs/
- EU Taxation and Customs (VAT): https://taxation-customs.ec.europa.eu/
- Korea NTS Hometax electronic tax invoice: https://www.hometax.go.kr/
현재 단락 (1/351)
A billing system is the last gate where a company revenue actually turns into cash. No matter how go...