Chapter 1 · Sessions 1 to 3
From accepted quote to issued invoice
An invoice is a promise written down: what was delivered, to whom, for how much, due when. This chapter follows one from an accepted quote to an issued, numbered invoice that nobody can edit, and names the ten tables that hold it.
25 min10 bill_ tables6 invoice sourcesNever edited
By the end of this chapter you can
- Say what the module does and what stays in the accounting system, tax engine and payment provider.
- Name the six sources an invoice can come from and the billing terms an account carries.
- Explain why a sent invoice is never edited and why numbering has no gaps.
What this module is for
Quotes, orders and invoicing turns accepted quotes and shipped or completed work into correct invoices: billing terms, tax by the rate in force, credit notes, payment application and statements. Every invoice is then posted once to the accounting system through a signed posting contract. The module stops there on purpose. The general ledger, payables and financial statements stay in the accounting system; tax calculation engines, subscription revenue schedules and card or bank processing stay with the products built for them.
- In: billing accounts and terms, invoices from six sources, tax lines, credit notes, payment application, statements and reminders, and one idempotent posting per document.
- Out: the general ledger, payables and financial statements; tax engines; revenue schedules; card and bank processing. These are integrated, not rebuilt.
- Later tiers: remittance matching from bank statements and e-invoicing export are extra capabilities, not part of the first version.
- Not built in the practice sessions: milestone and time-and-materials invoices, refunds, and the consumers for the shipped, completed and milestone-accepted events. There the billing clerk starts a draft from a shipped order or finished work by hand; only the quote-accepted consumer is built.
Billing accounts and terms
A billing account says who is billed and on what terms. It points to the customer as a party in the core tables, may link softly to the sales account, and carries a currency, payment terms, a tax-exempt flag with the certificate reference and its expiry date, an optional credit limit, and a status of active, on_hold or closed. The due date on an invoice is worked out from the terms when it is issued.
| payment_terms | Plain meaning |
|---|---|
| due_on_receipt | Payable as soon as the invoice is received |
| net_15, net_30, net_45, net_60 | Payable that many days after the invoice date |
| eom | Payable at the end of the month |
| deposit | An amount is due up front, before the work is done |
An exemption certificate has an expiry date for a reason: the day after it lapses, the account is taxable again unless a new certificate is on file. The invoice screen should warn before that day, not after.
Six sources, one invoice table
A hard foreign key goes only to the shared core tables and to this module's own tables. Links to the sales order, work order, project and quote are soft: the id is stored, but the other module can change independently. A manual invoice has no source document, so it carries one control instead: above a set amount it needs a second person's approval, held in core_approval, and the invoice keeps the approval id.
Money, currency and numbers
All money is held as whole numbers of the currency's smallest unit (cents for the dollar), never as fractions. The currency code and its minor unit come from ISO 4217: most currencies have two decimal places, some have none and some have three, so the module stores the code beside every amount [4]. A tax rate is a number, but an amount of tax is always rounded once, by a rule written down, into whole cents.
Invoice numbers are gapless within a series. A draft carries a temporary number so it can be saved, and the real number is drawn from the number sequence only at the moment of issue, inside the same database transaction that issues the invoice. If that transaction fails, no number is used up. Skipped numbers are the first thing an auditor looks for.
An issued invoice is never edited
The reason is plain: the customer, the accounting system and the tax authority may all hold a copy of what was sent. Records of this kind are kept for a period set by the rules that apply to the business, and guidance such as the IRS's record-keeping publication describes what to keep [2]. Keep the original exactly as issued.
The ten tables
Every column of the ten tables
Copy these tables into your own docs/bill-tables.md; your agent builds the migration from them. Types: uuid, text, boolean, date, integer and timestamptz (a date and time with its time zone) are plain; money is a bigint holding whole cents; quantity is numeric(18,4); a rate is numeric(9,6). Required means not null. An arrow means a hard foreign key (a column whose value must match the id of a row in that table); a soft link is a plain uuid with a comment and no constraint, because the module that owns the row may not be installed. Every table also has the standard columns: id, tenant_id, created_at, created_by, updated_at, updated_by, row_version, archived_at and ext.
bill_billing_account
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| party_id | uuid → core_party | yes | |
| crm_account_id | uuid, soft link to crm_account | no | Scopes what a salesperson may see |
| currency | text | yes | A three-letter ISO 4217 code |
| payment_terms | text | yes | due_on_receipt, net_15, net_30, net_45, net_60, eom, deposit |
| tax_exempt | boolean | yes | |
| exemption_certificate_ref | text | no | The certificate reference |
| exemption_expires_on | date | no | The day the certificate lapses |
| credit_limit | bigint (cents) | no | |
| status | text | yes | active, on_hold, closed |
| ext: old_customer_code | text inside ext | no | The old tool's customer code, set by the import; unique per tenant when present |
bill_invoice
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| number | text | yes | Unique per tenant; DRAFT- and eight characters of the id while a draft |
| billing_account_id | uuid → bill_billing_account | yes | |
| status | text | yes | draft, approved, issued, partially_paid, paid, void, disputed |
| issue_date | date | no | Set at issue |
| due_date | date | no | Worked out from the account's terms at issue |
| currency | text | yes | A three-letter ISO 4217 code |
| subtotal | bigint (cents) | yes | |
| tax_total | bigint (cents) | yes | |
| total | bigint (cents) | yes | Equals subtotal plus tax_total |
| balance_due | bigint (cents) | yes | Never negative and never above total; 0 when paid or void |
| source_type | text | yes | sales_order, shipment, service_work_order, project_milestone, time_and_materials, manual |
| sales_order_id | uuid, soft link to inv_sales_order | no | |
| service_work_order_id | uuid, soft link to fsm_service_work_order | no | |
| project_id | uuid, soft link to prj_project | no | |
| quote_id | uuid, soft link to crm_quote | no | |
| approval_id | uuid → core_approval | no | Set when a second person must approve |
| issued_hash | text | no | SHA-256 of the issued content |
bill_invoice_line
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| invoice_id | uuid → bill_invoice | yes | |
| line_no | integer | yes | |
| item_id | uuid → core_item | no | |
| description | text | yes | |
| quantity | numeric(18,4) | yes | |
| uom_id | uuid → core_uom | no | The unit of measure |
| unit_price | bigint (cents) | yes | |
| discount | bigint (cents) | no | |
| line_total | bigint (cents) | yes | |
| tax_code | text | no | EXEMPT on an exempt line |
| tax_amount | bigint (cents) | yes |
bill_tax_rate
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| jurisdiction | text | yes | Country and region, such as ZZ-T1 |
| tax_code | text | yes | |
| rate | numeric(9,6) | yes | A fraction: 0.07 means 7% |
| valid_from | date | yes | |
| valid_to | date | no | Empty on the current row; two rows for one jurisdiction and tax_code may not overlap |
bill_credit_note
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| number | text | yes | Unique per tenant |
| invoice_id | uuid → bill_invoice | yes | |
| reason_code_id | uuid → core_reason_code | yes | |
| amount | bigint (cents) | yes | Excludes tax |
| tax_amount | bigint (cents) | yes | The credit is amount plus tax_amount |
| status | text | yes | draft, approved, issued, applied |
| reissued_invoice_id | uuid → bill_invoice | no | The corrected invoice, once reissued |
bill_payment
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| billing_account_id | uuid → bill_billing_account | yes | |
| received_on | date | yes | |
| amount | bigint (cents) | yes | |
| currency | text | yes | Must match the account's currency |
| method | text | yes | card, ach, wire, check, cash, other |
| reference | text | no | A cheque number or similar; never a card or bank account number |
| provider_ref | text | no | The payment provider's own reference |
| unapplied_amount | bigint (cents) | yes | Between 0 and amount |
bill_payment_application
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| payment_id | uuid → bill_payment | yes | |
| invoice_id | uuid → bill_invoice | yes | |
| amount | bigint (cents) | yes | A mistake is reversed by a new row with a negative amount, never by an update |
| applied_on | date | yes |
bill_dunning_step
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| days_overdue | integer | yes | Unique per tenant |
| channel | text | yes | email, letter, call_task |
| template_key | text | yes | |
| stop_on_dispute | boolean | yes |
bill_dunning_event
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| invoice_id | uuid → bill_invoice | yes | One event per invoice and step |
| dunning_step_id | uuid → bill_dunning_step | yes | |
| at | timestamptz | yes | |
| outcome | text | yes | sent, skipped_dispute, skipped_paid, failed |
bill_posting
| Column | Type | Required | Allowed values or note |
|---|---|---|---|
| document_type | text | yes | invoice, credit_note, payment |
| document_id | uuid | yes | Unique per tenant together with document_type; a plain uuid, as the document may be in any of three tables |
| idempotency_key | text | yes | Unique per tenant |
| target_system | text | yes | quickbooks, xero, netsuite, sage_intacct, other |
| status | text | yes | queued, posted, failed, reversed |
| external_ref | text | no | The accounting system's reference |
| attempts | integer | yes | Starts at 0; one is added every time the service sends |
| last_error | text | no | |
| posted_at | timestamptz | no |
Outside this module, the invoice also follows the shape of an electronic invoice: the EN 16931 semantic model defines what an invoice must be able to say, and Peppol BIS Billing 3.0 applies it to a network [1][3]. The tables are built so an export can be produced later, without a second data model.
You need: Your invoicing tool (or spreadsheet), a private folder outside the repository, and a copy of the table above
Customer names and amounts are private business data. Work in your own tool, keep any export in a private folder and never paste it into a chat or a prompt.
Outcome: A one-page inventory of twenty invoices by source, terms, edits and gaps. It tells you which parts of the module you will use most, and where today's process breaks the never-edit rule.
Knowledge check
A customer reports a wrong quantity on an invoice that was issued last week. What does the module do?
Knowledge check
Why does a draft invoice carry a temporary number instead of a real one?
References
- European Commission: EN 16931, the European standard on electronic invoicing. https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Obtaining+a+copy+of+the+European+standard+on+eInvoicing
- IRS Publication 583: Starting a Business and Keeping Records. https://www.irs.gov/publications/p583
- Peppol BIS Billing 3.0 (OpenPeppol). https://docs.peppol.eu/poacc/billing/3.0/
- ISO 4217 currency codes. https://www.iso.org/iso-4217-currency-codes.html
Chapter 2 · Sessions 4 and 5
Tax, corrections and cash
Three things go wrong after an invoice is drafted: the tax is the wrong rate for the date, the invoice itself is wrong, or the money arrives in a shape nobody expected. Each has one rule here, and the rule never involves editing history.
25 minDated tax ratesCredit notes onlyPayments split across invoices
By the end of this chapter you can
- Choose the tax rate for an invoice from a dated rate table and keep it fixed afterwards.
- Correct an invoice with a credit note and a reissue, and apply a payment to one or many invoices.
- Describe a reminder schedule that stops when the invoice is paid or disputed.
Tax lines by ship-to and date
Each invoice line carries a tax code and a tax amount. The rate comes from one of two places: an integrated tax engine (best when the rules are complex, because the engine owns them) or, for simple cases, a dated rate table in bill_tax_rate. Either way, the rate depends on where the goods or service go, the ship-to, and on the date the rules say applies. Which date counts is a tax-rules question for the business's accountant; the module stores one date on the invoice and uses it for every line.
| bill_tax_rate column | Meaning |
|---|---|
| jurisdiction | The place the rate applies to |
| tax_code | The category the line carries, such as a standard or reduced rate |
| rate | The rate as a number |
| valid_from, valid_to | The first and last day the row applies; valid_to is empty on the current row |
The lookup is a date test: the row where valid_from is on or before the date and valid_to is empty or on or after it. A rate change is a new row with a new valid_from, never an edit to the old one, in the same way a price book change is a new version.
A worked example in whole cents
A two-line invoice: 4 units at 2,500 cents and one service at 12,500 cents, so a subtotal of 22,500. At the example rate of 6.00% the lines carry tax of 600 and 750, a tax_total of 1,350 and a total of 23,850. At 6.50% the same lines carry 650 and 813 (812.5 rounded half up), a tax_total of 1,463 and a total of 23,963. Write the rounding rule down (per line or on the total, and which way a half cent goes), apply it everywhere, and test it, because it will be questioned the first time two invoices differ by one cent.
The rates in this chapter (6.00% and 6.50%) are examples for reading the figures. The practice seed in the sessions uses other made-up rates (5% and 7%), so its hand-counted totals differ from these; neither is a real jurisdiction's rate.
An exempt customer is handled by the account, not by the line: when tax_exempt is set and the certificate has not expired, lines carry zero tax and the certificate reference, so the audit trail shows why.
Credit notes: the only correction
A credit note credits an invoice in full or in part, with a reason code from the shared reason-code table, an amount and a tax amount held separately, and a status of draft, approved, issued or applied. A reissued invoice is linked back by reissued_invoice_id. Credit notes use the same gapless numbering as invoices, in their own series, and the same approval rule above a limit.
Electronic invoice formats treat a credit note as a document type of its own, with the same structure as an invoice and a reference to the invoice it corrects. UBL 2.1, the OASIS standard also published as ISO/IEC 19845, defines both an Invoice and a CreditNote [1]. Modelling it this way now means the tables are built so an export can be produced later, with no workaround.
Payments and their application
A payment records money received: the account, the date, the amount and currency (in the currency's minor unit [2]), the method (card, ach, wire, check, cash or other), the reference the customer gave, and the payment provider's reference. The module never stores a card number or bank account number; the payment provider holds them. A payment is then applied to invoices in bill_payment_application, one row for each invoice it pays.
- Partial payment: less than the invoice total. The status becomes partially_paid and balance_due falls by the amount applied.
- Payment across invoices: one payment applied to several invoices, one application row each.
- Overpayment: the excess stays in unapplied_amount as unapplied cash. It can be applied to a later invoice, or handed back by the payment provider outside this module (refunds are not built here); the payment itself is never edited to hide it.
- The invariant: a payment's amount always equals its applications plus its unapplied_amount. A test can check this for every row.
Matching a bank statement line to an open invoice is a later capability. Bank statement and payment notification formats are defined by ISO 20022 (camt.053 for statements, camt.054 for notifications) [3]; the reference and provider_ref columns hold what a later remittance-matching step can use.
Statements and reminders
A statement lists a customer's open invoices, credits and payments at a date. Reminders are a schedule, not a habit: each bill_dunning_step says how many days overdue, which channel (email, letter or call_task), which template, and whether to stop on dispute. Each reminder that fires, or is skipped, is recorded in bill_dunning_event with an outcome of sent, skipped_dispute, skipped_paid or failed. A paid invoice simply leaves the ladder, so nothing more is recorded for it; skipped_paid is recorded only when a reminder was queued before the payment arrived and is then not sent.
You need: One real issued invoice from your tool, a calculator, and your accountant's written statement of the tax rate and the date that applies
Use a single invoice with at least two lines. Do not guess a tax rate: take it from your accountant, your tax authority's page or your tax engine. Keep the invoice in a private folder.
Outcome: A short table showing one invoice repriced in whole cents from a dated rate row, with the rounding rule, any difference from the issued invoice explained, and the rule for the next rate change.
Knowledge check
A tax rate changes on 1 July. An invoice issued in March is shown in a report in August. Which rate does its tax use?
Knowledge check
A payment of 30,000 cents is applied 23,850 to one invoice and 4,000 to another. What is the payment's unapplied_amount?
References
- OASIS: Universal Business Language (UBL) Version 2.1. https://docs.oasis-open.org/ubl/UBL-2.1.html
- ISO 4217 currency codes. https://www.iso.org/iso-4217-currency-codes.html
- ISO 20022: financial messaging standard. https://www.iso20022.org/
Chapter 3 · Sessions 4 to 6
Posting once, and proving it
The invoice is only correct if the accounting system agrees with it, once. This chapter covers the posting contract, who may do what, how the module is measured, and how you move from the old tool without losing a cent.
25 min1 key per document5 roles0 cents difference
By the end of this chapter you can
- Explain how an idempotency key stops a retry from posting twice.
- Name the roles and screens that touch money and the rule each follows.
- Reconcile the platform to the accounting system at cut-over and prove the module with its four KPIs.
The posting contract
Every invoice, credit note and payment is posted once to the accounting system. The contract is simple: the module writes a bill_posting row for the document with a unique idempotency key, a posting service sends the document and the key, and the accounting system answers with an acknowledgement and its own reference. The module does not keep a ledger; it keeps proof that each document reached the ledger exactly once.
The contract is signed: the adapter signs each request with a secret it shares only with the accounting side, kept in the host's environment settings, so the receiver can check who sent it and that nothing changed on the way. The database allows one posting row per document, whatever its key, so an opening posting loaded at cut-over is found and reused when that document is later paid or applied.
| bill_posting column | Why it is there |
|---|---|
| document_type, document_id (unique together) | Which invoice, credit note or payment this is; one row per document, whatever its key |
| idempotency_key (unique) | The same on every attempt; the database refuses a second row with the same key |
| target_system | quickbooks, xero, netsuite, sage_intacct or other |
| status | queued, posted, failed or reversed (undone by a reversing entry, never by deleting) |
| external_ref, posted_at | The accounting system's reference and when it confirmed |
| attempts, last_error | How many tries, and the latest reason a try failed |
An idempotent retry
A network can fail after the accounting system has done the work but before the reply arrives. The sender cannot tell this from a failure before the work, so it must be safe to send again. An operation is idempotent when doing it twice has the same effect as doing it once. Payment and billing APIs achieve this by asking the caller to attach a key, and by returning the earlier result when the same key comes back. Build the key from the document, such as its type and id, so every attempt produces the same one.
Where the accounting system does not honour keys itself, the adapter does the same work: it checks its own bill_posting row and looks the document up in the accounting system by the key, stored in the document's reference field, before it creates anything.
A failed posting is shown to a person with its last_error, can be retried from the postings screen, and publishes bill.posting.failed so another module or an alert can react. A retry never edits the document; it only repeats the posting.
Who may do what
| Role | Does | Never |
|---|---|---|
| Billing clerk | Drafts, issues and corrects invoices; records and applies payments; chases late payers | Approves a document; updates a posting row |
| Sales | Reads the invoices and balances of their own customers | Sees another salesperson's customers |
| Invoice approver | Approves invoices and credit notes that need a second person | Approves one they created; creates invoices |
| Posting service | Sends each document to the accounting system and records the result | Is used by a person |
| Cut-over importer | Loads the old tool's open items once, then is switched off | Updates anything; stays on after cut-over |
Nobody deletes anywhere. Two rules are enforced twice, by a restrictive rule in the database and by a test: the approver cannot approve what they created, and only the posting service updates a posting. The screens follow the roles: invoice, payment and setup screens for the billing clerk (setup is where accounts, dated tax rates and reminder steps are kept), an approvals queue for the approver, and a board for open balance, ageing and the four KPIs.
Measuring the module
| KPI | Definition | What a bad number says |
|---|---|---|
| DSO | Days sales outstanding: receivables ÷ credit sales × days | Customers pay late, or invoices go out late |
| Invoice accuracy | Invoices issued without a later credit note ÷ invoices issued | Errors reach customers |
| Time to invoice | Median hours from shipment or completion to issue | Billing waits on paper or people |
| Posting success | Documents posted first time ÷ documents issued | The integration is fragile |
Take each number once from the old tool before you build, and again from the new one after. For example, with receivables of 150,000 and credit sales of 450,000 over a 90-day period, DSO is 150,000 ÷ 450,000 × 90 = 30 days. The figures are illustrative; use your own.
Cut-over without losing a cent
Migration is a small, careful import, not a history transfer. Export the open invoices, customers and unapplied payments from the old tool; load each opening balance as one posting, in the same way as any other document, with a key that starts opening: so it can never clash with a live one; and issue new invoices from the cut-over date while the old system keeps its history. The importer role is created in Session 3, used only for the rehearsal and the cut-over run, and removed afterwards.
Two further capabilities are built on the same tables. E-invoicing export produces an EN 16931 or UBL 2.1 document for customers or mandates that require it [2]. Remittance matching proposes which open invoices a bank statement line pays. Neither changes the rules above.
Records are kept as long as the rules that apply to the business require; the IRS's record-keeping guide lists what to retain [1]. Because issued invoices and postings are never deleted, retention is a matter of archiving, not of rebuilding.
You need: Your rehearsal database (never the live one), your AI coding agent, and the journal file or test double that stands in for the accounting system
Do this in the rehearsal environment you built in the sessions. The aim is to see the guarantee hold, then to see what it looks like when it does not.
Outcome: Three observed results: one ledger entry after a retry with the same key, two entries with a time-based key, and refusals for the invoice update and the duplicate key. Together they show the guarantees hold and what each one prevents.
Knowledge check
A posting times out after the accounting system recorded the entry. What makes the retry safe?
Knowledge check
At cut-over the platform's open balance and the accounting system's receivables differ. What do you do?
References
- IRS Publication 583: Starting a Business and Keeping Records. https://www.irs.gov/publications/p583
- European Commission: EN 16931, the European standard on electronic invoicing. https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Obtaining+a+copy+of+the+European+standard+on+eInvoicing
Chapter 4 · 12 questions · 80% passes
Final assessment
Twelve questions across the element. Score 80% (10 of 12) to pass. Your LMS records your score and each answer; you can review the chapters and try again.
15 min12 questions≈ 15 minutesRetake allowed
Your result
CivOps AI Academy
Quotes, Orders and Invoicing: Order-to-Cash Up to the Ledger
Element M22 complete · Learner
Your LMS records this completion. For the CivOps Foundation certificate, finish the Foundation Course at https://civops.io/learn.