Off-the-shelf invoicing tools work for manual billing workflows. They become a constraint when you need billing embedded in a SaaS product, multi-currency support, GCC VAT compliance, or automated overdue follow-up built into your operational logic. Building it in Go gives you the control that third-party integrations cannot.
Off-the-shelf invoicing tools work for manual billing workflows. They become a constraint when you need billing embedded in a SaaS product, multi-currency support, GCC VAT compliance, or automated overdue follow-up built into your operational logic. Building a purpose-built billing system in Go gives you full control over the financial logic without the limitations of third-party integrations.
Why financial systems need different engineering defaults
Two engineering defaults that are fine in most systems cause real problems in billing:
Float arithmetic introduces rounding errors at the fourth decimal place. In financial calculations, this accumulates. A SaaS product processing 10,000 invoices per month will show balance discrepancies that grow over time if amounts are stored as floats. PostgreSQL's NUMERIC type stores exact decimal values with no floating-point representation error. Every money column must be NUMERIC(15,2).
Eventual consistency in payment workflows creates double-charge risk. If a user clicks "Pay" twice and both requests reach the server before the first payment is confirmed, you need idempotency guarantees at the invoice and payment level. A unique constraint on (invoice_id, payment_provider_reference) in the payments table prevents duplicate charges at the database level.
Schema design for a multi-currency invoicing system
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
customer_id UUID NOT NULL,
invoice_number TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
currency CHAR(3) NOT NULL DEFAULT 'USD',
subtotal NUMERIC(15, 2) NOT NULL DEFAULT 0,
tax_amount NUMERIC(15, 2) NOT NULL DEFAULT 0,
discount_amount NUMERIC(15, 2) NOT NULL DEFAULT 0,
total_amount NUMERIC(15, 2) NOT NULL DEFAULT 0,
notes TEXT,
due_date DATE,
issued_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, invoice_number)
);
CREATE TABLE invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity NUMERIC(10, 3) NOT NULL,
unit_price NUMERIC(15, 2) NOT NULL,
tax_rate NUMERIC(5, 4) NOT NULL DEFAULT 0,
line_total NUMERIC(15, 2) NOT NULL
);
CREATE TABLE invoice_sequences (
tenant_id UUID PRIMARY KEY,
last_seq BIGINT NOT NULL DEFAULT 0
);
The UNIQUE (tenant_id, invoice_number) constraint prevents duplicate invoice numbers per tenant at the database level.
Sequential invoice number generation
Invoice numbers need to be sequential, tenant-scoped, and generated atomically. PostgreSQL's ON CONFLICT DO UPDATE handles this without advisory locks:
func (s *InvoiceService) nextInvoiceNumber(ctx context.Context, tx pgx.Tx, tenantID uuid.UUID) (string, error) {
var seq int64
err := tx.QueryRow(ctx, `
INSERT INTO invoice_sequences (tenant_id, last_seq)
VALUES ($1, 1)
ON CONFLICT (tenant_id) DO UPDATE
SET last_seq = invoice_sequences.last_seq + 1
RETURNING last_seq
`, tenantID).Scan(&seq)
if err != nil {
return "", fmt.Errorf("invoice sequence: %w", err)
}
return fmt.Sprintf("INV-%d-%06d", time.Now().Year(), seq), nil
}
This runs inside the same transaction as the invoice insert. If invoice creation rolls back, the sequence increment rolls back with it. Sequence gaps are acceptable. Duplicate invoice numbers are not.
Tax calculation for multi-jurisdiction billing
GCC VAT rates differ by country: 15% in Saudi Arabia, 5% in the UAE and Bahrain, 10% in Bahrain after the 2023 rate change, no VAT in Lebanon. Lebanese businesses billing Gulf clients need the correct rate based on the customer's jurisdiction:
CREATE TABLE tax_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_code CHAR(2) NOT NULL,
tax_name TEXT NOT NULL,
rate NUMERIC(5, 4) NOT NULL,
effective_from DATE NOT NULL,
UNIQUE (country_code, effective_from)
);
INSERT INTO tax_rules (country_code, tax_name, rate, effective_from) VALUES
('SA', 'VAT', 0.1500, '2020-07-01'),
('AE', 'VAT', 0.0500, '2018-01-01'),
('BH', 'VAT', 0.1000, '2022-01-01'),
('LB', 'No Tax', 0.0000, '2000-01-01');
The effective_from date means historical invoices reflect the rate that was current when they were issued. Tax rates change; auditing historical invoices requires knowing what rate applied at the time of issuance.
Automated overdue invoice follow-up
Manual follow-up on unpaid invoices is the most common billing bottleneck for SaaS companies across MENA. A background job running daily sends staged reminders based on days overdue:
type OverdueStage struct {
MinDays int
MaxDays int
Tone string
}
var overduePipeline = []OverdueStage{
{MinDays: 1, MaxDays: 6, Tone: "gentle"},
{MinDays: 7, MaxDays: 29, Tone: "firm"},
{MinDays: 30, MaxDays: 9999, Tone: "final"},
}
func (s *InvoiceService) ProcessOverdueNotifications(ctx context.Context) error {
now := time.Now()
for _, stage := range overduePipeline {
invoices, err := s.db.GetInvoicesOverdueByDayRange(ctx, stage.MinDays, stage.MaxDays)
if err != nil {
return err
}
for _, inv := range invoices {
alreadySent, _ := s.db.WasOverdueNotificationSentToday(ctx, inv.ID, stage.Tone)
if alreadySent {
continue
}
s.notifications.SendOverdueReminder(ctx, inv, stage.Tone)
s.db.RecordOverdueNotification(ctx, inv.ID, stage.Tone, now)
}
}
return nil
}
The WasOverdueNotificationSentToday check prevents multiple reminders on the same day if the job runs more than once due to a retry.
PDF invoice generation
Invoices need a print-ready PDF. A Go HTML template rendered to PDF via chromedp or a lightweight renderer:
func (s *InvoiceService) GeneratePDF(ctx context.Context, invoiceID uuid.UUID) ([]byte, error) {
invoice, err := s.db.GetInvoiceWithItems(ctx, invoiceID)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, "invoice.html", invoice); err != nil {
return nil, fmt.Errorf("render invoice template: %w", err)
}
return s.pdfGen.RenderHTML(ctx, buf.String())
}
The HTML template handles RTL layout for Arabic invoices and LTR for English. The same template structure serves both with a direction switch based on the customer's language preference.
Handling partial payments and credit notes
Complex billing scenarios that appear in production involve partial payments and credit notes. The payments table tracks all payments against an invoice:
CREATE TABLE invoice_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
amount NUMERIC(15, 2) NOT NULL,
currency CHAR(3) NOT NULL,
payment_method TEXT NOT NULL,
payment_provider_ref TEXT,
paid_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (invoice_id, payment_provider_ref)
);
A view computes the outstanding balance on each invoice by summing payments against the total:
CREATE VIEW invoice_balances AS
SELECT
i.id,
i.total_amount,
COALESCE(SUM(p.amount), 0) AS paid_amount,
i.total_amount - COALESCE(SUM(p.amount), 0) AS outstanding
FROM invoices i
LEFT JOIN invoice_payments p ON p.invoice_id = i.id
GROUP BY i.id, i.total_amount;
Key lessons from production
Use NUMERIC for all money columns from the start. Converting FLOAT columns to NUMERIC after production data exists requires a migration and an audit of rounding discrepancies.
Generate invoice numbers inside the transaction that creates the invoice. Never generate the number before the transaction starts.
Idempotency on payment records prevents double charges. Add the unique constraint on (invoice_id, payment_provider_reference) before going live.
Store the tax rate on the line item at the time of invoice creation. Rates change; historical invoices must reflect the rate that applied when they were issued.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire builds billing and financial systems for SaaS products operating across Lebanon and the GCC. If you need an invoicing system embedded in your product or billing infrastructure built from scratch, reach out at https://voxire.com/get-a-quote/



