# TripTalley — Architecture & Design Spec

Status: draft (v1) · Owner: TripTalley team · Derived from `PLAN.md`

TripTalley tracks money spent during a group trip so that "who owes whom" is
trivial at the end. This spec turns the notes in `PLAN.md` into concrete,
buildable decisions. It is the source of truth for the backend.

---

## 1. Guiding principles

1. **All money logic lives server-side.** Splits, rounding, settlement,
   currency conversion. The client renders and captures input; it never
   computes balances. This is what preserves cross-platform optionality.
2. **Capture cheap, compute late.** An expense stores only *what was spent*
   (amount, currency, date). No FX at entry time. Conversion happens once, at
   tally, so the app works offline for the whole trip.
3. **Money is exact.** All amounts are integer **minor units** (cents) with an
   explicit currency. No floats in storage or settlement. Rounding is
   deterministic and always reconciles to the total.

---

## 2. Currency & money representation

- Amounts stored as **integer minor units** + ISO-4217 currency code
  (e.g. `{amount_minor: 1050, currency: "USD"}` = $10.50).
- Minor-unit exponent is per-currency (USD/EUR = 2, JPY = 0, BHD = 3). A small
  static table drives formatting and split math.
- FX rates are decimal, applied as: `minor_dest = round(minor_src *
  rate_src_to_dest * 10^(exp_dest - exp_src))`. Rounding = half-up, then the
  largest-remainder pass (below) fixes any penny drift across a set.

