Offline-first Flutter trip expense splitter
Flutter app for group trips with offline expense entry, sync on reconnect, multi-currency day-of rates, and minimum-transfer debt settlement.
flutterdartdriftsqliteriverpodfreezed
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add offline-first-flutter-trip-expense-splitter-bccc70CLAUDE.md@REVIEW.md
@SPEC.md
# the app
## Purpose
Offline-first group trip expense splitter in Flutter. Solo developer, built for daily personal use. SPEC.md has the product and technical spec. PLAN.md has the phases.
## Commands
- `flutter pub get`
- `dart run build_runner build --delete-conflicting-outputs`
- `dart format --set-exit-if-changed .`
- `flutter analyze`
- `flutter test`
- `flutter test integration_test`
- `flutter run`
## Architecture
- Feature-first folders: `lib/features/{trips,expenses,settlement,sync,currency}`.
- Shared code lives in `lib/core` (db, money, clock).
- Riverpod for state.
- Drift (SQLite) is the single local source of truth.
- UI never calls the network directly. Go through a repository.
## Offline rule
- Every write goes to the local DB and an outbox table first.
- A sync worker drains the outbox when connectivity returns.
- The app must be fully usable in airplane mode.
## Money rule
- Never use `double` for money or rate math.
- Store amounts as integer minor units plus an ISO 4217 currency code.
- The `Money` value class in `lib/core/money` owns rounding and allocation.
- Allocation remainders follow the deterministic rule in SPEC.md.
- Currency decimals come from one table: JPY 0, USD/EUR 2, KWD 3.
## FX rule
- Each expense stores its own rate, rate date and rate source at creation time.
- Never recompute a stored rate later.
- Offline with no cached rate: the user enters a manual rate, flagged as `manual`.
- Rates are fetched through the `RateProvider` interface only.
## Sync rule
- Client-generated UUIDv7 ids.
- Every record has `updatedAt` and a device id.
- Deletes are soft (tombstones).
- Every sync operation must be idempotent. Duplicate delivery must be harmless.
- Conflict policy: last-writer-wins per field. Details live in SPEC.md.
## Settlement rule
- Compute net balance per person per trip base currency.
- Then apply greedy min-cash-flow matching.
- Settlement code is a pure Dart function in `lib/features/settlement`.
- No Flutter imports in settlement code.
- Balances must sum to exactly zero after rounding. The remainder goes to a deterministic member.
## Time rule
- Inject a `Clock`. Never call `DateTime.now()` directly.
- Store timestamps in UTC.
- An expense date is the user's local calendar date at the trip location.
## Database and generated code
- After changing Drift tables or freezed classes, run build_runner.
- Schema changes need a migration and a migration test.
- Never hand-edit `*.g.dart` or `*.freezed.dart`.
- Whether generated files are committed or ignored follows the decision in SPEC.md.
## Testing
- Unit tests for money, FX and settlement, with property-style randomized cases.
- Use a fake sync backend (`FakeSyncRemote`) for offline and online transitions.
- No real network calls in tests.
- Integration tests must pass with the network off.
## Dependencies
- Ask before adding any package beyond: drift, drift_dev, build_runner, flutter_riverpod, freezed, freezed_annotation, uuid, sqlite3_flutter_libs.
- Justify every new dependency in the PR or commit message.
## Secrets
- Never commit secrets or real keys.
- Backend keys use `YOUR_API_KEY` placeholders in examples.
- Real values come from `--dart-define` or a gitignored env file.
## Workflow
- Explore, then plan, then code, then verify.
- Run the `verify` skill before saying a task is done.
- Use `/clear` between unrelated tasks and `/compact` at natural breaks.
- Use `test-runner` for the full suite to keep logs out of the main context.
.claude/agents/test-runner.md---
name: test-runner
description: Use proactively to run flutter analyze and flutter test for this project. Returns only failing tests and a pass/fail summary, keeping logs out of the main context.
model: haiku
tools: Bash, Read, Grep, Glob
maxTurns: 12
---
You run the Flutter checks for the trip expense splitter and report results briefly.
## Steps
1. Run `flutter analyze`. Record each issue as file, line and one-line message.
2. Run `flutter test`. If the task names a folder (for example `test/sync`), run that instead.
3. For each failing test, open the test file only as far as needed to confirm the file and line.
## Report format
- Summary: analyze passed or failed (issue count), tests passed or failed (counts).
- Failures: one bullet per failure with file, line and the one-line error.
- Nothing else. Do not paste full logs, stack traces or passing test names.
## Rules
- Never edit, create or delete files.
- Never run `flutter pub add`, `flutter pub upgrade`, `flutter build` or any git write command.
- If a failure looks like a stale generated file (missing `*.g.dart` or `*.freezed.dart` symbols, or mismatched generated code), say so and suggest running `dart run build_runner build --delete-conflicting-outputs`. Do not run it yourself.
- If a command cannot run at all, report the exact error line and stop.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(flutter analyze:*)",
"Bash(flutter test:*)",
"Bash(flutter pub get:*)",
"Bash(dart format:*)",
"Bash(dart run build_runner:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"ask": [
"Bash(flutter pub add:*)",
"Bash(flutter pub upgrade:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(flutter build:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(**/google-services.json)",
"Read(**/GoogleService-Info.plist)",
"Bash(rm -rf:*)",
"Bash(git push --force:*)",
"Bash(git reset --hard:*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.dart) dart format \"$f\" ;; esac"
}
]
}
]
}
}
.claude/skills/settlement-check/SKILL.md---
name: settlement-check
description: Use when editing money, FX, balance or settlement code, to keep amounts exact, balances zero-sum and transfer counts minimal.
paths: lib/core/money/**, lib/features/settlement/**, lib/features/currency/**
---
# Settlement check
Use this checklist before and after any change in the paths above.
## Checklist
- Amounts are integer minor units. No `double` in the math or in stored values.
- Currency decimals come from one table: JPY 0, USD 2, EUR 2, KWD 3. Add new currencies to that table, never inline.
- Rounding remainders are assigned by the rule in `Money.allocate`. The same input always gives the same output.
- Zero-sum invariant: per base currency, the net balances sum to exactly 0 after rounding. Assert it, do not assume it.
- Settlement code in lib/features/settlement is pure Dart. No Flutter imports, no clock, no randomness, no I/O.
- FX uses the rate stored on each expense. Never fetch or recompute a rate in this path.
## Required tests
Add or update randomized tests:
- Groups of 2 to 12 people.
- Several currencies, including one 0-decimal and one 3-decimal currency.
- Random expenses using equal, share and exact splits.
- Assertions: balances net to zero; transfer count is at most n - 1; applying the transfers clears every balance to 0.
- Use a fixed seed so failures reproduce. Put the seed in the failure message.
## Worked example
Net balances in the base currency:
- A +20.00
- B 0.00
- C 0.00
- D -20.00
Raw debts from expenses:
- B owes A 10.00
- C owes B 10.00
- A owes C 10.00
- D owes A 20.00
Naive pairwise settlement: one transfer per debt pair, so 4 transfers. Applying them clears every balance.
Greedy matching on net balances: D pays A 20.00, so 1 transfer. It also clears every balance, and 1 is within the bound of n - 1 = 3.
Greedy is not always the true minimum (that problem is NP-hard). Compare counts against the bound and the clear-to-zero check. Do not claim optimality unless SPEC.md allows exact search for the group size.
## After the change
Run the verify skill.
.claude/skills/sync-scenarios/SKILL.md---
name: sync-scenarios
description: Use when touching the outbox, sync worker, tombstones or conflict handling, to write offline and sync tests against the fake remote.
---
# Sync scenarios
Use this skill when writing or changing offline and sync tests.
## Scenarios to cover
1. Add an expense offline, then sync when connected.
2. Two devices edit the same expense offline, then both reconnect.
3. One device deletes an expense while the other edits it offline.
4. The same push is delivered twice.
5. The connection drops mid-batch, then resumes.
6. Clock skew: one device's clock is minutes ahead or behind.
## Using FakeSyncRemote
Location: test/sync/fake_sync_remote.dart. If it does not exist yet, create it there as an implementation of the SyncRemote interface.
Controls:
- Connectivity: set online or offline. Calls made while offline throw the same error type the real remote would.
- Latency: add a delay per call using fake async. Never use real sleeps.
- Failure injection: fail the next N calls, or fail after the Kth item in a batch.
- Duplicate delivery: replay the last push.
- Clock: give each simulated device its own fake Clock with an offset.
Each simulated device gets its own in-memory Drift database and its own fake Clock.
## Assertions
After each scenario, assert:
- Both devices converge to identical state: same rows, same field values.
- No duplicate expenses or splits.
- The outbox is empty on every device at the end.
- Replaying an op with the same idempotency key leaves server state unchanged.
- The conflict result matches the policy in SPEC.md (last-writer-wins per field, with ties resolved as SPEC.md says).
- Tombstones stay deleted unless SPEC.md says otherwise.
## Rules
- No real network. No real sleeps.
- One scenario per test. Put the scenario name in the test title so failures read as a list.
- Run `flutter test test/sync` as part of verify.
## After the change
Run the verify skill.
.claude/skills/verify/SKILL.md---
name: verify
description: Run after any code change and before claiming a task or phase is done, to prove the Flutter project still generates, formats, analyzes and passes tests.
when_to_use: Use when finishing a task or a phase, or before saying done on any change under lib/ or test/.
allowed-tools: Bash(flutter *), Bash(dart *)
---
# Verify
Run these steps in order. Stop at the first failing step, fix it, then rerun from that step.
## Steps
1. Generated code: if Drift tables or freezed classes changed, run `dart run build_runner build --delete-conflicting-outputs`. Otherwise skip and say so.
2. Format: `dart format --set-exit-if-changed .` If it fails, run `dart format .` and rerun from this step.
3. Analyze: `flutter analyze`. Zero issues is the bar.
4. Tests: `flutter test`. Add `--coverage` only when asked. For sync or offline changes, also run `flutter test test/sync`.
5. Smoke run, when UI changed: `flutter test integration_test` on an available device or emulator. If none is available, say it was skipped and why.
6. Offline check, for offline changes: confirm the integration test passes with the fake remote offline, then again after it comes online.
## Report format
For each command, report:
- the command
- PASS or FAIL
- on FAIL: the first failing test name and its error line only
Never paste full logs. End with a one-line verdict.
## On failure
- Fix the root cause.
- Rerun from the failed step.
- Do not weaken, skip or delete tests to get green.
- If the failure looks like a stale generated file, rerun step 1 first.
INSTALL.md# Setup guide
Read this once before Phase 1. It explains where each file goes and why each choice was made.
## Prerequisites
- Flutter SDK (stable channel) and Dart on your PATH
- An emulator, simulator or physical phone
- git
- Claude Code 2.1.296 or newer (check with `claude --version`)
## Where each file goes
- `CLAUDE.md` (project root): memory Claude reads every session; imports REVIEW.md and SPEC.md.
- `SPEC.md` (project root): the product and technical spec the phases build against.
- `PLAN.md` (project root): five phases, each with a /goal prompt, so work stays scoped.
- `REVIEW.md` (project root): review rules; imported by CLAUDE.md so /code-review applies them.
- `INSTALL.md` (project root): this guide; delete it whenever you like.
- `.claude/settings.json`: committed permissions and one format hook.
- `.claude/skills/verify/`: proves a change works; run before saying done.
- `.claude/skills/settlement-check/`: checklist for money, FX and settlement edits.
- `.claude/skills/sync-scenarios/`: offline and sync test scenarios.
- `.claude/agents/test-runner.md`: small-model subagent that returns only failures.
## Why the settings look like this
JSON cannot hold comments, so the reasons are here.
- Allow: analyze, test, format, pub get, build_runner and read-only git run without prompts, so the loop stays fast.
- Ask: adding or upgrading packages, building, committing and pushing need your approval.
- Deny: `.env` files, Firebase config files and destructive commands (`rm -rf`, force push, hard reset) are blocked.
- `blockWritesOutsideWorkingDirectories`: keeps edits inside this project.
- `CLAUDE_CODE_SUBAGENT_MODEL=haiku`: default for subagents with no model of their own.
- No `model` key: you get the default, Opus 5.5 at medium effort.
- One PostToolUse hook: runs `dart format` on each edited `.dart` file so formatting never drifts.
## Plugins
Run these in the project root:
- `claude plugin install feature-dev@claude-plugins-official`: guided design for sync and settlement.
- `claude plugin install code-review@claude-plugins-official`: reviews diffs against REVIEW.md.
- `claude plugin install commit-commands@claude-plugins-official`: clean commits at phase ends.
- `claude plugin install security-guidance@claude-plugins-official`: catches key and local-storage mistakes.
- Optional: a Dart or other LSP plugin, if `/plugin` lists one on your machine.
## Create the project
1. `flutter create --org com.example the_app`
2. `cd the_app` then `git init`
3. Copy CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md and INSTALL.md into the root.
4. Copy `.claude/` into the project.
5. Start with `claude` in the project root.
## Running /init safely
The provided CLAUDE.md is already complete, so you do not need /init. If you want a draft to compare, ask Claude to write it to `CLAUDE.init.md` and not touch CLAUDE.md. Merge anything useful by hand, then delete `CLAUDE.init.md`.
## Starting work
1. Confirm the version: `claude --version` should show 2.1.296 or newer.
2. Run `/skill-doctor` to confirm the skills load.
3. Run `/verify` once on the empty project to see the report format.
4. Fill the Open questions in SPEC.md, especially the backend choice and the FX rate provider.
5. Paste the Phase 1 /goal prompt from PLAN.md.
6. To stop a goal early, run `/goal clear`.
The goal evaluator runs no tools and judges only what Claude has shown in the conversation, so ask Claude to print the command output before it claims a check passed.
## Cost habits
- Leave the default model and effort. Use `/effort` to change effort mid-task; switching models breaks the prompt cache.
- The test-runner subagent keeps log output out of the main context.
- Use `/clear` between unrelated tasks and between phases; `/compact` at natural breaks.
- Check spend with `/usage`.
- Use `ultrathink` only for sync conflict design, not routine edits.
## Offline testing
- On a real device, turn on airplane mode, add expenses, turn airplane mode off and confirm they sync.
- Automated tests use the fake remote with controllable connectivity. Tests make no real network calls.
## Backend keys
- Code and docs use `YOUR_API_KEY` placeholders only.
- Put real values in a gitignored env file such as `.env.local`, and add it to `.gitignore` before you create it.
- Pass values with `--dart-define` or `--dart-define-from-file`.
- The deny rules block Claude from reading `.env` files, and the security-guidance plugin watches for leaked keys.
PLAN.md# Build plan
Five phases. Run each phase as one `/goal` session. Stop at the turn bound and read the report before starting the next phase.
Between phases: run `/clear`. Run `/compact` at natural breaks inside a phase.
## Phase 1: Foundation
Builds the project skeleton, Money, Clock and the Drift schema. No screens beyond a placeholder.
Goal prompt:
/goal Phase 1 Foundation for the offline-first Flutter trip expense splitter. Read CLAUDE.md, SPEC.md and PLAN.md first. End state: (1) lib/ uses feature-first layout with lib/core and lib/features/{trips,expenses,settlement,sync,currency}; (2) a Money value class in lib/core/money stores integer minor units plus an ISO 4217 code, has a decimals table covering USD, EUR, JPY and KWD, supports add, subtract, allocate-by-ratio with a deterministic remainder rule, and never uses double; (3) a Clock abstraction in lib/core/clock with a fake for tests; (4) a Drift database in lib/core/db with tables for Trip, Member, Expense, ExpenseSplit, FxRate and OutboxOp, schemaVersion 1 and a migration test scaffold; (5) analysis_options.yaml with strict lints; (6) Riverpod ProviderScope in main.dart with a placeholder home screen. Tests: Money unit tests for rounding, JPY (0 decimals), KWD (3 decimals), allocation that sums to the original amount, and currency mismatch errors. Check: run the verify skill, which runs build_runner, dart format, flutter analyze and flutter test; all must pass with zero analyzer issues. Show a command output summary. Constraints: do not touch other phases' work; no CRUD screens, sync, FX fetching or settlement yet; no new dependencies beyond drift, drift_dev, build_runner, flutter_riverpod, freezed, freezed_annotation, uuid and sqlite3_flutter_libs without asking; never hand-edit generated files; no secrets or real keys; keep verify green. Use the test-runner subagent to run the suite. Turn bound: stop after 40 turns and report what is done, what is failing and what remains. Finish by listing created files and decisions needing my input.
## Phase 2: Offline core
Trips, members and expenses with local CRUD and outbox writes. Works with no network.
Goal prompt:
/goal Phase 2 Offline core. Read CLAUDE.md, SPEC.md and PLAN.md first. End state: (1) repositories for Trip, Member and Expense in lib/features/trips and lib/features/expenses that write the entity and an OutboxOp in one transaction; (2) Riverpod providers exposing these through repositories only, never network calls; (3) basic screens: trip list, trip detail with members, add-expense form with payer and equal, shares or exact split; (4) splits sum exactly to the amount, with the remainder rule from Money; (5) a SyncRemote interface with an in-memory FakeSyncRemote that is not yet drained; (6) UUIDv7 ids and UTC timestamps via Clock. Tests: widget tests for the forms and lists, and an integration test in integration_test/ that creates a trip, adds members and an expense with the fake remote offline, then checks the outbox has one op per write. Check: run the verify skill; all steps pass, including the integration test. Constraints: do not implement FX fetching, settlement math or sync drain; do not touch Phase 3 to 5 files; no new dependencies without asking; never hand-edit generated files; keep verify green. Turn bound: stop after 40 turns and report what is done, what is failing and what remains.
## Phase 3: Currency
Multi-currency entry with stored rate snapshots and a manual fallback.
Goal prompt:
/goal Phase 3 Currency. Read CLAUDE.md, SPEC.md and PLAN.md first. End state: (1) a RateProvider interface in lib/features/currency with a manual implementation and a stub provider for tests; (2) day-rate cache keyed by date and currency pair in FxRate; (3) each expense stores its rate, rateDate and source at creation and the stored rate is never recomputed on read; (4) if offline with no cached rate, the form asks for a manual rate and stores source = manual; (5) trip base currency balances convert each expense using its own snapshot; (6) the expense form and balances view show the currency and manual-rate marker. Tests: unit tests that a historical expense keeps its rate after the cache is cleared; manual fallback when offline; 0-decimal (JPY) and 3-decimal (KWD) currencies round and display correctly; conversion uses the snapshot only. Check: run the verify skill; all steps pass. Constraints: do not implement settlement transfers or sync drain; do not touch other phases' files; no new dependencies without asking; no real rate API keys in code (use YOUR_API_KEY placeholder and --dart-define); keep verify green. Turn bound: stop after 40 turns and report what is done, what is failing and what remains.
## Phase 4: Settlement
Pure min-transfer algorithm and the balance and settle-up screens.
Goal prompt:
/goal Phase 4 Settlement. Read CLAUDE.md, SPEC.md and PLAN.md first. End state: (1) a pure Dart settlement module in lib/features/settlement with no Flutter imports, computing net balances per member in base currency from expense snapshots and greedy max-creditor/max-debtor matching; (2) remainder handling and zero-sum enforcement so balances net to exactly zero; (3) an optional exact search used only when members are 8 or fewer, behind a flag; (4) balances screen and settle-up screen wired to the providers; (5) a removed member with unsettled balance blocks removal with a clear message. Tests: randomized property-style tests with groups of 2 to 12 people and multiple currencies, asserting balances net to zero, transfers are at most n-1, and applying the transfers clears every balance; a worked example test matching SPEC.md (3 transfers for the 4-person case); determinism test that the same input gives the same output. Check: run the verify skill and the settlement-check skill; all steps pass. Constraints: do not touch sync files or other phases' files; the settlement module must not import Flutter; no new dependencies without asking; keep verify green. Turn bound: stop after 40 turns and report what is done, what is failing and what remains.
## Phase 5: Sync
SyncRemote with the chosen backend, outbox drain, pull cursor, tombstones and conflict handling.
Goal prompt:
/goal Phase 5 Sync. Read CLAUDE.md, SPEC.md and PLAN.md first. The backend choice is in SPEC.md open questions; if it is not filled in, stop and ask before writing the remote. End state: (1) the sync worker in lib/features/sync drains OutboxOp in batches oldest first, with idempotency keys, retry with backoff and attempt counts; (2) pull by cursor applies changes in revision order; (3) tombstones are kept until the server confirms them, and deletes never resurrect; (4) per-field last-writer-wins with updatedAt then deviceId tie-break, as documented in SPEC.md; (5) the fake remote supports controllable connectivity, latency and failure injection; (6) a real SyncRemote implementation for the chosen backend behind the same interface, with keys from --dart-define. Tests in test/sync: two offline devices editing the same expense converge; duplicate push is idempotent; connection drops mid-batch then retries; delete on one device and edit on the other; clock skew; outbox ends empty. Check: run the verify skill including flutter test test/sync and the integration test offline then online; all pass. Constraints: no real network in tests; no secrets or real keys committed; do not touch files outside lib/features/sync and the listed backend files without asking; no new dependencies without asking; keep verify green. Turn bound: stop after 40 turns and report what is done, what is failing and what remains.
## Between phases
- Run `/clear` before each new phase prompt.
- Run `/usage` to check spend. Keep the default model and medium effort.
- Use `ultrathink` only for sync conflict design in phase 5.
REVIEW.md# Review rules
Apply these on top of general review. Skip anything `dart format` or `flutter analyze` already catches.
## Money and FX
- Flag any `double` used for money or rate math.
- Flag amounts stored without an ISO 4217 currency code.
- Flag rates fetched at read time instead of the per-expense snapshot (rate, rate date, rate source).
- Flag rounding outside the Money class.
## Offline and sync
- Flag network calls from UI or providers that bypass the repository and outbox.
- Flag writes that skip the outbox or are not idempotent.
- Flag sync changes that break last-writer-wins per field or drop tombstones.
- Flag client ids that are not UUIDv7.
## Time
- Flag `DateTime.now()` instead of the injected Clock.
- Flag timestamps stored in non-UTC form.
## Database
- Flag Drift table changes with no migration and no migration test.
- Flag a schemaVersion bump without a matching migration step.
## Settlement
- Flag settlement code that imports Flutter or is non-deterministic.
- Flag balance logic that does not sum to exactly zero after rounding.
## Dependencies, secrets and generated files
- Flag new dependencies without a justification.
- Flag any secret, real API key or env file content.
- Flag hand edits to `*.g.dart` or `*.freezed.dart`.
## Tests
- Flag missing tests for new money, FX, sync or settlement logic.
- Flag sync changes with no fake-remote scenario test.
SPEC.md# the app: product and technical spec
## Problem and users
- Friends on a trip share costs and want to know who owes whom.
- Signal is patchy on trains, in mountains and abroad, so entry must work offline.
- Each person uses their own phone. There is no shared device.
## MVP scope
- Create a trip with a base currency.
- Add members.
- Add an expense with a payer, a currency and a split.
- Split types: equal, shares, exact amounts.
- Multi-currency expenses with a rate snapshot per expense.
- Balances view per trip in the base currency.
- Settle-up view with minimum transfers.
- Offline entry with background sync on reconnect.
## Non-goals for v1
- Bank integration.
- Receipt OCR.
- In-app payments.
- Web client.
## Data model
Ids are UUIDv7 generated on the client. Every synced record has `updatedAt` (UTC), `deviceId`, `revision` (server cursor, null until acknowledged) and `deletedAt` (tombstone, nullable).
- **Trip**: id, name, baseCurrency (ISO 4217), createdAt, updatedAt, deviceId, deletedAt.
- **Member**: id, tripId, displayName, removedAt (nullable), updatedAt, deviceId, deletedAt.
- **Expense**: id, tripId, payerMemberId, description, amountMinor (integer), currency, expenseDate (local calendar date at trip location, stored as text YYYY-MM-DD), splitType, fxRateId, updatedAt, deviceId, deletedAt.
- **ExpenseSplit**: id, expenseId, memberId, shareMinor (integer, in expense currency), updatedAt, deviceId.
- **FxRate snapshot**: id, fromCurrency, toCurrency, rate (stored as a decimal string with fixed scale), rateDate, source (`provider` or `manual`), fetchedAt.
- **OutboxOp**: id (idempotency key), entityType, entityId, operation (`upsert` or `delete`), payloadJson, createdAt, attempts, lastError.
- **SyncState**: key, cursor (last server revision pulled), lastSyncAt.
Rules:
- Money is never a double. Amounts are integer minor units plus a currency code.
- Expense rate fields are written once at creation and are not recomputed.
- Splits must sum exactly to `amountMinor` in the expense currency.
## FX design
- A `RateProvider` interface returns a rate for (from, to, date).
- Provider results are cached by date and currency pair.
- A rate is stored on the expense at creation. Later edits to the rate are explicit user actions and create a new snapshot.
- If offline and no cached rate exists, the user enters a manual rate. It is stored with `source = manual`.
- Each trip has one base currency. Balances convert each expense using its own snapshot rate.
## Sync design
- Writes go to the local database and an outbox row in one transaction.
- The sync worker drains the outbox in batches, oldest first.
- Each op carries its idempotency key (the outbox id). The server ignores a repeated key and returns the stored result.
- Pull uses a cursor (server revision). The client applies changes in revision order.
- Deletes are tombstones. A tombstone is never removed locally until the server confirms it.
- Conflict policy: last-writer-wins per field. Ties are broken by `updatedAt`, then `deviceId` (lexicographic).
Example, two phones offline:
- Phone A changes an expense amount from 40.00 to 45.00 at 10:00.
- Phone B changes the same expense description at 10:05 and leaves the amount alone.
- Both edits survive: the amount comes from A and the description from B.
- If both change the amount, the 10:05 value wins on both phones after sync.
`SyncRemote` hides the backend. Phase 2 starts with an in-memory fake. The backend choice is made in phase 5 (see open questions).
## Settlement algorithm
1. For each expense, convert the amount to base currency using its snapshot rate. Round once, at the end of conversion, with the Money rounding rule.
2. Compute each member's net balance: amounts paid minus amounts owed. Net balances sum to exactly zero.
3. Greedy matching: repeatedly pair the largest creditor with the largest debtor and transfer the smaller of the two absolute balances. Remove any settled party. Repeat.
4. Optionally, for groups of 8 or fewer, run exact search for the minimum transfer count.
The true minimum number of transfers is NP-hard in general. Greedy is the accepted approach and yields at most n-1 transfers.
Worked example (base currency USD, 4 people, amounts in whole dollars):
- Raw debts as recorded: C owes A 20, C owes B 5, D owes A 10, D owes B 5.
- Naive pairwise settlement: 4 transfers.
- Net balances: A +30, B +10, C -25, D -15. Sum is 0.
- Greedy step 1: C pays A 25. A now +5, C settled.
- Greedy step 2: D pays A 5. A settled, D now -10.
- Greedy step 3: D pays B 10. B and D settled.
- Greedy result: 3 transfers (n-1).
## Edge cases
- **Rounding remainders**: when a split does not divide evenly, remainder minor units go one each to members in ascending member id order.
- **Member removed with unsettled balance**: removal is blocked until the balance is zero, or the user confirms a transfer to another member first.
- **Currencies with 0 or 3 decimals**: JPY has 0, KWD has 3. Decimals come from one table in `lib/core/money`.
- **Expense edited after sync**: edits create an upsert op; the server keeps the latest per-field value.
- **Clock skew**: phones may disagree on time. Ordering uses server revision for pulls and `updatedAt` only for conflict ties. Display uses local time.
- **Duplicate delivery**: the idempotency key makes repeated pushes and pulls harmless.
- **Connection drops mid-batch**: ops already acknowledged stay acknowledged. Unacknowledged ops retry with the same keys.
- **Manual rate used**: balances show a marker when any included expense uses a manual rate.
## Acceptance criteria
Foundation:
- Money adds, subtracts and allocates without using double.
- Allocation always sums to the original amount.
- Adding amounts in different currencies raises an error.
Trips and expenses:
- A user can create a trip, add members and add an expense while airplane mode is on.
- The outbox contains one op per write, and it empties after sync.
Currency:
- An expense created on a given date keeps its rate after the cache is cleared.
- With no network and no cached rate, the user can save an expense with a manual rate.
- JPY amounts show zero decimals and KWD amounts show three.
Settlement:
- Balances net to zero for random groups of 2 to 12 people.
- Applying the suggested transfers clears every balance.
- The transfer count is at most n-1.
Sync:
- Two offline devices editing the same expense converge to identical state after reconnect.
- A duplicate push does not create a second record.
- A delete on one device and an edit on the other end with the record deleted and no resurrection.
## Open questions
Fill these in before phase 2 and phase 5.
- Backend: Supabase, Firebase or a minimal custom API? Default if unanswered: Supabase.
- FX rate provider and its free-tier limits? Default if unanswered: a `RateProvider` with a manual-only implementation first.
- Commit generated files (`*.g.dart`, `*.freezed.dart`) or ignore them? Default if unanswered: ignore them and regenerate in verify.
- Maximum group size for exact settlement search? Default: 8.
- Who can edit a trip's base currency after expenses exist? Default: nobody in v1.
- Do you need sign-in, or are members identified by display name only? Default: display name only in v1.