dimesum

Home / Blog / Money

Money

Why editing a shared expense must restate the split

· 5 min read ·

An edit's defaults are money bugs, so Dimesum makes participants and split_type required on every expense PATCH, alongside the base_version that rejects a stale edit instead of merging it.

Fixing a typo should not change who owes money. In Dimesum, editing a shared expense restates the whole split. participants and split_type are required on every PATCH, and an edit that omits either comes back 400 rather than filling in a default. The create defaults are every current member and an equal split, which on an edit are money bugs.

Two failures make the case concrete. A flatmate who joined in August is pulled into July's dinner, because "every current member" is evaluated when the edit lands, not when the expense was written. A deliberate 70/30 rent split flattens to 50/50, because an absent split_type means EQUAL. Neither failure raises an error, and both move real money.

The stance

An edit's defaults are money bugs. A create-time default guesses about the group the author is looking at right now. An edit arrives later, against a group that has moved, and the same guess quietly rewrites what people owe.

Create defaults describe a new expense, not an old one

At create time the defaults are honest. The author is looking at the group as it stands, and an equal split across everyone is the common case, so Dimesum fills both in. The expense stores its inputs rather than only its results: splits.percent_bp, splits.weight and splits.exact_minor keep what the user typed, so a later edit can reopen it.

An edit is a different act. The same expense can be corrected weeks later, once a new flatmate has joined or a ghost member has been claimed. Membership is a moving target and the split is not. Reusing the create defaults asks today's group to answer a question the old expense already answered.

Two versions of the same edit: inherited defaults change every share, while a restated split changes only the description EDIT INHERITS THE CREATE DEFAULTS EDIT RESTATES THE SPLIT Rent, 20,000 dollars, July Rent, 20,000 dollars, July v1 Asha 70%, 14,000 Bhavna 30%, 6,000 v1 Asha 70%, 14,000 Bhavna 30%, 6,000 Chetan joins the flat in August. Chetan joins the flat in August. PATCH fixes a typo, sends no split. PATCH fixes a typo, sends the split: participants: Asha, Bhavna split_type: PERCENT 7000 / 3000 v2 Asha 6,666.67 Bhavna 6,666.67 Chetan 6,666.66 v2 Asha 70%, 14,000 Bhavna 30%, 6,000 Chetan owes a month he never lived. Only the description changed.
The same one-character edit, twice. Inheriting the create defaults re-splits 20,000 dollars three ways and charges a member who joined a month later; restating the split leaves every share untouched.

The refusal to guess is not new here. A multi-payer expense must restate its payers too. soleStoredPayer reuses the stored payer only when the expense has exactly one, so an edit that stays silent about two payers is refused rather than reassigned. An edit that says nothing about payers keeps the expense's own payer, never the person doing the editing.

The API refuses an edit that does not restate its split

The gateway check runs before any money is computed. When participants is empty or split_type is blank, the request comes back 400 with the code invalid_expense and the message "an edit must restate the split: participants and split_type are required". The expense service repeats the rule in validateAmend, so a caller reaching the service another way meets the same refusal.

What a create sends versus what an edit must restate, on POST and PATCH /v1/groups/{id}/expenses.
FieldOn createOn an editWhat the default would cost
participantsOptional. Defaults to every current memberRequiredA member who joined afterwards joins an old expense
split_typeOptional. Defaults to EQUALRequiredA 70/30 split flattens to 50/50
payersOptional. Defaults to the authorOmitted keeps the expense's sole payer; two payers must be restatedThe editor becomes the payer, inverting who owes whom
base_versionNot sentRequired, and must equal the current versionA stale edit overwrites a change its author never read
revision_idClient UUIDv7, the idempotency keySame, one per versionA retried edit charges the group twice
currencyStated per expenseMust match; a change is refusedA balances row holds one currency per member

Currency belongs to the same family of refusals. An edit cannot re-denominate an expense, because a balances row holds one currency per member. The write side rejects the change, and the append-only ledger parks such an amendment if one ever reaches it. Refusing at the door keeps the two halves from disagreeing.

A stale edit is rejected for its author, never merged

base_version is the other half of the contract. Every PATCH carries the version its author read, and checkTransition compares it against the row the transaction just locked. Equal, and the edit applies at version plus one. Different, and the caller gets HTTP 409 with the code stale_version.

Merging is the tempting alternative, and it is wrong. Two edits to one expense are two complete statements of what the bill means. Merging them produces a third statement nobody wrote, with shares neither author would recognise. Rejection hands the conflict back to the one person who can resolve it.

