Skip to content
Published on

Designing an Invoicing and Billing System — So the Money Does Not Leak

Authors

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

ComponentDescriptionExample
Invoice numberUnique, sequential identifierINV-2026-000123
Issue dateDate the invoice was issued2026-07-01
Issuer infoSeller name, address, tax numberAcme Corp, tax number 123-45-67890
Recipient infoBuyer name, address, tax numberBeta Inc, address, tax ID
Line itemsList of goods or services soldPro plan 1 month, quantity 10
SubtotalSum before taxUSD 100.00
TaxApplied tax such as VATVAT 10 percent, USD 10.00
TotalFinal amount chargedUSD 110.00
CurrencyBilling currency (ISO 4217)KRW, USD, JPY
Payment termsDue window and methodNet 30, bank transfer
Due dateFinal payment deadline2026-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.

TermMeaning
Due on receiptPay immediately upon receipt
Net 15Pay within 15 days of issue
Net 30Pay within 30 days of issue
Net 60Pay within 60 days of issue
EOMPay 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

ItemFlatUsage-basedSubscription
PredictabilityHighLowHigh
Compute complexityLowHighMedium
Metering neededNoRequiredNo or partial
Main challengeNoneAccurate meteringProration, renewal failure
Typical caseLicensesCloud, APISaaS

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

StateDescriptionPossible next states
draftNot yet finalized, editableissued, void
issuedCharged to the customer, amount fixedpaid, overdue, void
paidPaid in fullrefunded
overduePast due, unpaidpaid, void
voidCancelled, voided for accountingnone (terminal)
refundedRefunded after paymentnone (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 typeCauseResponse
We say success, PSP says failureMissed webhook, unupdated statusCorrect to the PSP status
PSP says success, we have nothingLost webhook, missed responseCreate the payment record
Amount mismatchFee deduction, partial refundReflect fees and refunds
Currency mismatchFX movement, multi-currency settlementRecompute 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.

CurrencyDecimalsMinor unitMeaning of 100
USD2cent1.00 dollar
EUR2cent1.00 euro
KRW0won100 won
JPY0yen100 yen
BHD3fils0.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

PitfallSymptomResponse
RoundingLine sum vs total mismatchConsistent rounding policy, tests
Tax boundaryWrong rate appliedRate snapshot, boundary tests
ProrationPer-customer amount driftClear day-count policy
Currency100x error, total errorPer-currency minor unit, single currency
Double chargeDuplicate paymentIdempotency 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