Xero Integration
This page explains how the Billing domain will send numbers to Xero.
Read it if you are:
- an engineer who will build the finance export
- the platform owner who needs to talk to finance about it
- anyone who must explain why a number in Xero differs from a number in Jod
After reading you should be able to:
- read a journal entry and say if it is correct
- explain what finance does today by hand, and why
- explain which Jod event creates which Xero line
- explain what we must agree with finance before the first export
Related pages:
- Billing overview — entitlements, ledger, projections
- Billing layers — layer 4 is reporting and finance exports
- Billing ramp-up — section 5 first named the two export modes
- Billing requirements — the raw notes from finance about today's process
- Billing domain decisions — the decisions page for this domain
1. Words used on this page
Words from our own system:
| Word | Meaning |
|---|---|
| Entitlement | What a customer can still use on the platform. Measured in units. |
| Instrument | The kind of entitlement. Today: placement and gig. Stored in billing_entitlements.instrument. |
| Placement credit | One unit that buys visibility: an ad, a careers job post, or a boost. |
| Gig credit | One cent of wage value the customer paid us in advance. 100 gig credits = SGD 1.00 of wage. |
| Ledger entry | One row in billing_ledger_entries. It records one change in units or money. Rows are only added, never edited. |
| Lot | One purchase batch. One row in billing_entitlement_lots. It carries units, the money attached to those units, and an optional expiry date. |
| Grant | A ledger entry that adds units. It creates one lot. Usually after a customer pays. Grants for trial credits, goodwill credits, and opening lots add units with no payment. |
| Reserve | A ledger entry that moves units from available to reserved, so the customer cannot spend them twice. |
| Release | A ledger entry that moves reserved units back to available. |
| Consume | A ledger entry that removes units because we delivered the service. |
| Expire | A ledger entry that removes leftover units when a lot passes its expiry date. |
| Refund | A ledger entry that removes available units when we pay a customer back. Gig only, and always a full exit — every available credit, every lot. |
| Adjust | A correction entry, or the zero-movement entry an expiry extension writes so the date change appears in the audit trail. |
| Platform fee | Our margin on a gig purchase. The customer pays it on top of the wage value. |
Words from accounting:
| Word | Meaning |
|---|---|
| Revenue | Money we have earned because we delivered the service. |
| Deferred revenue | Money we already collected, for a service we have not delivered yet. It is a debt until we deliver. Also called deferred income. |
| Principal liability | Stored value we owe back to the customer. Gig credits are this. It is not revenue, even after the customer pays. |
| Breakage | Revenue we earn when a customer's credits expire unused. We keep the money and we no longer owe the service. |
| Output tax | GST we charge the customer on a sale. We collect it and pay it to IRAS. It is never our revenue. |
| Recognise | To move money from deferred revenue into revenue at the moment we earn it. |
Xero's own words (organisation, chart of accounts, account code, manual journal, contact, tax rate, lock date, tracking category) are defined in section 9.
This page follows decisions D2 to D6 on the billing decisions page. The schema in jodapp-api/docs/db/billing.dbml implements them — see jodapp-api PR #1938. Older pages in this folder still describe placement credits as one shared pool with no expiry date; those parts are out of date until the spec rewrite lands.
2. What Xero is, and why Jod uses it
Xero is cloud accounting software. Our finance team already uses it. It holds the official books of the company:
- the bank accounts and what was paid in and out
- what we owe and what we are owed
- the profit and loss statement and the balance sheet
- the GST return we file with IRAS
Xero is the record that auditors, the bank, and the tax authority look at. Jod is not an accounting system and should not try to be one.
The split of work is simple:
| Question | Answered by |
|---|---|
| How many credits does this customer have left? | Jod |
| Which shift used which credits? | Jod |
| How much revenue did the company earn in September? | Xero |
| What is our GST bill this quarter? | Xero |
One rule matters more than the rest. Xero never sees units. Xero only sees money. A customer having 16 placement credits means nothing to Xero. The SGD 128 we still owe them as a service does mean something to Xero. Our export turns unit movements into money movements.
3. Double-entry accounting in plain words
You only need four ideas to read the rest of this page.
An account
An account is a named bucket of money. A company keeps a list of them. Xero calls that list the chart of accounts. Every account has a short code, for example 200 or 820.
Accounts come in kinds. We use three kinds:
- Asset — something we own. Example: the bank account.
- Liability — something we owe. Example: gig credits we must give back.
- Revenue — money we earned.
Debit and credit
Debit and credit are just the left side and the right side of an entry. They are not "plus" and "minus". What they do depends on the kind of account:
| Account kind | A debit does | A credit does |
|---|---|---|
| Asset | increases it | decreases it |
| Liability | decreases it | increases it |
| Revenue | decreases it | increases it |
Read the table with our own accounts:
- Money arrives in the bank. That is a debit to the bank account.
- We now owe the customer gig credits. That is a credit to the liability account.
- A shift finishes and we earned our fee. That is a credit to a revenue account.
A journal entry
A journal entry is a set of lines posted together on one date. Each line names one account and one amount. The total of the debits must equal the total of the credits. If they do not match, the entry is wrong and Xero will not accept it.
A short money example
A customer buys 100 gig credits with a 20% platform fee. Following the invoice line tax policy, the stored value carries no GST and the fee carries 9% GST:
- stored value: SGD 100.00
- platform fee: SGD 20.00
- GST on the fee: 20.00 × 0.09 = SGD 1.80
- total the customer pays: SGD 121.80
The entry when the money arrives:
| Account | Kind | Debit | Credit |
|---|---|---|---|
| Bank | Asset | 121.80 | |
| Jod credits | Liability | 100.00 | |
| Deferred income — gig platform fee | Liability | 20.00 | |
| GST payable | Liability | 1.80 | |
| Total | 121.80 | 121.80 |
Notice that no revenue appears yet. We have taken money but delivered nothing. Everything sits as a debt. Revenue only appears later, when a shift finishes or when credits expire.
The billing requirements notes show this same example with a total of SGD 121.90. That is an addition error in the notes. 120.00 + 1.80 = 121.80.
4. How Jod finance works in Xero today
Everything in this section comes from the finance notes in billing requirements. It describes the gig business, which is the part running in production today.
The accounts finance uses
| Account name in Xero | Kind | What it holds |
|---|---|---|
| Jod credits | Liability | All unused gig credits, for every customer, in one number |
| Deferred income | Liability | All platform fees collected but not yet earned |
There is no customer detail in either account. Xero holds one total.
The daily loop, Monday to Saturday
- Ops exports the previous day's completed jobs as a payment summary file.
- Ops sends that file to finance.
- Finance pays the workers from the file.
- Finance posts one lump-sum entry to Xero for the day. The entry carries four totals:
- total gig credits used
- total insurance deducted from workers
- total insurance we sponsored
- total paid break we sponsored
- The entry names no customer. It only moves the single "Jod credits" total.
The monthly margin step
At month end finance builds a list of all paid jobs for the month. They then type the service margin percentage against those jobs, taken from the customer's latest invoice. The revenue for the month is the total of that column. This is the moment the platform fee becomes revenue in Xero.
The monthly reconciliation
Finance compares two numbers:
- the closing balance of the "Jod credits" account in Xero
- the total of all customer statements of account in the gig system
The difference is between SGD 1,000 and SGD 5,000 every month.
Why this process exists, and where it is weak
Finance did not choose this process because it is ideal. They chose it because the system gives them nothing better. Their numbers must tie to real payments, and the payment file is the only reliable list they have.
| Step today | Why it exists | Where it is weak |
|---|---|---|
| Ops emails a daily payment file | The platform has no finance export | The file describes payouts, not credit movements. Credits actually deducted in the system can differ. |
| One lump sum to "Jod credits" | One line per job would create thousands of lines a month | Xero holds no customer detail, so no customer question can be answered from Xero |
| Margin typed in at month end from the latest invoice | Different purchases carry different fee rates | One rate gets applied to a whole month. The rate that belonged to each purchase batch is lost. |
| Monthly compare of Xero against customer statements | Nothing else proves the balance is right | A SGD 1k–5k difference every month, and nobody can point at the rows that cause it |
| CAFS and other credit adjustments handled by hand | Adjustments have no system record finance can pull | Each adjustment is a chance to get the two systems further apart |
The billing ledger fixes the root cause. It records every unit and every cent at the moment it moves. The export then reads the ledger, not a payout file.
5. The two export modes
The billing design planned for two ways to get numbers into Xero. We build them in order.
Journal mode — first
Our system reads the ledger, adds up one day of activity, and produces journal lines. Finance imports those lines into Xero as one manual journal per day. A manual journal is an entry a human or an integration posts directly, without an invoice behind it.
- First version: we produce a CSV file. Finance uploads it in Xero.
- Later version: we post the same lines through the Xero API.
Journal mode gives Xero correct totals. It gives Xero no customer names.
Invoice mode — future
Our system creates one sales invoice per customer inside Xero, using billing_invoices and billing_invoice_lines as the source. Each invoice is linked to a Xero contact, which is Xero's word for a customer record.
Invoice mode gives Xero customer detail. It also gives Xero the accounts receivable and the GST return handling for free. It costs much more to build and to keep correct.
Which mode to choose
| Question | Journal mode | Invoice mode |
|---|---|---|
| What does finance get? | Correct daily totals | Correct totals plus a customer name on every sale |
| Who chases unpaid customers? | Our system | Xero |
| Does Xero know each customer's balance? | No | Yes |
| Does it touch the bank account or accounts receivable? | No | Yes |
| What must exist first? | The ledger and an account map | A Xero contact for every company, plus invoice numbering owned by one side only |
| How hard is it to correct a mistake? | Post a small reversing journal | Credit note, then a new invoice |
| Build cost | Low | High |
Choose journal mode while finance is happy to answer customer questions from Jod. Move to invoice mode when finance wants Xero to hold customer balances, or when an auditor asks for invoice-level records inside Xero.
Both modes read the same ledger. The ledger stays the record of what we delivered and what we earned. Xero holds the money view of the same events.
6. Building blocks of the journal export
6.1 The accounts we need in Xero
Today finance uses two accounts. The export needs a few more. Splitting them costs nothing and makes each monthly check simple.
| Example account name | Kind | What it holds | Example code |
|---|---|---|---|
| Customer receipts clearing | Liability | Money received that the export has not turned into credits yet | 810 |
| Jod credits | Liability | Gig stored value we owe back to customers | 820 |
| Talent payouts payable | Liability | Net wages we owe workers but have not paid yet | 825 |
| Insurance payable | Liability | Insurance premiums we owe the insurer | 826 |
| Customer refunds payable | Liability | Refunded gig principal we owe back but have not paid out yet | 827 |
| Deferred income — placement credits | Liability | Placement money collected, not yet earned | 830 |
| Deferred income — gig platform fee | Liability | Gig fees collected, not yet earned | 831 |
| GST payable | Liability | Output tax charged on sales | 840 |
| Revenue — placement credits | Revenue | Earned when placement credits are consumed | 200 |
| Revenue — gig platform fee | Revenue | Earned when a shift finishes | 201 |
| Revenue — credit breakage | Revenue | Earned when credits expire unused | 202 |
| Credit adjustments / marketing cost | Expense | Only used if finance chooses to book free credits as a cost when they are used — open question in the auditor section | 470 |
The codes above are examples. Finance owns the real codes. See section 6.6.
Two limits shape this list:
- Xero blocks manual journals from posting to some system accounts, including accounts receivable, accounts payable, and bank accounts. The usual answer is a normal liability or asset account that you control, which is why "Talent payouts payable", "Customer refunds payable" and "Customer receipts clearing" exist above.
- Only invoice mode can post to accounts receivable, because invoices create those lines themselves.
6.2 The mapping table
This is the heart of the export. Each ledger entry type, for each instrument, maps to a fixed pair of accounts.
| Ledger entry | Instrument | Debit | Credit | Amount comes from |
|---|---|---|---|---|
grant | placement | Customer receipts clearing | Deferred income — placement, and GST payable | Invoice line amounts on the posting |
grant | gig | Customer receipts clearing | Jod credits (stored value), Deferred income — gig platform fee, and GST payable | Invoice line amounts on the posting |
grant | any, trial or goodwill | none | none | the lot carries SGD 0 |
grant | any, opening balance | none | none | the money already sits inside the Xero balances — posting it again would double it |
reserve | any | none | none | no money moved |
release | any | none | none | no money moved |
consume | placement | Deferred income — placement | Revenue — placement credits | recognised_revenue_cents |
consume | gig (stored value part) | Jod credits | Talent payouts payable, and Insurance payable | available_delta, split by the gig settlement |
consume | gig (fee part) | Deferred income — gig platform fee | Revenue — gig platform fee | recognised_revenue_cents |
expire | placement | Deferred income — placement | Revenue — credit breakage | recognised_revenue_cents on the expire entry |
expire | gig | none today | none today | gig lots carry no expiry date |
refund | gig (principal part) | Jod credits | Customer refunds payable | available_delta — the credits paid back |
refund | gig (fee part) | Deferred income — gig platform fee | Revenue — gig platform fee | recognised_revenue_cents on the refund entry |
adjust | expiry extension | none | none | zero-movement entry — only the date changed |
adjust | any, reversing revenue | Revenue account | Deferred income account | the negative money delta on the entry. No such entry is designed yet — this row only says how one would post if a future correction design adds them. |
Notes on the table.
Free credits produce no money lines. A trial or goodwill lot has units and SGD 0 attached. Consuming it recognises 0. Expiring it recognises 0. The export skips zero-value lines, so Xero sees nothing. That is correct: nobody paid us, so no money moved. (If finance chooses to book free credits as a marketing cost instead — the open question in the auditor section — this row changes and a reference price is needed.)
Opening lots never produce journal lines. At the gig flip, each company's opening lot carries real money — but that money is already inside Xero's "Jod credits" and "Deferred income" balances from years of manual entries. The grant that creates the lot only copies the position into our ledger. Posting it would count the same balances twice.
A refund posts no GST and touches no bank account. The fee is kept, so nothing tax-related reverses. The principal goes to "Customer refunds payable" because manual journals cannot post to bank accounts; when finance makes the actual bank transfer, they clear it against that account.
Gig stored value never becomes our revenue. It is the customer's money. When a shift finishes, that money leaves the "Jod credits" liability and becomes a debt to the worker and to the insurer. Only the platform fee crosses into revenue.
The gig payout split does not come from the billing ledger. The ledger knows how many credits were consumed. The split between net wage, insurance deducted, insurance sponsored, and sponsored paid break comes from the gig settlement records. The export joins both sources for the day. This mirrors what ops sends finance today.
6.3 Why reserve and release map to nothing
A reservation moves units from available to reserved. Nothing else happens:
- no money enters or leaves the company
- the customer is not richer or poorer
- we have not delivered anything
Accounting only records money movements. So a reserve produces zero journal lines, and so does a release. This is not missing data. Posting a reservation to Xero would state that something happened when nothing did, and the balance sheet would then be wrong until the shift finished.
Holds still matter inside Jod. They stop a customer from spending the same credits twice. That is a platform rule, not an accounting event.
6.4 Daily aggregation from the ledger
One export run covers one calendar day for one legal entity. The steps:
- Pick the day. Day boundaries use the company's timezone,
Asia/Singaporetoday. See the datetime conventions. - Select ledger rows where
occurred_atfalls inside that day. - Group them by
entry_typeand bybilling_entitlement_id(the instrument). - Sum the money columns for each group:
deferred_revenue_delta_cents,recognised_revenue_cents, andavailable_deltafor gig stored value. There are no separate fee columns — for a gig row, the deferred and recognised amounts are the fee, because the principal is a liability, not revenue. - Look each group up in the mapping table from section 6.2.
- Write one journal line per account, per group.
- Check that debits equal credits before writing anything out.
One day becomes about ten lines, not thousands. Finance keeps the lump-sum shape they already work with. The difference is that the numbers now come from the ledger instead of a payout file.
6.5 Why we never recompute history
Every ledger row stores the money amounts that were true at that moment. recognised_revenue_cents on a consume row is the amount we recognised then. It is copied at that moment and never changes afterwards.
The export only reads those stored amounts. It never recalculates them from today's balances. This matters because balances keep moving:
- the lot the credits came from may be empty now
- the customer may have bought more credits at a different price
- an adjustment may have changed the account since
If the export recalculated, then re-running last March would produce different numbers than March produced. The books would drift away from the ledger and nobody could prove which version is right. Reading stored amounts means March always exports the same numbers, forever.
6.6 Account codes are configuration, not code
Account codes must never be written into Ruby code. Reasons:
- Each legal entity has its own Xero organisation, with its own chart of accounts.
- Finance can rename or renumber accounts without telling engineering.
- A new country will need a different set of accounts and a different tax treatment.
Keep the map in a configuration table, keyed by legal entity, entry type, instrument, and the role of the line (debit or credit). Adding a country then means adding rows, not shipping code. Every export run copies the codes it used into the run record, so an old run can still be explained after the map changes.
Each Xero organisation is one tenant in the API. Requests carry the organisation in the xero-tenant-id header. Store that id next to the legal entity, alongside the account map.
6.7 GST stays at invoice time
GST is charged when we sell, not when we deliver. Those are different days.
| Event | Day | GST |
|---|---|---|
| Customer buys 100 gig credits with a 20% fee | 1 September | 9% on the SGD 20 fee. Stored value is not taxed. |
| A shift consumes SGD 80 of those credits | 15 September | none |
| Leftover placement credits expire | 30 November | none |
| The company exits gig and we refund the rest | any day | none. The fee is kept, and the principal never carried GST. |
So the recognition journal — consume, expire, adjust — carries no tax at all. Every line goes out with no tax. In the CSV the tax rate column says the org's no-tax rate. In the API the manual journal sets LineAmountTypes to NoTax.
Only the sale carries output tax, and the sale is the grant. Whether the export posts the sale side at all is a choice for finance. Two options:
| Option | Who posts the sale and the GST | What the export posts |
|---|---|---|
| A | Finance, as they do today | Recognition only: consume, expire, adjust |
| B | The export, through the clearing account | Everything, including grants |
Option B has one useful property. If both sides are posted, the "Customer receipts clearing" account must return to zero. A balance left in it means a payment we never granted credits for, or a grant with no payment behind it. That is a real control, and it is free.
If finance picks option B, ask their accountant one question first: posting the GST amount straight to the GST account may not feed the GST return correctly, because Xero usually builds that return from tax rates on the lines. Get this confirmed with finance before the first run.
6.8 Safe to run twice, and closed periods
Nobody should be able to double-post a day. Two mechanisms cover this.
Inside Jod. Every export run gets a record and a unique key, built from the legal entity, the date, and the run type. Re-running a day that already posted returns the existing run instead of creating a new one. The run record stores the file we produced and, once posted, the manual journal id Xero gave back.
At the Xero API. Xero accepts an Idempotency-Key header, up to 128 characters. It lets you retry a request safely, without the risk of the same journal being created twice. Send the export run key as that header. A network timeout then costs nothing: retry with the same key.
Closed periods. Finance closes a month when it is reported. In Xero this is a lock date, set in the financial settings. There are two of them: one for all users, and one for all users except advisers. Once a lock date is set, nobody can add or change a transaction dated on or before it.
Our rules around that:
| Case | Rule |
|---|---|
| Export for a day inside an open period | Post normally, dated that day |
| Export for a day on or before the lock date | Refuse. Do not post. Raise it for a human. |
| A late ledger entry for a closed month | Post it in the current open period. Name the original date in the narration. |
| Finance asks to change a posted day | Never edit. Post a reversing journal in an open period. |
The ledger already works this way. You cannot edit a row, only add a correcting one. Xero lock dates enforce the same habit on the other side.
6.9 The verification artifact
Every export run produces a report a human can read. Finance approves the report before anything is posted, and checks the same report again after posting. This follows the repo rule that any export or migration must name the artifact a human approves.
The report contains:
| Part | Why finance needs it |
|---|---|
| The day, the legal entity, and the run key | To know exactly what this run covers |
| One row per journal line: account code, account name, debit, credit | This is what will land in Xero |
| Debit total, credit total, and the difference | The difference must be 0.00 |
| Ledger row count and the id range read | To prove no row was read twice or missed |
| The unit movements behind each money line | To connect SGD 34.00 back to 4 placement credits |
| Rows skipped, with the reason | Zero-value trial credits, reserves, releases |
| Closing balances after this run: Jod credits, both deferred income accounts | To compare against the same accounts in Xero |
| Which account map version was used | To explain old runs after the map changes |
Run the export in dry-run mode first. It writes the report and nothing else. Only after approval does the run post. After posting, compare the report's closing balances against Xero's account balances. They must match.
6.10 What happens to the monthly reconciliation
Today finance compares the Xero "Jod credits" balance against the total of all customer statements, and finds a SGD 1k–5k difference every month.
After journal mode, both numbers come from the same ledger:
- the customer statement is ledger rows for one account
- the Xero balance is the total of the daily journals, which are also ledger rows
So they agree by construction. Any remaining difference has exactly one possible source: an entry a human posted straight into Xero, outside the export. That is a short list to check, and the daily reports say which day to open.
The monthly reconciliation does not disappear. It gets much smaller, and a difference now points at a specific day and a specific account instead of at a whole month.
7. Worked example: one day traced into Xero
Date: 15 September 2026, Asia/Singapore. Four ledger entries happened.
The starting position
| Lot | Company | Instrument | Bought | Units then | Money attached | Expires |
|---|---|---|---|---|---|---|
| L-11 | Acme Foods | placement | 1 Sep 2026 | 10 credits | SGD 100.00 | 1 Sep 2027 |
| L-12 | Bright Mart | placement | 3 Sep 2026 | 20 credits | SGD 160.00 | 3 Sep 2027 |
| L-07 | Sunrise Cafe | placement | 15 Sep 2025 | 10 credits | SGD 80.00 | 15 Sep 2026 |
| G-03 | Crispy Prata | gig | 10 Sep 2026 | 10,000 credits (SGD 100.00 of wage) | SGD 20.00 fee, 2000 bps | none |
Sunrise Cafe already used 4 of their 10 credits earlier in the year. They have 6 left.
The four ledger entries
Entry 9001 — consume, placement, Acme Foods. They post one careers job. It costs 1 credit, taken from lot L-11.
- money per unit in L-11: 100.00 / 10 = SGD 10.00
recognised_revenue_cents= 1 × 1000 = 1000 (SGD 10.00)
Entry 9002 — consume, placement, Bright Mart. They start a 3-day ad campaign. It costs 3 credits, taken from lot L-12.
- money per unit in L-12: 160.00 / 20 = SGD 8.00
recognised_revenue_cents= 3 × 800 = 2400 (SGD 24.00)
Entry 9003 — expire, placement, Sunrise Cafe. Lot L-07 reaches its expiry date with 6 credits unused.
- money per unit in L-07: 80.00 / 10 = SGD 8.00
available_delta= −6 unitsrecognised_revenue_cents= 6 × 800 = 4800 (SGD 48.00), recognised as breakage
Entry 9004 — consume, gig, Crispy Prata. A worker finishes an 8-hour shift at SGD 10.00 per hour. The worker is a Bronze member, so the insurance premium is deducted from their wage.
- wage: 8 × 10.00 = SGD 80.00, so
available_delta= −8000 gig credits - insurance: 8 × 0.35 = SGD 2.80
- net payable to the worker: 80.00 − 2.80 = SGD 77.20
- platform fee earned: 8000 × 2000 / 10,000 = 1600 cents, so
recognised_revenue_cents= 1600 (SGD 16.00). For a gig entry this amount is the fee — the wage part is a liability movement, never revenue.
The journal finance receives
| # | Account | Code | Debit | Credit | From |
|---|---|---|---|---|---|
| 1 | Deferred income — placement credits | 830 | 34.00 | entries 9001 + 9002 | |
| 2 | Revenue — placement credits | 200 | 34.00 | entries 9001 + 9002 | |
| 3 | Deferred income — placement credits | 830 | 48.00 | entry 9003 | |
| 4 | Revenue — credit breakage | 202 | 48.00 | entry 9003 | |
| 5 | Jod credits | 820 | 80.00 | entry 9004 | |
| 6 | Talent payouts payable | 825 | 77.20 | gig settlement for 9004 | |
| 7 | Insurance payable | 826 | 2.80 | gig settlement for 9004 | |
| 8 | Deferred income — gig platform fee | 831 | 16.00 | entry 9004 | |
| 9 | Revenue — gig platform fee | 201 | 16.00 | entry 9004 | |
| Total | 178.00 | 178.00 |
Checks a human can do in ten seconds:
- 10.00 + 24.00 = 34.00, so lines 1 and 2 agree with the two consumes
- 77.20 + 2.80 = 80.00, so the gig credits used equal what leaves the company
- 80.00 × 20% = 16.00, so the fee matches the lot's rate
- debits 178.00 equal credits 178.00
Balances after the day:
- lot G-03 has SGD 20.00 of wage value left, and SGD 4.00 of fee still deferred. 20.00 × 20% = 4.00, which is consistent.
- lot L-07 is closed. Its units are gone and its money is now revenue.
- Sunrise Cafe's SGD 48.00 became revenue on 15 September, not spread over the year before.
The same day as a CSV
Xero's manual journal import uses one amount column. Debits are positive and credits are negative. The narration and date appear on the first row only. A row with a blank date and blank narration belongs to the journal above it.
Narration,Date,Description,AccountCode,TaxRate,Amount
Jod billing 2026-09-15 run 412,2026-09-15,Placement credits consumed (2 entries),830,No Tax,34.00
,,Placement revenue recognised,200,No Tax,-34.00
,,Placement credits expired (lot L-07),830,No Tax,48.00
,,Breakage revenue,202,No Tax,-48.00
,,Gig credits consumed (1 shift),820,No Tax,80.00
,,Net wage payable to talent,825,No Tax,-77.20
,,Insurance premium deducted,826,No Tax,-2.80
,,Gig platform fee earned,831,No Tax,16.00
,,Gig platform fee revenue,201,No Tax,-16.00
The amounts add up to zero, which is what a balanced journal looks like with this sign convention.
Two rules for the file:
- Download the template from Xero and copy its header row exactly. Some Xero templates put a
*in front of the required column names. Do not rename or reorder the columns, or the import fails. - The value in the tax rate column must match a tax rate name in that Xero organisation. Ask finance for the exact wording of their no-tax rate.
One template file holds up to 300 lines. Ten lines a day is far below that, so one file per day is safe.
The same day through the API
Later we post the same lines to POST https://api.xero.com/api.xro/2.0/ManualJournals. Shortened to the first four lines:
{
"ManualJournals": [
{
"Narration": "Jod billing 2026-09-15 run 412",
"Date": "2026-09-15",
"Status": "POSTED",
"LineAmountTypes": "NoTax",
"JournalLines": [
{ "AccountCode": "830", "LineAmount": 34.00, "Description": "Placement credits consumed" },
{ "AccountCode": "200", "LineAmount": -34.00, "Description": "Placement revenue recognised" },
{ "AccountCode": "830", "LineAmount": 48.00, "Description": "Placement credits expired (lot L-07)" },
{ "AccountCode": "202", "LineAmount": -48.00, "Description": "Breakage revenue" }
]
}
]
}
Request headers that matter:
xero-tenant-idpicks which Xero organisation receives the journalIdempotency-Key: billing-export-412makes a retry safe
Status can be DRAFT or POSTED. Sending DRAFT first is a useful extra safety step: finance opens the draft in Xero, checks it against our report, and posts it themselves.
8. What "the first Xero export" means as a milestone
The milestone is small on purpose: one approved CSV, for one day, imported into Xero by finance, with the report matching Xero afterwards. No API, no invoices, no automation on a schedule.
Already decided
| Question | Decision |
|---|---|
| Where does truth live? | billing_ledger_entries, append only |
| Do placement credits use lots? | Yes. Both instruments use one lot mechanism. |
| Which lot is spent first? | Earliest expiry, then earliest purchase, then id. Gig lots have no expiry, so gig is plain first-in-first-out. |
| When do we recognise breakage? | On the expiry date, in full. Not spread out by estimate. We have no usage history yet to base an estimate on. |
| What is gig stored value? | A refundable debt to the customer. Never our revenue. |
| Do reserve and release reach Xero? | No. No money moves. |
| When is GST charged? | At sale time, on the invoice. Never in the recognition journal. |
| What do free trial credits recognise? | SGD 0.00, because the attached money is SGD 0.00 |
| Can a customer refund part of their credits? | No. A gig refund is a full exit: every available credit, every lot. The fee is kept and recognised. |
| Do companies carry placement balances into launch? | No. Placement launches at zero — there is no old placement system. Manual invoices stay in Xero only. |
| What does the gig flip post to Xero? | Nothing. The opening balances are already inside the Xero accounts, so the export skips the grants that create opening lots. |
What finance supplies before the first run
None of these block engineering. The export code reads them from configuration, so the work can start now and the values can arrive later.
- the real account codes for every row in section 6.1
- the exact name of the no-tax rate in their Xero organisation
- whether the export posts the sale side too, which is option A or option B in section 6.7
- which existing manual entry the export replaces, so the same day is not recorded twice
- who approves the report, and by when each day
- the lock date policy: which day of the month a period closes
Questions for the auditor
Finance passes these to the auditor. The answers must arrive before the first export is posted. None of them block the engineering work.
| Question | Our position | Decision | Status |
|---|---|---|---|
| When do we recognise breakage? | On the expiry date, in full. We have no usage history to estimate with, and SFRS(I) 15 allows this when breakage cannot be estimated. | D2 | ✅ Finance head agreed (Slack, 2026-08-06). Get the auditor's written confirmation before the first export. |
| Does a gig refund need a GST adjustment? | No. GST was charged on the platform fee only, and the fee is kept on refund. The principal never carried GST. So a refund moves no tax. | D4 | Open — not yet asked. |
| When free credits are used, do we book marketing expense and revenue at the normal price (gross-up), or book nothing? | Either works on lot data. Free lots carry SGD 0 and are identifiable by their source (Billing::CreditAction), so the export can book them at a reference price if finance chooses the gross-up. If chosen, the grant must snapshot the product's list price as that reference. | — | Open — finance head proposed the gross-up (Slack, 2026-08-06). Needs her confirmation and the reference price choice. |
Two answers arrived on 2026-08-06 without being asked (Slack, finance head):
- Gig refunds: finance stated on their own that the whole remaining fee becomes revenue when credits are refunded — the same rule as D4.
- Averaging: the auditor accepts pooled averages "as long as we can prove how the average per credit is computed". We keep lot-level records anyway. Any average finance wants is computed from the ledger and the allocation rows — which is exactly the proof the auditor asks for. Permission to average does not require averaging; see the derivability point in D2.
The audit schedules
The auditor's written requirements arrived on 2026-08-07, via finance. Vincent (the audit partner — the person who signs the audit) had no objection to the recognition method. At every year-end audit we must hand over four tables, per customer. Accountants call each one a movement schedule: opening balance + what came in − what went out = closing balance. The auditor re-computes them, ties the totals to Xero, and traces samples back to source documents.
All four are reports over tables that already exist. None needs new bookkeeping.
| # | The auditor asks for | What it is | Where it comes from |
|---|---|---|---|
| 1 | Opening balance, new additions, consumption, adjustments, closing balance of JOD credits — in quantities and value | Credit movement table, in units and dollars. The two columns must agree: value ÷ units = price per credit. | Ledger entries per account grouped by entry type. Grants are additions; consumes are consumption; expire, refund and adjust are adjustments. |
| 2 | The same movement table for deferred income | Opening + new sales − revenue transferred out = closing, in dollars. "Transferred out" = recognised. | The two ledger money columns (deferred_revenue_delta_cents, recognised_revenue_cents) summed per period. |
| 3 | Detailed calculation of average price for each customer | Remaining value ÷ remaining credits, with the history behind the number. | Derived from lots at any date. The ledger replay in id order is the proof of how the number was built. |
| 4 | Detailed working of each customer's revenue for the whole year | Every revenue event: date, credits moved, price applied, amount, and which purchase batch the credits came from. | Recognition ledger entries plus their allocation rows. |
Two rules that follow:
- We recognise revenue at the actual price of each purchase batch (accountants call this specific identification), not at a blended average. Requirements 1, 2 and 4 are identical under any method. Requirement 3 becomes a derived figure we print on request. Tell the auditor the method before year end — auditors accept either method; what they object to is surprise, or changing method mid-year.
- The year-one opening balances in table 1 are the migration's flip-day opening lots. The migration's verification report — the dry-run a human approves before any writes — is therefore audit evidence, not only an internal safety step.
Order of work
- Daily aggregation from the ledger, plus the report. No file, no Xero.
- CSV output, checked by finance against their own manual numbers for the same days.
- Finance stops posting the daily entry by hand.
- API posting, with the export run key as the
Idempotency-Key. - Invoice mode, only when finance wants customer balances inside Xero.
Steps 1 and 2 can run beside the current manual process for a few weeks. Running both and comparing them is the cheapest way to find a mapping mistake.
9. Xero glossary
| Xero term | Meaning |
|---|---|
| Organisation | One set of books inside Xero, for one legal entity. Each has its own accounts and its own GST setup. The API calls it a tenant, and requests name it in the xero-tenant-id header. |
| Chart of accounts | The list of every account in an organisation. |
| Account code | The short code of an account, for example 200. Codes are set by whoever set up the organisation, so they differ between organisations. |
| Manual journal | A journal entry posted directly, without an invoice or a bill behind it. Its status can be DRAFT, POSTED, DELETED or VOIDED. Each line carries an account code, an amount, and an optional description. |
| Journal line | One line of a manual journal. In the API the field is LineAmount, where debits are positive and credits are negative. |
| Contact | A customer or supplier record. Invoice mode needs one contact per company. Journal mode needs none. |
| Tax rate / tax type | The GST treatment of a line. The API uses a tax type code, not the display name. Singapore codes exist in the API, for example SROUTPUT for standard-rated supplies. Confirm the codes with finance, because each organisation configures its own. |
| Line amount types | Whether the amounts on a journal include tax, exclude tax, or carry no tax. We use NoTax for recognition journals. |
| Lock date | A date set in financial settings. Nobody can add or change transactions dated on or before it. There are two: one for all users, and one for all users except advisers. Only the adviser role can change them. |
| Tracking category | An optional label on a line, used to split reports, for example by region. A line can carry at most two. Each category holds a limited number of options, so it does not work as a way to tag every customer. |
| System account | An account Xero creates and controls, such as accounts receivable, accounts payable, and bank accounts. Manual journals cannot post to several of these, which is why the export uses its own liability accounts instead. |
10. Sources checked
Xero facts on this page were checked against these sources on 6 August 2026:
- Xero Accounting API OpenAPI specification,
xero_accounting.yamlin XeroAPI/Xero-OpenAPI — theManualJournalsendpoint, the manual journal statuses,LineAmountTypes, theLineAmountsign convention ("Debits are positive, credits are negative value"), the two tracking categories per line limit, theIdempotency-Keyheader with a 128 character maximum, and thexero-tenant-idheader - Xero Central — add, import and post manual journals — the CSV template, the rule that column headings must not be changed, the blank date and narration rule, and the 300 line limit per file
- Xero Central — set up or remove lock dates — the two lock dates and the adviser role
- Xero Central — locked and system accounts in your chart of accounts — which accounts manual journals cannot reach, and the advice to add your own account instead
Anything not on this list is our own design, not a Xero rule. Before the first run, check the account codes, the tax rate names, and the GST return treatment with finance.