### 2.1 Three currency roles (don't conflate them)
- **Original currency** — what the expense was actually paid in. Stored.
- **Settlement currency** — the *single canonical* currency the trip settles
  in. Set per trip (defaults to trip creator's home currency). All netting and
  debt-simplification happen here, and only here.
- **Display currency** — each viewer's `home_currency`. Purely a presentation
  conversion applied to already-computed settlement amounts. Never used for
  math.

> Critical rule: **you cannot net mixed currencies.** Convert every expense to
> the settlement currency first, net there, simplify there, then optionally
> convert the resulting payment amounts to the viewer's home currency for
> display.

---

## 3. Data model

Integer surrogate PKs internally; expenses also carry a **client-generated
UUID** for idempotent sync. Timestamps are UTC epoch millis. `updated_at`
drives last-write-wins.

### User
| field | type | notes |
|---|---|---|
| id | int PK | |
| email | text unique | login identity |
| password_hash | text | argon2/bcrypt (magic-link optional later) |
| name | text | display name |
| home_currency | text | ISO-4217; default display currency |
| created_at / updated_at | int | |

### Group  (standing entity)
| field | type | notes |
|---|---|---|
| id | int PK | |
| name | text | |
| created_by | int FK User | |
| created_at / updated_at | int | |

### GroupMember  (join)
| group_id, user_id | FK | composite key |
| role | text | `owner` / `member` |
| joined_at | int | |

### Trip
| field | type | notes |
|---|---|---|
| id | int PK | |
| group_id | int FK | |
| name | text | |
| start_date / end_date | date | nullable |
| settlement_currency | text | defaults to creator's home_currency |
| created_at / updated_at | int | |

### Expense
| field | type | notes |
|---|---|---|
| id | int PK | |
| client_uuid | text unique | supplied by client; idempotency key |
| trip_id | int FK | |
| payer_id | int FK User | who fronted the money |
| amount_minor | int | in original_currency |
| original_currency | text | ISO-4217 |
| spent_on | date | drives historical FX at tally |
| gps_suggested_currency | text nullable | suggestion only |
| currency_overridden | bool | user changed the GPS suggestion |
| split_type | text | `equal` / `custom` / `percentage` |
| note | text nullable | |
| deleted | bool | soft delete for sync |
| created_at / updated_at | int | LWW |

### ExpenseParticipant
| expense_id, user_id | FK | who shares this expense |
| share_weight | int nullable | custom: exact minor units; percentage: basis points; equal: null |

### Settlement — **not stored.** Computed on demand from a trip's expenses.

---

## 4. Split engine (largest-remainder rounding)

Splitting must always sum **exactly** to the expense total — no lost or
invented pennies.

- **equal**: divide `amount_minor` by N participants. Base = floor(total/N).
  Distribute the `total - base*N` leftover pennies one each to the first
  `remainder` participants, ordered by a stable key (user_id). Deterministic.
- **custom**: participants supply exact minor-unit amounts; validate they sum
  to the total (reject otherwise).
- **percentage**: weights in basis points summing to 10000. Compute each raw
  share, floor it, then hand out leftover pennies by **largest fractional
  remainder** (ties broken by user_id). Guarantees Σ shares = total.

Output: a per-participant `owed_minor` in the expense's **original currency**.

---

## 5. Settlement algorithm

At tally, for a trip:

1. **Convert every expense to settlement currency** using the historical rate
   for that expense's `spent_on` date (§6).
2. For each expense: `payer` is credited the full converted total;
   each participant is debited their converted share. Accumulate a net balance
   per user (positive = is owed, negative = owes). Balances sum to zero.
3. **Debt simplification (minimize transfers):** greedily match the largest
   creditor with the largest debtor, settle `min(|debtor|, creditor)`, repeat.
   Produces at most N-1 payments. Output: `[{from, to, amount_minor,
   currency: settlement}]`.
4. **Display conversion:** for a given viewer, convert each payment's
   `amount_minor` from settlement currency to the viewer's `home_currency`
   using the *tally-time* rate (a single as-of date), returned alongside the
   canonical settlement amount. Display only — never re-netted.

Determinism: stable ordering on equal balances so the same expenses always
produce the same payment list.

---

## 6. FX service

- Provider: **Frankfurter** (ECB daily reference rates, free, no key). Fallback
  provider slot left in the interface.
- Rates fetched **server-side only**, cached by `(date, base, quote)` in a
  local table so a trip re-tallies without re-hitting the API.
- **Business-day fallback:** ECB publishes no rate on weekends/holidays. If a
  date has no rate, walk backwards to the nearest earlier published date (cap
  the walk, e.g. 7 days) and record which date was actually used.
- **Coverage gaps:** if a currency pair or date isn't available, surface a
  clear error on that expense rather than silently zeroing it; the tally can
  still report the rest.
- Pre-1999 dates and unknown ISO codes are rejected at entry validation where
  possible.

---

## 7. Auth & invites

- **Accounts**, not per-trip ad-hoc identities. Email + password to start
  (argon2). JWT (short-lived access + refresh) or server sessions — access
  tokens for the API.
- **Invite links:** a signed, expiring token encodes `group_id` + nonce (HMAC,
  not a guessable id). Tapping it: if unauthenticated, the signup form is shown
  and, on account creation, the user is added to the group in the same step
  (invite and signup are coupled). Existing users are added on tap.
- Invite tokens are single-group, time-boxed, and revocable.

---

## 8. Offline-first sync

- The client owns a local store and works fully offline during the trip.
- **Idempotency:** every expense carries a `client_uuid`. Sync is an **upsert**
  keyed on it — re-sending after a dropped connection updates, never
  duplicates.
- **Conflict policy:** last-write-wins per record using `updated_at`. Deletes
  are soft (`deleted=true`) so they propagate.
- **Sync endpoints (periodic, not realtime):**
  - `POST /sync/push` — batch of locally-changed expenses (upsert by uuid).
  - `GET  /sync/pull?trip_id=&since=` — records changed since a watermark.
- No websockets/push infra in v1 (sync-on-open + optional light poll).

---

## 9. API surface (v1 sketch)

```
Auth
  POST /auth/signup                 {email,password,name,home_currency}
  POST /auth/login                  -> {access, refresh}
  POST /auth/refresh

Groups
  POST /groups                      create
  GET  /groups                      mine
  POST /groups/{id}/invite          -> signed link
  POST /invites/{token}/accept      join (couples with signup for new users)

Trips
  POST /trips                       {group_id,name,dates,settlement_currency?}
  GET  /trips/{id}
  GET  /groups/{id}/trips

Expenses
  POST /trips/{id}/expenses         {client_uuid,payer_id,amount_minor,
                                     original_currency,spent_on,split_type,
                                     participants[...]}
  PATCH/DELETE /expenses/{uuid}

Settlement
  GET  /trips/{id}/settlement?as_of=&display_currency=
       -> {balances[], payments[], fx_used[], as_of}

Sync
  POST /sync/push
  GET  /sync/pull?trip_id=&since=

Reference
  GET  /currencies                  supported ISO codes + exponents
```

---

## 10. Stack & structure

- **Backend:** FastAPI (Python), SQLite via SQLAlchemy, Pydantic schemas,
  Alembic (or lightweight metadata create) for schema, `httpx` for FX,
  `pytest` for tests.
- **Money math** isolated in a pure, dependency-free module
  (`money`, `split`, `settle`) so it is exhaustively unit-testable without a DB
  or network.
- **Client:** thin. Native SwiftUI (iOS) per PLAN, *or* a small web client for
  in-container demos — the backend is identical either way. TBD with owner.

Proposed layout:
```
backend/
  app/
    main.py            FastAPI app + routers
    db.py              engine/session
    models.py          SQLAlchemy tables
    schemas.py         Pydantic I/O
    core/
      money.py         minor-unit + currency table
      split.py         largest-remainder split engine
      settle.py        netting + debt simplification
      fx.py            Frankfurter client + cache + business-day fallback
    routers/           auth, groups, trips, expenses, settlement, sync
  tests/
```

---

## 11. Open decisions (need owner input)

1. **Client for v1 demo:** native SwiftUI only (not runnable/demoable in this
   Linux container) vs. a small web client now (fully demoable) with SwiftUI
   later. Backend is unaffected either way.
2. **Auth:** email/password now, magic-link later — confirm.
3. **Multiple payers per expense** (someone splits the bill on two cards) —
   out of scope for v1? Current model = one payer per expense.
```
