Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
An expense-management SaaS is an approval workflow on top of a ledger. The workflow routes each claim by policy (amount, category, manager); the ledger records every approved amount as double entry so the books always balance. Build the approval as a state machine, store money as integer minor units, record each reimbursement as a balanced ledger entry, and test that no claim is approved out of policy or paid twice.
This is general engineering guidance, not accounting, tax or compliance advice; confirm expense policy, tax treatment and any licensing with your adviser. If you would rather have it built and tested for you, see how RAITHub would build this below.
What are the two systems inside an expense-management product?
A workflow and a ledger, and they fail for different reasons. The workflow is where a claim is submitted, routed, approved or rejected; it fails when a claim slips through without the right approval, or gets approved twice. The ledger is where the money is recorded; it fails when amounts drift, currencies are mixed, or a reimbursement is paid more than once. Build them as distinct concerns: the workflow decides, the ledger records what was decided, and the join between them is a single, tested step.
| Concern | How it fails | The correct rule |
|---|---|---|
| Approval routing | A claim is approved by the wrong person, or by no one | Policy-driven routing as a state machine; approver roles enforced |
| Double approval | A re-submitted or retried claim is approved again | Idempotent transitions keyed to the claim |
| Money drift | Floats and mixed currencies lose precision | Integer minor units; store the currency; convert explicitly |
| Double payment | A reimbursement is paid twice on a retry | Idempotent payout keyed to the claim; recorded before marked paid |
How do you build the approval workflow?
As a state machine driven by policy data, not hard-coded rules. A claim moves submitted → pending approval → approved or rejected → reimbursed, and the policy decides who must approve: a line manager under a threshold, a second approver above it, finance for certain categories. Store the policy as data so it can change without a code change, and enforce approver roles so only the right person can advance a claim. The role model is in designing SaaS authorization, and the workflow must be multi-tenant from day one, with each company's claims isolated, covered in multi-tenant isolation.
CREATE TABLE expense_claims (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id bigint NOT NULL, -- tenant key: isolate every company
employee_id bigint NOT NULL,
amount_minor bigint NOT NULL, -- integer minor units
currency char(3) NOT NULL,
category text NOT NULL,
state text NOT NULL DEFAULT 'submitted',
-- submitted | pending_approval | approved | rejected | reimbursed
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE claim_events (
id bigserial PRIMARY KEY,
claim_id uuid NOT NULL REFERENCES expense_claims(id),
to_state text NOT NULL,
by_user bigint NOT NULL,
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
How do you record the money?
As double entry, in integer minor units. When a claim is reimbursed, the amount leaves one account and arrives in another, entries are append-only, and the whole ledger sums to zero, which is the invariant that lets you prove the books balance at any moment. Store money as integers in the smallest unit, never floats, so rounding cannot lose a cent. This is exactly the design in double-entry ledger database design; an expense product is one of its clearest uses.
For multi-currency, store the amount and its currency as entered, record the exchange rate used at the moment of conversion, and keep the converted amount as its own value. Never reconvert at display time with today's rate: a claim's recorded value must not change after it is approved. Reconciling what you recorded against what the bank or card actually moved is its own job, covered in payment reconciliation software.
How do you stop a claim being paid twice?
Make the payout idempotent and the approval idempotent. Give each reimbursement a stable key (the claim ID plus an attempt), record the payout result before marking the claim reimbursed, and on a retry return the recorded result instead of paying again. The same reasoning for any money-moving request is in idempotency in API design, and for payment webhooks in testing payments and webhooks. A double-clicked "approve and pay" is how an expense tool reimburses the same claim twice.
How do you test the money logic?
Prove the invariants, not just the happy path.
import { describe, it, expect } from 'vitest'
import { reimburse, ledgerSum } from './expenses'
describe('expense ledger', () => {
it('the ledger sums to zero after a reimbursement', async () => {
const claim = await approveClaim({ amountMinor: 4599, currency: 'USD' })
await reimburse(claim.id, { idempotencyKey: claim.id + ':1' })
expect(await ledgerSum()).toBe(0) // double entry always balances
})
it('does not reimburse the same claim twice', async () => {
const claim = await approveClaim({ amountMinor: 1000, currency: 'USD' })
const key = claim.id + ':1'
await reimburse(claim.id, { idempotencyKey: key })
await reimburse(claim.id, { idempotencyKey: key }) // retry: must be a no-op
expect(await payoutCount(claim.id)).toBe(1)
})
})
Add a test that a claim cannot be approved by someone without the approver role, one that sums many small amounts and asserts the exact integer total (to catch rounding), and one that a company cannot read another company's claims.
Buy, build or hire?
| Option | Choose this when | The catch |
|---|---|---|
| An off-the-shelf expense tool | Your policy, currencies and accounting fit a vendor's model | You fit their workflow, fees and integrations; your own rules and local payouts may not map |
| Spreadsheets and bank transfers | A tiny team and few claims | No enforced approval, no ledger, no audit; drift and double payments are invisible |
| Custom build | You are building an expense-management product to sell, or have policy and ledger needs no tool covers | You own the workflow, the double-entry ledger and the tests that keep the books exact |
How long does it take to build yourself, and what is the risk?
For an experienced team building the approval workflow, a double-entry ledger, multi-currency and idempotent payouts, our estimate is 4 to 6 weeks for a focused first release at a fixed scope, or 6 to 12 weeks with reconciliation, reporting and card feeds. The main risk is the ledger: double entry and idempotency are unforgiving, and a tool that looks right can quietly drift or double-pay under retries. Tax and accounting treatment of expenses varies by jurisdiction; this is general information, so confirm it with your adviser.
Why RAITHub for this
- Ledgers and workflows in production. Sundor Skin enforces credit and tier pricing as append-only entries with a hash-chained audit log (146 tables, 530+ tests); PropDesk runs multi-role approval workflows (1,024 tests). See the Sundor Skin case study.
- Integer money. TheSkinProof, the founder's own venture built and run by RAITHub, stores amounts as integer minor units across four payment rails, with 750+ tests.
- Honest limits. RAITHub has built a fintech dashboard for a client and payment flows on its own platforms; it has not shipped a regulated or licensed fintech product. Confirm any licensing with your adviser.
When you don't need us
- An off-the-shelf tool fits. If your policy and accounting match a vendor's model, buy it.
- Your team is tiny. A documented process may be enough for a handful of claims.
- You only need the model. The ledger and idempotent payout above are a fair start for your own developer.
How RAITHub would build this
- Approval workflow: a policy-driven state machine with enforced approver roles, multi-tenant from day one, every transition audit-logged.
- Double-entry ledger: append-only balanced entries in integer minor units, with explicit multi-currency conversion recorded at the moment it happens.
- Idempotent payouts: stable keys, recorded results and safe retries, with a reconciliation job against the bank or card feed.
- Tests: sum-to-zero, idempotency, rounding and tenant-isolation suites gated in CI.
Timeline: a focused first release fits the 4 to 6 week fixed-scope range; a ledger- and integration-heavy backend sits nearer 6 to 12 weeks. See SaaS development, QA as a Service and the FinTech industry page. A related build is subscription billing and dunning done right.
You receive: the product on accounts you own, tests and CI covering the money logic, handover runbooks, and full IP under NDA. Licensing and tax are handled with your adviser, not claimed by RAITHub.
Frequently asked questions
What are the two systems inside an expense-management SaaS?
An approval workflow and a ledger. The workflow routes each claim by policy and decides who must approve; the ledger records every approved amount as double entry so the books balance. Build them as distinct concerns joined by a single, tested step.
How should expense amounts be stored?
As integers in the smallest currency unit, with the currency stored alongside, so arithmetic is exact. Record the exchange rate used at the moment of conversion and keep the converted amount as its own value, and never reconvert at display time with a different rate.
Why use a double-entry ledger for expenses?
Because every amount leaves one account and arrives in another, entries are append-only, and the whole ledger sums to zero, which lets you prove the books balance at any moment. A test asserts the ledger sums to zero after every reimbursement.
How do I stop a claim being reimbursed twice?
Make the payout idempotent: give each reimbursement a stable key, record the result before marking the claim reimbursed, and on a retry return the recorded result instead of paying again. A double-clicked approve-and-pay is the usual cause of a double reimbursement.
Should I build an expense tool or buy one?
Buy when your policy, currencies and accounting fit a vendor's model. Build when you are creating an expense-management product to sell, or your approval rules, multi-currency needs or local payouts are not covered by an off-the-shelf tool.
Does RAITHub handle the accounting and tax rules?
RAITHub builds the workflow and the ledger. Expense policy, accounting treatment and tax vary by jurisdiction and are for your adviser to define. This is general engineering information; confirm the rules that apply with your adviser before relying on it.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.