Two authors read version 3; the first edit posts version 4 and the second is refused with 409 stale_version expense, version 3 Author A PATCH base_version: 3 200 OK, version 4 Author B PATCH base_version: 3 409 stale_version Ledger, one transaction: EXPENSE_REVERSAL of v3 EXPENSE v4 Author B re-reads version 4, reapplies the change, sends base_version: 4
Optimistic concurrency on an expense. The losing edit is handed back to its author with the version it must be rebuilt on. The winning edit reaches the ledger as a reversal plus a repost, never an update.

Idempotency and concurrency are kept apart deliberately. The revision insert runs before the version check, because a retried edit carries the base version it originally read, which is now stale. A replay must read as a replay rather than a conflict, so revision_id answers first and returns the stored result.

The response already carries what the next edit needs

Requiring more fields on a PATCH is only fair if a client can get them cheaply. Every expense response carries version, so a client that just wrote an expense can edit it with no second read. The create response, the amend response and every list row carry the same field.

For the client that did not just write the expense, GET /v1/groups/{id}/expenses/{id} returns the version, the computed shares, and the inputs behind them. The inputs come back under the same names a PATCH accepts: participants, split_type, percents, weights, shares, items, pools. A client reads one shape and posts it back with edits, instead of translating between two vocabularies for one bill.

The GET response and the PATCH request use the same field names, with version renamed to base_version GET RETURNS PATCH ACCEPTS version base_version participants participants split_type split_type percents, weights, shares percents, weights, shares items, pools items, pools renamed
One vocabulary, two directions. Only the version field changes name across the round trip, so an edit client never maintains a translation layer between what it reads and what it writes.

The symmetry matters most for splits that cannot be reconstructed. A PERCENT split or an itemised bill cannot be restated from its resolved shares alone, because rounding has already been applied and the basis points and line items are gone. Revision snapshots carry the inputs too, so the history sheet can show what was itemised on any past version.

Required now, because a requirement cannot be added later

Requiring a field on day one is a decision about the future rather than about today. Relaxing a required field later is backward compatible: clients already send it, and the server starts accepting requests without it. Adding a requirement later breaks every client that was relying on the old default.

So the direction gets chosen once, early. Dimesum requires participants, split_type and base_version on an edit while the client count is still small enough to change. If a safe default for edits is ever found, the fields become optional and nothing already shipped stops working.

Make an edit restate what it means

Defaults belong to creation, where the author can see the group they are agreeing to. On an edit the same defaults are a guess about a group that has since moved. Your next step: open your own read endpoint and check that it hands back the split inputs under the exact field names your write endpoint accepts. A client that has to translate between the two will eventually translate one of them wrong.

Common questions

Why does editing a shared expense require participants and split_type?

Dimesum requires both fields because the create defaults are wrong for an edit. On create, Dimesum defaults to every current member, split equally, which matches the group the author is looking at. An edit can land weeks later, after somebody joined. Reusing those defaults would pull a new flatmate into an old dinner and flatten a deliberate 70/30 rent split back to 50/50, with no error shown.

What happens when two people edit the same expense at once?

The second edit is refused with HTTP 409 and the error code stale_version. Every PATCH carries base_version, the version its author read, and the amend path compares it against the locked row. A mismatch means the expense moved, so the edit comes back for its author to reapply against the version they can now see. Nothing is merged.

Does editing an expense update the ledger rows in place?

No, editing an expense never updates a ledger row in Dimesum. An edit posts two journals in one transaction: an EXPENSE_REVERSAL that negates the old version leg for leg, then an EXPENSE journal for the new version. UPDATE and DELETE are revoked from the ledger's own database role, so a mutation is impossible even for buggy code. You see an edited badge and a history sheet.

Do I need a second API call before editing a shared expense?

No, a client that just wrote the expense already holds the version an edit needs. Every expense response carries version, which is what the next PATCH sends as base_version. A client that did not write the expense calls GET /v1/groups/{id}/expenses/{id}, which returns the version plus the split inputs under the same field names a PATCH accepts.

Why require participants and split_type now instead of adding them later?

Requiring a field on day one is reversible, and adding one later is not. Relaxing a required field later is backward compatible: clients already send it, and the server starts accepting requests without it. Adding a requirement later breaks every client that relied on the old default, and in a money API the breakage is silent until somebody's balance is wrong.