Time billing, WIP and write-offs
How logged time becomes billing lines, how a fee below the time is recorded, and the objects behind it
Overview
- A TimeEntry represents logged time, which can be set as billable.
- A TimeEntryAllocation records how much of that billable time was billed to a client and how much was written off. One allocation can do both.
- A TimeEntry can have multiple TimeEntryAllocations.
- Anything invoiced to a client, not just time, starts life as a ClientBillingLineItem. When you call the endpoint to bill for time it creates the ClientBillingLineItem and one TimeEntryAllocation per entry, in one transaction, so the line always matches the time behind it.
- A ClientInvoiceSubmission is a batch of ClientBillingLineItems sent to the accounting platform as one invoice.
A billable TimeEntry has a billableValue: its hours multiplied by its hourlyRate. That value needs to be either billed to a client or written off (given up without charging). openWip is the part of the billableValue that has not yet been billed or written off.
A TimeEntryAllocation is recorded against a TimeEntry. It carries a settledValue: how much of the billableValue this allocation deals with. It also carries a billedAmount: how much the client was charged for that time. If billedAmount is less than settledValue, the difference is what is being written off, and a WriteOffReason must be set so it can be reported on later. The billedAmount can also be higher than the settledValue (a "write-on") and that needs a reason too.
The objects
TimeEntry
Logged time. The fields that matter for billing, all read-only and derived on the server:
| Field | Meaning |
|---|---|
billableValue | Hours rounded to two decimal places, times hourlyRate. Zero with no rate. |
settledValue | The running total of billableValue billed or written off across the entry's TimeEntryAllocations. |
billedAmount | What the client has been charged for this entry across its TimeEntryAllocations. |
openWip | billableValue minus settledValue: what is left to bill or write off. |
settlementState | Open (nothing settled), PartSettled (some billableValue still open), or Settled (nothing left). |
billedLineItemCode is deprecated. It only holds the most recent line the entry was billed on; read the TimeEntryAllocations instead.
TimeEntryAllocation
A record of billable time on one TimeEntry being billed or written off. An entry can have many.
| Field | Meaning |
|---|---|
settledValue | How much of the billableValue this allocation deals with. |
billedAmount | What the client was charged for it. Always zero on a pure write-off. |
writeOffAmount | Settled minus billed. Positive is time written off, negative is a write-on. Derived, never stored. |
clientBillingLineItemCode | The ClientBillingLineItem the time was billed on, or null for a pure write-off. |
writeOffReason | Required whenever writeOffAmount is not zero. |
notes, createdBy, createdDate | Who took the decision, when, and any context they left. |
TimeEntryAllocations are never edited directly. They are created by billing or writing off, and removed by deleting the ClientBillingLineItem (which deletes every TimeEntryAllocation on it) or by undoing a pure write-off. Each removal hands the settled amount back to the entry's openWip.
ClientBillingLineItem
A line for an invoice, created by either the billing schedule (charges raised automatically from a client's ClientBillableServices), a manual entry, or a time billing run.
Two fields matter here.
| Field | Meaning |
|---|---|
timeEntryCount | How many entries were billed on the line. When it is non-zero the line's quantity and unit price are locked, because the amount is the sum of the TimeEntryAllocations behind it. To change it, delete the line and bill the time again. |
clientBillableService | Which of the client's ClientBillableServices the line is for. Lines created by the billing schedule have it set already. A line created by hand (a fixed fee, an amount on account, a progress bill) has no time behind it, so the only way the WIP Value report can count that money toward the service's recovery is if the line says which service it belongs to. Set it on create, or later on update. |
Deleting a line that has TimeEntryAllocations deletes them too. Nothing is written in their place: each entry's settledValue drops by what the allocation had settled, and that amount is back in openWip.
ClientInvoiceSubmission
The batch of ClientBillingLineItems pushed to the accounting platform. submittedTotal is the line total at the moment of the push, written once and never updated. Recovery in Sodium is measured against Sodium's ClientBillingLineItems, not against what the platform later invoiced or collected. The snapshot pins what was actually sent, so a figure that drifts on the platform side can at least be detected.
Billing time
POST /tenants/{tenant}/time-entries/bill bills a list of TimeEntrys belonging to one client. In one transaction it creates the ClientBillingLineItems, creates one TimeEntryAllocation per entry, and adds each entry's share to its settledValue. The response is the ClientBillingLineItems created.
Every entry in timeEntryCodes must belong to the same client, be billable, have an hourlyRate, and have some openWip. An entry that is already PartSettled can be billed again; only its openWip is billed.
The request decides how much the client is charged in one of three ways: the full value of the time, a fixed fee, or a split you set for each entry yourself.
1Charging the full value of the time
Leave feeAmount out. Each entry is billed at its full openWip, so settledValue and billedAmount on every allocation are equal and nothing is written off.
lineMode decides what the invoice shows. When it is omitted, billingLineMode on the practice's TimeRecordingSettings is used.
| Value | Lines created |
|---|---|
Combined | One ClientBillingLineItem per distinct hourly rate, with quantity the hours and unitPrice the hourlyRate. |
PerEntry | One line per entry, shaped the same way. |
Single | One line for the whole request whatever the rates: quantity is 1, unitPrice is the total, and the hours go into the description unless you give one. |
To keep one line per rate or per entry but show an amount on each rather than hours and a rate, send a feeAmount equal to the entries' total openWip instead (below) and give a description. Nothing is written off and no reason is needed.
/tenants/acme/time-entries/bill{
"timeEntryCodes": ["time_3f9a12", "time_5c0d77", "time_9b1e40"],
"vatRate": "Standard",
"description": "Bookkeeping, August",
"lineMode": "Combined"
}[
{
"code": "bli_2a90f4",
"description": "Bookkeeping, August",
"quantity": 10.00,
"unitPrice": 100.00,
"vatRate": "Standard",
"status": "Pending",
"timeEntryCount": 3
}
]2Charging a fixed fee
Set feeAmount. The client is charged that amount regardless of the value of the time. The fee is split across the TimeEntrys in proportion to their openWip, rounded to the penny, with the last entry taking any rounding difference so the shares add up to the feeAmount exactly. Each share is the billedAmount on that entry's TimeEntryAllocation.
The line is built from the fee rather than the hours: quantity is 1, unitPrice is the fee, and the hours go into the description. That way the line total equals the fee to the penny.
Three entries worth £400, £350 and £250 (£1,000 in total) billed at a £700 fee:
/tenants/acme/time-entries/bill{
"timeEntryCodes": ["time_3f9a12", "time_5c0d77", "time_9b1e40"],
"vatRate": "Standard",
"feeAmount": 700.00,
"remainderHandling": "WriteOff",
"writeOffReasonCode": "quoted_fixed_fee",
"notes": "Fixed fee agreed in the engagement letter"
} time_3f9a12 settledValue 400.00 billedAmount 280.00 writeOffAmount 120.00
time_5c0d77 settledValue 350.00 billedAmount 245.00 writeOffAmount 105.00
time_9b1e40 settledValue 250.00 billedAmount 175.00 writeOffAmount 75.00
------ ------
700.00 300.00The fee is £300 short of the time. remainderHandling says what happens to that £300:
LeaveOpen(the default). Each entry'ssettledValueis set to its share of the fee, the same as itsbilledAmount, so nothing is written off. The £300 stays inopenWipacross the three entries, they read asPartSettled, and the remainder can be billed later. No reason is needed.WriteOff. Each entry'ssettledValueis set to its fullopenWip, so the £300 gap between settled and billed is recorded as written off, as in the figures above.writeOffReasonCodeis required.
A fee higher than the time is a write-on. Every entry is settled in full and the excess is spread the same way, giving each allocation a negative writeOffAmount. A reason is required for that too.
A fee of zero is rejected: to give time up without charging, use the write-off endpoint.
3Setting each entry's split yourself
In the first two ways the server decides, for each TimeEntry, how much of its openWip is used up and how much of that the client pays. The third way is to state both numbers yourself, entry by entry. Use it when the entries should not all be treated alike: charge some in full, discount one, leave part of another open for a later invoice.
Send allocations instead of feeAmount: one item for every entry in timeEntryCodes, giving its timeEntryCode, a settledValue (how much of the entry's openWip to use up; at most all of it, and anything less stays open) and a billedAmount (what the client is charged for that entry). Where the two differ, the difference is written off, or written on if the charge is higher, and writeOffReasonCode is required. remainderHandling is ignored, because you have said what happens to every entry. The billed amounts must add up to more than zero, and if feeAmount is sent as well it must equal their sum.
The same three entries, worth £400, £350 and £250: the first charged in full, the second discounted by £50, and half of the third billed now with the other half left open.
/tenants/acme/time-entries/bill{
"timeEntryCodes": ["time_3f9a12", "time_5c0d77", "time_9b1e40"],
"vatRate": "Standard",
"writeOffReasonCode": "goodwill",
"allocations": [
{
"timeEntryCode": "time_3f9a12",
"settledValue": 400.00,
"billedAmount": 400.00
},
{
"timeEntryCode": "time_5c0d77",
"settledValue": 350.00,
"billedAmount": 300.00
},
{
"timeEntryCode": "time_9b1e40",
"settledValue": 125.00,
"billedAmount": 125.00
}
]
} time_3f9a12 settledValue 400.00 billedAmount 400.00 writeOffAmount 0.00
time_5c0d77 settledValue 350.00 billedAmount 300.00 writeOffAmount 50.00
time_9b1e40 settledValue 125.00 billedAmount 125.00 writeOffAmount 0.00
------ ------
825.00 50.00The client is charged £825. The first two entries read as Settled, the second with £50 written off as goodwill; the third reads as PartSettled with £125 still in openWip.
Billing several clients in one call
POST /tenants/{tenant}/time-entries/bill-clients bills the open time of every client in clientCodes, without listing entry codes. fromDate, toDate and userCode narrow which entries are included. Each client is billed at the full value of its time unless clientFees names a fee for it. One remainderHandling and one writeOffReasonCode apply to every client whose fee differs from its time. lineMode applies here too, and Single gives each client one line.
Two differences from the single-client call. There is no vatRate: each client is billed at its own VAT treatment. And entries with no hourlyRate are skipped rather than failing the run; the count comes back as skippedUnratedCount. The response is one result per client with its line and entry counts, the amount billed, and an error if that client could not be billed.
Writing time off
POST /tenants/{tenant}/time-entries/write-off gives time up without charging for it. It creates one TimeEntryAllocation per entry with no ClientBillingLineItem and a billedAmount of zero, so the whole settledValue is the write-off. writeOffReasonCode is always required. The entries can belong to different clients.
How much comes off each entry is set in one of three ways:
- Leave
amountout to write off everything still open on each entry. - Set
amountto write off that much in total. It is spread across the entries in proportion to theiropenWip. No entry is taken below zero, and the amount cannot be more than the total open. - Send
allocationsto say exactly how much comes off each entry. Entries intimeEntryCodeswithout an allocation are written off in full.
/tenants/acme/time-entries/write-off{
"timeEntryCodes": ["time_a71c3e", "time_b82d4f"],
"amount": 60.00,
"writeOffReasonCode": "training_junior_time",
"notes": "First-year trainee, second pass over the same schedules"
}[
{
"code": "tea_8b31c0",
"timeEntryCode": "time_a71c3e",
"clientBillingLineItemCode": null,
"settledValue": 32.00,
"billedAmount": 0,
"writeOffAmount": 32.00,
"writeOffReason": {
"code": "training_junior_time",
"name": "Training / junior time"
}
},
{
"code": "tea_8b31c1",
"timeEntryCode": "time_b82d4f",
"clientBillingLineItemCode": null,
"settledValue": 28.00,
"billedAmount": 0,
"writeOffAmount": 28.00,
"writeOffReason": {
"code": "training_junior_time",
"name": "Training / junior time"
}
}
]The response is the TimeEntryAllocations created.
Reading and undoing allocations
- GET /tenants/{tenant}/time-entries/{code}/allocations lists the TimeEntryAllocations on an entry, oldest first.
- DELETE /tenants/{tenant}/time-entries/{code}/allocations/{allocationCode} deletes an allocation and puts its
settledValueback into the entry'sopenWip. It only accepts an allocation with no ClientBillingLineItem behind it, which means a pure write-off. To undo a billed allocation, delete its ClientBillingLineItem instead: that deletes every allocation on the line. - GET /tenants/{tenant}/time-entries has two filters for this.
hasOpenWip=truereturns the unbilled list: entries withopenWipabove zero, plus billable entries that have nohourlyRateyet, because they are billable time nobody has dealt with.isBilled=truereturns entries with at least one allocation that has a ClientBillingLineItem behind it. A pure write-off is not a bill, so a written-off entry is not billed.
Write-off reasons
A WriteOffReason has a code, a name, a sortOrder and an isActive flag. Every practice gets six the first time the list is read: quoted_fixed_fee, overrun, goodwill, client_dispute, training_junior_time and other. Bad debt is deliberately not one of them: that happens after the invoice, and belongs in the accounting platform.
List, create, get, update and delete live under /tenants/{tenant}/write-off-reasons. The list takes isActive so a picker can hide retired reasons. A reason that is already on an allocation cannot be deleted; set isActive to false instead, and it disappears from pickers while staying on the history it belongs to.
/tenants/acme/write-off-reasons{
"name": "Scope creep",
"sortOrder": 10,
"isActive": true
}/tenants/acme/write-off-reasons/goodwill{
"name": "Goodwill",
"sortOrder": 2,
"isActive": false
}Permissions
Billing and writing off are billing decisions, so they use the client billing permissions rather than the time tracking ones.
- Billing time (both endpoints) needs
ClientBillingcreate. - Writing time off needs
ClientBillingupdate. - Undoing a write-off needs
ClientBillingdelete. - Reading an entry's allocations needs
TimeTrackingview all.
Rules the server enforces
- Hours are rounded to two decimal places before they are multiplied by the rate, everywhere: the
billableValue, the ClientBillingLineItems, the pay runs and the reports. - Money is split to the penny. Whenever an amount is spread across entries, it is in proportion to their
openWip, the last entry takes the rounding difference, and no entry is ever settled beyond its ownopenWip. settledValuecan never exceedbillableValue. The bound is applied in the same statement that increments the running total, and the database has a check constraint as a backstop. If two requests try to settle the same time at once, the second gets a validation error telling it to reload.- Once an entry has any TimeEntryAllocations, its duration, rate, billability and client are locked. Changing any of them could shrink the
billableValuebelow what has already been settled. The description, task and workflow step stay editable. - Recovery is measured against Sodium's ClientBillingLineItems. Not against the invoice the accounting platform issued, and not against what the client paid. The reports say so.
Reports
- Cost & Profit adds settled, billed, written off and a recovery rate (billed as a share of
billableValue) per team member or client, for the time logged in the window. - Write-off Analysis groups the TimeEntryAllocations created in the window by reason, team member or client, showing written off, written on and the net.
- WIP Value is a snapshot of
openWipby client, service or team member, aged from the day the time was worked. Each group also shows its all-timebillableValue, what was billed from time, what was billed to the service on lines with no time behind them, and the recovery rate.
Each report has a /csv sibling with the same parameters.
Worked examples
Two scenarios, each stepped through as the API calls involved. Responses are trimmed to the fields that matter.
A fixed fee that overran
- Find the client's open time with GET /tenants/{tenant}/time-entries. Each TimeEntry carries its
billableValueand how much of it is stillopenWip.GET/tenants/acme/time-entries?clientCode=bluebird&hasOpenWip=true200 OK[ { "code": "time_3f9a12", "durationMinutes": 240, "hourlyRate": 100.00, "billableValue": 400.00, "settledValue": 0, "billedAmount": 0, "openWip": 400.00, "settlementState": "Open" }, { "code": "time_5c0d77", "durationMinutes": 210, "hourlyRate": 100.00, "billableValue": 350.00, "settledValue": 0, "billedAmount": 0, "openWip": 350.00, "settlementState": "Open" }, { "code": "time_9b1e40", "durationMinutes": 150, "hourlyRate": 100.00, "billableValue": 250.00, "settledValue": 0, "billedAmount": 0, "openWip": 250.00, "settlementState": "Open" } ] - Bill the three entries at the fee with POST /tenants/{tenant}/time-entries/bill.
remainderHandlingofWriteOffsettles every entry in full and records the £300 the fee does not cover, so awriteOffReasonCodeis required. The response is the ClientBillingLineItem created: quantity 1, the fee as the unit price, the hours in the description.POST/tenants/acme/time-entries/bill{ "timeEntryCodes": ["time_3f9a12", "time_5c0d77", "time_9b1e40"], "vatRate": "Standard", "description": "Year-end accounts", "feeAmount": 700.00, "remainderHandling": "WriteOff", "writeOffReasonCode": "quoted_fixed_fee", "notes": "Fixed fee agreed in the engagement letter" }200 OK[ { "code": "bli_7d2e91", "client": { "code": "bluebird", "name": "Bluebird Bakery Ltd" }, "description": "Year-end accounts", "quantity": 1, "unitPrice": 700.00, "vatRate": "Standard", "status": "Pending", "timeEntryCount": 3 } ] - Read one entry's history with GET /tenants/{tenant}/time-entries/{code}/allocations. The £700 was spread pro rata to open WIP, so the £400 entry was billed £280 and the other £120 is the write-off, with the reason on it.GET
/tenants/acme/time-entries/time_3f9a12/allocations200 OK[ { "code": "tea_0c4b88", "timeEntryCode": "time_3f9a12", "clientBillingLineItemCode": "bli_7d2e91", "settledValue": 400.00, "billedAmount": 280.00, "writeOffAmount": 120.00, "writeOffReason": { "code": "quoted_fixed_fee", "name": "Quoted fixed fee" }, "notes": "Fixed fee agreed in the engagement letter", "createdBy": { "code": "jane", "name": "Jane Hartley" }, "createdDate": "2026-09-09T11:42:07Z" } ] - The entries now read as
Settledwith nothing open, and drop out of the unbilled list. The ClientBillingLineItem waits for its billing date and goes to the accounting platform in a ClientInvoiceSubmission like any other line. If the fee turns out to be wrong, delete the line with DELETE /tenants/{tenant}/clients/{client}/billinglineitems/{code}: all three TimeEntryAllocations are deleted and the £1,000 is open again.GET/tenants/acme/time-entries/time_3f9a12200 OK{ "code": "time_3f9a12", "billableValue": 400.00, "settledValue": 400.00, "billedAmount": 280.00, "openWip": 0, "settlementState": "Settled" }
Trainee time written off, then reinstated
- Write off part of the entry with POST /tenants/{tenant}/time-entries/write-off.
amountcaps what comes off; leave it out to write off everything still open. The response is the TimeEntryAllocations written: no billing line,billedAmountzero, the reason on each.POST/tenants/acme/time-entries/write-off{ "timeEntryCodes": ["time_a71c3e"], "amount": 60.00, "writeOffReasonCode": "training_junior_time", "notes": "Second pass over schedules already reviewed" }200 OK[ { "code": "tea_5e19d3", "timeEntryCode": "time_a71c3e", "clientBillingLineItemCode": null, "settledValue": 60.00, "billedAmount": 0, "writeOffAmount": 60.00, "writeOffReason": { "code": "training_junior_time", "name": "Training / junior time" }, "notes": "Second pass over schedules already reviewed", "createdBy": { "code": "sam", "name": "Sam Okafor" }, "createdDate": "2026-09-09T14:05:31Z" } ] - The TimeEntry is now
PartSettled: £60 settled, £120 still open. It stays on the unbilled list for the remainder.GET/tenants/acme/time-entries/time_a71c3e200 OK{ "code": "time_a71c3e", "durationMinutes": 180, "hourlyRate": 60.00, "billableValue": 180.00, "settledValue": 60.00, "billedAmount": 0, "openWip": 120.00, "settlementState": "PartSettled" } - Undo the write-off with DELETE /tenants/{tenant}/time-entries/{code}/allocations/{allocationCode}. This only works on a pure write-off; an allocation with a billing line behind it is removed by deleting the line. The £60 is handed back and the entry reads
Openagain at its full £180.DELETE/tenants/acme/time-entries/time_a71c3e/allocations/tea_5e19d3204 No ContentGET/tenants/acme/time-entries/time_a71c3e200 OK{ "code": "time_a71c3e", "billableValue": 180.00, "settledValue": 0, "billedAmount": 0, "openWip": 180.00, "settlementState": "Open" } - The write-off no longer exists, so it does not appear in the Write-off Analysis report. Had the partner wanted the history kept, the alternative is to leave the allocation and bill the remaining £120 as normal.
Things that might surprise you
- A
Combinedline and its TimeEntryAllocations can differ by a few pence when billing the full value of the time. The line rounds the total hours once per rate; each allocation rounds its own entry's hours. Fixed-fee billing has no such drift, because the line is the fee. ASingleline does not drift either: itsunitPriceis the sum of its allocations. - Billing a
PartSettledentry without a fee does not bill hours times rate. It bills the entry'sopenWipas a line with a quantity of 1. - Deleting a ClientBillingLineItem reopens the time. Its allocations are deleted, the entries go back to
OpenorPartSettled, and they reappear in the unbilled list. - Updating a ClientBillingLineItem without
clientBillableServiceCodekeeps the existing link. To detach a service from a line, delete the line and create it again.