Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ What changed in each version of the app, newest first. The format is
- On the web, OpenSplit tells you when an update is ready, and restarts into it when you choose.
- Groups and people have pictures: initials, an emoji or an icon, on a colour you choose. Tap a picture to change it.
- Each group has a cover drawn from what it spends on, so a trip fills with flights and dinners and a flat with bills.
- With "Suggest the fewest payments" off, balances show what each person owes each person they shared expenses with, each with its own Settle button.
- Somebody who left a group can come back with an invite link, to the same place they had.

### Changed
- Sign-in code emails have a new look to match the app.
Expand All @@ -23,13 +25,33 @@ What changed in each version of the app, newest first. The format is
- Amounts use the app's own typeface, with figures that line up.
- Lists are grouped the Material 3 Expressive way, as connected rows rather than separate cards, and icon buttons change shape as you press them.
- The sign-in code email sets its code in the app's own typeface.
- Groups are listed by their newest activity rather than when they were made.
- You need to be settled up before leaving a group, as you already did to remove somebody else.
- Groups you have left move to the archived list, read-only, and stop syncing.
- Amounts can be typed with a comma as the decimal point. Thousands separators are no longer accepted while typing.
- Exchange-rate estimates only use a rate from within a week of the expense's day, and the server keeps a full year of rates to draw on.
- Two people editing the same expense at once no longer quietly lose one edit: whichever arrives second is set aside, with the newer version shown, to be applied again on top of it.
- Saving something syncs that group alone, and all your groups are read in one request, so syncing uses far less data and battery.

### Fixed
- Settling up with somebody and then removing them while offline no longer leaves the removal refused once you are back online.
- One group that can't be read yet, such as one created offline, no longer stops your other groups from syncing.
- A group is no longer archived as unused while people are still editing its expenses.
- A name change could occasionally never reach the people you share groups with.
- Updates the app received in the background now show on screen as soon as you return, and screens no longer redraw after every sync when nothing changed.
- A group where you're owed one currency and owe another now says both on the groups list, instead of calling it all owed to you.
- Signing in with Google on Android works again.
- A name you give while creating your first group now shows on your profile straight away.
- Reloading the web app no longer flashes "This site can't be reached" first.
- Signing out stops notifications for that account on the device.
- Changing who is being paid in Settle up now changes the UPI ID handed to your payment app too, instead of keeping the previous person's.
- An amount typed as 12,50 is no longer read as 1,250.
- A very long amount no longer breaks the Settle up screen.
- You can no longer record a payment from someone to themselves.
- Changing the email address or Google account an account signs in with now needs a recent sign-in, like deleting the account does.
- Nobody can add a new expense that charges somebody who has left a group.
- An expense split among 26 or more people now saves; before, the server refused it and the app kept retrying.
- In Settle up, making the person being paid the payer now clears "Who is being paid" on screen too, not just behind it.

## [2.0.0] - 2026-10-01
### Changed
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ cd server && pnpm format # Biome, across the whole repository; CI runs `pnpm
outranks a personal default formatter.

The server tests are not optional decoration. They cover the balance invariant
rejecting an expense that does not add up, that a stale edit is refused only
when applying it would move money, that entries cannot be hard-deleted, that an
rejecting an expense that does not add up, that an edit composed on an older
version is refused rather than undoing the one in between, that entries cannot
be hard-deleted, that an
invite token can be spent exactly once by somebody with no other access to the
group, and that a collected group answers everybody with a tombstone rather
than refusing every device that still holds a copy.
Expand Down
62 changes: 49 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,30 @@ of changes is only ever cut between sequence numbers — so a device sees a whol
change or none of it, and can never hold an expense whose shares have not
arrived.

One feed uses a timestamp cursor instead, and honestly: profiles live in D1, which
several requests write concurrently, so there is nothing there that can issue a
sequence number. A time cursor cannot see a profile that becomes *visible*
without changing — somebody named long ago claiming a place in your group — so
each group page carries the profiles of its current members, read by the ids
the page itself names. The profile feed then only has renames to deliver.
Profiles live in D1, outside every group, and are numbered the same way by D1
itself: each write takes the next value of a `counters` row in the same batch
(`server/src/db/d1/profile-version.ts`). D1 commits one write at a time, so the
versions follow commit order, which a timestamp from a Worker's clock does not:
two requests can stamp their times in one order and commit in the other, and a
device that read in between would pass one of them for good. A counter rather
than `max(version) + 1`, which would hand a deleted row's number out again.

