Shared Trip Expense Ledger
Problem
A travel group needs balances that remain explainable after expenses are settled or corrected. Design TripExpenseLedger with fixed membership, an immutable journal of expenses and settlements, compensating reversals, and current member balances. Calls on one instance are sequential.
Requirements
- Fixed membership. Construction accepts 2 to 100 unique member IDs that match
[A-Za-z0-9_-]{1,40}. Invalid construction raises the runtime's standard invalid-argument exception, and membership never changes. - Immutable journal. Each accepted expense, settlement, or reversal appends one entry with a unique entry ID. Existing entries are never edited or removed, and rejected operations append nothing and do not reserve the proposed entry ID.
- Expense splits. An expense credits its payer by the full amount and debits every participant by its share.
EQUALdivides by sorted participant ID and assigns one remainder cent to each earliest participant;EXACTpreserves the input pairing between participants and positive shares. - Balanced postings. Posting amounts within each entry sum to zero. A member balance is the sum of that member's postings across the journal, and all member balances also sum to zero.
- Controlled settlements. A settlement moves value from a member with a negative balance to one with a positive balance. It cannot move either balance through zero and does not depend on a stored pairwise debt.
- Compensating reversals. Reversing an expense or settlement appends its exact negated postings. A target may be reversed once, and a reversal cannot itself be reversed.
- Failure atomicity. Validation uses the stated precedence. A rejected call changes no balance, journal entry, or reversal state.
- Stable snapshots. Balance results contain every member sorted by
memberId. Entry results follow successful append order, with nonzero postings sorted bymemberId. Returned arrays and view objects are detached from ledger state. - Exact arithmetic. Money and balances use signed 64-bit integer cents. Floating-point arithmetic is not part of the contract.
API
| Signature | Returns | Behavior |
|---|---|---|
TripExpenseLedger(memberIds: string[]) | Not applicable | Creates an empty ledger after validating fixed membership. Invalid input raises Python ValueError, Java IllegalArgumentException, or C++ std::invalid_argument. |
recordExpense(entryId: string, payerId: string, amountCents: int64, participantIds: string[], splitMode: string, splitValues: int64[]) | string | Returns an expense status. On OK, appends one EXPENSE entry. |
recordSettlement(entryId: string, debtorId: string, creditorId: string, amountCents: int64) | string | Returns a settlement status. On OK, posts +amountCents to the debtor and -amountCents to the creditor. |
reverseEntry(entryId: string, targetEntryId: string) | string | Returns a reversal status. On OK, appends one REVERSAL entry and marks its target as reversed. |
getBalances() | BalanceView[] | Returns a detached, sorted snapshot of every member's current balance. |
getEntries() | LedgerEntryView[] | Returns a detached snapshot of the journal in append order. |
BalanceView has fields memberId: string and balanceCents: int64.
LedgerEntryView has fields entryId: string, kind: string, amountCents: int64, primaryMemberId: string, secondaryMemberId: string, splitMode: string, reversalOfEntryId: string, and postings: BalanceView[].
Member and entry IDs are case-sensitive. Entry IDs use the same pattern as member IDs. All arguments are non-null, and input arrays must not be retained or modified.
Expense validation checks entry ID format, entry ID uniqueness, membership, amount, then split rules. It returns the first applicable status:
| Status | Condition |
|---|---|
OK | The expense was appended. |
INVALID_ENTRY_ID | entryId does not match [A-Za-z0-9_-]{1,40}. |
DUPLICATE_ENTRY | entryId belongs to any existing journal entry. |
UNKNOWN_MEMBER | The payer or a participant is not a group member. |
INVALID_AMOUNT | amountCents is outside 1 through 1_000_000_000_000. |
INVALID_SPLIT | A split rule is violated. |
The participant list must be nonempty, contain no duplicates, and contain no more members than the group. The payer may be present or absent. For EQUAL, splitValues must be empty and the amount must be at least the participant count. If division gives quotient q and remainder r, each participant owes q, and the first r sorted participant IDs owe one additional cent. For EXACT, splitValues must have the same length as participantIds; each value is the positive share for the participant at the same index, and all shares must sum to the amount. Any other split mode is invalid. The payer credit and participant debit are combined when they name the same member, and zero net postings are omitted.
Settlement validation checks entry ID format, entry ID uniqueness, membership, amount, then current eligibility. It returns:
| Status | Condition |
|---|---|
OK | The settlement was appended. |
INVALID_ENTRY_ID | entryId violates the entry ID rule. |
DUPLICATE_ENTRY | entryId already exists. |
UNKNOWN_MEMBER | The debtor or creditor is not a member. |
INVALID_AMOUNT | amountCents is outside 1 through 1_000_000_000_000. |
INVALID_SETTLEMENT | The members are equal, the debtor balance is not negative, the creditor balance is not positive, or the amount exceeds either the debt or the credit. |
Reversal validation checks entry ID format, entry ID uniqueness, then target eligibility. It returns:
| Status | Condition |
|---|---|
OK | The reversal was appended. |
INVALID_ENTRY_ID | The proposed reversal ID violates the entry ID rule. |
DUPLICATE_ENTRY | The proposed reversal ID already exists. |
INVALID_REVERSAL | The target is missing, is a reversal, or was already reversed. |
An expense entry stores its payer as primaryMemberId, its mode as splitMode, and empty strings for secondaryMemberId and reversalOfEntryId. A settlement stores its debtor and creditor as the primary and secondary members. A reversal stores only its target in reversalOfEntryId. Fields not named for a kind are empty strings. A reversal's amountCents equals its target amount.
Examples
Starting with TripExpenseLedger(["cara", "alice", "bob"]):
| Step | Operation | Result |
|---|---|---|
| 1 | recordExpense("e1", "alice", 1000, ["cara", "alice", "bob"], "EQUAL", []) | OK |
| 2 | getBalances() | [{"memberId":"alice","balanceCents":666},{"memberId":"bob","balanceCents":-333},{"memberId":"cara","balanceCents":-333}] |
| 3 | recordSettlement("s1", "cara", "alice", 200) | OK |
| 4 | getBalances() | [{"memberId":"alice","balanceCents":466},{"memberId":"bob","balanceCents":-333},{"memberId":"cara","balanceCents":-133}] |
| 5 | recordSettlement("s2", "bob", "alice", 400) | INVALID_SETTLEMENT; state is unchanged. |
| 6 | reverseEntry("r1", "s1") | OK |
| 7 | getBalances() | [{"memberId":"alice","balanceCents":666},{"memberId":"bob","balanceCents":-333},{"memberId":"cara","balanceCents":-333}] |
In step 1, sorted participants receive shares alice=334, bob=333, and cara=333. Combining Alice's credit and share produces her +666 posting.
Constraints
- Group size is 2 to 100 members.
- At most 10,000 mutating calls and 10,000 query calls are made per ledger.
- Expense and settlement amounts are 1 through
1_000_000_000_000cents. - All balances stay within the signed 64-bit range.
- A mutation should not scan prior entries. Returning
nbalances costs ; returningeentries withptotal postings costs .
Notes
The judge uses one thread per testcase and preserves call order. Concurrent calls on one instance are outside scope, and different instances share no state. Multiple groups, membership changes, currencies, exchange rates, taxes, percentage splits, timestamps, descriptions, persistence, editing, deletion, debt minimization, and automatic idempotency beyond duplicate entry rejection are outside scope.