Skip to main content

Payables and input VAT

The payables family is the money path GreatBook was built to get right, and the one with the most rules per document. A payable is never one leg and never one row. It is a balanced entry, an AP sub-ledger obligation, and - when the document states VAT - a VAT register line, all written in one transaction.

A supplier bill, in full

The canonical case: a Vietnamese supplier VAT invoice for 1,100,000 dong of raw materials, 1,000,000 net plus 100,000 recoverable VAT. Written alongside it, in the same transaction:

The gross is the anchor

What the supplier is owed is the total on the document, so the AP credit carries the gross and the coded leg is derived as gross - VAT. That direction is not a preference. Three independently extracted figures cannot be trusted to add up, and an entry that does not balance is not a rounding question - the ledger refuses it outright. Deriving one of the three from the other two makes the entry balance by construction, however the document was written.

The VAT is read, never inferred

The recoverable VAT - thuế GTGT được khấu trừ - is an asset the business offsets against output VAT, not a cost. Burying it inside the coded leg overstates the cost of everything purchased, understates a real asset, and leaves the VAT return with nothing to declare. A document stating a net and a rate and no total therefore resolves no gross and refuses at propose time, rather than crediting AP its pretax figure.
Crediting AP the pretax figure would balance, chain, land in an open period, and pass every Close checksum, while understating what a supplier is owed. That is the entire class of defect this family’s rules exist to close: the wrong number that survives every check because it is internally consistent.
A document that states a VAT amount and a net that disagree with the gross raises InconsistentVatError and posts nothing. So does a negative VAT, and a VAT that consumes the whole payable. The system does not choose between two figures a human can reconcile in a minute.
The rate on the register line is derived, not read. tax_entries.rate is NUMERIC(20,6), and the Close list re-checks each line as tax_amount == ROUND(base_amount × rate, 0). So the rate is computed from the two amounts that actually posted and quantized to the column’s own scale - and because a 6dp rate cannot always reproduce a VAT that was itself rounded, the nearest rate is tried first and its two neighbours after it. A pair no 6dp rate can express falls back to the nearest and legitimately fails that checksum, because the checksum exists to flag a VAT that does not match its base.

The AP obligation is half the accounting fact

A credit to the control account and the sub-ledger row that explains it are one fact. Before this was true, the Bookkeeper posted Dr expense / Cr 2000 and opened nothing, so GL 2000 accumulated a balance no bill accounted for and close_checks.ap_control_tie failed on every document-originated payable. It was found by the first live Close run. A supplier is therefore required: MissingCounterpartyError, because posting the control leg alone would leave the ledger owing money to nobody. A due date is not - the column is nullable, the tie does not read it, and inventing one to fill a column is worse than an honest NULL.
Preferring the master id narrows the one-supplier-two-keys split; it does not close it. Resolution happens at post time, and the party master can be unavailable, so the same supplier can land under the master id on one bill and under the free-text name on the next. Every posting therefore records party_resolution in its audit detail, so the fallback rows are identifiable for a later normalisation rather than indistinguishable from coded ones.

The account map for the coded leg

Resolved from the bill’s kind first, and from the registry document type only when the source did not tag a kind. Document-type fallbacks cover every creates_ap type in the registry, so the common path never needs a kind: There is no wildcard default, in either table.

The ledger idempotency key

source_type is the constant "bill" for every creates_ap document, and source_id is namespaced by the intake row id: "<intake_id>:<doc_no>". Keying on the document’s natural reference alone made a model-extracted field decide whether a second document was a new liability or a replay. Two distinct bills quoting the same reference collapsed onto the first entry, and the second row was flipped to posted pointing at a journal entry that never contained it - the reviewer was told “posted” for money that never reached the book. Genuine cross-upload duplicates are the Clerk’s find_duplicate and the Auditor’s reconciliation to catch, not the ledger key’s.

A material bill that clears a goods receipt

When goods arrived before the invoice, the receipt already posted Dr 1200 / Cr 2010 GRNI at PO price. The bill then clears the GRNI rather than debiting inventory a second time:
The GRNI path is replay-safe through the obligation’s durable journal source identity. An exact retry returns the same obligation; conflicting accounting facts under that identity are refused. The key is not (org, kind, doc_no), so two genuine documents that quote the same human bill number do not collapse into one liability.

Landed cost, and foreign-contractor tax

Where it is enforced

The test that would fail if it broke

agents/bookkeeper/tests/test_input_vat_split.py and test_ap_control_tie.py. Read test_the_pre_fix_shape_leaves_input_vat_off_the_books before adding a Close checksum here: unlike the AP tie’s twin, no Close checksum fails on the pre-split shape, because the register ties at 0 == 0. That is precisely why the split is asserted directly rather than left to a reconciliation.