A version cannot see a profile that becomes *visible* without changing —
somebody named long ago claiming a place in your group — so each group page
carries the profiles of its current members, read by the ids the page itself
names. The profile feed then only has renames to deliver.

### An edit is judged against the version it was made on

Every write replaces the whole row (see below), so an expense edit carries
`baseSeq`, the version it was composed on, and the object refuses it as
`stale_base` unless that is the version stored now. Refusing only edits that
move money would be wrong: the edit still carries the old value of every field
it did not touch, so a stale rename would silently put back the amount
somebody else just corrected. The device parks a refused edit for its author to
apply again on top of the newer version. Sending exactly what is stored is
answered with the stored row, whatever the base, which is what a retry after a
lost response is.

---

Expand All @@ -57,8 +75,9 @@ the page itself names. The profile feed then only has renames to deliver.
tables that exist because the object is a little machine as well as a table:
`counter` (the sequence allocator), `meta` (the group row itself), `tombstone`
(what is left after a purge, so a device that still holds a copy is told to drop
it rather than being refused forever), `outbox` (writes owed to D1) and
`schedule` (the dormancy alarm).
it rather than being refused forever), `outbox` (writes owed to D1),
`schedule` (the dormancy alarm) and `activity` (when a member last changed
anything, which is what quiet is measured from; housekeeping never writes it).

A purge happens when a group is archived, settled in every currency and quiet
for a year, or at once when its last account holder deletes their account.
Expand All @@ -68,7 +87,8 @@ shares and activity. An edit that device never pushed goes with it. The copy
that survives is whatever somebody exported beforehand.

**D1** (`server/src/db/d1/schema.ts`) — `profiles`, `memberships`,
`device_tokens`, `link_tokens`, plus Better Auth's own four tables. Everything
`device_tokens`, `link_tokens`, `counters` (the profile feed's version), plus
Better Auth's own four tables. Everything
here except `profiles` is a **derived index**: the objects are the truth, and D1
is what makes "which groups am I in" and "what does this token point at"
answerable without asking every object in the account.
Expand Down Expand Up @@ -243,9 +263,21 @@ No column changes, so no migration and no device rebuild.

## Sync is event-triggered, never polled

The client syncs on real triggers — screen open, pull-to-refresh, a data-only
push waking the device — and asks each group's object for everything after its
cursor, as one-shot requests.
The client syncs on real triggers, as one-shot requests, and each trigger asks
for only what it could have changed:

- **App start, resume, pull-to-refresh, a retry after a failure:** everything.
The outbox, the shared feeds (reference data with its `ETag`, rates,
profiles), and every group the account is in.
- **A local write, a screen opening, a push waking the device:** the outbox,
then the named group and the groups the push wrote to. Nothing else on the
device changed, and another device's change arrives with its own push.

Groups are read with one `POST /sync`, which takes up to fifty groups and their
cursors, reads them from their objects in parallel, and answers a page each. It
also names every group the account is in, so a group joined on another device
is found by the same request rather than a separate one. A group the server
cannot read is listed under `refusals`, and the rest are answered.

This is a cost decision as much as a correctness one. Request volume from sync
is the line that scales with usage here; Durable Object compute, storage and
Expand Down Expand Up @@ -327,7 +359,11 @@ tokens.

## Cron: two schedules

`0 4 * * *` refreshes exchange rates. `0 5 * * *` collects abandoned guest
`0 4 * * *` refreshes exchange rates, then keeps the last year of them
complete: any stretch of more than a week with no publication (all of it, on a
new deployment) is fetched whole in one range request. A rate more than a week
older than a day does not answer for that day, on the server or the device.
`0 5 * * *` collects abandoned guest
accounts (over ninety days old, in no group, no session used in ninety days) and
finishes any account deletion a request started and did not complete.

Expand Down
Loading