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 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:

FieldMeaning
billableValueHours rounded to two decimal places, times hourlyRate. Zero with no rate.
settledValueThe running total of billableValue billed or written off across the entry's TimeEntryAllocations.
billedAmountWhat the client has been charged for this entry across its TimeEntryAllocations.
openWipbillableValue minus settledValue: what is left to bill or write off.
settlementStateOpen (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.

FieldMeaning
settledValueHow much of the billableValue this allocation deals with.
billedAmountWhat the client was charged for it. Always zero on a pure write-off.
writeOffAmountSettled minus billed. Positive is time written off, negative is a write-on. Derived, never stored.
clientBillingLineItemCodeThe ClientBillingLineItem the time was billed on, or null for a pure write-off.
writeOffReasonRequired whenever writeOffAmount is not zero.
notes, createdBy, createdDateWho 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.

FieldMeaning
timeEntryCountHow 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.
clientBillableServiceWhich 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.

ValueLines created
CombinedOne ClientBillingLineItem per distinct hourly rate, with quantity the hours and unitPrice the hourlyRate.
PerEntryOne line per entry, shaped the same way.
SingleOne 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.

POST/tenants/acme/time-entries/bill
{
  "timeEntryCodes": ["time_3f9a12", "time_5c0d77", "time_9b1e40"],
  "vatRate": "Standard",
  "description": "Bookkeeping, August",
  "lineMode": "Combined"
}
200 OK
[
  {
    "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:

POST/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"
}
TimeEntryAllocations created
  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.00

The fee is £300 short of the time. remainderHandling says what happens to that £300:

  • LeaveOpen (the default). Each entry's settledValue is set to its share of the fee, the same as its billedAmount, so nothing is written off. The £300 stays in openWip across the three entries, they read as PartSettled, and the remainder can be billed later. No reason is needed.
  • WriteOff. Each entry's settledValue is set to its full openWip, so the £300 gap between settled and billed is recorded as written off, as in the figures above. writeOffReasonCode is 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.

POST/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
    }
  ]
}
TimeEntryAllocations created
  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.00

The 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 amount out to write off everything still open on each entry.
  • Set amount to write off that much in total. It is spread across the entries in proportion to their openWip. No entry is taken below zero, and the amount cannot be more than the total open.
  • Send allocations to say exactly how much comes off each entry. Entries in timeEntryCodes without an allocation are written off in full.
POST/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"
}
200 OK
[
  {
    "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

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.

POST/tenants/acme/write-off-reasons
{
  "name": "Scope creep",
  "sortOrder": 10,
  "isActive": true
}
PUT/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 ClientBilling create.
  • Writing time off needs ClientBilling update.
  • Undoing a write-off needs ClientBilling delete.
  • Reading an entry's allocations needs TimeTracking view 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 own openWip.
  • settledValue can never exceed billableValue. 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 billableValue below 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 openWip by client, service or team member, aged from the day the time was worked. Each group also shows its all-time billableValue, 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

  1. Find the client's open time with GET /tenants/{tenant}/time-entries. Each TimeEntry carries its billableValue and how much of it is still openWip.
    GET/tenants/acme/time-entries?clientCode=bluebird&hasOpenWip=true
    200 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"
      }
    ]
  2. Bill the three entries at the fee with POST /tenants/{tenant}/time-entries/bill. remainderHandling of WriteOff settles every entry in full and records the £300 the fee does not cover, so a writeOffReasonCode is 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
      }
    ]
  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/allocations
    200 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"
      }
    ]
  4. The entries now read as Settled with 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_3f9a12
    200 OK
    {
      "code": "time_3f9a12",
      "billableValue": 400.00,
      "settledValue": 400.00,
      "billedAmount": 280.00,
      "openWip": 0,
      "settlementState": "Settled"
    }

Trainee time written off, then reinstated

  1. Write off part of the entry with POST /tenants/{tenant}/time-entries/write-off. amount caps what comes off; leave it out to write off everything still open. The response is the TimeEntryAllocations written: no billing line, billedAmount zero, 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"
      }
    ]
  2. 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_a71c3e
    200 OK
    {
      "code": "time_a71c3e",
      "durationMinutes": 180,
      "hourlyRate": 60.00,
      "billableValue": 180.00,
      "settledValue": 60.00,
      "billedAmount": 0,
      "openWip": 120.00,
      "settlementState": "PartSettled"
    }
  3. 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 Open again at its full £180.
    DELETE/tenants/acme/time-entries/time_a71c3e/allocations/tea_5e19d3
    204 No Content
    GET/tenants/acme/time-entries/time_a71c3e
    200 OK
    {
      "code": "time_a71c3e",
      "billableValue": 180.00,
      "settledValue": 0,
      "billedAmount": 0,
      "openWip": 180.00,
      "settlementState": "Open"
    }
  4. 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 Combined line 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. A Single line does not drift either: its unitPrice is the sum of its allocations.
  • Billing a PartSettled entry without a fee does not bill hours times rate. It bills the entry's openWip as a line with a quantity of 1.
  • Deleting a ClientBillingLineItem reopens the time. Its allocations are deleted, the entries go back to Open or PartSettled, and they reappear in the unbilled list.
  • Updating a ClientBillingLineItem without clientBillableServiceCode keeps the existing link. To detach a service from a line, delete the line and create it again.