diff --git a/CHANGELOG.md b/CHANGELOG.md index 750dcff..2d75e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/README.md b/README.md index 15591f7..6d574a3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 919a114..63fcabe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. --- @@ -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. @@ -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. @@ -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 @@ -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. diff --git a/docs/openapi.json b/docs/openapi.json index 953b2c4..d7f6008 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -86,6 +86,11 @@ "format": "date-time", "example": "2026-09-23T11:20:41.865Z" }, + "lastActivityAt": { + "type": "string", + "format": "date-time", + "example": "2026-09-23T11:20:41.865Z" + }, "archivedAt": { "type": "string", "nullable": true, @@ -118,6 +123,7 @@ "coverPhoto", "createdBy", "createdAt", + "lastActivityAt", "archivedAt", "updatedAt", "seq" @@ -704,7 +710,7 @@ "flow" ] }, - "GroupIds": { + "Pull": { "type": "object", "properties": { "groupIds": { @@ -715,10 +721,24 @@ "maxLength": 64, "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" } + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChangePage" + } + }, + "refusals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupRefusal" + } } }, "required": [ - "groupIds" + "groupIds", + "pages", + "refusals" ] }, "ChangePage": { @@ -903,6 +923,11 @@ "nullable": true, "format": "date-time", "example": "2026-09-23T11:20:41.865Z" + }, + "version": { + "type": "integer", + "minimum": 0, + "example": 412 } }, "required": [ @@ -915,7 +940,8 @@ "avatarIcon", "avatarPhoto", "updatedAt", - "deletedAt" + "deletedAt", + "version" ] }, "Entry": { @@ -1196,6 +1222,69 @@ "ordinal" ] }, + "GroupRefusal": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + } + }, + "required": [ + "groupId", + "code", + "message" + ] + }, + "PullRequest": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupCursor" + }, + "maxItems": 50 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "groups", + "limit" + ] + }, + "GroupCursor": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" + }, + "since": { + "type": "integer", + "minimum": 0, + "example": 412 + } + }, + "required": [ + "groupId", + "since" + ] + }, "GroupInput": { "type": "object", "properties": { @@ -1541,6 +1630,9 @@ }, "isMember": { "type": "boolean" + }, + "hasLeft": { + "type": "boolean" } }, "required": [ @@ -1553,7 +1645,8 @@ "isRedeemed", "isExpired", "isRevoked", - "isMember" + "isMember", + "hasLeft" ] }, "LinkKind": { @@ -1646,9 +1739,10 @@ "$ref": "#/components/schemas/Profile" } }, - "cursor": { - "type": "string", - "nullable": true + "seq": { + "type": "integer", + "minimum": 0, + "example": 412 }, "hasMore": { "type": "boolean" @@ -1656,7 +1750,7 @@ }, "required": [ "profiles", - "cursor", + "seq", "hasMore" ] }, @@ -2193,6 +2287,16 @@ } } }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "409": { "description": "That Google account already has an account of its own, and allowSignIn was not set.", "content": { @@ -2253,6 +2357,16 @@ } } } + }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -2294,6 +2408,16 @@ } } }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many attempts. Wait a minute and try again.", "content": { @@ -2364,8 +2488,8 @@ } } }, - "429": { - "description": "Too many attempts. Wait a minute and try again.", + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", "content": { "application/json": { "schema": { @@ -2373,38 +2497,9 @@ } } } - } - } - } - }, - "/api/groups": { - "get": { - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - } - ], - "operationId": "listGroups", - "tags": [ - "sync" - ], - "summary": "The groups this account is still in", - "responses": { - "200": { - "description": "The group ids", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupIds" - } - } - } }, - "401": { - "description": "No session.", + "429": { + "description": "Too many attempts. Wait a minute and try again.", "content": { "application/json": { "schema": { @@ -2416,8 +2511,8 @@ } } }, - "/api/groups/{groupId}/changes": { - "get": { + "/api/sync": { + "post": { "security": [ { "bearer": [] @@ -2426,50 +2521,29 @@ "cookie": [] } ], - "operationId": "getChanges", + "operationId": "pull", "tags": [ "sync" ], - "summary": "Everything that changed in one group since a cursor", - "description": "`limit` counts changes, not rows, and a page is cut only between sequence numbers, so a device sees a whole write or none of it. Send `seq` back as `since` next time.", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" - }, - "required": true, - "name": "groupId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "example": 412 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": "integer", - "example": 200 - }, - "required": false, - "name": "limit", - "in": "query" + "summary": "Every group this account is in, and what changed in the ones asked for", + "description": "Each group asked for gets its own page past its own cursor, read in parallel; a group that cannot be read is listed in `refusals` and the rest are still answered. `groupIds` names every group the account is in now, so one with no cursor yet is asked for next time, from 0. A page is cut only between sequence numbers, so a device sees a whole write or none of it; send each page's `seq` back as that group's `since`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullRequest" + } + } } - ], + }, "responses": { "200": { - "description": "The page, and the cursor to send next time", + "description": "The account's groups, and a page or a refusal for each group asked for", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePage" + "$ref": "#/components/schemas/Pull" } } } @@ -2493,56 +2567,6 @@ } } } - }, - "403": { - "description": "You are not a member of this group, or not allowed to change that.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "No such group, entry, member or link.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "The request conflicts with the group's current state. Read `error.retry` before retrying.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "410": { - "description": "The group was collected, or the link has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The expense does not add up.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } } } } @@ -2819,7 +2843,7 @@ "entries" ], "summary": "Record, edit, delete or restore an expense, whole", - "description": "A stale `baseSeq` is refused only when the write would move money, so two people fixing a typo never arbitrate. Deleting and restoring always move money.", + "description": "`baseSeq` must be the version stored now (null for a new expense), or the write is refused as `stale_base`: the expense is replaced whole, so a write composed on an older version would undo the edit in between. Sending the stored contents again is answered with the stored row, whatever the base.", "parameters": [ { "schema": { @@ -3710,14 +3734,15 @@ "sync" ], "summary": "Everybody you share a group with, plus yourself", - "description": "Cursored on time, since profiles have no single writer to number them. Send `cursor` back as `after`.", + "description": "Cursored on each profile's `version`, which D1 numbers in commit order. Send `seq` back as `since` next time.", "parameters": [ { "schema": { - "type": "string" + "type": "integer", + "example": 412 }, "required": false, - "name": "after", + "name": "since", "in": "query" }, { diff --git a/drift_schemas/drift_schema_v10.json b/drift_schemas/drift_schema_v10.json new file mode 100644 index 0000000..6e3ba83 --- /dev/null +++ b/drift_schemas/drift_schema_v10.json @@ -0,0 +1,2236 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "currencies", + "was_declared_in_moor": false, + "columns": [ + { + "name": "code", + "getter_name": "code", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 3, + "max": 3 + } + } + ] + }, + { + "name": "exponent", + "getter_name": "exponent", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "symbol", + "getter_name": "symbol", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "code" + ] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "groups", + "was_declared_in_moor": false, + "columns": [ + { + "name": "avatar_kind", + "getter_name": "avatarKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'initials\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", + "dart_type_name": "AvatarKind" + } + }, + { + "name": "avatar_color", + "getter_name": "avatarColor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", + "dart_type_name": "AvatarColor" + } + }, + { + "name": "avatar_emoji", + "getter_name": "avatarEmoji", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "avatar_icon", + "getter_name": "avatarIcon", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", + "dart_type_name": "AvatarIcon" + } + }, + { + "name": "avatar_photo", + "getter_name": "avatarPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "default_currency", + "getter_name": "defaultCurrency", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES currencies (code)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES currencies (code)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "currencies", + "column": "code" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "is_direct", + "getter_name": "isDirect", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_direct\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "simplify_debts", + "getter_name": "simplifyDebts", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cover_kind", + "getter_name": "coverKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'generated\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(CoverKind.values,CoverKind.unknownDefaultOpenApi)", + "dart_type_name": "CoverKind" + } + }, + { + "name": "cover_photo", + "getter_name": "coverPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_by", + "getter_name": "createdBy", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "archived_at", + "getter_name": "archivedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "entries", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", + "dart_type_name": "EntryKind" + } + }, + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category_id", + "getter_name": "categoryId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount_minor", + "getter_name": "amountMinor", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "entry_date", + "getter_name": "entryDate", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const CalendarDateConverter()", + "dart_type_name": "DateTime" + } + }, + { + "name": "occurred_at", + "getter_name": "occurredAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "time_zone", + "getter_name": "timeZone", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "split_kind", + "getter_name": "splitKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", + "dart_type_name": "SplitKind" + } + }, + { + "name": "fx_rate", + "getter_name": "fxRate", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fx_source", + "getter_name": "fxSource", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fx_at", + "getter_name": "fxAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_by", + "getter_name": "createdBy", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "deleted_at", + "getter_name": "deletedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [ + 2 + ], + "type": "table", + "data": { + "name": "entries_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 4, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_insert", + "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" + } + }, + { + "id": 5, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_delete", + "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" + } + }, + { + "id": 6, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_update", + "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "profiles", + "was_declared_in_moor": false, + "columns": [ + { + "name": "avatar_kind", + "getter_name": "avatarKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'initials\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", + "dart_type_name": "AvatarKind" + } + }, + { + "name": "avatar_color", + "getter_name": "avatarColor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", + "dart_type_name": "AvatarColor" + } + }, + { + "name": "avatar_emoji", + "getter_name": "avatarEmoji", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "avatar_icon", + "getter_name": "avatarIcon", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", + "dart_type_name": "AvatarIcon" + } + }, + { + "name": "avatar_photo", + "getter_name": "avatarPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "upi_vpa", + "getter_name": "upiVpa", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "version", + "getter_name": "version", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "members", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "profile_id", + "getter_name": "profileId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "joined_at", + "getter_name": "joinedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "left_at", + "getter_name": "leftAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "upi_vpa", + "getter_name": "upiVpa", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "categories", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "icon", + "getter_name": "icon", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [ + 2, + 8 + ], + "type": "table", + "data": { + "name": "entry_payers", + "was_declared_in_moor": false, + "columns": [ + { + "name": "entry_id", + "getter_name": "entryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "entries", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "member_id", + "getter_name": "memberId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES members (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES members (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "members", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "amount_minor", + "getter_name": "amountMinor", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "entry_id", + "member_id" + ] + } + }, + { + "id": 11, + "references": [ + 2, + 8 + ], + "type": "table", + "data": { + "name": "entry_shares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "entry_id", + "getter_name": "entryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "entries", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "member_id", + "getter_name": "memberId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES members (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES members (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "members", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "amount_minor", + "getter_name": "amountMinor", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "weight_micros", + "getter_name": "weightMicros", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "entry_id", + "member_id" + ] + } + }, + { + "id": 12, + "references": [ + 1, + 8 + ], + "type": "table", + "data": { + "name": "group_events", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "actor_id", + "getter_name": "actorId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES members (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "members", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", + "dart_type_name": "EventKind" + } + }, + { + "name": "subject_id", + "getter_name": "subjectId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "entry", + "getter_name": "entry", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", + "dart_type_name": "EntrySnapshot" + } + }, + { + "name": "member", + "getter_name": "member", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", + "dart_type_name": "MemberEventPayload" + } + }, + { + "name": "group", + "getter_name": "group", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", + "dart_type_name": "GroupEventPayload" + } + }, + { + "name": "link", + "getter_name": "link", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", + "dart_type_name": "LinkEventPayload" + } + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "ordinal", + "getter_name": "ordinal", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_provisional", + "getter_name": "isProvisional", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 13, + "references": [ + 2, + 1 + ], + "type": "table", + "data": { + "name": "entry_conflicts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "entry_id", + "getter_name": "entryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "entries", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "attempted", + "getter_name": "attempted", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", + "dart_type_name": "EntrySnapshot" + } + }, + { + "name": "base_seq", + "getter_name": "baseSeq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rejected_at", + "getter_name": "rejectedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "entry_id" + ] + } + }, + { + "id": 14, + "references": [], + "type": "table", + "data": { + "name": "fx_rates", + "was_declared_in_moor": false, + "columns": [ + { + "name": "as_of", + "getter_name": "asOf", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rate", + "getter_name": "rate", + "moor_type": "double", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "as_of", + "currency" + ] + } + }, + { + "id": 15, + "references": [], + "type": "table", + "data": { + "name": "outbox", + "was_declared_in_moor": false, + "columns": [ + { + "name": "target", + "getter_name": "target", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OutboxTarget.values)", + "dart_type_name": "OutboxTarget" + } + }, + { + "name": "target_id", + "getter_name": "targetId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "revision", + "getter_name": "revision", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "position", + "getter_name": "position", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "attempts", + "getter_name": "attempts", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "next_attempt_at", + "getter_name": "nextAttemptAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_error", + "getter_name": "lastError", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "dead_lettered_at", + "getter_name": "deadLetteredAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "target", + "target_id" + ] + } + }, + { + "id": 16, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "group_cursors", + "was_declared_in_moor": false, + "columns": [ + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_synced_at", + "getter_name": "lastSyncedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "group_id" + ] + } + }, + { + "id": 17, + "references": [], + "type": "table", + "data": { + "name": "feed_cursors", + "was_declared_in_moor": false, + "columns": [ + { + "name": "feed", + "getter_name": "feed", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cursor", + "getter_name": "cursor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_synced_at", + "getter_name": "lastSyncedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "feed" + ] + } + }, + { + "id": 18, + "references": [], + "type": "table", + "data": { + "name": "sync_leases", + "was_declared_in_moor": false, + "columns": [ + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owner", + "getter_name": "owner", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expires_at", + "getter_name": "expiresAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "name" + ] + } + }, + { + "id": 19, + "references": [], + "type": "table", + "data": { + "name": "sync_sessions", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "epoch", + "getter_name": "epoch", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "enabled", + "getter_name": "enabled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"enabled\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 20, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + }, + { + "column": "seq", + "order_by": null + }, + { + "column": "ordinal", + "order_by": null + } + ] + } + }, + { + "id": 21, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_subject", + "sql": null, + "unique": false, + "columns": [ + { + "column": "subject_id", + "order_by": null + } + ] + } + }, + { + "id": 22, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_actor", + "sql": null, + "unique": false, + "columns": [ + { + "column": "actor_id", + "order_by": null + } + ] + } + }, + { + "id": 23, + "references": [ + 8 + ], + "type": "index", + "data": { + "on": 8, + "name": "members_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + } + ] + } + }, + { + "id": 24, + "references": [ + 8 + ], + "type": "index", + "data": { + "on": 8, + "name": "members_profile", + "sql": null, + "unique": false, + "columns": [ + { + "column": "profile_id", + "order_by": null + } + ] + } + }, + { + "id": 25, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "entries_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + }, + { + "column": "entry_date", + "order_by": null + } + ] + } + }, + { + "id": 26, + "references": [ + 13 + ], + "type": "index", + "data": { + "on": 13, + "name": "entry_conflicts_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + } + ] + } + }, + { + "id": 27, + "references": [ + 10 + ], + "type": "index", + "data": { + "on": 10, + "name": "entry_payers_member", + "sql": null, + "unique": false, + "columns": [ + { + "column": "member_id", + "order_by": null + } + ] + } + }, + { + "id": 28, + "references": [ + 11 + ], + "type": "index", + "data": { + "on": 11, + "name": "entry_shares_member", + "sql": null, + "unique": false, + "columns": [ + { + "column": "member_id", + "order_by": null + } + ] + } + }, + { + "id": 29, + "references": [ + 14 + ], + "type": "index", + "data": { + "on": 14, + "name": "fx_rates_currency", + "sql": null, + "unique": false, + "columns": [ + { + "column": "currency", + "order_by": null + }, + { + "column": "as_of", + "order_by": null + } + ] + } + } + ], + "fixed_sql": [ + { + "name": "currencies", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" + } + ] + }, + { + "name": "groups", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"cover_kind\" TEXT NOT NULL DEFAULT 'generated', \"cover_photo\" TEXT NULL, \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entries", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entries_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" + } + ] + }, + { + "name": "entries_fts_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" + } + ] + }, + { + "name": "entries_fts_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" + } + ] + }, + { + "name": "entries_fts_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" + } + ] + }, + { + "name": "profiles", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"version\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "members", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "categories", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entry_payers", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" + } + ] + }, + { + "name": "entry_shares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" + } + ] + }, + { + "name": "group_events", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entry_conflicts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" + } + ] + }, + { + "name": "fx_rates", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" + } + ] + }, + { + "name": "outbox", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"position\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" + } + ] + }, + { + "name": "group_cursors", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" + } + ] + }, + { + "name": "feed_cursors", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" + } + ] + }, + { + "name": "sync_leases", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" + } + ] + }, + { + "name": "sync_sessions", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "group_events_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_group ON group_events (group_id, seq, ordinal)" + } + ] + }, + { + "name": "group_events_subject", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_subject ON group_events (subject_id)" + } + ] + }, + { + "name": "group_events_actor", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_actor ON group_events (actor_id)" + } + ] + }, + { + "name": "members_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX members_group ON members (group_id)" + } + ] + }, + { + "name": "members_profile", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX members_profile ON members (profile_id)" + } + ] + }, + { + "name": "entries_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entries_group ON entries (group_id, entry_date)" + } + ] + }, + { + "name": "entry_conflicts_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_conflicts_group ON entry_conflicts (group_id)" + } + ] + }, + { + "name": "entry_payers_member", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_payers_member ON entry_payers (member_id)" + } + ] + }, + { + "name": "entry_shares_member", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_shares_member ON entry_shares (member_id)" + } + ] + }, + { + "name": "fx_rates_currency", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX fx_rates_currency ON fx_rates (currency, as_of)" + } + ] + } + ] +} \ No newline at end of file diff --git a/lib/application/entry_form.dart b/lib/application/entry_form.dart new file mode 100644 index 0000000..7cfb38d --- /dev/null +++ b/lib/application/entry_form.dart @@ -0,0 +1,519 @@ +/// The expense editor's state: what has been typed and chosen, and the one +/// way each of those changes. +library; + +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:opensplit_api/opensplit_api.dart' show SplitKind; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/local/database.dart'; +import '../domain/calendar_date.dart'; +import '../domain/category_guess.dart'; +import '../domain/decimal_text.dart'; +import '../domain/entry_draft.dart'; +import '../domain/fx/fx_quote.dart'; +import '../domain/models/entry.dart'; +import '../domain/money_format.dart'; +import '../domain/split/allocation.dart'; +import '../domain/split/splitter.dart'; +import 'ledger_providers.dart'; +import 'local_providers.dart'; +import 'sync_providers.dart'; + +part 'entry_form.freezed.dart'; +part 'entry_form.g.dart'; + +/// Everything in the expense editor, exactly as entered. +/// +/// Amounts stay the text that was typed: whether that text is an amount +/// depends on the currency, which can change after it was typed. What the +/// form adds up to is derived from this, never stored beside it. +@freezed +abstract class EntryFormState with _$EntryFormState { + const factory EntryFormState({ + /// The expense as it was when the editor opened; null for a new one. + /// Saving refuses if it has changed since. + Entry? editing, + required String currencyCode, + @Default('') String amount, + @Default('') String description, + String? categoryId, + + /// Whether the category was picked by hand, or came with the expense + /// being edited. Until then it follows the description. + @Default(false) bool categoryChosen, + + /// The day it happened, at UTC midnight. + required DateTime day, + + /// When on [day] it happened, and the zone it happened in: both or + /// neither. + DateTime? occurredAt, + String? timeZone, + @Default(SplitKind.equal) SplitKind splitKind, + @Default({}) Set participants, + + /// Whole shares per person, for a split by shares. One when unset. + @Default({}) Map shareCounts, + + /// Typed per person, for a split by amounts or percentages. + @Default({}) Map exactAmounts, + @Default({}) Map percents, + @Default({}) Set payers, + @Default(false) bool severalPayers, + + /// Typed per payer, when several people paid. + @Default({}) Map paidAmounts, + @Default(false) bool saving, + String? error, + }) = _EntryFormState; + + const EntryFormState._(); + + /// A new expense: everyone splits it and the person adding it paid. The + /// overwhelmingly common case, so the fast path is two fields and Save. + factory EntryFormState.blank(GroupLedger ledger, {required DateTime now}) { + final me = ledger.me?.id ?? ledger.members.firstOrNull?.id; + return EntryFormState( + currencyCode: ledger.group.defaultCurrency, + day: calendarDay(now), + // It happened now, here, unless the time is taken off. + occurredAt: now.toUtc(), + participants: {for (final member in ledger.members) member.id}, + payers: {?me}, + ); + } + + /// [entry] as it stands, to be edited. + factory EntryFormState.of(Entry entry, Currency? currency) { + String typed(int minor) => currency?.formatPlain(minor) ?? ''; + return EntryFormState( + editing: entry, + currencyCode: entry.row.currency, + amount: typed(entry.row.amountMinor), + description: entry.row.description, + categoryId: entry.row.categoryId, + categoryChosen: entry.row.categoryId != null, + day: entry.row.entryDate, + occurredAt: entry.row.occurredAt, + timeZone: entry.row.timeZone, + splitKind: entry.row.splitKind, + participants: {for (final share in entry.shares) share.memberId}, + shareCounts: { + for (final share in entry.shares) + if (share.weightMicros case final weight?) + share.memberId: (weight / weightScale).round(), + }, + exactAmounts: { + for (final share in entry.shares) + share.memberId: typed(share.amountMinor), + }, + percents: { + for (final share in entry.shares) + if (share.weightMicros case final weight?) + share.memberId: formatPercentMicros(weight), + }, + payers: {for (final payer in entry.payers) payer.memberId}, + severalPayers: entry.payers.length > 1, + paidAmounts: { + for (final payer in entry.payers) + payer.memberId: typed(payer.amountMinor), + }, + ); + } + + bool get isEditing => editing != null; + + /// The total in [currency], once what was typed reads as one. + int? totalIn(Currency currency) { + final total = currency.parseToMinor(amount); + return total != null && total > 0 ? total : null; + } + + int shareCountOf(String memberId) => shareCounts[memberId] ?? 1; + + /// What each person ends up owing, for splits that can be worked out before + /// anything else is typed. Null for the rest, or until there is a total. + Map? preview(Currency currency) { + final total = totalIn(currency); + if (total == null || participants.isEmpty) return null; + final spec = switch (splitKind) { + SplitKind.equal => EqualSplit(participants.toList()), + SplitKind.shares => SharesSplit({ + for (final id in participants) id: shareCountOf(id), + }), + _ => null, + }; + if (spec == null) return null; + try { + return { + for (final share in spec.resolve(total)) + share.memberId: share.amountMinor, + }; + } on SplitException { + return null; + } + } + + /// The expense this form describes in [groupId], or what is still missing + /// from it. + DraftCheck check( + Currency currency, { + required String groupId, + FxQuote? fx, + String? deviceZone, + }) { + final total = totalIn(currency); + if (total == null) { + return DraftIncomplete( + currency.exponent == 0 + ? 'Enter a whole amount.' + : 'Enter an amount with at most ${currency.exponent} decimal ' + 'places.', + ); + } + final split = _split(currency); + if (split == null) { + return const DraftIncomplete( + 'Check the split — some amounts are missing.', + ); + } + final paid = _paid(currency, total); + if (paid == null) { + return const DraftIncomplete( + 'Check who paid — some amounts are missing.', + ); + } + + // A time with no zone yet (a new expense) is this device's. Saving never + // waits to learn which that is: with no zone known, only the day is kept. + final zone = timeZone ?? deviceZone; + final moment = occurredAt; + final timed = moment != null && zone != null; + return DraftReady( + EntryDraft( + groupId: groupId, + currency: currency.code, + amountMinor: total, + description: description.trim(), + categoryId: categoryId, + split: split, + payerAmounts: paid, + entryDate: day, + occurredAt: timed ? moment : null, + timeZone: timed ? zone : null, + // A fact about the transaction, captured once. + fxRate: fx?.rate, + fxSource: fx == null ? null : '${fx.source}@${calendarDate(fx.date)}', + ), + ); + } + + SplitSpec? _split(Currency currency) { + final ids = participants.toList(); + if (ids.isEmpty) return null; + + Map? each( + int? Function(String text) parse, + Map typed, + ) { + final values = {}; + for (final id in ids) { + final value = parse(typed[id] ?? ''); + if (value == null) return null; + values[id] = value; + } + return values; + } + + return switch (splitKind) { + SplitKind.equal => EqualSplit(ids), + SplitKind.shares => SharesSplit({ + for (final id in ids) id: shareCountOf(id), + }), + SplitKind.exact => switch (each(currency.parseToMinor, exactAmounts)) { + final amounts? => ExactSplit(amounts), + null => null, + }, + SplitKind.percent => switch (each(parsePercentMicros, percents)) { + final percents? => PercentSplit(percents), + null => null, + }, + // A split rule from a newer server: saving would have to guess it. + SplitKind.unknownDefaultOpenApi => null, + }; + } + + Map? _paid(Currency currency, int total) { + if (!severalPayers) { + final only = payers.firstOrNull; + return only == null ? null : {only: total}; + } + final amounts = {}; + for (final id in payers) { + final amount = currency.parseToMinor(paidAmounts[id] ?? ''); + if (amount == null || amount <= 0) return null; + amounts[id] = amount; + } + return amounts.isEmpty ? null : amounts; + } +} + +/// The expense editor for one group: a new expense, or [entryId] being +/// edited. +/// +/// Seeded once, as soon as the group and the currencies have arrived. It +/// listens to them rather than watching, so a sync landing while the editor +/// is open never resets what has been typed; saving is what checks the +/// expense has not moved underneath it. +@riverpod +class EntryForm extends _$EntryForm { + /// Dates and currencies a rate has already been asked for. + final _ratesAsked = {}; + + @override + Future build(String groupId, {String? entryId}) { + final seeded = Completer(); + void seed() { + if (seeded.isCompleted) return; + final ledger = ref.read(groupLedgerProvider(groupId)); + final currencies = ref.read(currenciesProvider).value; + if (ledger == null || currencies == null) return; + + if (entryId == null) { + seeded.complete(EntryFormState.blank(ledger, now: DateTime.now())); + return; + } + final entry = ledger.entries.where((e) => e.id == entryId).firstOrNull; + if (entry != null) { + seeded.complete( + EntryFormState.of(entry, currencies[entry.row.currency]), + ); + } + } + + ref.listen(groupLedgerProvider(groupId), (_, _) => seed()); + ref.listen(currenciesProvider, (_, _) => seed()); + seed(); + return seeded.future.then((form) { + unawaited(_askForRateIfMissing(form)); + return form; + }); + } + + EntryFormState get _form => state.requireValue; + + void _change(EntryFormState Function(EntryFormState form) change) => + state = AsyncData(change(_form).copyWith(error: null)); + + void setAmount(String text) => _change((form) => form.copyWith(amount: text)); + + /// The category follows the description until one is picked. A guess that + /// stops matching is taken back, so typing "Uber" and then correcting it to + /// "Usha's gift" doesn't leave the expense a taxi ride. + void setDescription(String text) => _change((form) { + if (form.categoryChosen) return form.copyWith(description: text); + final icon = guessCategoryIcon(text); + final categories = ref.read(categoriesProvider).value ?? const []; + return form.copyWith( + description: text, + categoryId: categories.where((c) => c.icon == icon).firstOrNull?.id, + ); + }); + + void chooseCategory(String? id) => + _change((form) => form.copyWith(categoryId: id, categoryChosen: true)); + + void chooseCurrency(String code) { + _change((form) => form.copyWith(currencyCode: code)); + unawaited(_askForRateIfMissing(_form)); + } + + /// Another day: the time no longer applies, since it is not known. + void chooseDay(DateTime day) { + final picked = calendarDay(day); + if (picked == _form.day) return; + _change( + (form) => form.copyWith(day: picked, occurredAt: null, timeZone: null), + ); + unawaited(_askForRateIfMissing(_form)); + } + + /// A time picked on this device's clock happened in this device's [zone]. + void chooseTime({ + required int hour, + required int minute, + required String zone, + }) => _change((form) { + final day = form.day; + return form.copyWith( + occurredAt: DateTime(day.year, day.month, day.day, hour, minute).toUtc(), + timeZone: zone, + ); + }); + + void clearTime() => + _change((form) => form.copyWith(occurredAt: null, timeZone: null)); + + void chooseSplitKind(SplitKind kind) => + _change((form) => form.copyWith(splitKind: kind)); + + void setInSplit(String memberId, {required bool included}) => _change( + (form) => form.copyWith( + participants: included + ? {...form.participants, memberId} + : ({...form.participants}..remove(memberId)), + ), + ); + + /// One more or one fewer share for [memberId], never below none. + void nudgeShares(String memberId, int by) => _change( + (form) => form.copyWith( + shareCounts: { + ...form.shareCounts, + memberId: (form.shareCountOf(memberId) + by).clamp(0, 1 << 30), + }, + ), + ); + + void setExactAmount(String memberId, String text) => _change( + (form) => + form.copyWith(exactAmounts: {...form.exactAmounts, memberId: text}), + ); + + void setPercent(String memberId, String text) => _change( + (form) => form.copyWith(percents: {...form.percents, memberId: text}), + ); + + /// The one person who paid it all. + void choosePayer(String memberId) => + _change((form) => form.copyWith(payers: {memberId})); + + void setPaying(String memberId, {required bool paying}) => _change( + (form) => form.copyWith( + payers: paying + ? {...form.payers, memberId} + : ({...form.payers}..remove(memberId)), + ), + ); + + /// Back to one payer keeps the first of several. + void setSeveralPayers(bool several) => _change( + (form) => form.copyWith( + severalPayers: several, + payers: several ? form.payers : {?form.payers.firstOrNull}, + ), + ); + + void setPaidAmount(String memberId, String text) => _change( + (form) => form.copyWith(paidAmounts: {...form.paidAmounts, memberId: text}), + ); + + /// Saves the expense, and says whether it was saved. When it was not, the + /// state says why. + Future save() async { + final ledger = ref.read(groupLedgerProvider(groupId)); + final form = _form; + final currency = ref.read(currenciesProvider).value?[form.currencyCode]; + if (ledger == null || currency == null || form.saving) return false; + + final fx = await _quote(form, ledger.group.defaultCurrency); + if (!ref.mounted) return false; + final check = form.check( + currency, + groupId: groupId, + fx: fx, + deviceZone: ref.read(deviceZoneProvider).value, + ); + final EntryDraft draft; + switch (check) { + case DraftIncomplete(:final reason): + state = AsyncData(form.copyWith(error: reason)); + return false; + case DraftReady(draft: final ready): + draft = ready; + } + + state = AsyncData(form.copyWith(saving: true, error: null)); + final repository = ref.read(entryRepositoryProvider); + try { + if (form.editing case final editing?) { + // Who is editing, which need not be who created it: that difference + // is most of what makes a feed line worth reading. + await repository.update( + editing.id, + draft, + actorId: ledger.me?.id, + expected: editing, + ); + } else { + await repository.create( + draft, + createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, + ); + } + return true; + } on SplitException catch (error) { + // The domain refused it, so nothing was written. Its messages are + // written for people, so they are shown as they are. + _fail(error.message); + return false; + } catch (error) { + _fail('$error'); + return false; + } + } + + /// Deletes the expense being edited, and says whether it was deleted. + Future delete() async { + final editing = _form.editing; + if (editing == null) return false; + try { + await ref + .read(entryRepositoryProvider) + .delete( + editing.id, + actorId: ref.read(groupLedgerProvider(groupId))?.me?.id, + expected: editing, + ); + return true; + } catch (error) { + _fail('$error'); + return false; + } + } + + void _fail(String reason) { + if (ref.mounted) { + state = AsyncData(_form.copyWith(saving: false, error: reason)); + } + } + + /// The rate on the expense's own day, not today's. + Future _quote(EntryFormState form, String target) async => + form.currencyCode == target + ? null + : ref + .read(fxRepositoryProvider) + .quote(base: form.currencyCode, quote: target, asOf: form.day); + + /// Nothing on this device prices this day, so the server is asked. The rate + /// lands on a later sync, and the expense saves without one meanwhile. + Future _askForRateIfMissing(EntryFormState form) async { + final target = ref + .read(groupLedgerProvider(groupId)) + ?.group + .defaultCurrency; + if (target == null || form.currencyCode == target) return; + if (await _quote(form, target) != null) return; + if (!_ratesAsked.add('${calendarDate(form.day)}|${form.currencyCode}')) { + return; + } + await ref + .read(syncEngineProvider) + ?.shared + .requestBackfill(form.day, form.currencyCode); + } +} diff --git a/lib/application/ledger_providers.dart b/lib/application/ledger_providers.dart index 96017fb..7628eec 100644 --- a/lib/application/ledger_providers.dart +++ b/lib/application/ledger_providers.dart @@ -3,10 +3,12 @@ import 'package:opensplit_api/opensplit_api.dart' show AvatarKind; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../data/local/database.dart'; +import '../data/repositories/drift_group_repository.dart'; import '../domain/analytics/analytics_query.dart'; import '../domain/avatar.dart'; import '../domain/balance/balance_fold.dart'; import '../domain/balance/member_balance.dart'; +import '../domain/balance/pairwise.dart'; import '../domain/balance/simplify.dart'; import '../domain/fx/estimated_total.dart'; import '../domain/member_identity.dart'; @@ -85,10 +87,11 @@ Stream> currencies(Ref ref) => ref .watchAll() .map((list) => {for (final c in list) c.code: c}); +/// Every group on this device, the most recently active first, and whether +/// this account has left each. @riverpod -Stream> groups(Ref ref, {bool includeArchived = false}) => ref - .watch(groupRepositoryProvider) - .watchGroups(includeArchived: includeArchived); +Stream> groupListings(Ref ref) => + ref.watch(groupRepositoryProvider).watchListings(); @riverpod Stream group(Ref ref, String groupId) => @@ -133,8 +136,9 @@ class GroupLedger { /// Net position per member per currency. Members who are settled are absent. final List balances; - /// The suggested payments that would settle the group, per currency. Empty - /// when the group has simplification switched off. + /// The suggested payments that would settle the group, per currency: the + /// fewest that do it when the group simplifies its debts, otherwise each + /// pair's own debt to each other. final List transfers; /// This device's member in the group, if it has one. @@ -168,6 +172,14 @@ class GroupLedger { return null; } + /// Who a payment can be recorded between: everybody here, and anybody who + /// left with a balance still open, which only settling can close. + List get settleable => [ + ...members, + for (final member in pastMembers) + if (!isSettledUp(member.id)) member, + ]; + /// Whether [memberId] is square with the group in every currency. bool isSettledUp(String memberId) => !balances.any((balance) => balance.memberId == memberId); @@ -233,7 +245,9 @@ GroupLedger? groupLedger(Ref ref, String groupId) { ], entries: entryList, balances: balances, - transfers: group.simplifyDebts ? simplifyDebts(balances) : const [], + transfers: group.simplifyDebts + ? simplifyDebts(balances) + : pairwiseDebts(entryList), me: memberList.where((m) => m.profileId == accountId).firstOrNull, profiles: profiles, brokenEntries: broken, diff --git a/lib/application/local_providers.dart b/lib/application/local_providers.dart index 25a3545..7016a18 100644 --- a/lib/application/local_providers.dart +++ b/lib/application/local_providers.dart @@ -69,6 +69,7 @@ Stream> failedWrites(Ref ref) => DriftGroupRepository groupRepository(Ref ref) => DriftGroupRepository( ref.watch(appDatabaseProvider), outbox: ref.watch(outboxQueueProvider), + accountId: ref.watch(currentAccountIdProvider), ); @Riverpod(keepAlive: true) diff --git a/lib/application/push_providers.dart b/lib/application/push_providers.dart index a62c571..f5a4dcc 100644 --- a/lib/application/push_providers.dart +++ b/lib/application/push_providers.dart @@ -37,7 +37,7 @@ PushService pushService(Ref ref) { ), onOpenGroup: (groupId) { // The background isolate writes through another Drift connection. - ref.read(appDatabaseProvider).refreshAfterExternalSync(); + unawaited(ref.read(appDatabaseProvider).noticeWritesElsewhere()); ref.read(routerProvider).go(groupNotificationRoute(groupId)); unawaited(ref.read(syncControllerProvider.notifier).syncGroup(groupId)); }, diff --git a/lib/application/session_providers.dart b/lib/application/session_providers.dart index 0d7451c..f2518bb 100644 --- a/lib/application/session_providers.dart +++ b/lib/application/session_providers.dart @@ -112,11 +112,7 @@ class AccountController extends _$AccountController { /// How many groups a guest signing in to an existing account brings along. Future groupsToHandOver() async => - (await ref - .read(groupRepositoryProvider) - .watchGroups(includeArchived: true) - .first) - .length; + (await ref.read(groupRepositoryProvider).watchListings().first).length; AuthService _auth() { final auth = ref.read(authServiceProvider); diff --git a/lib/application/settlement_form.dart b/lib/application/settlement_form.dart new file mode 100644 index 0000000..741a07c --- /dev/null +++ b/lib/application/settlement_form.dart @@ -0,0 +1,212 @@ +/// Settle up's state: who pays whom, how much, and where the money goes. +library; + +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/local/database.dart'; +import '../domain/calendar_date.dart'; +import '../domain/entry_draft.dart'; +import '../domain/money_format.dart'; +import 'ledger_providers.dart'; +import 'local_providers.dart'; + +part 'settlement_form.freezed.dart'; +part 'settlement_form.g.dart'; + +/// A UPI ID somebody typed, and the person it was typed for. +typedef TypedHandle = ({String payeeId, String handle}); + +/// Everything on the settle up screen, exactly as entered. +@freezed +abstract class SettlementFormState with _$SettlementFormState { + const factory SettlementFormState({ + String? fromId, + String? toId, + required String currencyCode, + @Default('') String amount, + + /// A UPI ID typed over the payee's own. It is only ever read back for + /// the payee it was typed for, so choosing somebody else can never hand + /// a payment app the last person's handle. + TypedHandle? typedHandle, + @Default(false) bool saving, + String? error, + }) = _SettlementFormState; + + const SettlementFormState._(); + + /// Where the money goes: whatever was typed for this payee, or else the + /// handle the group knows them by. + String? handleIn(GroupLedger ledger) { + final to = toId; + if (to == null) return null; + if (typedHandle case ( + payeeId: final payee, + :final handle, + ) when payee == to) { + return handle; + } + return knownHandleIn(ledger); + } + + /// The payee's handle as the group knows it. + String? knownHandleIn(GroupLedger ledger) { + final payee = toId == null ? null : ledger.memberById(toId!); + return payee == null ? null : ledger.upiOf(payee); + } + + /// The payment this form describes in [groupId], made [at] in [zone], or + /// what is still missing from it. + DraftCheck check( + Currency currency, { + required String groupId, + required DateTime at, + String? zone, + }) { + final (from, to) = (fromId, toId); + if (from == null || to == null || from == to) { + return const DraftIncomplete('Choose who is paying whom.'); + } + final amountMinor = currency.parseToMinor(amount); + if (amountMinor == null) { + return DraftIncomplete( + currency.exponent == 0 + ? 'Enter a whole amount.' + : 'Enter an amount with at most ${currency.exponent} decimal ' + 'places.', + ); + } + if (amountMinor <= 0) { + return const DraftIncomplete('Enter an amount greater than zero.'); + } + return DraftReady( + EntryDraft.settlement( + groupId: groupId, + currency: currency.code, + amountMinor: amountMinor, + fromMemberId: from, + toMemberId: to, + entryDate: calendarDay(at), + // With no zone known yet, only the day is kept. + occurredAt: zone == null ? null : at.toUtc(), + timeZone: zone, + ), + ); + } +} + +/// Settling up in one group, optionally prefilled with a suggested payment. +/// +/// Seeded once, as soon as the group and the currencies have arrived, and +/// listened to rather than watched after that, as the expense editor is. +@riverpod +class SettlementForm extends _$SettlementForm { + @override + Future build( + String groupId, { + String? fromId, + String? toId, + int? amountMinor, + String? currency, + }) { + final seeded = Completer(); + void seed() { + if (seeded.isCompleted) return; + final ledger = ref.read(groupLedgerProvider(groupId)); + final currencies = ref.read(currenciesProvider).value; + if (ledger == null || currencies == null) return; + + final from = fromId ?? ledger.me?.id; + final code = currency ?? ledger.group.defaultCurrency; + final amount = amountMinor; + seeded.complete( + SettlementFormState( + fromId: from, + toId: toId == from ? null : toId, + currencyCode: code, + amount: amount == null + ? '' + : currencies[code]?.formatPlain(amount) ?? '', + ), + ); + } + + ref.listen(groupLedgerProvider(groupId), (_, _) => seed()); + ref.listen(currenciesProvider, (_, _) => seed()); + seed(); + return seeded.future; + } + + SettlementFormState get _form => state.requireValue; + + void _change(SettlementFormState Function(SettlementFormState form) change) => + state = AsyncData(change(_form).copyWith(error: null)); + + /// Paying yourself is not a payment, so choosing the payee as the payer + /// leaves nobody being paid. + void choosePayer(String? id) => _change( + (form) => form.copyWith( + fromId: id, + toId: id != null && id == form.toId ? null : form.toId, + ), + ); + + void choosePayee(String? id) => _change((form) => form.copyWith(toId: id)); + + void chooseCurrency(String code) => + _change((form) => form.copyWith(currencyCode: code)); + + void setAmount(String text) => _change((form) => form.copyWith(amount: text)); + + /// A UPI ID typed for whoever is being paid now. + void setHandle(String text) => _change((form) { + final to = form.toId; + return to == null + ? form + : form.copyWith(typedHandle: (payeeId: to, handle: text)); + }); + + /// Records the payment, and says whether it was recorded. When it was not, + /// the state says why. + Future record() async { + final form = _form; + final ledger = ref.read(groupLedgerProvider(groupId)); + final currency = ref.read(currenciesProvider).value?[form.currencyCode]; + if (ledger == null || currency == null || form.saving) return false; + + // Paid now, here. Saving never waits to learn where "here" is. + final check = form.check( + currency, + groupId: groupId, + at: DateTime.now(), + zone: ref.read(deviceZoneProvider).value, + ); + final EntryDraft draft; + switch (check) { + case DraftIncomplete(:final reason): + state = AsyncData(form.copyWith(error: reason)); + return false; + case DraftReady(draft: final ready): + draft = ready; + } + + state = AsyncData(form.copyWith(saving: true, error: null)); + try { + await ref + .read(entryRepositoryProvider) + .create( + draft, + createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, + ); + return true; + } catch (error) { + if (ref.mounted) { + state = AsyncData(_form.copyWith(saving: false, error: '$error')); + } + return false; + } + } +} diff --git a/lib/application/sync_coordinator.dart b/lib/application/sync_coordinator.dart index 69e188b..7dc64c1 100644 --- a/lib/application/sync_coordinator.dart +++ b/lib/application/sync_coordinator.dart @@ -31,7 +31,7 @@ class SyncStatus { class SyncCoordinator extends ChangeNotifier { SyncCoordinator({ required this._syncAll, - required this._syncGroup, + required this._syncGroups, this._online = const Stream.empty(), this._writes = const Stream.empty(), DateTime Function()? clock, @@ -39,8 +39,11 @@ class SyncCoordinator extends ChangeNotifier { this.writeDelay = const Duration(seconds: 1), }) : _clock = clock ?? DateTime.now; + /// The outbox, the shared feeds and every group. final Future Function() _syncAll; - final Future Function(String) _syncGroup; + + /// The outbox, then these groups and every group it wrote to. + final Future Function(Set groupIds) _syncGroups; final Stream _online; final Stream _writes; final DateTime Function() _clock; @@ -50,6 +53,9 @@ class SyncCoordinator extends ChangeNotifier { final _pendingGroups = {}; final _subscriptions = >[]; bool _allPending = false; + + /// A partial run is owed: the outbox, read back, and [_pendingGroups]. + bool _partialPending = false; bool _disposed = false; int _failures = 0; DateTime? _lastStarted; @@ -93,13 +99,22 @@ class SyncCoordinator extends ChangeNotifier { return syncAll(); } _pendingGroups.add(groupId); + return _syncWrites(); + } + + /// What was written here, sent, and the groups it touched read back. The + /// rest of the account waits for the next full sync: nothing else changed + /// on this device, and another device's change arrives with its push. + Future _syncWrites() { + if (_disposed) return Future.value(); + _partialPending = true; return _begin(); } void _writeQueued() { if (_disposed) return; _writeTimer?.cancel(); - _writeTimer = Timer(writeDelay, () => unawaited(syncAll())); + _writeTimer = Timer(writeDelay, () => unawaited(_syncWrites())); } Future _begin() { @@ -116,20 +131,18 @@ class SyncCoordinator extends ChangeNotifier { Future _drain(Completer completed) async { if (!_disposed) _setStatus(isSyncing: true); - while (!_disposed && (_allPending || _pendingGroups.isNotEmpty)) { + while (!_disposed && (_allPending || _partialPending)) { + // A full run covers everything a partial one would. final full = _allPending; - final groupId = full ? null : _pendingGroups.first; + final groups = {..._pendingGroups}; _allPending = false; - if (full) { - _pendingGroups.clear(); - _lastStarted = _clock(); - } else { - _pendingGroups.remove(groupId); - } + _partialPending = false; + _pendingGroups.clear(); + if (full) _lastStarted = _clock(); SyncReport report; try { - report = await (full ? _syncAll() : _syncGroup(groupId!)); + report = await (full ? _syncAll() : _syncGroups(groups)); } catch (error, stackTrace) { report = SyncReport( pushed: 0, @@ -150,6 +163,7 @@ class SyncCoordinator extends ChangeNotifier { ); // One failure ends this pass; the retry is a full sync. _allPending = false; + _partialPending = false; _pendingGroups.clear(); } _status = SyncStatus( @@ -164,8 +178,11 @@ class SyncCoordinator extends ChangeNotifier { completed.complete(); } + /// After a failure, a full sync once it has backed off; after a write the + /// server could not take yet, that write again when it is due. void _scheduleRetry() { Duration? delay; + var retry = syncAll; if (_status.error != null) { delay = Duration(seconds: math.min(5 * (1 << _failures), 300)); _failures = math.min(_failures + 1, 6); @@ -179,9 +196,10 @@ class SyncCoordinator extends ChangeNotifier { 1000, ), ); + retry = _syncWrites; } } - if (delay != null) _retry = Timer(delay, () => unawaited(syncAll())); + if (delay != null) _retry = Timer(delay, () => unawaited(retry())); _setStatus( isSyncing: false, retryAt: delay == null ? null : _clock().add(delay), diff --git a/lib/application/sync_providers.dart b/lib/application/sync_providers.dart index 618f980..ed29da5 100644 --- a/lib/application/sync_providers.dart +++ b/lib/application/sync_providers.dart @@ -16,6 +16,7 @@ SyncEngine? syncEngine(Ref ref) { db: ref.watch(appDatabaseProvider), client: client, outbox: ref.watch(outboxQueueProvider), + accountId: ref.watch(currentAccountIdProvider), ); ref.onDispose(engine.dispose); return engine; @@ -48,8 +49,8 @@ class SyncController extends _$SyncController { if (engine == null) return const SyncStatus(enabled: false); final coordinator = SyncCoordinator( - syncAll: () => _refreshing(engine.syncEverything()), - syncGroup: (groupId) => _refreshing(engine.syncGroup(groupId)), + syncAll: engine.syncEverything, + syncGroups: engine.syncGroups, online: ref.watch(networkSignalProvider).changes, // Every local write, from every screen, through one stream. writes: ref.watch(outboxQueueProvider).queued, @@ -78,14 +79,6 @@ class SyncController extends _$SyncController { await ref.read(syncEngineProvider)?.discardRefused(); await syncAll(); } - - /// Streams notice writes made through this database on their own; this - /// covers ones a background isolate made to the same file. - Future _refreshing(Future sync) async { - final report = await sync; - if (ref.mounted) ref.read(appDatabaseProvider).refreshAfterExternalSync(); - return report; - } } /// Syncs one group when a screen showing it opens. diff --git a/lib/data/fx/drift_fx_repository.dart b/lib/data/fx/drift_fx_repository.dart index ff5c628..752a843 100644 --- a/lib/data/fx/drift_fx_repository.dart +++ b/lib/data/fx/drift_fx_repository.dart @@ -10,6 +10,10 @@ class DriftFxRepository { final AppDatabase _db; + static String _daysBefore(String day) => calendarDate( + parseCalendarDate(day).subtract(const Duration(days: rateStaleAfterDays)), + ); + /// The rate for converting [base] into [quote] as it stood on [asOf]. Future quote({ required String base, @@ -49,13 +53,14 @@ class DriftFxRepository { ); } - /// The most recent publication for a currency on or before [day]. + /// The most recent publication for a currency that answers for [day]: on + /// it, or no more than [rateStaleAfterDays] before. Future _rateOn(String currency, String day) => (_db.select(_db.fxRates) ..where( (t) => t.currency.equals(currency) & - t.asOf.isSmallerOrEqualValue(day), + t.asOf.isBetweenValues(_daysBefore(day), day), ) ..orderBy([(t) => OrderingTerm.desc(t.asOf)]) ..limit(1)) diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index 7c689bd..0c474c2 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -79,15 +79,30 @@ class AppDatabase extends _$AppDatabase { /// Bump this on **any** change to a table in `tables.dart`. @override - int get schemaVersion => 9; + int get schemaVersion => 10; /// Timestamps are stored as ISO-8601 text rather than Unix seconds. @override DriftDatabaseOptions get options => const DriftDatabaseOptions(storeDateTimeAsText: true); - /// Re-runs live queries after the background isolate changed this file. - void refreshAfterExternalSync() => markTablesUpdated(allTables); + /// SQLite's `data_version` when this connection last looked. + int? _dataVersion; + + /// Re-runs live queries if another connection has committed to this file + /// since the last look: the push handler, which wakes in its own Flutter + /// engine and so cannot share this connection. Drift sees only the writes + /// made through it; `data_version` changes for every commit but this + /// connection's own, so nothing is re-run when nothing else wrote. + Future noticeWritesElsewhere() async { + final seen = _dataVersion; + final now = _dataVersion = await _readDataVersion(); + if (seen != null && seen != now) markTablesUpdated(allTables); + } + + Future _readDataVersion() async => (await customSelect( + 'PRAGMA data_version', + ).getSingle()).read('data_version'); @override MigrationStrategy get migration => MigrationStrategy( @@ -113,6 +128,7 @@ class AppDatabase extends _$AppDatabase { await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA busy_timeout = 5000'); } + _dataVersion = await _readDataVersion(); }, ); } diff --git a/lib/data/local/tables.dart b/lib/data/local/tables.dart index 8120e53..a9de69f 100644 --- a/lib/data/local/tables.dart +++ b/lib/data/local/tables.dart @@ -113,9 +113,9 @@ class Profiles extends Table with AvatarColumns { /// Personal UPI handle; a member's own handle takes precedence. TextColumn get upiVpa => text().nullable()(); - /// The server's timestamp, and the profile feed's cursor. Null for a row - /// this device wrote and has not pushed. - DateTimeColumn get updatedAt => dateTime().nullable()(); + /// The server's version of this row, which the profile feed is cursored + /// on. Null for a row this device wrote and has not pushed. + IntColumn get version => integer().nullable()(); @override Set get primaryKey => {id}; @@ -124,6 +124,9 @@ class Profiles extends Table with AvatarColumns { /// The server's record of what happened, plus provisional lines this device /// wrote for changes it has not pushed yet. @DataClassName('GroupEventRow') +@TableIndex(name: 'group_events_group', columns: {#groupId, #seq, #ordinal}) +@TableIndex(name: 'group_events_subject', columns: {#subjectId}) +@TableIndex(name: 'group_events_actor', columns: {#actorId}) class GroupEvents extends Table { TextColumn get id => text()(); TextColumn get groupId => @@ -213,6 +216,8 @@ class Groups extends Table with AvatarColumns { /// a member with no [profileId] is a placeholder — a full member who has no /// account yet. Claiming an invite sets that one column. @DataClassName('Member') +@TableIndex(name: 'members_group', columns: {#groupId}) +@TableIndex(name: 'members_profile', columns: {#profileId}) class Members extends Table { TextColumn get id => text()(); TextColumn get groupId => @@ -245,6 +250,7 @@ class Categories extends Table { } @DataClassName('EntryRow') +@TableIndex(name: 'entries_group', columns: {#groupId, #entryDate}) class Entries extends Table { TextColumn get id => text()(); TextColumn get groupId => @@ -288,7 +294,7 @@ class Entries extends Table { DateTimeColumn get createdAt => dateTime()(); /// Also the base the next edit is judged against: the server refuses an - /// edit composed on an older `seq` only when it would move money. + /// edit composed on any older `seq`. IntColumn get seq => integer().nullable()(); /// Soft delete; entries are never removed. @@ -300,6 +306,7 @@ class Entries extends Table { /// An edit the server refused because the expense had moved underneath it. @DataClassName('EntryConflictRow') +@TableIndex(name: 'entry_conflicts_group', columns: {#groupId}) class EntryConflicts extends Table { TextColumn get entryId => text().references(Entries, #id, onDelete: KeyAction.cascade)(); @@ -320,6 +327,7 @@ class EntryConflicts extends Table { } @DataClassName('EntryPayerRow') +@TableIndex(name: 'entry_payers_member', columns: {#memberId}) class EntryPayers extends Table { TextColumn get entryId => text().references(Entries, #id, onDelete: KeyAction.cascade)(); @@ -331,6 +339,7 @@ class EntryPayers extends Table { } @DataClassName('EntryShareRow') +@TableIndex(name: 'entry_shares_member', columns: {#memberId}) class EntryShares extends Table { TextColumn get entryId => text().references(Entries, #id, onDelete: KeyAction.cascade)(); @@ -348,6 +357,7 @@ class EntryShares extends Table { /// Exchange rates against a USD pivot: one row per currency per day, and any /// pair is a division. @DataClassName('FxRateRow') +@TableIndex(name: 'fx_rates_currency', columns: {#currency, #asOf}) class FxRates extends Table { /// Publication date, `yyyy-MM-dd`; ISO dates sort, which is the lookup. TextColumn get asOf => text()(); @@ -363,8 +373,9 @@ class FxRates extends Table { Set get primaryKey => {asOf, currency}; } -/// What an outbox item refers to. Pushed in declaration order within a batch: -/// a group before its members, members before the entries that name them. +/// What an outbox item refers to. Declared in dependency order, which breaks +/// a tie in [Outbox.position]: a group before its members, members before the +/// entries that name them. enum OutboxTarget { group, member, entry, profile } /// Rows this device changed and has not pushed. Client-only. @@ -376,7 +387,12 @@ class Outbox extends Table { /// Identifies one edit, so a response for an older one is not applied over /// a newer one made during the upload. TextColumn get revision => text()(); - DateTimeColumn get createdAt => dateTime()(); + + /// Where this item is pushed, in the order this device made its changes: + /// the server checks each write against what it already has, so a + /// settlement has to arrive before the removal it makes possible. See + /// `OutboxQueue.enqueue` for how a later edit moves an item. + IntColumn get position => integer()(); IntColumn get attempts => integer().withDefault(const Constant(0))(); DateTimeColumn get nextAttemptAt => dateTime().nullable()(); TextColumn get lastError => text().nullable()(); diff --git a/lib/data/push/background_handler.dart b/lib/data/push/background_handler.dart index eeebebd..39bfc3d 100644 --- a/lib/data/push/background_handler.dart +++ b/lib/data/push/background_handler.dart @@ -72,6 +72,7 @@ Future handleBackgroundEntryMessage(RemoteMessage message) async { // no cookie jar on Android. client: buildApiClient(baseUrl: apiBaseUrl, token: session.token), outbox: outbox, + accountId: profileId, ); // Sync first. The notification describes what is now on the device, not diff --git a/lib/data/repositories/drift_entry_repository.dart b/lib/data/repositories/drift_entry_repository.dart index 04418b2..6cd29f2 100644 --- a/lib/data/repositories/drift_entry_repository.dart +++ b/lib/data/repositories/drift_entry_repository.dart @@ -30,7 +30,7 @@ final class DriftEntryRepository { /// Queues a row for the server. Future _enqueue(String entryId) async => - outbox?.enqueue(OutboxTarget.entry, entryId); + outbox?.enqueueInTransaction(OutboxTarget.entry, entryId); /// Writes an entry, a provisional feed line for it, and its outbox item. Future _writeWithSnapshot({ diff --git a/lib/data/repositories/drift_group_repository.dart b/lib/data/repositories/drift_group_repository.dart index 91da73c..211cf51 100644 --- a/lib/data/repositories/drift_group_repository.dart +++ b/lib/data/repositories/drift_group_repository.dart @@ -7,11 +7,34 @@ import '../local/database.dart'; import '../local/tables.dart'; import '../sync/outbox_queue.dart'; +/// A group as a list of groups shows it. +class GroupListing { + const GroupListing({ + required this.group, + required this.hasLeft, + required this.lastActivityAt, + }); + + final Group group; + + /// Whether this account left the group, or was removed from it. Read from + /// the account's own member row, which the server confirms, rather than + /// from anything this device decided. + final bool hasLeft; + + /// The newest line in the group's activity feed, or when it was made. + final DateTime lastActivityAt; + + /// In the main list: neither put away nor left. + bool get isCurrent => !group.isArchived && !hasLeft; +} + /// Local-first group and membership storage. final class DriftGroupRepository { DriftGroupRepository( this._db, { this.outbox, + this.accountId, Uuid? uuid, DateTime Function()? clock, }) : _uuid = uuid ?? const Uuid(), @@ -19,19 +42,53 @@ final class DriftGroupRepository { final AppDatabase _db; + /// The account this ledger belongs to, which is how a group it has left is + /// told apart. Null in a purely local build, which has no accounts. + final String? accountId; + /// Null in a purely local build, where there is nothing to sync to. final OutboxQueue? outbox; final Uuid _uuid; final DateTime Function() _clock; - /// Groups this user belongs to, newest activity first. - Stream> watchGroups({bool includeArchived = false}) { - final query = _db.select(_db.groups) - ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]); - if (!includeArchived) { - query.where((t) => t.archivedAt.isNull()); - } - return query.watch().map((rows) => [for (final row in rows) row]); + /// Every group on this device, the most recently active first: what a + /// list of groups is ordered by, and where it is put. + Stream> watchListings() { + final groups = _db.groups; + final newestLine = subqueryExpression( + _db.selectOnly(_db.groupEvents) + ..addColumns([_db.groupEvents.createdAt.max()]) + ..where(_db.groupEvents.groupId.equalsExp(groups.id)), + ); + final account = accountId; + final Expression hasLeft = account == null + ? const Constant(false) + : existsQuery( + _db.selectOnly(_db.members) + ..addColumns([_db.members.id]) + ..where( + _db.members.groupId.equalsExp(groups.id) & + _db.members.profileId.equals(account) & + _db.members.leftAt.isNotNull(), + ), + ); + + final query = _db.select(groups).addColumns([newestLine, hasLeft]); + return query.watch().map((rows) { + final listings = [ + for (final row in rows) + GroupListing( + group: row.readTable(groups), + hasLeft: row.read(hasLeft) ?? false, + lastActivityAt: + row.read(newestLine) ?? row.readTable(groups).createdAt, + ), + ]; + // Ordered here rather than in SQL, where an aggregate over a date and + // the date column itself are stored differently and would not compare. + return listings + ..sort((a, b) => b.lastActivityAt.compareTo(a.lastActivityAt)); + }); } Stream watchGroup(String groupId) => (_db.select( @@ -106,8 +163,8 @@ final class DriftGroupRepository { await _db.into(_db.members).insert(creator); // Both rows have to reach the server, and the group has to land first: // members and entries reference it by foreign key. - await outbox?.enqueue(OutboxTarget.group, group.id); - await outbox?.enqueue(OutboxTarget.member, creator.id); + await outbox?.enqueueInTransaction(OutboxTarget.group, group.id); + await outbox?.enqueueInTransaction(OutboxTarget.member, creator.id); }); return (group: group, creator: creator); @@ -136,24 +193,18 @@ final class DriftGroupRepository { // Bumped on every local write. ), ); - await outbox?.enqueue(OutboxTarget.group, group.id); + await outbox?.enqueueInTransaction(OutboxTarget.group, group.id); }); } - /// Leaves a group: marks your own member row as having left, and archives the - /// group on this device. - Future leaveGroup({ - required String groupId, - required String memberId, - }) async { - final now = _clock(); + /// Leaves a group by marking your own member row as having left. The group + /// stays on this device, read-only, among the groups put away. The server + /// refuses this while you owe or are owed anything in it. + Future leaveGroup({required String memberId}) async { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) - .write(MembersCompanion(leftAt: Value(now))); - await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( - GroupsCompanion(archivedAt: Value(now)), - ); - await outbox?.enqueue(OutboxTarget.member, memberId); + .write(MembersCompanion(leftAt: Value(_clock()))); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -186,7 +237,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await _db.into(_db.members).insert(member); - await outbox?.enqueue(OutboxTarget.member, member.id); + await outbox?.enqueueInTransaction(OutboxTarget.member, member.id); }); return member; } @@ -203,7 +254,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(displayName: Value(trimmed))); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -215,7 +266,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(leftAt: Value(_clock()))); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -233,7 +284,7 @@ final class DriftGroupRepository { upiVpa: Value(trimmed == null || trimmed.isEmpty ? null : trimmed), ), ); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -263,9 +314,11 @@ final class DriftGroupRepository { } Future setArchived(String groupId, {required bool archived}) async { - await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( - GroupsCompanion(archivedAt: Value(archived ? _clock() : null)), - ); - await outbox?.enqueue(OutboxTarget.group, groupId); + await _db.transaction(() async { + await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( + GroupsCompanion(archivedAt: Value(archived ? _clock() : null)), + ); + await outbox?.enqueueInTransaction(OutboxTarget.group, groupId); + }); } } diff --git a/lib/data/repositories/drift_profile_repository.dart b/lib/data/repositories/drift_profile_repository.dart index cae1202..0c476a6 100644 --- a/lib/data/repositories/drift_profile_repository.dart +++ b/lib/data/repositories/drift_profile_repository.dart @@ -30,6 +30,6 @@ final class DriftProfileRepository { Future upsert(Profile profile) => _db.transaction(() async { await _db.into(_db.profiles).insertOnConflictUpdate(profile); - await outbox?.enqueue(OutboxTarget.profile, profile.id); + await outbox?.enqueueInTransaction(OutboxTarget.profile, profile.id); }); } diff --git a/lib/data/sync/apply_changes.dart b/lib/data/sync/apply_changes.dart index 7ba1704..2aee4d1 100644 --- a/lib/data/sync/apply_changes.dart +++ b/lib/data/sync/apply_changes.dart @@ -1,6 +1,8 @@ /// Writing one page of a group's history into the local database. library; +import 'dart:developer' as developer; + import 'package:drift/drift.dart'; import 'package:opensplit_api/opensplit_api.dart' as api; @@ -17,6 +19,48 @@ Future> _dirtyIds(AppDatabase db, OutboxTarget target) async { return {for (final row in rows) row.targetId}; } +/// Which of [ids] this device holds under a group other than [groupId]. +/// +/// Ids are minted by devices and each group's server only checks them within +/// that group, so a member of one group could reuse an id this device holds +/// in another. Applying it would move that row between groups here; it is +/// skipped instead, and the group it belongs to keeps it. +Future> _heldByOtherGroups( + AppDatabase db, + ResultSetImplementation table, { + required GeneratedColumn id, + required GeneratedColumn owner, + required String groupId, + required List ids, +}) async { + if (ids.isEmpty) return const {}; + final rows = + await (db.selectOnly(table) + ..addColumns([id]) + ..where(id.isIn(ids) & owner.equals(groupId).not())) + .get(); + final held = {for (final row in rows) row.read(id)!}; + for (final skipped in held) { + _logForeign(groupId, skipped); + } + return held; +} + +Future> _memberIds(AppDatabase db, String groupId) async => { + for (final row + in await (db.selectOnly(db.members) + ..addColumns([db.members.id]) + ..where(db.members.groupId.equals(groupId))) + .get()) + row.read(db.members.id)!, +}; + +void _logForeign(String groupId, String id) => developer.log( + 'Skipped $id in group $groupId: it names a row of another group.', + name: 'opensplit.sync', + level: 900, +); + /// Applies one page and advances the group's cursor, in one transaction, so a /// crash between them can neither re-read nor skip a page. Future applyGroupChanges( @@ -51,9 +95,20 @@ Future applyGroupChanges( } final dirtyMembers = await _dirtyIds(db, OutboxTarget.member); + final foreignMembers = await _heldByOtherGroups( + db, + db.members, + id: db.members.id, + owner: db.members.groupId, + groupId: groupId, + ids: [for (final member in page.members) member.id], + ); await db.batch((batch) { for (final member in page.members) { - if (dirtyMembers.contains(member.id)) continue; + if (dirtyMembers.contains(member.id) || + foreignMembers.contains(member.id)) { + continue; + } final row = member.toRow(groupId).toCompanion(false); // DO UPDATE, never REPLACE: SQLite's REPLACE deletes first, and // payers and shares reference members without a cascade. @@ -62,9 +117,27 @@ Future applyGroupChanges( }); final dirtyEntries = await _dirtyIds(db, OutboxTarget.entry); + final foreignEntries = await _heldByOtherGroups( + db, + db.entries, + id: db.entries.id, + owner: db.entries.groupId, + groupId: groupId, + ids: [for (final entry in page.entries) entry.id], + ); + final ownMembers = await _memberIds(db, groupId); var applied = 0; for (final entry in page.entries) { if (dirtyEntries.contains(entry.id)) continue; + final names = [ + for (final payer in entry.payers) payer.memberId, + for (final share in entry.shares) share.memberId, + ]; + if (foreignEntries.contains(entry.id) || + !names.every(ownMembers.contains)) { + _logForeign(groupId, entry.id); + continue; + } await writeEntryInTransaction(db, entry.toEntry(groupId)); applied++; } @@ -137,9 +210,8 @@ Future _applyEvents( } } -/// Profiles, the one feed still cursored on a timestamp (they live in D1, -/// which has several writers and so no sequence number). Here a newer local -/// `updatedAt` really can mean the remote row is older. +/// Profiles, which arrive on their own feed and on group pages, so a row can +/// arrive older than the copy already held: a newer local `version` wins. Future applyProfiles(AppDatabase db, List rows) async { if (rows.isEmpty) return 0; @@ -147,12 +219,12 @@ Future applyProfiles(AppDatabase db, List rows) async { final locals = await (db.select( db.profiles, )..where((t) => t.id.isIn([for (final row in rows) row.id]))).get(); - final heldAt = {for (final local in locals) local.id: local.updatedAt}; + final held = {for (final local in locals) local.id: local.version}; final winners = [ for (final profile in rows) if (!dirty.contains(profile.id) && - _remoteWins(heldAt[profile.id], profile.updatedAt)) + (held[profile.id] ?? 0) < profile.version) profile.toRow().toCompanion(false), ]; @@ -163,6 +235,3 @@ Future applyProfiles(AppDatabase db, List rows) async { }); return winners.length; } - -bool _remoteWins(DateTime? local, DateTime? remote) => - local == null || remote == null || local.isBefore(remote); diff --git a/lib/data/sync/outbox_queue.dart b/lib/data/sync/outbox_queue.dart index ae5ccc1..94424b1 100644 --- a/lib/data/sync/outbox_queue.dart +++ b/lib/data/sync/outbox_queue.dart @@ -49,11 +49,30 @@ class OutboxQueue { /// Marks [targetId] as dirty, coalescing with any item already waiting for /// the same row. - Future enqueue(OutboxTarget target, String targetId) async { + /// + /// The item goes to the back of the queue, where this change was made, + /// unless the server has never seen the row: then it keeps its place, ahead + /// of the changes that came after its creation and may name it. Pushing + /// reads each row as it is then, so a creation sent from its old place + /// carries any later edit too. + Future enqueue(OutboxTarget target, String targetId) => + _db.transaction(() => enqueueInTransaction(target, targetId)); + + /// Queues a row as part of the caller's active database transaction. + /// + /// The local write and its queue row must commit together. A separate + /// transaction would nest on browsers, where Drift holds one Web Lock for + /// the outer transaction. + Future enqueueInTransaction( + OutboxTarget target, + String targetId, + ) async { if (!(await readSyncSession(_db)).enabled) { throw StateError('This account session has ended.'); } final revision = const Uuid().v4(); + final position = await _nextPosition(); + final moves = await _isOnServer(target, targetId); await _db .into(_db.outbox) .insert( @@ -61,18 +80,20 @@ class OutboxQueue { target: target, targetId: targetId, revision: revision, - createdAt: _clock(), + position: position, ), onConflict: DoUpdate( - // A fresh change deserves an immediate attempt even if a previous - // one had been backed off, or set aside as a dead letter: whatever - // the server refused may be exactly what this edit changed. + // A fresh change deserves an immediate attempt even if a + // previous one had been backed off, or set aside as a dead + // letter: whatever the server refused may be exactly what this + // edit changed. (_) => OutboxCompanion( revision: Value(revision), - attempts: Value(0), - nextAttemptAt: Value(null), - lastError: Value(null), - deadLetteredAt: Value(null), + position: moves ? Value(position) : const Value.absent(), + attempts: const Value(0), + nextAttemptAt: const Value(null), + lastError: const Value(null), + deadLetteredAt: const Value(null), ), ), ); @@ -80,6 +101,36 @@ class OutboxQueue { if (_queued.hasListener) _queued.add(null); } + Future _nextPosition() async { + final last = _db.outbox.position.max(); + final row = await (_db.selectOnly( + _db.outbox, + )..addColumns([last])).getSingle(); + return (row.read(last) ?? 0) + 1; + } + + /// Whether the server holds a version of the row: a sequence number, or for + /// a profile a version, came back for it. + Future _isOnServer(OutboxTarget target, String id) async { + final BaseSelectStatement acknowledged = switch (target) { + OutboxTarget.group => _db.select( + _db.groups, + )..where((t) => t.id.equals(id) & t.seq.isNotNull()), + OutboxTarget.member => _db.select( + _db.members, + )..where((t) => t.id.equals(id) & t.seq.isNotNull()), + OutboxTarget.entry => _db.select( + _db.entries, + )..where((t) => t.id.equals(id) & t.seq.isNotNull()), + OutboxTarget.profile => _db.select( + _db.profiles, + )..where((t) => t.id.equals(id) & t.version.isNotNull()), + }; + final exists = existsQuery(acknowledged); + return (await _db.selectExpressions([exists]).getSingle()).read(exists) ?? + false; + } + /// Items ready to be attempted now, in an order the server can accept. Future> due({int limit = 100}) async { final now = _clock(); @@ -98,8 +149,10 @@ class OutboxQueue { )..where((t) => t.deadLetteredAt.isNull())).get(); rows.sort((a, b) { - final byKind = a.target.index.compareTo(b.target.index); - return byKind != 0 ? byKind : a.createdAt.compareTo(b.createdAt); + final byPosition = a.position.compareTo(b.position); + return byPosition != 0 + ? byPosition + : a.target.index.compareTo(b.target.index); }); return rows; @@ -119,6 +172,15 @@ class OutboxQueue { return row == null ? null : row.nextAttemptAt ?? _clock(); } + /// Rows of [target] changed here that the server has not taken yet: + /// queued, backing off, or refused. + Future> unsentIds(OutboxTarget target) async => { + for (final row in await (_db.select( + _db.outbox, + )..where((t) => t.target.equalsValue(target))).get()) + row.targetId, + }; + /// Whether leaving this account would strand an edit held only here. Future hasUnresolvedWrites() async { final pending = await (_db.select(_db.outbox)..limit(1)).get(); diff --git a/lib/data/sync/shared_feeds.dart b/lib/data/sync/shared_feeds.dart index 47deeca..61241d5 100644 --- a/lib/data/sync/shared_feeds.dart +++ b/lib/data/sync/shared_feeds.dart @@ -38,6 +38,7 @@ class SharedFeeds { static const _fxFloor = 'fx:floor'; static const _profileFeed = 'profiles'; + static const _referenceTag = 'reference:etag'; Future pullAll() async { await pullReferenceData(); @@ -47,12 +48,24 @@ class SharedFeeds { /// Currencies and categories, which must land before a group can be /// created. Upsert, never delete: a withdrawn category is still on the - /// entries that used it. + /// entries that used it. Asked for with the last copy's tag, so an + /// unchanged list is a 304 with no body. Future pullReferenceData() async { try { - final reference = await fetch( - client.getReferenceApi().getReference(), - ).timeout(requestTimeout); + final held = await hasReferenceData() + ? await _readCursor(_referenceTag) + : null; + final response = await client + .getReferenceApi() + .getReference( + ifNoneMatch: held, + validateStatus: (status) => + status != null && (status ~/ 100 == 2 || status == 304), + ) + .timeout(requestTimeout); + final reference = response.data; + if (response.statusCode == 304 || reference == null) return; + await db.batch((batch) { for (final currency in reference.currencies) { final row = currency.toRow().toCompanion(false); @@ -63,6 +76,9 @@ class SharedFeeds { batch.insert(db.categories, row, onConflict: DoUpdate((_) => row)); } }); + if (response.headers.value('etag') case final tag?) { + await _writeCursor(_referenceTag, tag); + } } catch (error, stackTrace) { _log('Reference data was not refreshed', error, stackTrace); } @@ -129,28 +145,28 @@ class SharedFeeds { } } - /// Everybody this account shares a group with, cursored by the server. + /// Everybody this account shares a group with, past the newest version + /// already read. Future pullProfiles() async { - var cursor = await _readCursor(_profileFeed); + var since = int.tryParse(await _readCursor(_profileFeed) ?? '') ?? 0; var applied = 0; while (true) { await assertActive(); final page = await fetch( - client.getSyncApi().getProfiles(after: cursor, limit: pageSize), + client.getSyncApi().getProfiles(since: since, limit: pageSize), ).timeout(requestTimeout); if (page.profiles.isEmpty) break; - final next = page.cursor; applied += await db.transaction(() async { await assertActive(); final count = await applyProfiles(db, page.profiles); - if (next != null) await _writeCursor(_profileFeed, next); + await _writeCursor(_profileFeed, '${page.seq}'); return count; }); - if (next == null || !page.hasMore) break; - cursor = next; + if (!page.hasMore || page.seq == since) break; + since = page.seq; } return applied; } diff --git a/lib/data/sync/sync_engine.dart b/lib/data/sync/sync_engine.dart index e77cb04..ffea910 100644 --- a/lib/data/sync/sync_engine.dart +++ b/lib/data/sync/sync_engine.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'package:dio/dio.dart' as dio; import 'package:drift/drift.dart'; @@ -62,6 +63,7 @@ class SyncEngine { required this.db, required this.client, required this.outbox, + this.accountId, SyncGate? gate, DateTime Function()? clock, this.pageSize = 200, @@ -81,6 +83,10 @@ class SyncEngine { final AppDatabase db; final api.OpensplitApi client; final OutboxQueue outbox; + + /// The account this ledger belongs to, which is how a group it has left is + /// recognised. Null only where there is no account to ask about. + final String? accountId; final DateTime Function() _clock; final SyncGate _gate; final int pageSize; @@ -111,36 +117,91 @@ class SyncEngine { Future _call(Future> request) => fetch(request).timeout(requestTimeout); - /// The server's groups for this account, plus local ones it has not seen. - /// Throws when the server cannot be asked: the local list alone cannot show - /// that an account has no other groups. + /// The groups this account is in, by the server's account and this + /// device's, less those [_unreadable] says not to ask about. Throws when the + /// server cannot be asked: the local list alone cannot show that an account + /// has no other groups. Future> discoverGroups() async { - final local = await db.select(db.groups).get(); - final remote = await _call(client.getSyncApi().listGroups()); - return {for (final row in local) row.id, ...remote.groupIds}.toList() - ..sort(); + final answer = await _call( + client.getSyncApi().pull( + pullRequest: api.PullRequest(groups: const [], limit: pageSize), + ), + ); + final skipped = await _unreadable(); + return { + for (final row in await db.select(db.groups).get()) row.id, + ...answer.groupIds, + }.difference(skipped).toList()..sort(); + } + + /// Groups a pull can only waste a request on: ones the server has never + /// acknowledged (their creation is still in the outbox, and the server + /// would answer `no_group`), and ones this account has left for good. + Future> _unreadable() async => { + for (final group in await (db.select( + db.groups, + )..where((t) => t.seq.isNull())).get()) + group.id, + ...await _leftForGood(), + }; + + /// Groups this account has left or been removed from, and this device has + /// already read that back from the server. A pull of one can only ever come + /// back empty. An invite link brings the group back, and the server then + /// names it among the account's groups again. + Future> _leftForGood() async { + final account = accountId; + if (account == null) return const {}; + + final departed = + await (db.select(db.members)..where( + (t) => + t.profileId.equals(account) & + t.leftAt.isNotNull() & + t.seq.isNotNull(), + )) + .get(); + if (departed.isEmpty) return const {}; + + final cursors = await _groupCursors(); + // Unsent, the leave is this device's word alone, and the server may still + // refuse it. + final unsent = await outbox.unsentIds(OutboxTarget.member); + return { + for (final member in departed) + if (!unsent.contains(member.id) && + (cursors[member.groupId] ?? 0) >= member.seq!) + member.groupId, + }; } - Future syncGroup(String groupId) => - _serialized(() => _run(() => pull(groupId))); + /// Pushes what is waiting, then reads [groupIds] and every group the push + /// wrote to. What a screen, a local write or a push message asks for: the + /// shared feeds are left to [syncEverything]. + Future syncGroups(Set groupIds) => + _serialized(() => _run(groupIds: groupIds)); - /// Every group in one run: the outbox and the shared feeds once. A group - /// that cannot be pulled ends the sweep; the cause is likely the connection. - Future syncEverything() => _serialized( - () => _run(() async { - var pulled = 0; - for (final groupId in await discoverGroups()) { - pulled += await pull(groupId); - } - return pulled; - }), - ); + Future syncGroup(String groupId) => syncGroups({groupId}); - Future _run(Future Function() pullGroups) async { + /// The outbox, the shared feeds, and every group the account is in. + Future syncEverything() => + _serialized(() => _run(groupIds: const {}, everything: true)); + + Future _run({ + required Set groupIds, + bool everything = false, + }) async { final pushed = await push(); try { - await shared.pullAll(); - final pulled = await pullGroups(); + if (everything) { + await shared.pullAll(); + } else { + await shared.pullFxRates(); + } + final pulled = await _pull({ + ...groupIds, + ...pushed.groups, + }, everything: everything); return SyncReport( pushed: pushed.sent, pulled: pulled, @@ -171,6 +232,8 @@ class SyncEngine { return await _gate.synchronized(() async { _activeEpoch = (await readSyncSession(db)).epoch; await _assertActive(); + // Another isolate may have held the gate, and written, just now. + await db.noticeWritesElsewhere(); final report = await operation(); return report.withNextPushAt(await outbox.nextAttemptAt()); }); @@ -193,9 +256,12 @@ class SyncEngine { /// dead-lettered, so the loop ends unless the queue refills as it drains. static const int _maxPushRounds = 50; - Future<({int sent, int failed})> push() async { + /// Sends the outbox in order. [groups] are the ones it wrote to, or found + /// it needs to read back. + Future<({int sent, int failed, Set groups})> push() async { var sent = 0; var failed = 0; + final groups = {}; for (var round = 0; round < _maxPushRounds; round++) { await _assertActive(); @@ -204,16 +270,18 @@ class SyncEngine { for (final item in due) { try { - await _pushOne(item); + if (await _pushOne(item) case final groupId?) groups.add(groupId); await outbox.complete(item); sent++; } on ApiFailure catch (e) { switch (e.retry) { case api.Retry.stale: - await _parkConflict(item); + if (await _parkConflict(item) case final groupId?) { + groups.add(groupId); + } case api.Retry.transient: await outbox.fail(item, e.message); - return (sent: sent, failed: failed + 1); + return (sent: sent, failed: failed + 1, groups: groups); // Refused identically next time; retrying would wedge the queue. case api.Retry.permanent: case api.Retry.unknownDefaultOpenApi: @@ -223,21 +291,21 @@ class SyncEngine { } catch (e) { // A connection failure affects the whole batch. await outbox.fail(item, '$e'); - return (sent: sent, failed: failed + 1); + return (sent: sent, failed: failed + 1, groups: groups); } } } - return (sent: sent, failed: failed); + return (sent: sent, failed: failed, groups: groups); } /// Parks an edit the server refused as stale for a person to read, and /// rewinds the cursor to its base so the next pull brings the server's - /// version (skipped while the row was dirty). - Future _parkConflict(OutboxRow item) async { - await db.transaction(() async { - if (!await outbox.isCurrent(item)) return; + /// version (skipped while the row was dirty). Answers the group to read. + Future _parkConflict(OutboxRow item) async { + return db.transaction(() async { + if (!await outbox.isCurrent(item)) return null; final entry = await _loadEntry(item.targetId); - if (entry == null) return; + if (entry == null) return null; await db .into(db.entryConflicts) @@ -252,6 +320,7 @@ class SyncEngine { ); await _rewindCursor(entry.row.groupId, to: entry.row.seq ?? 0); await outbox.complete(item); + return entry.row.groupId; }); } @@ -303,9 +372,9 @@ class SyncEngine { if (row.seq case final seq?) return _rewindCursor(id, to: seq - 1); await (db.delete(db.groups)..where((t) => t.id.equals(id))).go(); case OutboxTarget.profile: - // Cursored on time, not seq: forget this copy's timestamp and re-read. + // Not on a group's feed: forget this copy's version and re-read. await (db.update(db.profiles)..where((t) => t.id.equals(id))).write( - const ProfilesCompanion(updatedAt: Value(null)), + const ProfilesCompanion(version: Value(null)), ); await shared.resetProfileFeed(); } @@ -348,13 +417,14 @@ class SyncEngine { /// Sends one dirty row, read now rather than at queue time, and records the /// sequence number the server gave it: its version and the next edit's base. - Future _pushOne(OutboxRow item) async { + /// Answers the group it wrote to, if it sent anything that has one. + Future _pushOne(OutboxRow item) async { switch (item.target) { case OutboxTarget.entry: final entry = await _snapshot(item, () => _loadEntry(item.targetId)); - if (entry == null) return; + if (entry == null) return null; // Created and deleted before its first push: nothing remote to delete. - if (entry.isDeleted && entry.row.seq == null) return; + if (entry.isDeleted && entry.row.seq == null) return null; final stored = await _call( client.getEntriesApi().putEntry( @@ -368,6 +438,7 @@ class SyncEngine { await (db.update(db.entries)..where((t) => t.id.equals(stored.id))) .write(EntriesCompanion(seq: Value(stored.seq))); }); + return entry.row.groupId; case OutboxTarget.group: final group = await _snapshot( @@ -376,7 +447,7 @@ class SyncEngine { db.groups, )..where((t) => t.id.equals(item.targetId))).getSingleOrNull(), ); - if (group == null) return; + if (group == null) return null; // A group the server has never seen is created with its creator. final creator = await _creatorOf(group); @@ -396,6 +467,7 @@ class SyncEngine { .write(MembersCompanion(seq: Value(stored.seq))); } }); + return group.id; case OutboxTarget.member: final member = await _snapshot( @@ -404,7 +476,7 @@ class SyncEngine { db.members, )..where((t) => t.id.equals(item.targetId))).getSingleOrNull(), ); - if (member == null) return; + if (member == null) return null; final stored = await _call( client.getGroupsApi().putMember( @@ -418,6 +490,7 @@ class SyncEngine { await (db.update(db.members)..where((t) => t.id.equals(member.id))) .write(MembersCompanion(seq: Value(stored.seq))); }); + return member.groupId; case OutboxTarget.profile: final profile = await _snapshot( @@ -426,7 +499,7 @@ class SyncEngine { db.profiles, )..where((t) => t.id.equals(item.targetId))).getSingleOrNull(), ); - if (profile == null) return; + if (profile == null) return null; final stored = await _call( client.getSyncApi().updateProfile(profileUpdate: profile.toUpdate()), @@ -434,8 +507,9 @@ class SyncEngine { await db.transaction(() async { if (!await outbox.isCurrent(item)) return; await (db.update(db.profiles)..where((t) => t.id.equals(profile.id))) - .write(ProfilesCompanion(updatedAt: Value(stored.updatedAt))); + .write(ProfilesCompanion(version: Value(stored.version))); }); + return null; } } @@ -456,35 +530,80 @@ class SyncEngine { } /// One group's changes after its cursor, page by page. - Future pull(String groupId) async { - var cursor = await _readGroupCursor(groupId); - var applied = 0; + Future pull(String groupId) => _pull({groupId}, everything: false); + + /// The most groups the server reads in one pull. + static const _groupsPerPull = 50; + + /// Reads [groupIds] past their cursors, many groups to a request, until each + /// is caught up or refused. With [everything], also every group this + /// account is in, and the server's answer names any it joined elsewhere. + /// + /// A group the server refuses is logged and left as it is here: one group + /// it cannot read says nothing about the others. + Future _pull(Set groupIds, {required bool everything}) async { + final cursors = await _groupCursors(); + final skipped = await _unreadable(); + final wanted = {}; + final done = {}; + void want(String groupId) { + if (done.contains(groupId) || skipped.contains(groupId)) return; + wanted.putIfAbsent(groupId, () => cursors[groupId] ?? 0); + } - while (true) { + if (everything) { + for (final group in await db.select(db.groups).get()) { + want(group.id); + } + } + groupIds.forEach(want); + + var applied = 0; + var asked = false; + while (wanted.isNotEmpty || (everything && !asked)) { await _assertActive(); - final page = await _call( - client.getSyncApi().getChanges( - groupId: groupId, - since: cursor, - limit: pageSize, + final batch = [ + for (final MapEntry(key: groupId, value: since) in wanted.entries.take( + _groupsPerPull, + )) + api.GroupCursor(groupId: groupId, since: since), + ]; + final answer = await _call( + client.getSyncApi().pull( + pullRequest: api.PullRequest(groups: batch, limit: pageSize), ), ); + asked = true; - applied += await applyGroupChanges(db, page, now: _clock()); - - if (page.purgedAt != null) return applied; - if (page.seq == cursor) break; - cursor = page.seq; - if (!page.hasMore) break; + for (final cursor in batch) { + wanted.remove(cursor.groupId); + done.add(cursor.groupId); + } + for (final page in answer.pages) { + applied += await applyGroupChanges(db, page, now: _clock()); + final since = cursors[page.groupId] ?? 0; + cursors[page.groupId] = page.seq; + if (page.hasMore && page.purgedAt == null && page.seq > since) { + done.remove(page.groupId); + want(page.groupId); + } + } + for (final refusal in answer.refusals) { + developer.log( + 'Group ${refusal.groupId} was not read: ${refusal.message}', + name: 'opensplit.sync', + level: 900, + ); + } + if (everything) answer.groupIds.forEach(want); } return applied; } - Future _readGroupCursor(String groupId) async => - (await (db.select( - db.groupCursors, - )..where((t) => t.groupId.equals(groupId))).getSingleOrNull())?.seq ?? - 0; + Future> _groupCursors() async => { + for (final cursor in await db.select(db.groupCursors).get()) + cursor.groupId: cursor.seq, + }; Future _loadEntry(String entryId) async { final row = await (db.select( diff --git a/lib/data/sync/wire.dart b/lib/data/sync/wire.dart index 8b48b05..dc1e571 100644 --- a/lib/data/sync/wire.dart +++ b/lib/data/sync/wire.dart @@ -99,7 +99,7 @@ extension ProfileFromWire on api.Profile { avatarEmoji: avatarEmoji, avatarIcon: avatarIcon, avatarPhoto: avatarPhoto, - updatedAt: updatedAt, + version: version, ); } diff --git a/lib/domain/balance/pairwise.dart b/lib/domain/balance/pairwise.dart new file mode 100644 index 0000000..5320352 --- /dev/null +++ b/lib/domain/balance/pairwise.dart @@ -0,0 +1,97 @@ +import '../models/entry.dart'; +import '../split/allocation.dart'; +import 'simplify.dart'; + +/// What one expense leaves each person owing each payer, keyed by +/// `(debtor, creditor)`. +/// +/// Every share is owed to the payers in proportion to what each of them paid. +/// Shares are taken one at a time, in member order, each divided by the same +/// largest-remainder rule the split uses against what every payer is still +/// owed. So it all comes out in whole minor units, each share is owed in full, +/// and each payer is paid back exactly what they paid, with no unit lost to +/// rounding. Nobody owes themselves. A deleted expense owes nothing, and nor +/// does one that does not add up, which the group screen flags instead. +Map<(String, String), int> debtsWithin(Entry entry) { + final debts = <(String, String), int>{}; + if (entry.isDeleted || !entry.isBalanced) return debts; + + final outstanding = { + for (final payer in entry.payers) payer.memberId: payer.amountMinor, + }; + final shares = [...entry.shares] + ..sort((a, b) => a.memberId.compareTo(b.memberId)); + + for (final share in shares) { + if (share.amountMinor == 0) continue; + final owed = allocateLargestRemainder( + totalMinor: share.amountMinor, + parties: [ + for (final MapEntry(key: payer, value: left) in outstanding.entries) + (memberId: payer, weightMicros: left), + ], + seed: '${entry.id}:${share.memberId}', + ); + for (final MapEntry(key: payer, value: amount) in owed.entries) { + outstanding[payer] = outstanding[payer]! - amount; + if (payer == share.memberId || amount == 0) continue; + final pair = (share.memberId, payer); + debts[pair] = (debts[pair] ?? 0) + amount; + } + } + return debts; +} + +/// What [debtor] owes [creditor] on account of [entry], less what [creditor] +/// owes [debtor] on it. Negative when the expense ran the other way. +int pairDebtWithin( + Entry entry, { + required String debtor, + required String creditor, +}) { + final debts = debtsWithin(entry); + return (debts[(debtor, creditor)] ?? 0) - (debts[(creditor, debtor)] ?? 0); +} + +/// Who owes whom, pair by pair, when a group does not simplify its debts. +/// +/// The debts of every expense between each two people are netted against +/// each other, per currency, and nothing more: a payment is only ever +/// suggested between two people who actually shared an expense. Settling +/// every one of them leaves everybody square, exactly as the fewest payments +/// from [simplifyDebts] would, in more steps. +List pairwiseDebts(Iterable entries) { + final net = <(String, String, String), int>{}; + for (final entry in entries) { + for (final MapEntry(key: (debtor, creditor), value: amount) in debtsWithin( + entry, + ).entries) { + // One key per unordered pair, signed: positive when the first named + // owes the second. + final forward = debtor.compareTo(creditor) < 0; + final key = forward + ? (entry.row.currency, debtor, creditor) + : (entry.row.currency, creditor, debtor); + net[key] = (net[key] ?? 0) + (forward ? amount : -amount); + } + } + + final transfers = [ + for (final MapEntry(key: (currency, first, second), value: amount) + in net.entries) + if (amount != 0) + Transfer( + fromMemberId: amount > 0 ? first : second, + toMemberId: amount > 0 ? second : first, + currency: currency, + amountMinor: amount.abs(), + ), + ]; + return transfers..sort( + (a, b) => [ + a.currency.compareTo(b.currency), + a.fromMemberId.compareTo(b.fromMemberId), + a.toMemberId.compareTo(b.toMemberId), + ].firstWhere((order) => order != 0, orElse: () => 0), + ); +} diff --git a/lib/domain/balance/transfer_basis.dart b/lib/domain/balance/transfer_basis.dart new file mode 100644 index 0000000..6784e9c --- /dev/null +++ b/lib/domain/balance/transfer_basis.dart @@ -0,0 +1,52 @@ +import '../models/entry.dart'; +import 'pairwise.dart'; +import 'simplify.dart'; + +/// One expense behind a suggested payment, and how far it moved what that +/// payment settles: positive when it put the payer in credit. +typedef Contribution = ({Entry entry, int delta}); + +/// The expenses behind [transfer], and the figure they come to. +/// +/// When the group simplifies its debts, a payment settles the payer's whole +/// position, so every expense in its currency that moved the payer counts. +/// Otherwise it settles one pair's debt, and only what passed between those +/// two counts. Either way [net] is the sum of the contributions, from the +/// payer's side, so a person can check the one against the other. +({List contributions, int net}) basisOf( + Transfer transfer, + Iterable entries, { + required bool simplified, +}) { + final contributions = [ + for (final entry in entries) + if (!entry.isDeleted && entry.row.currency == transfer.currency) + ( + entry: entry, + delta: simplified + ? _positionMovedBy(entry, transfer.fromMemberId) + : -pairDebtWithin( + entry, + debtor: transfer.fromMemberId, + creditor: transfer.toMemberId, + ), + ), + ]..removeWhere((contribution) => contribution.delta == 0); + + return ( + contributions: contributions, + net: contributions.fold(0, (sum, item) => sum + item.delta), + ); +} + +/// What [memberId] paid towards [entry], less their share of it. +int _positionMovedBy(Entry entry, String memberId) { + var delta = 0; + for (final payer in entry.payers) { + if (payer.memberId == memberId) delta += payer.amountMinor; + } + for (final share in entry.shares) { + if (share.memberId == memberId) delta -= share.amountMinor; + } + return delta; +} diff --git a/lib/domain/decimal_text.dart b/lib/domain/decimal_text.dart new file mode 100644 index 0000000..2c32b93 --- /dev/null +++ b/lib/domain/decimal_text.dart @@ -0,0 +1,50 @@ +/// Decimal numbers as people type them into a field. +library; + +import 'split/allocation.dart'; + +/// A non-negative decimal as typed, with either `.` or `,` as its point: a +/// phone's number keyboard shows whichever the device's region uses. The +/// whole part is group 1 and the fraction group 2, empty when absent. +/// +/// Matches every prefix of a valid number too, including the empty string, +/// so it can guard a field keystroke by keystroke. +RegExp decimalInputPattern({ + required int wholeDigits, + required int fractionDigits, +}) => RegExp( + fractionDigits == 0 + ? '^(\\d{0,$wholeDigits})()\$' + : '^(\\d{0,$wholeDigits})(?:[.,](\\d{0,$fractionDigits}))?\$', +); + +/// A percentage as typed: up to three whole digits, and six decimals, the +/// precision a share's weight is stored to. +final percentInputPattern = decimalInputPattern( + wholeDigits: 3, + fractionDigits: 6, +); + +/// A typed percentage in millionths, or null for anything that is not one. +int? parsePercentMicros(String text) { + final match = percentInputPattern.firstMatch(text.trim()); + if (match == null) return null; + final whole = match.group(1) ?? ''; + final fraction = match.group(2) ?? ''; + if (whole.isEmpty && fraction.isEmpty) return null; + return (whole.isEmpty ? 0 : int.parse(whole)) * weightScale + + (fraction.isEmpty ? 0 : int.parse(fraction.padRight(6, '0'))); +} + +/// A percentage in millionths as somebody would type it: `12.5`, not +/// `12.500000`. +String formatPercentMicros(int micros) { + final whole = micros ~/ weightScale; + final fraction = micros % weightScale; + if (fraction == 0) return '$whole'; + final decimals = fraction + .toString() + .padLeft(6, '0') + .replaceAll(RegExp(r'0+$'), ''); + return '$whole.$decimals'; +} diff --git a/lib/domain/entry_draft.dart b/lib/domain/entry_draft.dart index 9970a4f..b2178cb 100644 --- a/lib/domain/entry_draft.dart +++ b/lib/domain/entry_draft.dart @@ -79,6 +79,23 @@ class EntryDraft { final String? fxSource; } +/// Whether a form describes an entry yet. +sealed class DraftCheck { + const DraftCheck(); +} + +/// It does. +final class DraftReady extends DraftCheck { + const DraftReady(this.draft); + final EntryDraft draft; +} + +/// Not yet, and why, in words for the person filling it in. +final class DraftIncomplete extends DraftCheck { + const DraftIncomplete(this.reason); + final String reason; +} + /// Turns a [draft] into a balanced [Entry]. Entry composeEntry( EntryDraft draft, { diff --git a/lib/domain/fx/fx_quote.dart b/lib/domain/fx/fx_quote.dart index a2536b8..54c7d2a 100644 --- a/lib/domain/fx/fx_quote.dart +++ b/lib/domain/fx/fx_quote.dart @@ -2,6 +2,13 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'fx_quote.freezed.dart'; +/// How many days old a rate may be and still answer for a day. A week covers +/// every weekend and every ECB holiday; older than that, the rate describes +/// some other time. The server holds the same rule as `STALE_AFTER_DAYS` in +/// `server/src/do/fx/index.ts`, so the days it fills are the days this asks +/// for. +const rateStaleAfterDays = 7; + /// A rate for converting [base] into [quote], as published on [date]. @freezed abstract class FxQuote with _$FxQuote { diff --git a/lib/domain/money_format.dart b/lib/domain/money_format.dart index d5c2e69..0bb4f84 100644 --- a/lib/domain/money_format.dart +++ b/lib/domain/money_format.dart @@ -1,4 +1,5 @@ import '../data/local/database.dart'; +import 'decimal_text.dart'; /// Currencies conventionally grouped in the Indian system — the last three /// digits, then pairs: 1,23,45,678 rather than 12,345,678. @@ -90,24 +91,35 @@ extension CurrencyAmounts on Currency { } /// Parses user input in major units into minor units. + /// + /// The decimal point may be `.` or `,`, whichever the keyboard offered. + /// Grouping separators are not accepted, since `1,250` would otherwise mean + /// one thing with a dot keyboard and another with a comma keyboard. Null for + /// anything else, including more decimals than this currency has or more + /// digits than [maxAmountDigits]. int? parseToMinor(String input) { - final trimmed = input.trim().replaceAll(',', ''); - if (trimmed.isEmpty) return null; - - final match = RegExp(r'^(-)?(\d*)(?:\.(\d*))?$').firstMatch(trimmed); + final match = amountInputPattern(exponent).firstMatch(input.trim()); if (match == null) return null; - final sign = match.group(1) == null ? 1 : -1; - final majorText = match.group(2) ?? ''; - final fractionText = match.group(3) ?? ''; + final majorText = match.group(1) ?? ''; + final fractionText = match.group(2) ?? ''; if (majorText.isEmpty && fractionText.isEmpty) return null; - if (fractionText.length > exponent) return null; final major = majorText.isEmpty ? 0 : int.parse(majorText); final fraction = fractionText.isEmpty ? 0 : int.parse(fractionText.padRight(exponent, '0')); - - return sign * (major * minorPerMajor + fraction); + return major * minorPerMajor + fraction; } } + +/// Whole units an amount may have: a trillion, far past any shared bill, and +/// short enough that the minor units stay exact as a JavaScript number on the +/// web as well as in a 64-bit integer. +const maxAmountDigits = 12; + +/// An amount as typed: digits, then optionally `.` or `,` and up to +/// [exponent] more. Matches every prefix of a valid amount too, including the +/// empty string, so it can guard a field keystroke by keystroke. +RegExp amountInputPattern(int exponent) => + decimalInputPattern(wholeDigits: maxAmountDigits, fractionDigits: exponent); diff --git a/lib/presentation/amount_input.dart b/lib/presentation/amount_input.dart new file mode 100644 index 0000000..20ee937 --- /dev/null +++ b/lib/presentation/amount_input.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; + +import '../data/local/database.dart'; +import '../domain/money_format.dart'; + +/// Keeps a number field to something its parser reads: digits, one decimal +/// point, and no more digits either side than the value allows. +/// +/// The point may be typed as `.` or `,`, because a phone's number keyboard +/// shows whichever the device's region uses. A keystroke that would make the +/// text unreadable is refused, so the field never holds something that +/// silently means another number. +class DecimalInputFormatter extends TextInputFormatter { + DecimalInputFormatter(this._shape); + + /// An amount in [currency], as [CurrencyAmounts.parseToMinor] reads it. + DecimalInputFormatter.amount(Currency? currency) + : _shape = amountInputPattern(currency?.exponent ?? 2); + + final RegExp _shape; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) => _shape.hasMatch(newValue.text) ? newValue : oldValue; +} diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index 6b7b13f..3136cd3 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -100,7 +100,7 @@ class _OpenSplitAppState extends ConsumerState { /// the group been doing, and is there a new version of the app. void _onResume() { if (ref.read(signedInProvider)) { - ref.read(appDatabaseProvider).refreshAfterExternalSync(); + unawaited(ref.read(appDatabaseProvider).noticeWritesElsewhere()); } ref.read(syncControllerProvider.notifier).resumed(); _offerUpdate(); diff --git a/lib/presentation/screens/archived_groups_screen.dart b/lib/presentation/screens/archived_groups_screen.dart index 8c7b146..f7c413a 100644 --- a/lib/presentation/screens/archived_groups_screen.dart +++ b/lib/presentation/screens/archived_groups_screen.dart @@ -5,22 +5,22 @@ import 'package:intl/intl.dart'; import '../../application/ledger_providers.dart'; import '../../application/local_providers.dart'; -import '../../data/local/database.dart'; +import '../../data/repositories/drift_group_repository.dart'; import '../navigation.dart'; import '../widgets/empty_state.dart'; import '../widgets/page_body.dart'; -/// Groups that have been put away, and the way back. +/// Groups that have been put away or left, and the way back. class ArchivedGroupsScreen extends ConsumerWidget { const ArchivedGroupsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final groups = ref.watch(groupsProvider(includeArchived: true)).value; + final listings = ref.watch(groupListingsProvider).value; final archived = [ - for (final group in groups ?? const []) - if (group.isArchived) group, - ]..sort((a, b) => b.archivedAt!.compareTo(a.archivedAt!)); + for (final listing in listings ?? const []) + if (!listing.isCurrent) listing, + ]; return Scaffold( appBar: AppBar( @@ -36,7 +36,7 @@ class ArchivedGroupsScreen extends ConsumerWidget { separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) => index == 0 ? const _Explanation() - : _ArchivedTile(group: archived[index - 1]), + : _ArchivedTile(listing: archived[index - 1]), ), ), ); @@ -51,7 +51,9 @@ class _Explanation extends StatelessWidget { padding: const EdgeInsets.only(bottom: 8), child: Text( 'These are out of the way, not gone. Everything in them still adds up, ' - 'and adding an expense brings one back by itself.', + 'and adding an expense brings one back by itself. Groups you left are ' + 'here too, read-only: a link from somebody still in one brings you ' + 'back.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -60,13 +62,14 @@ class _Explanation extends StatelessWidget { } class _ArchivedTile extends ConsumerWidget { - const _ArchivedTile({required this.group}); + const _ArchivedTile({required this.listing}); - final Group group; + final GroupListing listing; @override Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; + final group = listing.group; return Card.outlined( child: ListTile( @@ -80,14 +83,19 @@ class _ArchivedTile extends ConsumerWidget { ), title: Text(group.name), subtitle: Text( - 'Archived ${DateFormat.yMMMd().format(group.archivedAt!.toLocal())}', - ), - trailing: TextButton( - onPressed: () => ref - .read(groupRepositoryProvider) - .setArchived(group.id, archived: false), - child: const Text('Restore'), + listing.hasLeft + // A link from somebody still in it is the way back, not a button. + ? 'You left · read-only' + : 'Archived ${DateFormat.yMMMd().format(group.archivedAt!.toLocal())}', ), + trailing: listing.hasLeft + ? null + : TextButton( + onPressed: () => ref + .read(groupRepositoryProvider) + .setArchived(group.id, archived: false), + child: const Text('Restore'), + ), ), ); } diff --git a/lib/presentation/screens/entry_editor_screen.dart b/lib/presentation/screens/entry_editor_screen.dart index fc726c1..c60a236 100644 --- a/lib/presentation/screens/entry_editor_screen.dart +++ b/lib/presentation/screens/entry_editor_screen.dart @@ -1,33 +1,30 @@ -import 'dart:async'; - import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:opensplit_api/opensplit_api.dart' show SplitKind; +import '../../application/entry_form.dart'; import '../../application/ledger_providers.dart'; import '../../application/local_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; import '../../domain/activity/activity_text.dart'; -import '../../domain/calendar_date.dart'; -import '../../domain/category_guess.dart'; -import '../../domain/entry_draft.dart'; -import '../../domain/fx/fx_quote.dart'; -import '../../domain/models/entry.dart'; +import '../../domain/decimal_text.dart'; import '../../domain/models/group_event.dart'; import '../../domain/money_format.dart'; -import '../../domain/split/allocation.dart'; -import '../../domain/split/splitter.dart'; +import '../amount_input.dart'; import '../feedback.dart'; import '../navigation.dart'; import '../theme.dart'; -import '../widgets/category_icon.dart'; import '../widgets/avatar_view.dart'; +import '../widgets/category_icon.dart'; import '../widgets/page_body.dart'; /// Creates or edits an expense. -class EntryEditorScreen extends ConsumerStatefulWidget { +/// +/// Holds no state of its own: what has been typed lives in [EntryForm], and +/// every field reports a change there rather than keeping a copy here. +class EntryEditorScreen extends ConsumerWidget { const EntryEditorScreen({super.key, required this.groupId, this.entryId}); final String groupId; @@ -36,371 +33,43 @@ class EntryEditorScreen extends ConsumerStatefulWidget { bool get isEditing => entryId != null; @override - ConsumerState createState() => _EntryEditorScreenState(); -} - -class _EntryEditorScreenState extends ConsumerState { - final _description = TextEditingController(); - final _amount = TextEditingController(); - - /// Per-member text input for exact amounts, percentages and payer amounts. - final _exact = {}; - final _percent = {}; - final _payerAmounts = {}; - - String? _currencyCode; - String? _categoryId; - - /// Whether the category was picked by hand, or came with the expense being - /// edited. Until then it follows the description. - bool _categoryChosen = false; - - /// The day it happened, and when on that day if known, with the zone it - /// happened in. Times are shown and picked on this device's clock. - late DateTime _date; - DateTime? _occurredAt; - String? _zone; - SplitKind _splitKind = SplitKind.equal; - - /// Who is in the split, who paid, and the relative weights. - /// - /// Replaced rather than mutated, so only this state object changes them. - Set _participants = {}; - Map _shares = {}; - Set _payers = {}; - - bool _multiplePayers = false; - bool _loaded = false; - Entry? _editingSnapshot; - bool _saving = false; - String? _error; - - @override - void initState() { - super.initState(); - final now = DateTime.now(); - _date = now; - _occurredAt = now.toUtc(); - // Seeding the form is initialisation from asynchronous data, so it listens - // instead of running inside build. - ref.listenManual( - groupLedgerProvider(widget.groupId), - (_, _) => _seedWhenReady(), - fireImmediately: true, - ); - ref.listenManual(currenciesProvider, (_, _) => _seedWhenReady()); - } - - @override - void dispose() { - _description.dispose(); - _amount.dispose(); - for (final c in _exact.values) { - c.dispose(); - } - for (final c in _percent.values) { - c.dispose(); - } - for (final c in _payerAmounts.values) { - c.dispose(); - } - super.dispose(); - } - - TextEditingController _controllerFor( - Map map, - String id, - ) => map.putIfAbsent(id, TextEditingController.new); - - /// Keeps the category in step with [description] until one is chosen. - /// - /// A guess that stops matching is taken back, so typing "Uber" and then - /// correcting it to "Usha's gift" doesn't leave the expense a taxi ride. - void _guessCategory(String description) { - if (_categoryChosen) return; - final icon = guessCategoryIcon(description); - final categories = ref.read(categoriesProvider).value ?? const []; - final guess = categories.where((c) => c.icon == icon).firstOrNull?.id; - if (guess != _categoryId) setState(() => _categoryId = guess); - } - - /// Seeds the form as soon as everything it needs has arrived. - void _seedWhenReady() { - if (_loaded) return; - - final ledger = ref.read(groupLedgerProvider(widget.groupId)); - final currencies = ref.read(currenciesProvider).value; - if (ledger == null || currencies == null) return; - - final existing = widget.isEditing - ? ledger.entries.where((e) => e.id == widget.entryId).firstOrNull - : null; - if (widget.isEditing && existing == null) return; - - _load(ledger, existing, currencies); - } - - void _load(GroupLedger ledger, Entry? existing, Map cx) { - _loaded = true; - _editingSnapshot = existing; - - _currencyCode = existing?.row.currency ?? ledger.group.defaultCurrency; - - if (existing == null) { - // Everyone splits, the person adding it paid. The overwhelmingly common - // case, pre-filled so the fast path is two fields and a button. - _participants = {for (final member in ledger.members) member.id}; - final me = ledger.me?.id ?? ledger.members.firstOrNull?.id; - if (me != null) _payers = {me}; - return; - } - - _description.text = existing.row.description; - _categoryId = existing.row.categoryId; - _categoryChosen = _categoryId != null; - _date = existing.row.entryDate; - _occurredAt = existing.row.occurredAt; - _zone = existing.row.timeZone; - _splitKind = existing.row.splitKind; - final currency = cx[existing.row.currency]; - if (currency != null) { - _amount.text = currency.formatPlain(existing.row.amountMinor); - } - - _participants = {for (final share in existing.shares) share.memberId}; - _payers = {for (final payer in existing.payers) payer.memberId}; - _multiplePayers = existing.payers.length > 1; - - _shares = {}; - for (final share in existing.shares) { - if (currency != null) { - _controllerFor(_exact, share.memberId).text = currency.formatPlain( - share.amountMinor, - ); - } - final weight = share.weightMicros; - if (weight != null) { - _shares[share.memberId] = (weight / weightScale).round(); - _controllerFor(_percent, share.memberId).text = _microsToText(weight); - } - } - for (final payer in existing.payers) { - if (currency != null) { - _controllerFor(_payerAmounts, payer.memberId).text = currency - .formatPlain(payer.amountMinor); - } - } - } - - static String _microsToText(int micros) { - final whole = micros ~/ weightScale; - final frac = micros % weightScale; - if (frac == 0) return '$whole'; - final decimals = frac - .toString() - .padLeft(6, '0') - .replaceAll(RegExp(r'0+$'), ''); - return '$whole.$decimals'; - } - - /// Parses a decimal string into units of 10^-6, the precision the weight - /// column stores. - static int? _textToMicros(String text) { - final trimmed = text.trim(); - if (trimmed.isEmpty) return null; - final match = RegExp(r'^(\d*)(?:\.(\d{0,6}))?$').firstMatch(trimmed); - if (match == null) return null; - final whole = match.group(1) ?? ''; - final frac = match.group(2) ?? ''; - if (whole.isEmpty && frac.isEmpty) return null; - final wholeValue = whole.isEmpty ? 0 : int.parse(whole); - final fracValue = frac.isEmpty ? 0 : int.parse(frac.padRight(6, '0')); - return wholeValue * weightScale + fracValue; - } - - SplitSpec? _buildSplit(Currency currency, int totalMinor) { - final ids = _participants.toList(); - if (ids.isEmpty) return null; - - switch (_splitKind) { - case SplitKind.equal: - return EqualSplit(ids); - - case SplitKind.exact: - final amounts = {}; - for (final id in ids) { - final parsed = currency.parseToMinor(_exact[id]?.text ?? ''); - if (parsed == null) return null; - amounts[id] = parsed; - } - return ExactSplit(amounts); - - case SplitKind.shares: - return SharesSplit({for (final id in ids) id: _shares[id] ?? 1}); - - case SplitKind.percent: - final percents = {}; - for (final id in ids) { - final parsed = _textToMicros(_percent[id]?.text ?? ''); - if (parsed == null) return null; - percents[id] = parsed; - } - return PercentSplit(percents); - - // A split rule from a newer server: saving would have to guess it. - case SplitKind.unknownDefaultOpenApi: - return null; - } - } - - Map? _buildPayers(Currency currency, int totalMinor) { - if (!_multiplePayers) { - final only = _payers.firstOrNull; - return only == null ? null : {only: totalMinor}; - } - final amounts = {}; - for (final id in _payers) { - final parsed = currency.parseToMinor(_payerAmounts[id]?.text ?? ''); - if (parsed == null || parsed <= 0) return null; - amounts[id] = parsed; - } - return amounts.isEmpty ? null : amounts; - } - - Future _save(GroupLedger ledger, Currency currency, FxQuote? fx) async { - setState(() => _error = null); - - final totalMinor = currency.parseToMinor(_amount.text); - if (totalMinor == null || totalMinor <= 0) { - setState( - () => _error = currency.exponent == 0 - ? 'Enter a whole amount.' - : 'Enter an amount with at most ${currency.exponent} decimal ' - 'places.', - ); - return; - } - - final split = _buildSplit(currency, totalMinor); - final payers = _buildPayers(currency, totalMinor); - if (split == null) { - setState(() => _error = 'Check the split — some amounts are missing.'); - return; - } - if (payers == null) { - setState(() => _error = 'Check who paid — some amounts are missing.'); - return; - } - - final moment = _moment(); - final draft = EntryDraft( - groupId: widget.groupId, - currency: currency.code, - amountMinor: totalMinor, - description: _description.text.trim(), - categoryId: _categoryId, - split: split, - payerAmounts: payers, - entryDate: calendarDay(_date), - occurredAt: moment?.at, - timeZone: moment?.zone, - // A fact about the transaction, captured once. - fxRate: fx?.rate, - fxSource: fx == null ? null : '${fx.source}@${calendarDate(fx.date)}', - ); + Widget build(BuildContext context, WidgetRef ref) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + final form = ref.watch(entryFormProvider(groupId, entryId: entryId)).value; - setState(() => _saving = true); - try { - final repository = ref.read(entryRepositoryProvider); - if (widget.isEditing) { - // Who is editing, which need not be who created it — that difference is - // most of what makes a feed line worth reading. - await repository.update( - widget.entryId!, - draft, - actorId: ledger.me?.id, - expected: _editingSnapshot, - ); - } else { - await repository.create( - draft, - createdBy: ledger.me?.id ?? payers.keys.first, - ); - } - if (!mounted) return; - goBack(context, '/g/${widget.groupId}'); - } on SplitException catch (e) { - // The domain refused it, so nothing was written. Its messages are written - // for people, so they are shown as-is. - if (mounted) setState(() => _error = e.message); - } catch (error) { - if (mounted) setState(() => _error = '$error'); - } finally { - if (mounted) setState(() => _saving = false); + if (ledger == null || form == null) { + return Scaffold(appBar: AppBar(leading: const BackButton())); } - } - - /// The moment to save. A time with no zone yet (a new expense) is this - /// device's. Saving never waits to learn which that is: with no zone known - /// yet, only the day is kept. - ({DateTime at, String zone})? _moment() { - final at = _occurredAt; - final zone = _zone ?? ref.read(deviceZoneProvider).value; - return at == null || zone == null ? null : (at: at, zone: zone); - } - - Future _pickDate() async { - final picked = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked == null || DateUtils.isSameDay(picked, _date)) return; - setState(() { - _date = picked; - _occurredAt = null; - _zone = null; - }); - } - - /// A time picked on this device's clock happened in this device's zone. - Future _pickTime(String device) async { - final picked = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime( - _occurredAt?.toLocal() ?? DateTime.now(), + return _Editor( + ledger: ledger, + form: form, + controller: ref.read( + entryFormProvider(groupId, entryId: entryId).notifier, ), ); - if (picked == null || !mounted) return; - setState(() { - _occurredAt = DateTime( - _date.year, - _date.month, - _date.day, - picked.hour, - picked.minute, - ).toUtc(); - _zone = device; - }); } +} - /// Dates already asked for, so a rebuild does not re-ask. - final _requestedRates = {}; +class _Editor extends ConsumerWidget { + const _Editor({ + required this.ledger, + required this.form, + required this.controller, + }); + + final GroupLedger ledger; + final EntryFormState form; + final EntryForm controller; - void _requestRate(DateTime asOf, String currency) { - final day = calendarDate(asOf); - if (!_requestedRates.add('$day|$currency')) return; + String get _groupPath => '/g/${ledger.group.id}'; - // Not awaited: this must not delay a frame or a save. - ref - .read(syncEngineProvider) - ?.shared - .requestBackfill(asOf, currency) - .ignore(); + Future _save(BuildContext context) async { + if (await controller.save() && context.mounted) { + goBack(context, _groupPath); + } } - Future _delete() async { + Future _delete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -425,72 +94,61 @@ class _EntryEditorScreenState extends ConsumerState { ), ); if (!(confirmed ?? false)) return; - - try { - await ref - .read(entryRepositoryProvider) - .delete( - widget.entryId!, - actorId: ref.read(groupLedgerProvider(widget.groupId))?.me?.id, - expected: _editingSnapshot, - ); - if (mounted) goBack(context, '/g/${widget.groupId}'); - } catch (error) { - if (mounted) setState(() => _error = '$error'); + if (await controller.delete() && context.mounted) { + goBack(context, _groupPath); } } + Future _pickDay(BuildContext context) async { + final day = form.day; + final picked = await showDatePicker( + context: context, + initialDate: DateTime(day.year, day.month, day.day), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) controller.chooseDay(picked); + } + + /// A time picked on this device's clock happened in this device's zone. + Future _pickTime(BuildContext context, String device) async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + form.occurredAt?.toLocal() ?? DateTime.now(), + ), + ); + if (picked == null) return; + controller.chooseTime( + hour: picked.hour, + minute: picked.minute, + zone: device, + ); + } + @override - Widget build(BuildContext context) { - final ledger = ref.watch(groupLedgerProvider(widget.groupId)); + Widget build(BuildContext context, WidgetRef ref) { final device = ref.watch(deviceZoneProvider).value; final currencies = ref.watch(currenciesProvider).value ?? const {}; - - if (ledger == null) { - return Scaffold(appBar: AppBar(leading: const BackButton())); - } - - final currency = currencies[_currencyCode]; - final totalMinor = currency?.parseToMinor(_amount.text); - - // The rate as it stood on the ENTRY's date, not today's. - final target = ledger.group.defaultCurrency; - FxQuote? fx; - if (currency != null && currency.code != target) { - final day = DateTime.utc(_date.year, _date.month, _date.day); - final quote = fxQuoteProvider(currency.code, target, day); - fx = ref.watch(quote).value; - - // Nothing local can price this date, so ask the server: the rate lands on - // a later sync and the entry saves without a snapshot in the meantime, - // which the schema allows. - final code = currency.code; - ref.listen(quote, (_, next) { - if (next.isLoading || next.value != null) return; - _requestRate(day, code); - }); - } - + final currency = currencies[form.currencyCode]; final textTheme = Theme.of(context).textTheme; - final ready = !_saving && currency != null; + final ready = !form.saving && currency != null; return Scaffold( appBar: AppBar( - leading: CloseButton( - onPressed: () => goBack(context, '/g/${widget.groupId}'), - ), - title: Text(widget.isEditing ? 'Edit expense' : 'Add expense'), + leading: CloseButton(onPressed: () => goBack(context, _groupPath)), + title: Text(form.isEditing ? 'Edit expense' : 'Add expense'), actions: [ - if (widget.isEditing) + if (form.isEditing) IconButton( tooltip: 'Delete', - onPressed: _delete, + onPressed: () => _delete(context), icon: const Icon(Icons.delete_outline), ), Padding( padding: const EdgeInsets.only(right: 8), child: FilledButton( - onPressed: ready ? () => _save(ledger, currency, fx) : null, + onPressed: ready ? () => _save(context) : null, child: const Text('Save'), ), ), @@ -506,17 +164,18 @@ class _EntryEditorScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, children: [ _CurrencyButton( - code: _currencyCode ?? ledger.group.defaultCurrency, - onChanged: (code) => setState(() => _currencyCode = code), + code: form.currencyCode, + onChanged: controller.chooseCurrency, ), const SizedBox(width: 16), Expanded( - child: TextField( - controller: _amount, - autofocus: !widget.isEditing, + child: TextFormField( + initialValue: form.amount, + autofocus: !form.isEditing, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [DecimalInputFormatter.amount(currency)], textInputAction: TextInputAction.next, style: moneyStyle(textTheme.displaySmall!), decoration: InputDecoration( @@ -529,7 +188,7 @@ class _EntryEditorScreenState extends ConsumerState { : '${currency!.symbol} ', border: InputBorder.none, ), - onChanged: (_) => setState(() {}), + onChanged: controller.setAmount, ), ), ], @@ -545,14 +204,14 @@ class _EntryEditorScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - TextField( - controller: _description, + TextFormField( + initialValue: form.description, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'What was it?', hintText: 'Dinner at Toit', ), - onChanged: _guessCategory, + onChanged: controller.setDescription, ), const SizedBox(height: 16), // The details most expenses leave as they are, as chips rather @@ -562,25 +221,21 @@ class _EntryEditorScreenState extends ConsumerState { runSpacing: 8, children: [ _CategoryChip( - value: _categoryId, - onChanged: (id) => setState(() { - _categoryId = id; - _categoryChosen = true; - }), + value: form.categoryId, + onChanged: controller.chooseCategory, ), ActionChip( avatar: const Icon(Icons.event_outlined), - label: Text(_dayName(_date)), + label: Text(_dayName(form.day)), tooltip: 'Change the day', - onPressed: _pickDate, + onPressed: () => _pickDay(context), ), _TimeChip( - shown: _occurredAt?.toLocal(), - onPick: device == null ? null : () => _pickTime(device), - onClear: () => setState(() { - _occurredAt = null; - _zone = null; - }), + shown: form.occurredAt?.toLocal(), + onPick: device == null + ? null + : () => _pickTime(context, device), + onClear: controller.clearTime, ), ], ), @@ -588,42 +243,24 @@ class _EntryEditorScreenState extends ConsumerState { _PayerSection( ledger: ledger, currency: currency, - payers: _payers, - multiple: _multiplePayers, - controllerFor: (id) => _controllerFor(_payerAmounts, id), - onToggleMultiple: (value) => setState(() { - _multiplePayers = value; - if (!value && _payers.length > 1) { - _payers = {_payers.first}; - } - }), - onPayersChanged: (next) => setState(() => _payers = next), - onAmountEdited: () => setState(() {}), + form: form, + controller: controller, ), const SizedBox(height: 32), _SplitSection( ledger: ledger, currency: currency, - totalMinor: totalMinor, - splitKind: _splitKind, - participants: _participants, - shares: _shares, - exactControllerFor: (id) => _controllerFor(_exact, id), - percentControllerFor: (id) => _controllerFor(_percent, id), - onKindChanged: (kind) => setState(() => _splitKind = kind), - onParticipantsChanged: (next) => - setState(() => _participants = next), - onSharesChanged: (next) => setState(() => _shares = next), - onAmountEdited: () => setState(() {}), + form: form, + controller: controller, ), - if (_error != null) ...[ + if (form.error case final error?) ...[ const SizedBox(height: 16), Card.filled( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Text( - _error!, + error, style: textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onErrorContainer, ), @@ -631,9 +268,9 @@ class _EntryEditorScreenState extends ConsumerState { ), ), ], - if (widget.isEditing) ...[ + if (form.editing case final editing?) ...[ const SizedBox(height: 32), - _History(entryId: widget.entryId!, ledger: ledger), + _History(entryId: editing.id, ledger: ledger), ], ], ), @@ -641,14 +278,14 @@ class _EntryEditorScreenState extends ConsumerState { ); } - /// "Today", "Yesterday", or the date. + /// "Today", "Yesterday", or the date, for a day stored at UTC midnight. static String _dayName(DateTime day) { - final today = DateUtils.dateOnly(DateTime.now()); - final daysAgo = today.difference(DateUtils.dateOnly(day)).inDays; + final local = DateTime(day.year, day.month, day.day); + final daysAgo = DateUtils.dateOnly(DateTime.now()).difference(local).inDays; return switch (daysAgo) { 0 => 'Today', 1 => 'Yesterday', - _ => DateFormat.yMMMEd().format(day), + _ => DateFormat.yMMMEd().format(local), }; } } @@ -689,39 +326,14 @@ class _PayerSection extends StatelessWidget { const _PayerSection({ required this.ledger, required this.currency, - required this.payers, - required this.multiple, - required this.controllerFor, - required this.onToggleMultiple, - required this.onPayersChanged, - required this.onAmountEdited, + required this.form, + required this.controller, }); final GroupLedger ledger; final Currency? currency; - - /// Who paid, to render. Read only: a new selection goes back up through - /// [onPayersChanged] rather than being edited in place. - final Set payers; - - final bool multiple; - final TextEditingController Function(String) controllerFor; - final ValueChanged onToggleMultiple; - final ValueChanged> onPayersChanged; - - /// An amount was typed. The controllers belong to the editor, and the field - /// has already written to one, so this only asks for a rebuild. - final VoidCallback onAmountEdited; - - Set _with(String memberId, {required bool selected}) { - final next = {...payers}; - if (selected) { - next.add(memberId); - } else { - next.remove(memberId); - } - return next; - } + final EntryFormState form; + final EntryForm controller; @override Widget build(BuildContext context) { @@ -731,11 +343,11 @@ class _PayerSection extends StatelessWidget { _SectionTitle( 'Paid by', action: TextButton( - onPressed: () => onToggleMultiple(!multiple), - child: Text(multiple ? 'One person' : 'Several people'), + onPressed: () => controller.setSeveralPayers(!form.severalPayers), + child: Text(form.severalPayers ? 'One person' : 'Several people'), ), ), - if (!multiple) + if (!form.severalPayers) Wrap( spacing: 8, runSpacing: 8, @@ -754,8 +366,8 @@ class _PayerSection extends StatelessWidget { ledger.nameOfMember(member) + (member.id == ledger.me?.id ? ' (you)' : ''), ), - selected: payers.contains(member.id), - onSelected: (_) => onPayersChanged({member.id}), + selected: form.payers.contains(member.id), + onSelected: (_) => controller.choosePayer(member.id), ), ], ) @@ -769,26 +381,32 @@ class _PayerSection extends StatelessWidget { child: CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, - value: payers.contains(member.id), + value: form.payers.contains(member.id), title: Text(ledger.nameOfMember(member)), - onChanged: (checked) => onPayersChanged( - _with(member.id, selected: checked ?? false), + onChanged: (checked) => controller.setPaying( + member.id, + paying: checked ?? false, ), ), ), SizedBox( width: 110, - child: TextField( - controller: controllerFor(member.id), - enabled: payers.contains(member.id), + child: TextFormField( + key: ValueKey(('paid', member.id)), + initialValue: form.paidAmounts[member.id], + enabled: form.payers.contains(member.id), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [ + DecimalInputFormatter.amount(currency), + ], decoration: InputDecoration( isDense: true, prefixText: currency?.symbol, ), - onChanged: (_) => onAmountEdited(), + onChanged: (text) => + controller.setPaidAmount(member.id, text), ), ), ], @@ -812,71 +430,19 @@ class _SplitSection extends StatelessWidget { const _SplitSection({ required this.ledger, required this.currency, - required this.totalMinor, - required this.splitKind, - required this.participants, - required this.shares, - required this.exactControllerFor, - required this.percentControllerFor, - required this.onKindChanged, - required this.onParticipantsChanged, - required this.onSharesChanged, - required this.onAmountEdited, + required this.form, + required this.controller, }); final GroupLedger ledger; final Currency? currency; - final int? totalMinor; - final SplitKind splitKind; - - /// Who is in the split and their relative weights, to render. Both read only: - /// a new value goes back up through the callbacks below. - final Set participants; - final Map shares; - - final TextEditingController Function(String) exactControllerFor; - final TextEditingController Function(String) percentControllerFor; - final ValueChanged onKindChanged; - final ValueChanged> onParticipantsChanged; - final ValueChanged> onSharesChanged; - - /// An amount or a percentage was typed. The controllers belong to the editor, - /// so this only asks for a rebuild of the preview. - final VoidCallback onAmountEdited; - - /// The weight for [memberId], nudged by [by] and never below zero. - Map _nudge(String memberId, int by) { - final next = {...shares}; - next[memberId] = ((next[memberId] ?? 1) + by).clamp(0, 1 << 30); - return next; - } - - /// A live preview of what each person ends up owing. - Map? _preview() { - if (currency == null || totalMinor == null || totalMinor! <= 0) return null; - if (participants.isEmpty) return null; - - try { - final spec = switch (splitKind) { - SplitKind.equal => EqualSplit(participants.toList()), - SplitKind.shares => SharesSplit({ - for (final id in participants) id: shares[id] ?? 1, - }), - _ => null, - }; - if (spec == null) return null; - return { - for (final share in spec.resolve(totalMinor!)) - share.memberId: share.amountMinor, - }; - } on SplitException { - return null; - } - } + final EntryFormState form; + final EntryForm controller; @override Widget build(BuildContext context) { - final preview = _preview(); + final currency = this.currency; + final preview = currency == null ? null : form.preview(currency); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -889,8 +455,8 @@ class _SplitSection extends StatelessWidget { ButtonSegment(value: SplitKind.shares, label: Text('Shares')), ButtonSegment(value: SplitKind.percent, label: Text('%')), ], - selected: {splitKind}, - onSelectionChanged: (set) => onKindChanged(set.first), + selected: {form.splitKind}, + onSelectionChanged: (set) => controller.chooseSplitKind(set.first), showSelectedIcon: false, ), const SizedBox(height: 8), @@ -901,58 +467,62 @@ class _SplitSection extends StatelessWidget { child: CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, - value: participants.contains(member.id), + value: form.participants.contains(member.id), title: Text( ledger.nameOfMember(member) + (member.id == ledger.me?.id ? ' (you)' : ''), ), - subtitle: preview != null && participants.contains(member.id) - ? Text( - formatMoney(currency, preview[member.id] ?? 0), - style: moneyStyle( - Theme.of(context).textTheme.bodySmall!, - ), - ) - : null, - onChanged: (checked) { - final next = {...participants}; - if (checked ?? false) { - next.add(member.id); - } else { - next.remove(member.id); - } - onParticipantsChanged(next); + subtitle: switch (preview?[member.id]) { + final owed? => Text( + formatMoney(currency, owed), + style: moneyStyle(Theme.of(context).textTheme.bodySmall!), + ), + null => null, }, + onChanged: (checked) => controller.setInSplit( + member.id, + included: checked ?? false, + ), ), ), - if (participants.contains(member.id)) - switch (splitKind) { + if (form.participants.contains(member.id)) + // Keyed by kind as well as person: an amount and a percentage + // sit in the same place, and must not share a field's text. + switch (form.splitKind) { SplitKind.exact => SizedBox( width: 110, - child: TextField( - controller: exactControllerFor(member.id), + child: TextFormField( + key: ValueKey(('exact', member.id)), + initialValue: form.exactAmounts[member.id], keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [DecimalInputFormatter.amount(currency)], decoration: InputDecoration( isDense: true, prefixText: currency?.symbol, ), - onChanged: (_) => onAmountEdited(), + onChanged: (text) => + controller.setExactAmount(member.id, text), ), ), SplitKind.percent => SizedBox( width: 90, - child: TextField( - controller: percentControllerFor(member.id), + child: TextFormField( + key: ValueKey(('percent', member.id)), + initialValue: form.percents[member.id], keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [ + DecimalInputFormatter(percentInputPattern), + ], decoration: const InputDecoration( isDense: true, suffixText: '%', ), - onChanged: (_) => onAmountEdited(), + onChanged: (text) => + controller.setPercent(member.id, text), ), ), SplitKind.shares => Row( @@ -960,12 +530,12 @@ class _SplitSection extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.remove_circle_outline), - onPressed: () => onSharesChanged(_nudge(member.id, -1)), + onPressed: () => controller.nudgeShares(member.id, -1), ), - Text('${shares[member.id] ?? 1}'), + Text('${form.shareCountOf(member.id)}'), IconButton( icon: const Icon(Icons.add_circle_outline), - onPressed: () => onSharesChanged(_nudge(member.id, 1)), + onPressed: () => controller.nudgeShares(member.id, 1), ), ], ), diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart index 305a40d..638801e 100644 --- a/lib/presentation/screens/group_list_screen.dart +++ b/lib/presentation/screens/group_list_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../application/ledger_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; +import '../../data/repositories/drift_group_repository.dart'; import '../../data/web/boot_hint.dart'; import '../widgets/avatar_view.dart'; import '../widgets/brand_mark.dart'; @@ -27,25 +28,26 @@ class GroupListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Archived groups are pulled in here rather than queried separately, so the - // list and the "archived" row at the bottom of it are two readings of one - // stream and cannot disagree about which group is where. - final source = groupsProvider(includeArchived: true); + // Archived and left groups are pulled in here rather than queried + // separately, so the list and the "archived" row at the bottom of it are + // two readings of one stream and cannot disagree about which group is + // where. + final source = groupListingsProvider; // Leaves a note for the next cold start, so the web loader knows whether to // draw group cards or just the chrome. ref.listen(source, (_, next) { final loaded = next.value; if (loaded != null) { - recordHasGroups(loaded.any((group) => !group.isArchived)); + recordHasGroups(loaded.any((listing) => listing.isCurrent)); } }); final groupsAsync = ref.watch(source); - final all = groupsAsync.value ?? const []; + final all = groupsAsync.value ?? const []; final groups = [ - for (final group in all) - if (!group.isArchived) group, + for (final listing in all) + if (listing.isCurrent) listing.group, ]; final archived = all.length - groups.length; diff --git a/lib/presentation/screens/group_settings_screen.dart b/lib/presentation/screens/group_settings_screen.dart index b140920..61f3019 100644 --- a/lib/presentation/screens/group_settings_screen.dart +++ b/lib/presentation/screens/group_settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -85,25 +87,23 @@ class _GroupSettingsScreenState extends ConsumerState { ]; } - /// Leaving is always available, settled or not. There are no roles, so there - /// is nothing to hand over first. + /// Leaving needs you settled up first, as removing anybody else does: a + /// balance left behind would sit with somebody who can no longer see it. + /// There are no roles, so there is nothing else to hand over. Future _leave(GroupLedger ledger, Member me) async { final debts = _outstanding(ledger, me); + if (debts.isNotEmpty) return _settleFirst(debts); + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Leave this group?'), - content: Text( - [ - 'You stop getting updates, and you will not appear in new ' - 'expenses.', - if (debts.isNotEmpty) - 'You are not settled up: ${debts.join(', ')}. Leaving does not ' - 'clear that — it stays in the group\'s history for everyone ' - 'still in it.', - 'Everything you have already paid for or owed stays exactly as it ' - 'is. This device keeps a copy, archived and read-only.', - ].join('\n\n'), + content: const Text( + 'You stop getting updates, and you will not appear in new ' + 'expenses.\n\n' + 'Everything you have already paid for or owed stays exactly as it ' + 'is. This device keeps a read-only copy with your archived groups, ' + 'and an invite link brings you back to the same place.', ), actions: [ TextButton( @@ -124,9 +124,7 @@ class _GroupSettingsScreenState extends ConsumerState { setState(() => _busy = true); try { - await ref - .read(groupRepositoryProvider) - .leaveGroup(groupId: widget.groupId, memberId: me.id); + await ref.read(groupRepositoryProvider).leaveGroup(memberId: me.id); // Best effort: the leave is already recorded locally and queued, so an // unreachable server only delays it. await ref.read(syncControllerProvider.notifier).syncGroup(widget.groupId); @@ -136,6 +134,33 @@ class _GroupSettingsScreenState extends ConsumerState { if (mounted) goBack(context, '/'); } + /// Says why leaving has to wait, and offers the way to get there. + Future _settleFirst(List debts) async { + final settle = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Settle up first'), + content: Text( + 'You are not settled up in this group: ${debts.join(', ')}. Once ' + 'nothing is owed either way, you can leave.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Settle up'), + ), + ], + ), + ); + if ((settle ?? false) && mounted) { + unawaited(context.push('/g/${widget.groupId}/settle')); + } + } + @override Widget build(BuildContext context) { final ledger = ref.watch(groupLedgerProvider(widget.groupId)); @@ -199,8 +224,10 @@ class _GroupSettingsScreenState extends ConsumerState { ), title: const Text('Suggest the fewest payments'), subtitle: const Text( - 'Nets debts down to as few transfers as settle the group. The ' - 'individual debts underneath are unchanged either way.', + 'On: as few payments as settle the group, which can mean ' + 'paying someone you never shared an expense with. Off: ' + 'each person settles with each person they owe. Either ' + 'way, everyone ends up square.', ), ), diff --git a/lib/presentation/screens/join_screen.dart b/lib/presentation/screens/join_screen.dart index 7b0dff7..adf8060 100644 --- a/lib/presentation/screens/join_screen.dart +++ b/lib/presentation/screens/join_screen.dart @@ -104,8 +104,26 @@ class _JoinScreenState extends ConsumerState { } } - /// Spends the token as whoever holds the session now. - Future _join() async { + /// Spends the token as whoever holds the session now, in the place chosen + /// on this screen. A named invite names the place; there is nothing to + /// choose. + Future _join() => _spend((invites) { + if (!(_preview?.isOpenLink ?? false)) return invites.join(widget.token); + final name = _name.text.trim(); + return invites.join( + widget.token, + memberId: _chosenMemberId, + displayName: name.isEmpty ? null : name, + ); + }); + + /// Comes back to the place this account left. The server gives that place + /// back whatever the link offers, so there is nothing to ask for. + Future _rejoin() => _spend((invites) => invites.join(widget.token)); + + Future _spend( + Future Function(Invites invites) request, + ) async { final invites = ref.read(invitesProvider); if (invites == null) return; @@ -114,14 +132,7 @@ class _JoinScreenState extends ConsumerState { _error = null; }); try { - // A named invite names the place; there is nothing to choose. - final joined = (_preview?.isOpenLink ?? false) - ? await invites.join( - widget.token, - memberId: _chosenMemberId, - displayName: _name.text.trim().isEmpty ? null : _name.text.trim(), - ) - : await invites.join(widget.token); + final joined = await request(invites); // Pull the group down before showing it, so it is populated on arrival // rather than filling in underneath them. await ref.read(syncControllerProvider.notifier).syncGroup(joined.groupId); @@ -199,6 +210,28 @@ class _JoinScreenState extends ConsumerState { }; } + /// Somebody who left, coming back. Whatever the link offered, the server + /// gives them their own place back, so that is the only thing to offer. + Widget _rejoinView(ThemeData theme, api.LinkPreview preview) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + BrandHeader( + title: 'Rejoin ${preview.groupName}', + subtitle: + 'You were in this group before. Rejoining brings back your place ' + 'as it was, with everything you paid and owed, and you get ' + 'updates again.', + ), + const SizedBox(height: 32), + ..._errorIfAny(theme), + FilledButton( + onPressed: _joining ? null : _rejoin, + child: Text(_joining ? 'Rejoining…' : 'Rejoin'), + ), + ], + ); + /// A link naming one place, for one person. Widget _invitation( ThemeData theme, @@ -219,6 +252,7 @@ class _JoinScreenState extends ConsumerState { 'new one.', ); } + if (preview.hasLeft) return _rejoinView(theme, preview); if (preview.isMember) { return _Dead( message: @@ -269,6 +303,7 @@ class _JoinScreenState extends ConsumerState { message: 'This link has expired. Ask ${preview.inviter} for a new one.', ); } + if (preview.hasLeft) return _rejoinView(theme, preview); if (preview.isMember) { return _Dead( message: @@ -304,7 +339,9 @@ class _JoinScreenState extends ConsumerState { if (account == null) IdentityChoices( onSignedIn: () async { - await _loadPlaces(); + // Asked again as somebody: whether they were in this group + // before is something only a session can say. + await _peek(); // Not joined yet, deliberately. if (mounted) setState(() {}); }, diff --git a/lib/presentation/screens/members_screen.dart b/lib/presentation/screens/members_screen.dart index d081ecf..6d7c89d 100644 --- a/lib/presentation/screens/members_screen.dart +++ b/lib/presentation/screens/members_screen.dart @@ -224,21 +224,15 @@ class MembersScreen extends ConsumerWidget { Member member, GroupLedger ledger, ) async { - final owes = ledger.balances.any( - (b) => b.memberId == member.id && b.balanceMinor != 0, - ); - + // Only offered once they are settled: the server refuses anything else, + // since a balance would be left with somebody who can no longer see it. final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('Remove ${ledger.nameOfMember(member)}?'), - content: Text( - owes - ? 'They still have an unsettled balance. Removing them keeps ' - 'every expense they were part of, and their balance stays ' - 'visible so it can be settled.' - : 'Their past expenses stay exactly as they are. They just stop ' - 'being included in new ones.', + content: const Text( + 'Their past expenses stay exactly as they are. They just stop ' + 'being included in new ones.', ), actions: [ TextButton( diff --git a/lib/presentation/screens/settle_up_screen.dart b/lib/presentation/screens/settle_up_screen.dart index ca9acda..4957315 100644 --- a/lib/presentation/screens/settle_up_screen.dart +++ b/lib/presentation/screens/settle_up_screen.dart @@ -7,21 +7,24 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../application/ledger_providers.dart'; +import '../../application/settlement_form.dart'; import '../../application/local_providers.dart'; import '../../application/preferences_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; -import '../../domain/calendar_date.dart'; -import '../../domain/entry_draft.dart'; import '../../domain/money_format.dart'; import '../../domain/settle/upi.dart'; +import '../amount_input.dart'; import '../navigation.dart'; import '../widgets/currency_picker.dart'; import '../widgets/page_body.dart'; /// Records a payment between two members, optionally handing off to a UPI app /// first. -class SettleUpScreen extends ConsumerStatefulWidget { +/// +/// Holds no state of its own: what has been chosen and typed lives in +/// [SettlementForm]. +class SettleUpScreen extends ConsumerWidget { const SettleUpScreen({ super.key, required this.groupId, @@ -38,84 +41,69 @@ class SettleUpScreen extends ConsumerStatefulWidget { final String? currency; @override - ConsumerState createState() => _SettleUpScreenState(); -} - -class _SettleUpScreenState extends ConsumerState { - final _amount = TextEditingController(); - final _payeeVpa = TextEditingController(); - - String? _from; - String? _to; - String? _currencyCode; - String? _amountError; - bool _saving = false; - bool _prefilled = false; - - @override - void dispose() { - _amount.dispose(); - _payeeVpa.dispose(); - super.dispose(); - } - - /// Applies route parameters and sensible defaults once the ledger arrives. - void _prefillOnce(GroupLedger ledger, Map currencies) { - if (_prefilled) return; - _prefilled = true; - - _from = widget.fromMemberId ?? ledger.me?.id; - _to = widget.toMemberId; - _currencyCode = widget.currency ?? ledger.group.defaultCurrency; - - if (widget.amountMinor != null) { - final currency = currencies[_currencyCode]; - if (currency != null) { - _amount.text = currency.formatPlain(widget.amountMinor!); - } - } - } + Widget build(BuildContext context, WidgetRef ref) { + final provider = settlementFormProvider( + groupId, + fromId: fromMemberId, + toId: toMemberId, + amountMinor: amountMinor, + currency: currency, + ); - @override - Widget build(BuildContext context) { // Started on the way in, so this device's zone is known by the time a // settlement is recorded. ref.watch(deviceZoneProvider); // Revalidate on the way in, and this is the one screen where it is not // merely tidiness. - ref.watch(groupSyncProvider(widget.groupId)); - - final ledger = ref.watch(groupLedgerProvider(widget.groupId)); - final currencies = ref.watch(currenciesProvider).value ?? const {}; + ref.watch(groupSyncProvider(groupId)); - if (ledger == null) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + final form = ref.watch(provider).value; + if (ledger == null || form == null) { return Scaffold(appBar: AppBar(leading: const BackButton())); } - _prefillOnce(ledger, currencies); + return _SettleUp( + ledger: ledger, + form: form, + controller: ref.read(provider.notifier), + ); + } +} - final currency = currencies[_currencyCode]; - final payee = _to == null ? null : ledger.memberById(_to!); +class _SettleUp extends ConsumerWidget { + const _SettleUp({ + required this.ledger, + required this.form, + required this.controller, + }); - // Fill the payee's handle the first time we learn it, without stamping over - // anything the user has typed. - final knownVpa = payee == null ? null : ledger.upiOf(payee); - if (_payeeVpa.text.isEmpty && knownVpa != null) { - _payeeVpa.text = knownVpa; - } + final GroupLedger ledger; + final SettlementFormState form; + final SettlementForm controller; - final amountMinor = currency?.parseToMinor(_amount.text); - final upiUri = buildUpiPaymentUri( - payeeVpa: _payeeVpa.text, - payeeName: payee == null ? '' : ledger.nameOfMember(payee), - amountMinor: amountMinor ?? 0, - currency: currency ?? const Currency(code: '', exponent: 2, name: ''), - ); + Future _record(BuildContext context, WidgetRef ref) async { + if (!await controller.record() || !context.mounted) return; + goBack(context, '/g/${ledger.group.id}'); + + // The one place the app asks for a review: a debt has just been cleared, + // which is the app finishing the thing it exists to do. + final review = ref.read(reviewPromptProvider); + unawaited(review.isDue().then((due) => due ? review.ask() : null)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currencies = ref.watch(currenciesProvider).value ?? const {}; + final currency = currencies[form.currencyCode]; + final payee = form.toId == null ? null : ledger.memberById(form.toId!); + final payeeName = payee == null ? '' : ledger.nameOfMember(payee); + final (from, to) = (form.fromId, form.toId); return Scaffold( appBar: AppBar( leading: BackButton( - onPressed: () => goBack(context, '/g/${widget.groupId}'), + onPressed: () => goBack(context, '/g/${ledger.group.id}'), ), title: const Text('Settle up'), ), @@ -125,66 +113,76 @@ class _SettleUpScreenState extends ConsumerState { children: [ _MemberDropdown( label: 'Who is paying', - members: ledger.members, + members: ledger.settleable, nameOf: ledger.nameOfMember, meId: ledger.me?.id, - value: _from, - onChanged: (id) => setState(() => _from = id), + value: from, + onChanged: controller.choosePayer, ), const SizedBox(height: 16), _MemberDropdown( label: 'Who is being paid', - members: ledger.members, + members: ledger.settleable, nameOf: ledger.nameOfMember, meId: ledger.me?.id, - value: _to, - excludeId: _from, - onChanged: (id) => setState(() => _to = id), + value: to, + excludeId: from, + onChanged: controller.choosePayee, ), const SizedBox(height: 16), CurrencyPicker( - value: _currencyCode ?? ledger.group.defaultCurrency, - onChanged: (code) => setState(() => _currencyCode = code), + value: form.currencyCode, + onChanged: controller.chooseCurrency, ), const SizedBox(height: 16), - TextField( - controller: _amount, + TextFormField( + initialValue: form.amount, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [DecimalInputFormatter.amount(currency)], decoration: InputDecoration( labelText: 'Amount', prefixText: currency?.symbol == null ? null : '${currency!.symbol} ', - errorText: _amountError, + errorText: form.error, ), - onChanged: (_) => setState(() => _amountError = null), + onChanged: controller.setAmount, ), - if (_from != null && _to != null && currency != null) ...[ + if (from != null && to != null && currency != null) ...[ const SizedBox(height: 8), _OutstandingHint( ledger: ledger, - from: _from!, - to: _to!, + from: from, + to: to, currency: currency, ), ], const SizedBox(height: 24), - if (currency?.code == 'INR') ...[ + if (currency != null && currency.code == 'INR') ...[ _UpiSection( - payeeName: payee == null ? '' : ledger.nameOfMember(payee), - vpaController: _payeeVpa, - uri: upiUri, - onChanged: () => setState(() {}), - onPaid: () => _record(ledger, currency!), + payeeName: payeeName, + // A new payee, or their handle arriving with their profile, + // is a new field: what it shows is never left over from + // somebody else. + fieldKey: ValueKey((to, form.knownHandleIn(ledger))), + handle: form.handleIn(ledger), + onHandleChanged: to == null ? null : controller.setHandle, + uri: buildUpiPaymentUri( + payeeVpa: form.handleIn(ledger), + payeeName: payeeName, + amountMinor: currency.parseToMinor(form.amount) ?? 0, + currency: currency, + ), + onPaid: () => _record(context, ref), ), const SizedBox(height: 24), ], FilledButton.icon( - onPressed: _saving || currency == null + onPressed: form.saving || currency == null ? null - : () => _record(ledger, currency), + : () => _record(context, ref), icon: const Icon(Icons.check), label: const Text('Record this payment'), ), @@ -199,66 +197,6 @@ class _SettleUpScreenState extends ConsumerState { ), ); } - - Future _record(GroupLedger ledger, Currency currency) async { - final from = _from; - final to = _to; - final amountMinor = currency.parseToMinor(_amount.text); - - if (from == null || to == null) { - setState(() => _amountError = 'Choose who is paying whom.'); - return; - } - if (amountMinor == null) { - setState( - () => _amountError = currency.exponent == 0 - ? 'Enter a whole amount.' - : 'Enter an amount with at most ${currency.exponent} decimal ' - 'places.', - ); - return; - } - if (amountMinor <= 0) { - setState(() => _amountError = 'Enter an amount greater than zero.'); - return; - } - - setState(() => _saving = true); - try { - // Paid now, here. Saving never waits to learn where "here" is: with no - // zone known yet, only the day is kept. - final zone = ref.read(deviceZoneProvider).value; - final now = DateTime.now(); - await ref - .read(entryRepositoryProvider) - .create( - EntryDraft.settlement( - groupId: widget.groupId, - currency: currency.code, - amountMinor: amountMinor, - fromMemberId: from, - toMemberId: to, - entryDate: calendarDay(now), - occurredAt: zone == null ? null : now.toUtc(), - timeZone: zone, - ), - createdBy: ledger.me?.id ?? from, - ); - - if (!mounted) return; - goBack(context, '/g/${widget.groupId}'); - - // The one place the app asks for a review: a debt has just been cleared, - // which is the app finishing the thing it exists to do. - final review = ref.read(reviewPromptProvider); - unawaited(review.isDue().then((due) => due ? review.ask() : null)); - } catch (error) { - if (!mounted) return; - setState(() => _amountError = '$error'); - } finally { - if (mounted) setState(() => _saving = false); - } - } } /// Shows what is actually outstanding between the two chosen members, so a @@ -296,16 +234,23 @@ class _OutstandingHint extends StatelessWidget { class _UpiSection extends StatelessWidget { const _UpiSection({ required this.payeeName, - required this.vpaController, + required this.fieldKey, + required this.handle, + required this.onHandleChanged, required this.uri, - required this.onChanged, required this.onPaid, }); final String payeeName; - final TextEditingController vpaController; + + /// Identifies what the field was filled from, so a change there gives a + /// fresh field rather than one still showing the old handle. + final Key fieldKey; + final String? handle; + + /// Null until somebody is chosen to be paid. + final ValueChanged? onHandleChanged; final Uri? uri; - final VoidCallback onChanged; final Future Function() onPaid; @override @@ -327,15 +272,17 @@ class _UpiSection extends StatelessWidget { ], ), const SizedBox(height: 12), - TextField( - controller: vpaController, + TextFormField( + key: fieldKey, + initialValue: handle, + enabled: onHandleChanged != null, decoration: InputDecoration( labelText: payeeName.isEmpty ? 'Their UPI ID' : "$payeeName's UPI ID", hintText: 'name@bank', ), - onChanged: (_) => onChanged(), + onChanged: onHandleChanged, ), const SizedBox(height: 16), if (uri == null) @@ -462,6 +409,11 @@ class _MemberDropdown extends StatelessWidget { ]; return DropdownMenu( + // DropdownMenu reads its selection once, and on later changes only + // follows one it can find among its entries: cleared to nobody, it + // would go on showing the last name. Keyed by the value, a different + // value is a fresh menu that shows exactly that. + key: ValueKey(value), initialSelection: options.any((m) => m.id == value) ? value : null, label: Text(label), // A group's member list is short and every name is already visible, so diff --git a/lib/presentation/widgets/balances_panel.dart b/lib/presentation/widgets/balances_panel.dart index f21ad65..4bc31de 100644 --- a/lib/presentation/widgets/balances_panel.dart +++ b/lib/presentation/widgets/balances_panel.dart @@ -8,7 +8,7 @@ import '../../application/ledger_providers.dart'; import '../../data/local/database.dart'; import '../../domain/balance/member_balance.dart'; import '../../domain/balance/simplify.dart'; -import '../../domain/models/entry.dart'; +import '../../domain/balance/transfer_basis.dart'; import '../../domain/money_format.dart'; import '../theme.dart'; import 'avatar_view.dart'; @@ -379,7 +379,7 @@ class _TransferTile extends StatelessWidget { } } -/// Explains where a simplified payment came from. +/// Explains where a suggested payment came from. class _TransferExplanation extends StatelessWidget { const _TransferExplanation({ required this.ledger, @@ -395,22 +395,14 @@ class _TransferExplanation extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final debtor = transfer.fromMemberId; - final net = ledger.balanceOf(debtor, transfer.currency); - - // Every entry in this currency that moved the debtor's position, with the - // amount it moved it by. - final contributions = <({Entry entry, int delta})>[]; - for (final entry in ledger.entries) { - if (entry.row.currency != transfer.currency) continue; - var delta = 0; - for (final payer in entry.payers) { - if (payer.memberId == debtor) delta += payer.amountMinor; - } - for (final share in entry.shares) { - if (share.memberId == debtor) delta -= share.amountMinor; - } - if (delta != 0) contributions.add((entry: entry, delta: delta)); - } + final creditor = transfer.toMemberId; + final simplified = ledger.group.simplifyDebts; + + final (:contributions, :net) = basisOf( + transfer, + ledger.entries, + simplified: simplified, + ); final name = ledger.nameOf(debtor); final isMe = debtor == ledger.me?.id; @@ -419,15 +411,21 @@ class _TransferExplanation extends StatelessWidget { final rest = transfer.amountMinor == net.abs() ? '' : ', plus the other suggested payments'; - final why = - '${isMe ? 'You owe' : '$name owes'} ' - '${formatMoneyAbs(currency, net)} in total across this group. ' - 'Rather than paying several people separately, that whole amount is ' - 'cleared by paying ${ledger.nameOf(transfer.toMemberId)} ' - '${formatMoney(currency, transfer.amountMinor)}$rest.'; - const caveat = - 'This can name someone you never directly owed. It is the shortest ' - 'set of payments that leaves everybody square.'; + final why = simplified + ? '${isMe ? 'You owe' : '$name owes'} ' + '${formatMoneyAbs(currency, net)} in total across this group. ' + 'Rather than paying several people separately, that whole amount ' + 'is cleared by paying ${ledger.nameOf(creditor)} ' + '${formatMoney(currency, transfer.amountMinor)}$rest.' + : '${isMe ? 'You owe' : '$name owes'} ${ledger.nameOf(creditor)} ' + '${formatMoney(currency, transfer.amountMinor)}, from the ' + 'expenses the two of you shared, each way netted against the ' + 'other.'; + final caveat = simplified + ? 'This can name someone you never directly owed. It is the shortest ' + 'set of payments that leaves everybody square.' + : 'Only the expenses these two shared count here. Turn on "Suggest ' + 'the fewest payments" in group settings to settle in fewer steps.'; return DraggableScrollableSheet( expand: false, diff --git a/lib/presentation/widgets/link_account_prompt.dart b/lib/presentation/widgets/link_account_prompt.dart index 10bd861..7eef538 100644 --- a/lib/presentation/widgets/link_account_prompt.dart +++ b/lib/presentation/widgets/link_account_prompt.dart @@ -22,8 +22,7 @@ class LinkAccountPrompt extends ConsumerWidget { final account = ref.watch(accountProvider).value; if (account == null || !account.isAnonymous) return const SizedBox.shrink(); - final groups = - ref.watch(groupsProvider(includeArchived: true)).value ?? const []; + final groups = ref.watch(groupListingsProvider).value ?? const []; final dismissed = ref.watch(promptDismissedProvider); if (groups.isEmpty || dismissed) return const SizedBox.shrink(); diff --git a/packages/opensplit_api/lib/opensplit_api.dart b/packages/opensplit_api/lib/opensplit_api.dart index 9f7acbe..d1c55c3 100644 --- a/packages/opensplit_api/lib/opensplit_api.dart +++ b/packages/opensplit_api/lib/opensplit_api.dart @@ -50,10 +50,11 @@ export 'package:opensplit_api/src/model/google_identity_request.dart'; export 'package:opensplit_api/src/model/google_redirect.dart'; export 'package:opensplit_api/src/model/google_redirect_request.dart'; export 'package:opensplit_api/src/model/group.dart'; +export 'package:opensplit_api/src/model/group_cursor.dart'; export 'package:opensplit_api/src/model/group_event_payload.dart'; -export 'package:opensplit_api/src/model/group_ids.dart'; export 'package:opensplit_api/src/model/group_input.dart'; export 'package:opensplit_api/src/model/group_link.dart'; +export 'package:opensplit_api/src/model/group_refusal.dart'; export 'package:opensplit_api/src/model/health.dart'; export 'package:opensplit_api/src/model/identity_outcome.dart'; export 'package:opensplit_api/src/model/identity_outcome_kind.dart'; @@ -76,6 +77,8 @@ export 'package:opensplit_api/src/model/platform.dart'; export 'package:opensplit_api/src/model/profile.dart'; export 'package:opensplit_api/src/model/profile_page.dart'; export 'package:opensplit_api/src/model/profile_update.dart'; +export 'package:opensplit_api/src/model/pull.dart'; +export 'package:opensplit_api/src/model/pull_request.dart'; export 'package:opensplit_api/src/model/push_data.dart'; export 'package:opensplit_api/src/model/reauth_start.dart'; export 'package:opensplit_api/src/model/reauth_verify_request.dart'; diff --git a/packages/opensplit_api/lib/src/api/entries_api.dart b/packages/opensplit_api/lib/src/api/entries_api.dart index d08544f..a9c5338 100644 --- a/packages/opensplit_api/lib/src/api/entries_api.dart +++ b/packages/opensplit_api/lib/src/api/entries_api.dart @@ -19,7 +19,7 @@ class EntriesApi { const EntriesApi(this._dio); /// Record, edit, delete or restore an expense, whole - /// A stale `baseSeq` is refused only when the write would move money, so two people fixing a typo never arbitrate. Deleting and restoring always move money. + /// `baseSeq` must be the version stored now (null for a new expense), or the write is refused as `stale_base`: the expense is replaced whole, so a write composed on an older version would undo the edit in between. Sending the stored contents again is answered with the stored row, whatever the base. /// /// Parameters: /// * [groupId] diff --git a/packages/opensplit_api/lib/src/api/sync_api.dart b/packages/opensplit_api/lib/src/api/sync_api.dart index 315a194..02dbc27 100644 --- a/packages/opensplit_api/lib/src/api/sync_api.dart +++ b/packages/opensplit_api/lib/src/api/sync_api.dart @@ -9,23 +9,22 @@ import 'dart:convert'; import 'package:opensplit_api/src/deserialize.dart'; import 'package:dio/dio.dart'; -import 'package:opensplit_api/src/model/change_page.dart'; import 'package:opensplit_api/src/model/error.dart'; -import 'package:opensplit_api/src/model/group_ids.dart'; import 'package:opensplit_api/src/model/profile.dart'; import 'package:opensplit_api/src/model/profile_page.dart'; import 'package:opensplit_api/src/model/profile_update.dart'; +import 'package:opensplit_api/src/model/pull.dart'; +import 'package:opensplit_api/src/model/pull_request.dart'; class SyncApi { final Dio _dio; const SyncApi(this._dio); - /// Everything that changed in one group since a cursor - /// `limit` counts changes, not rows, and a page is cut only between sequence numbers, so a device sees a whole write or none of it. Send `seq` back as `since` next time. + /// Everybody you share a group with, plus yourself + /// Cursored on each profile's `version`, which D1 numbers in commit order. Send `seq` back as `since` next time. /// /// Parameters: - /// * [groupId] /// * [since] /// * [limit] /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation @@ -35,10 +34,9 @@ class SyncApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [ChangePage] as data + /// Returns a [Future] containing a [Response] with a [ProfilePage] as data /// Throws [DioException] if API call or serialization fails - Future> getChanges({ - required String groupId, + Future> getProfiles({ int? since, int? limit, CancelToken? cancelToken, @@ -48,12 +46,7 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/changes'.replaceAll( - '{' - r'groupId' - '}', - groupId.toString(), - ); + final _path = r'/api/profiles'; final _options = Options( method: r'GET', headers: {...?headers}, @@ -86,15 +79,15 @@ class SyncApi { onReceiveProgress: onReceiveProgress, ); - ChangePage? _responseData; + ProfilePage? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( + : deserialize( rawData, - 'ChangePage', + 'ProfilePage', growable: true, ); } catch (error, stackTrace) { @@ -107,7 +100,7 @@ class SyncApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, @@ -119,12 +112,11 @@ class SyncApi { ); } - /// Everybody you share a group with, plus yourself - /// Cursored on time, since profiles have no single writer to number them. Send `cursor` back as `after`. + /// Every group this account is in, and what changed in the ones asked for + /// Each group asked for gets its own page past its own cursor, read in parallel; a group that cannot be read is listed in `refusals` and the rest are still answered. `groupIds` names every group the account is in now, so one with no cursor yet is asked for next time, from 0. A page is cut only between sequence numbers, so a device sees a whole write or none of it; send each page's `seq` back as that group's `since`. /// /// Parameters: - /// * [after] - /// * [limit] + /// * [pullRequest] /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation /// * [headers] - Can be used to add additional headers to the request /// * [extras] - Can be used to add flags to the request @@ -132,11 +124,10 @@ class SyncApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [ProfilePage] as data + /// Returns a [Future] containing a [Response] with a [Pull] as data /// Throws [DioException] if API call or serialization fails - Future> getProfiles({ - String? after, - int? limit, + Future> pull({ + required PullRequest pullRequest, CancelToken? cancelToken, Map? headers, Map? extra, @@ -144,9 +135,9 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/profiles'; + final _path = r'/api/sync'; final _options = Options( - method: r'GET', + method: r'POST', headers: {...?headers}, extra: { 'secure': >[ @@ -160,115 +151,39 @@ class SyncApi { ], ...?extra, }, + contentType: 'application/json', validateStatus: validateStatus, ); - final _queryParameters = { - if (after != null) r'after': after, - if (limit != null) r'limit': limit, - }; - - final _response = await _dio.request( - _path, - options: _options, - queryParameters: _queryParameters, - cancelToken: cancelToken, - onSendProgress: onSendProgress, - onReceiveProgress: onReceiveProgress, - ); - - ProfilePage? _responseData; + dynamic _bodyData; try { - final rawData = _response.data; - _responseData = rawData == null - ? null - : deserialize( - rawData, - 'ProfilePage', - growable: true, - ); + _bodyData = jsonEncode(pullRequest); } catch (error, stackTrace) { throw DioException( - requestOptions: _response.requestOptions, - response: _response, + requestOptions: _options.compose(_dio.options, _path), type: DioExceptionType.unknown, error: error, stackTrace: stackTrace, ); } - return Response( - data: _responseData, - headers: _response.headers, - isRedirect: _response.isRedirect, - requestOptions: _response.requestOptions, - redirects: _response.redirects, - statusCode: _response.statusCode, - statusMessage: _response.statusMessage, - extra: _response.extra, - ); - } - - /// The groups this account is still in - /// - /// - /// Parameters: - /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation - /// * [headers] - Can be used to add additional headers to the request - /// * [extras] - Can be used to add flags to the request - /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response - /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress - /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress - /// - /// Returns a [Future] containing a [Response] with a [GroupIds] as data - /// Throws [DioException] if API call or serialization fails - Future> listGroups({ - CancelToken? cancelToken, - Map? headers, - Map? extra, - ValidateStatus? validateStatus, - ProgressCallback? onSendProgress, - ProgressCallback? onReceiveProgress, - }) async { - final _path = r'/api/groups'; - final _options = Options( - method: r'GET', - headers: {...?headers}, - extra: { - 'secure': >[ - { - 'type': 'apiKey', - 'name': 'cookie', - 'keyName': 'better-auth.session_token', - 'where': '', - }, - {'type': 'http', 'scheme': 'bearer', 'name': 'bearer'}, - ], - ...?extra, - }, - validateStatus: validateStatus, - ); - final _response = await _dio.request( _path, + data: _bodyData, options: _options, cancelToken: cancelToken, onSendProgress: onSendProgress, onReceiveProgress: onReceiveProgress, ); - GroupIds? _responseData; + Pull? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( - rawData, - 'GroupIds', - growable: true, - ); + : deserialize(rawData, 'Pull', growable: true); } catch (error, stackTrace) { throw DioException( requestOptions: _response.requestOptions, @@ -279,7 +194,7 @@ class SyncApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, diff --git a/packages/opensplit_api/lib/src/deserialize.dart b/packages/opensplit_api/lib/src/deserialize.dart index 7905904..537fdd3 100644 --- a/packages/opensplit_api/lib/src/deserialize.dart +++ b/packages/opensplit_api/lib/src/deserialize.dart @@ -22,10 +22,11 @@ import 'package:opensplit_api/src/model/google_identity_request.dart'; import 'package:opensplit_api/src/model/google_redirect.dart'; import 'package:opensplit_api/src/model/google_redirect_request.dart'; import 'package:opensplit_api/src/model/group.dart'; +import 'package:opensplit_api/src/model/group_cursor.dart'; import 'package:opensplit_api/src/model/group_event_payload.dart'; -import 'package:opensplit_api/src/model/group_ids.dart'; import 'package:opensplit_api/src/model/group_input.dart'; import 'package:opensplit_api/src/model/group_link.dart'; +import 'package:opensplit_api/src/model/group_refusal.dart'; import 'package:opensplit_api/src/model/health.dart'; import 'package:opensplit_api/src/model/identity_outcome.dart'; import 'package:opensplit_api/src/model/invite.dart'; @@ -45,6 +46,8 @@ import 'package:opensplit_api/src/model/placeholder_list.dart'; import 'package:opensplit_api/src/model/profile.dart'; import 'package:opensplit_api/src/model/profile_page.dart'; import 'package:opensplit_api/src/model/profile_update.dart'; +import 'package:opensplit_api/src/model/pull.dart'; +import 'package:opensplit_api/src/model/pull_request.dart'; import 'package:opensplit_api/src/model/push_data.dart'; import 'package:opensplit_api/src/model/reauth_start.dart'; import 'package:opensplit_api/src/model/reauth_verify_request.dart'; @@ -141,15 +144,17 @@ ReturnType deserialize( as ReturnType; case 'Group': return Group.fromJson(value as Map) as ReturnType; + case 'GroupCursor': + return GroupCursor.fromJson(value as Map) as ReturnType; case 'GroupEventPayload': return GroupEventPayload.fromJson(value as Map) as ReturnType; - case 'GroupIds': - return GroupIds.fromJson(value as Map) as ReturnType; case 'GroupInput': return GroupInput.fromJson(value as Map) as ReturnType; case 'GroupLink': return GroupLink.fromJson(value as Map) as ReturnType; + case 'GroupRefusal': + return GroupRefusal.fromJson(value as Map) as ReturnType; case 'Health': return Health.fromJson(value as Map) as ReturnType; case 'IdentityOutcome': @@ -197,6 +202,10 @@ ReturnType deserialize( case 'ProfileUpdate': return ProfileUpdate.fromJson(value as Map) as ReturnType; + case 'Pull': + return Pull.fromJson(value as Map) as ReturnType; + case 'PullRequest': + return PullRequest.fromJson(value as Map) as ReturnType; case 'PushData': return PushData.fromJson(value as Map) as ReturnType; case 'ReauthStart': diff --git a/packages/opensplit_api/lib/src/model/group.dart b/packages/opensplit_api/lib/src/model/group.dart index db23274..f2e2903 100644 --- a/packages/opensplit_api/lib/src/model/group.dart +++ b/packages/opensplit_api/lib/src/model/group.dart @@ -48,6 +48,8 @@ class Group { required this.createdAt, + required this.lastActivityAt, + required this.archivedAt, required this.updatedAt, @@ -117,6 +119,9 @@ class Group { @JsonKey(name: r'createdAt', required: true, includeIfNull: false) final DateTime createdAt; + @JsonKey(name: r'lastActivityAt', required: true, includeIfNull: false) + final DateTime lastActivityAt; + @JsonKey(name: r'archivedAt', required: true, includeIfNull: true) final DateTime? archivedAt; @@ -145,6 +150,7 @@ class Group { other.coverPhoto == coverPhoto && other.createdBy == createdBy && other.createdAt == createdAt && + other.lastActivityAt == lastActivityAt && other.archivedAt == archivedAt && other.updatedAt == updatedAt && other.seq == seq; @@ -165,6 +171,7 @@ class Group { (coverPhoto == null ? 0 : coverPhoto.hashCode) + createdBy.hashCode + createdAt.hashCode + + lastActivityAt.hashCode + (archivedAt == null ? 0 : archivedAt.hashCode) + updatedAt.hashCode + seq.hashCode; diff --git a/packages/opensplit_api/lib/src/model/group.g.dart b/packages/opensplit_api/lib/src/model/group.g.dart index 4269ab3..a70ae01 100644 --- a/packages/opensplit_api/lib/src/model/group.g.dart +++ b/packages/opensplit_api/lib/src/model/group.g.dart @@ -26,6 +26,7 @@ Group _$GroupFromJson( 'coverPhoto', 'createdBy', 'createdAt', + 'lastActivityAt', 'archivedAt', 'updatedAt', 'seq', @@ -74,6 +75,10 @@ Group _$GroupFromJson( coverPhoto: $checkedConvert('coverPhoto', (v) => v as String?), createdBy: $checkedConvert('createdBy', (v) => v as String), createdAt: $checkedConvert('createdAt', (v) => DateTime.parse(v as String)), + lastActivityAt: $checkedConvert( + 'lastActivityAt', + (v) => DateTime.parse(v as String), + ), archivedAt: $checkedConvert( 'archivedAt', (v) => v == null ? null : DateTime.parse(v as String), @@ -99,6 +104,7 @@ Map _$GroupToJson(Group instance) => { 'coverPhoto': instance.coverPhoto, 'createdBy': instance.createdBy, 'createdAt': instance.createdAt.toIso8601String(), + 'lastActivityAt': instance.lastActivityAt.toIso8601String(), 'archivedAt': instance.archivedAt?.toIso8601String(), 'updatedAt': instance.updatedAt.toIso8601String(), 'seq': instance.seq, diff --git a/packages/opensplit_api/lib/src/model/group_cursor.dart b/packages/opensplit_api/lib/src/model/group_cursor.dart new file mode 100644 index 0000000..6ecc2df --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_cursor.dart @@ -0,0 +1,44 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:json_annotation/json_annotation.dart'; + +part 'group_cursor.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class GroupCursor { + /// Returns a new [GroupCursor] instance. + GroupCursor({required this.groupId, required this.since}); + + @JsonKey(name: r'groupId', required: true, includeIfNull: false) + final String groupId; + + // minimum: 0 + @JsonKey(name: r'since', required: true, includeIfNull: false) + final int since; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupCursor && other.groupId == groupId && other.since == since; + + @override + int get hashCode => groupId.hashCode + since.hashCode; + + factory GroupCursor.fromJson(Map json) => + _$GroupCursorFromJson(json); + + Map toJson() => _$GroupCursorToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/group_cursor.g.dart b/packages/opensplit_api/lib/src/model/group_cursor.g.dart new file mode 100644 index 0000000..e1adf52 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_cursor.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_cursor.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupCursor _$GroupCursorFromJson(Map json) => + $checkedCreate('GroupCursor', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groupId', 'since']); + final val = GroupCursor( + groupId: $checkedConvert('groupId', (v) => v as String), + since: $checkedConvert('since', (v) => (v as num).toInt()), + ); + return val; + }); + +Map _$GroupCursorToJson(GroupCursor instance) => + {'groupId': instance.groupId, 'since': instance.since}; diff --git a/packages/opensplit_api/lib/src/model/group_ids.dart b/packages/opensplit_api/lib/src/model/group_ids.dart deleted file mode 100644 index a8d56aa..0000000 --- a/packages/opensplit_api/lib/src/model/group_ids.dart +++ /dev/null @@ -1,39 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// - -// ignore_for_file: unused_element -import 'package:json_annotation/json_annotation.dart'; - -part 'group_ids.g.dart'; - -@JsonSerializable( - checked: true, - createToJson: true, - disallowUnrecognizedKeys: false, - explicitToJson: true, -) -class GroupIds { - /// Returns a new [GroupIds] instance. - GroupIds({required this.groupIds}); - - @JsonKey(name: r'groupIds', required: true, includeIfNull: false) - final List groupIds; - - @override - bool operator ==(Object other) => - identical(this, other) || other is GroupIds && other.groupIds == groupIds; - - @override - int get hashCode => groupIds.hashCode; - - factory GroupIds.fromJson(Map json) => - _$GroupIdsFromJson(json); - - Map toJson() => _$GroupIdsToJson(this); - - @override - String toString() { - return toJson().toString(); - } -} diff --git a/packages/opensplit_api/lib/src/model/group_ids.g.dart b/packages/opensplit_api/lib/src/model/group_ids.g.dart deleted file mode 100644 index 22c1ba0..0000000 --- a/packages/opensplit_api/lib/src/model/group_ids.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'group_ids.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GroupIds _$GroupIdsFromJson(Map json) => - $checkedCreate('GroupIds', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['groupIds']); - final val = GroupIds( - groupIds: $checkedConvert( - 'groupIds', - (v) => (v as List).map((e) => e as String).toList(), - ), - ); - return val; - }); - -Map _$GroupIdsToJson(GroupIds instance) => { - 'groupIds': instance.groupIds, -}; diff --git a/packages/opensplit_api/lib/src/model/group_refusal.dart b/packages/opensplit_api/lib/src/model/group_refusal.dart new file mode 100644 index 0000000..03c2904 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_refusal.dart @@ -0,0 +1,61 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:opensplit_api/src/model/error_code.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'group_refusal.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class GroupRefusal { + /// Returns a new [GroupRefusal] instance. + GroupRefusal({ + required this.groupId, + + required this.code, + + required this.message, + }); + + @JsonKey(name: r'groupId', required: true, includeIfNull: false) + final String groupId; + + @JsonKey( + name: r'code', + required: true, + includeIfNull: false, + unknownEnumValue: ErrorCode.unknownDefaultOpenApi, + ) + final ErrorCode code; + + @JsonKey(name: r'message', required: true, includeIfNull: false) + final String message; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupRefusal && + other.groupId == groupId && + other.code == code && + other.message == message; + + @override + int get hashCode => groupId.hashCode + code.hashCode + message.hashCode; + + factory GroupRefusal.fromJson(Map json) => + _$GroupRefusalFromJson(json); + + Map toJson() => _$GroupRefusalToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/group_refusal.g.dart b/packages/opensplit_api/lib/src/model/group_refusal.g.dart new file mode 100644 index 0000000..0d372d6 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_refusal.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_refusal.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupRefusal _$GroupRefusalFromJson(Map json) => + $checkedCreate('GroupRefusal', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groupId', 'code', 'message']); + final val = GroupRefusal( + groupId: $checkedConvert('groupId', (v) => v as String), + code: $checkedConvert( + 'code', + (v) => $enumDecode( + _$ErrorCodeEnumMap, + v, + unknownValue: ErrorCode.unknownDefaultOpenApi, + ), + ), + message: $checkedConvert('message', (v) => v as String), + ); + return val; + }); + +Map _$GroupRefusalToJson(GroupRefusal instance) => + { + 'groupId': instance.groupId, + 'code': _$ErrorCodeEnumMap[instance.code]!, + 'message': instance.message, + }; + +const _$ErrorCodeEnumMap = { + ErrorCode.notMember: 'not_member', + ErrorCode.noGroup: 'no_group', + ErrorCode.groupPurged: 'group_purged', + ErrorCode.noSuchEntry: 'no_such_entry', + ErrorCode.noSuchMember: 'no_such_member', + ErrorCode.unbalanced: 'unbalanced', + ErrorCode.staleBase: 'stale_base', + ErrorCode.forbidden: 'forbidden', + ErrorCode.notSettled: 'not_settled', + ErrorCode.inviteInvalid: 'invite_invalid', + ErrorCode.inviteSpent: 'invite_spent', + ErrorCode.inviteExpired: 'invite_expired', + ErrorCode.alreadyMember: 'already_member', + ErrorCode.slotTaken: 'slot_taken', + ErrorCode.malformed: 'malformed', + ErrorCode.noSession: 'no_session', + ErrorCode.notFound: 'not_found', + ErrorCode.internal: 'internal', + ErrorCode.identityAlreadyInUse: 'identity_already_in_use', + ErrorCode.authFailed: 'auth_failed', + ErrorCode.rateLimited: 'rate_limited', + ErrorCode.reauthRequired: 'reauth_required', + ErrorCode.unknownDefaultOpenApi: 'unknown_default_open_api', +}; diff --git a/packages/opensplit_api/lib/src/model/link_preview.dart b/packages/opensplit_api/lib/src/model/link_preview.dart index 98ef2e4..6e33473 100644 --- a/packages/opensplit_api/lib/src/model/link_preview.dart +++ b/packages/opensplit_api/lib/src/model/link_preview.dart @@ -36,6 +36,8 @@ class LinkPreview { required this.isRevoked, required this.isMember, + + required this.hasLeft, }); @JsonKey( @@ -74,6 +76,9 @@ class LinkPreview { @JsonKey(name: r'isMember', required: true, includeIfNull: false) final bool isMember; + @JsonKey(name: r'hasLeft', required: true, includeIfNull: false) + final bool hasLeft; + @override bool operator ==(Object other) => identical(this, other) || @@ -87,7 +92,8 @@ class LinkPreview { other.isRedeemed == isRedeemed && other.isExpired == isExpired && other.isRevoked == isRevoked && - other.isMember == isMember; + other.isMember == isMember && + other.hasLeft == hasLeft; @override int get hashCode => @@ -100,7 +106,8 @@ class LinkPreview { isRedeemed.hashCode + isExpired.hashCode + isRevoked.hashCode + - isMember.hashCode; + isMember.hashCode + + hasLeft.hashCode; factory LinkPreview.fromJson(Map json) => _$LinkPreviewFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/link_preview.g.dart b/packages/opensplit_api/lib/src/model/link_preview.g.dart index 1e27c83..844fdff 100644 --- a/packages/opensplit_api/lib/src/model/link_preview.g.dart +++ b/packages/opensplit_api/lib/src/model/link_preview.g.dart @@ -21,6 +21,7 @@ LinkPreview _$LinkPreviewFromJson(Map json) => 'isExpired', 'isRevoked', 'isMember', + 'hasLeft', ], ); final val = LinkPreview( @@ -41,6 +42,7 @@ LinkPreview _$LinkPreviewFromJson(Map json) => isExpired: $checkedConvert('isExpired', (v) => v as bool), isRevoked: $checkedConvert('isRevoked', (v) => v as bool), isMember: $checkedConvert('isMember', (v) => v as bool), + hasLeft: $checkedConvert('hasLeft', (v) => v as bool), ); return val; }); @@ -57,6 +59,7 @@ Map _$LinkPreviewToJson(LinkPreview instance) => 'isExpired': instance.isExpired, 'isRevoked': instance.isRevoked, 'isMember': instance.isMember, + 'hasLeft': instance.hasLeft, }; const _$LinkKindEnumMap = { diff --git a/packages/opensplit_api/lib/src/model/profile.dart b/packages/opensplit_api/lib/src/model/profile.dart index bb1852c..2ab7655 100644 --- a/packages/opensplit_api/lib/src/model/profile.dart +++ b/packages/opensplit_api/lib/src/model/profile.dart @@ -38,6 +38,8 @@ class Profile { required this.updatedAt, required this.deletedAt, + + required this.version, }); @JsonKey(name: r'id', required: true, includeIfNull: false) @@ -85,6 +87,10 @@ class Profile { @JsonKey(name: r'deletedAt', required: true, includeIfNull: true) final DateTime? deletedAt; + // minimum: 0 + @JsonKey(name: r'version', required: true, includeIfNull: false) + final int version; + @override bool operator ==(Object other) => identical(this, other) || @@ -98,7 +104,8 @@ class Profile { other.avatarIcon == avatarIcon && other.avatarPhoto == avatarPhoto && other.updatedAt == updatedAt && - other.deletedAt == deletedAt; + other.deletedAt == deletedAt && + other.version == version; @override int get hashCode => @@ -111,7 +118,8 @@ class Profile { (avatarIcon == null ? 0 : avatarIcon.hashCode) + (avatarPhoto == null ? 0 : avatarPhoto.hashCode) + updatedAt.hashCode + - (deletedAt == null ? 0 : deletedAt.hashCode); + (deletedAt == null ? 0 : deletedAt.hashCode) + + version.hashCode; factory Profile.fromJson(Map json) => _$ProfileFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/profile.g.dart b/packages/opensplit_api/lib/src/model/profile.g.dart index 3c5d9e6..1c9c65e 100644 --- a/packages/opensplit_api/lib/src/model/profile.g.dart +++ b/packages/opensplit_api/lib/src/model/profile.g.dart @@ -21,6 +21,7 @@ Profile _$ProfileFromJson(Map json) => 'avatarPhoto', 'updatedAt', 'deletedAt', + 'version', ], ); final val = Profile( @@ -61,6 +62,7 @@ Profile _$ProfileFromJson(Map json) => 'deletedAt', (v) => v == null ? null : DateTime.parse(v as String), ), + version: $checkedConvert('version', (v) => (v as num).toInt()), ); return val; }); @@ -76,6 +78,7 @@ Map _$ProfileToJson(Profile instance) => { 'avatarPhoto': instance.avatarPhoto, 'updatedAt': instance.updatedAt.toIso8601String(), 'deletedAt': instance.deletedAt?.toIso8601String(), + 'version': instance.version, }; const _$AvatarKindEnumMap = { diff --git a/packages/opensplit_api/lib/src/model/profile_page.dart b/packages/opensplit_api/lib/src/model/profile_page.dart index 2d25ba4..d0ef568 100644 --- a/packages/opensplit_api/lib/src/model/profile_page.dart +++ b/packages/opensplit_api/lib/src/model/profile_page.dart @@ -19,7 +19,7 @@ class ProfilePage { ProfilePage({ required this.profiles, - required this.cursor, + required this.seq, required this.hasMore, }); @@ -27,8 +27,9 @@ class ProfilePage { @JsonKey(name: r'profiles', required: true, includeIfNull: false) final List profiles; - @JsonKey(name: r'cursor', required: true, includeIfNull: true) - final String? cursor; + // minimum: 0 + @JsonKey(name: r'seq', required: true, includeIfNull: false) + final int seq; @JsonKey(name: r'hasMore', required: true, includeIfNull: false) final bool hasMore; @@ -38,14 +39,11 @@ class ProfilePage { identical(this, other) || other is ProfilePage && other.profiles == profiles && - other.cursor == cursor && + other.seq == seq && other.hasMore == hasMore; @override - int get hashCode => - profiles.hashCode + - (cursor == null ? 0 : cursor.hashCode) + - hasMore.hashCode; + int get hashCode => profiles.hashCode + seq.hashCode + hasMore.hashCode; factory ProfilePage.fromJson(Map json) => _$ProfilePageFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/profile_page.g.dart b/packages/opensplit_api/lib/src/model/profile_page.g.dart index 8d1ac40..0fadb9a 100644 --- a/packages/opensplit_api/lib/src/model/profile_page.g.dart +++ b/packages/opensplit_api/lib/src/model/profile_page.g.dart @@ -8,7 +8,7 @@ part of 'profile_page.dart'; ProfilePage _$ProfilePageFromJson(Map json) => $checkedCreate('ProfilePage', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['profiles', 'cursor', 'hasMore']); + $checkKeys(json, requiredKeys: const ['profiles', 'seq', 'hasMore']); final val = ProfilePage( profiles: $checkedConvert( 'profiles', @@ -16,7 +16,7 @@ ProfilePage _$ProfilePageFromJson(Map json) => .map((e) => Profile.fromJson(e as Map)) .toList(), ), - cursor: $checkedConvert('cursor', (v) => v as String?), + seq: $checkedConvert('seq', (v) => (v as num).toInt()), hasMore: $checkedConvert('hasMore', (v) => v as bool), ); return val; @@ -25,6 +25,6 @@ ProfilePage _$ProfilePageFromJson(Map json) => Map _$ProfilePageToJson(ProfilePage instance) => { 'profiles': instance.profiles.map((e) => e.toJson()).toList(), - 'cursor': instance.cursor, + 'seq': instance.seq, 'hasMore': instance.hasMore, }; diff --git a/packages/opensplit_api/lib/src/model/pull.dart b/packages/opensplit_api/lib/src/model/pull.dart new file mode 100644 index 0000000..65d4a4e --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull.dart @@ -0,0 +1,50 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:opensplit_api/src/model/group_refusal.dart'; +import 'package:opensplit_api/src/model/change_page.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'pull.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class Pull { + /// Returns a new [Pull] instance. + Pull({required this.groupIds, required this.pages, required this.refusals}); + + @JsonKey(name: r'groupIds', required: true, includeIfNull: false) + final List groupIds; + + @JsonKey(name: r'pages', required: true, includeIfNull: false) + final List pages; + + @JsonKey(name: r'refusals', required: true, includeIfNull: false) + final List refusals; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Pull && + other.groupIds == groupIds && + other.pages == pages && + other.refusals == refusals; + + @override + int get hashCode => groupIds.hashCode + pages.hashCode + refusals.hashCode; + + factory Pull.fromJson(Map json) => _$PullFromJson(json); + + Map toJson() => _$PullToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/pull.g.dart b/packages/opensplit_api/lib/src/model/pull.g.dart new file mode 100644 index 0000000..b0f76a5 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pull.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Pull _$PullFromJson(Map json) => + $checkedCreate('Pull', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groupIds', 'pages', 'refusals']); + final val = Pull( + groupIds: $checkedConvert( + 'groupIds', + (v) => (v as List).map((e) => e as String).toList(), + ), + pages: $checkedConvert( + 'pages', + (v) => (v as List) + .map((e) => ChangePage.fromJson(e as Map)) + .toList(), + ), + refusals: $checkedConvert( + 'refusals', + (v) => (v as List) + .map((e) => GroupRefusal.fromJson(e as Map)) + .toList(), + ), + ); + return val; + }); + +Map _$PullToJson(Pull instance) => { + 'groupIds': instance.groupIds, + 'pages': instance.pages.map((e) => e.toJson()).toList(), + 'refusals': instance.refusals.map((e) => e.toJson()).toList(), +}; diff --git a/packages/opensplit_api/lib/src/model/pull_request.dart b/packages/opensplit_api/lib/src/model/pull_request.dart new file mode 100644 index 0000000..3726363 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull_request.dart @@ -0,0 +1,46 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:opensplit_api/src/model/group_cursor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'pull_request.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class PullRequest { + /// Returns a new [PullRequest] instance. + PullRequest({required this.groups, required this.limit}); + + @JsonKey(name: r'groups', required: true, includeIfNull: false) + final List groups; + + // minimum: 1 + // maximum: 500 + @JsonKey(name: r'limit', required: true, includeIfNull: false) + final int limit; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PullRequest && other.groups == groups && other.limit == limit; + + @override + int get hashCode => groups.hashCode + limit.hashCode; + + factory PullRequest.fromJson(Map json) => + _$PullRequestFromJson(json); + + Map toJson() => _$PullRequestToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/pull_request.g.dart b/packages/opensplit_api/lib/src/model/pull_request.g.dart new file mode 100644 index 0000000..597d7d7 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull_request.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pull_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PullRequest _$PullRequestFromJson(Map json) => + $checkedCreate('PullRequest', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groups', 'limit']); + final val = PullRequest( + groups: $checkedConvert( + 'groups', + (v) => (v as List) + .map((e) => GroupCursor.fromJson(e as Map)) + .toList(), + ), + limit: $checkedConvert('limit', (v) => (v as num).toInt()), + ); + return val; + }); + +Map _$PullRequestToJson(PullRequest instance) => + { + 'groups': instance.groups.map((e) => e.toJson()).toList(), + 'limit': instance.limit, + }; diff --git a/server/migrations/20261002071250_nifty_rhino/migration.sql b/server/migrations/20261004205652_dark_maximus/migration.sql similarity index 94% rename from server/migrations/20261002071250_nifty_rhino/migration.sql rename to server/migrations/20261004205652_dark_maximus/migration.sql index 678c8a4..3fa2032 100644 --- a/server/migrations/20261002071250_nifty_rhino/migration.sql +++ b/server/migrations/20261004205652_dark_maximus/migration.sql @@ -1,3 +1,8 @@ +CREATE TABLE `counters` ( + `name` text PRIMARY KEY, + `value` integer NOT NULL +); +--> statement-breakpoint CREATE TABLE `device_tokens` ( `token` text PRIMARY KEY, `profile_id` text NOT NULL, @@ -32,6 +37,7 @@ CREATE TABLE `profiles` ( `avatar_photo` text, `updated_at` text NOT NULL, `deleted_at` text, + `version` integer NOT NULL, CONSTRAINT "profiles_avatar_emoji" CHECK(("avatar_kind" = 'emoji') = ("avatar_emoji" is not null)), CONSTRAINT "profiles_avatar_icon" CHECK(("avatar_kind" = 'icon') = ("avatar_icon" is not null)), CONSTRAINT "profiles_avatar_photo" CHECK(("avatar_kind" = 'photo') = ("avatar_photo" is not null)) @@ -94,7 +100,7 @@ CREATE TABLE `verification` ( CREATE INDEX `device_tokens_profile` ON `device_tokens` (`profile_id`);--> statement-breakpoint CREATE INDEX `link_tokens_group` ON `link_tokens` (`group_id`);--> statement-breakpoint CREATE INDEX `memberships_group` ON `memberships` (`group_id`);--> statement-breakpoint -CREATE INDEX `profiles_updated` ON `profiles` (`updated_at`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `profiles_version` ON `profiles` (`version`);--> statement-breakpoint CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`); \ No newline at end of file diff --git a/server/migrations/20261002071250_nifty_rhino/snapshot.json b/server/migrations/20261004205652_dark_maximus/snapshot.json similarity index 94% rename from server/migrations/20261002071250_nifty_rhino/snapshot.json rename to server/migrations/20261004205652_dark_maximus/snapshot.json index 5cc0165..d946fc2 100644 --- a/server/migrations/20261002071250_nifty_rhino/snapshot.json +++ b/server/migrations/20261004205652_dark_maximus/snapshot.json @@ -1,11 +1,15 @@ { "version": "7", "dialect": "sqlite", - "id": "6ac080d4-f4b5-4906-880a-e81a1df1e0e1", + "id": "d14dcdc6-1286-4f24-841b-0a76aea5c4f6", "prevIds": [ "00000000-0000-0000-0000-000000000000" ], "ddl": [ + { + "name": "counters", + "entityType": "tables" + }, { "name": "device_tokens", "entityType": "tables" @@ -42,6 +46,26 @@ "name": "verification", "entityType": "tables" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "counters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "counters" + }, { "type": "text", "notNull": false, @@ -272,6 +296,16 @@ "entityType": "columns", "table": "profiles" }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "profiles" + }, { "type": "text", "notNull": false, @@ -682,6 +716,15 @@ "entityType": "pks", "table": "memberships" }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "counters_pk", + "table": "counters", + "entityType": "pks" + }, { "columns": [ "token" @@ -799,18 +842,14 @@ { "columns": [ { - "value": "updated_at", - "isExpression": false - }, - { - "value": "id", + "value": "version", "isExpression": false } ], - "isUnique": false, + "isUnique": true, "where": null, "origin": "manual", - "name": "profiles_updated", + "name": "profiles_version", "entityType": "indexes", "table": "profiles" }, diff --git a/server/src/api/account.ts b/server/src/api/account.ts index 9bf1351..681c677 100644 --- a/server/src/api/account.ts +++ b/server/src/api/account.ts @@ -1,22 +1,20 @@ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, eq, gt, inArray, isNull, or } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import { user } from "../auth-schema"; -import type { AppEnv } from "../context"; +import { type AppEnv, mayChangeIdentity } from "../context"; +import { nextProfileVersion, profileVersion } from "../db/d1/profile-version"; import { deviceTokens, memberships, profiles } from "../db/d1/schema"; import { forgetAccount } from "../forget"; import { AccountDeletionSchema, DeviceForgottenSchema, DeviceSchema, ProfilePageSchema, ProfileSchema, ProfileUpdateSchema } from "../schemas/account"; import { apiError, errorResponse, jsonBody, jsonResponse } from "../schemas/common"; -import { refusals, signedIn } from "./routing"; +import { refusals, SeqQuerySchema, signedIn } from "./routing"; /** The person, their devices, and ending the account. */ const PAGE_MAX = 200; -/** How recently somebody must have proved who they are to delete their account: Better Auth's `freshAge`, for our route. */ -const REAUTH_WINDOW = 10 * 60 * 1000; - const profileFeedRoute = createRoute({ ...signedIn, method: "get", @@ -24,13 +22,10 @@ const profileFeedRoute = createRoute({ path: "/profiles", tags: ["sync"], summary: "Everybody you share a group with, plus yourself", - description: "Cursored on time, since profiles have no single writer to number them. Send `cursor` back as `after`.", + description: "Cursored on each profile's `version`, which D1 numbers in commit order. Send `seq` back as `since` next time.", request: { query: z.object({ - after: z - .string() - .optional() - .openapi({ param: { name: "after", in: "query" } }), + since: SeqQuerySchema.default(0), limit: z.coerce.number().int().min(1).max(PAGE_MAX).default(100).openapi({ type: "integer", example: 100 }), }), }, @@ -90,30 +85,30 @@ const deleteAccountRoute = createRoute({ export function accountRoutes(routes: OpenAPIHono) { routes.openapi(profileFeedRoute, async (c) => { - const { after, limit } = c.req.valid("query"); - const position = after === undefined ? null : decodeCursor(after); - if (position === undefined) return c.json(apiError("malformed", "That is not a cursor from this feed."), 400); - + const { since, limit } = c.req.valid("query"); const rows = await c.var.db .select() .from(profiles) - .where(and(visibleTo(c.var.db, c.var.session.userId), position ? sql`(${profiles.updatedAt}, ${profiles.id}) > (${position.updatedAt}, ${position.id})` : undefined)) - .orderBy(profiles.updatedAt, profiles.id) + .where(and(visibleTo(c.var.db, c.var.session.userId), gt(profiles.version, since))) + .orderBy(profiles.version) .limit(limit + 1) .all(); const page = rows.slice(0, limit); - const last = page.at(-1); - return c.json({ profiles: page, cursor: last ? encodeCursor(last) : null, hasMore: rows.length > limit }, 200); + return c.json({ profiles: page, seq: page.at(-1)?.version ?? since, hasMore: rows.length > limit }, 200); }); routes.openapi(updateProfileRoute, async (c) => { const update = c.req.valid("json"); - const [row] = await c.var.db - .update(profiles) - .set({ ...update, updatedAt: new Date().toISOString() }) - .where(eq(profiles.id, c.var.session.userId)) - .returning(); + const db = c.var.db; + const [, [row]] = await db.batch([ + nextProfileVersion(db), + db + .update(profiles) + .set({ ...update, updatedAt: new Date().toISOString(), version: profileVersion }) + .where(eq(profiles.id, c.var.session.userId)) + .returning(), + ]); // Created by a databaseHook with the account, so a missing row is a bug, not a case. if (!row) throw new Error(`No profile for account ${c.var.session.userId}`); return c.json(row, 200); @@ -142,7 +137,7 @@ export function accountRoutes(routes: OpenAPIHono) { const db = c.var.db; // A session lasts a year, so holding one is not enough to end the account behind it. - if (!c.var.session.isAnonymous && Date.now() - c.var.session.createdAt > REAUTH_WINDOW) { + if (!mayChangeIdentity(c.var.session)) { return c.json(apiError("reauth_required", "Confirm it is you before deleting this account."), 403); } @@ -166,14 +161,3 @@ function visibleTo(db: DrizzleD1Database, self: string) { .where(and(inArray(memberships.groupId, mine), isNull(memberships.leftAt))); return or(eq(profiles.id, self), inArray(profiles.id, together)); } - -type Position = { updatedAt: string; id: string }; - -const encodeCursor = ({ updatedAt, id }: Position) => `${updatedAt}|${id}`; - -/** A position, or undefined for something this feed did not issue. */ -function decodeCursor(cursor: string): Position | undefined { - const [updatedAt, id, ...rest] = cursor.split("|"); - if (!updatedAt || !id || rest.length > 0 || Number.isNaN(Date.parse(updatedAt))) return undefined; - return { updatedAt, id }; -} diff --git a/server/src/api/invites.ts b/server/src/api/invites.ts index e31f7ec..b4546d3 100644 --- a/server/src/api/invites.ts +++ b/server/src/api/invites.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import type { AppEnv } from "../context"; +import { nextProfileVersion, profileVersion } from "../db/d1/profile-version"; import { linkTokens, profiles } from "../db/d1/schema"; import { apiError, jsonBody, jsonResponse } from "../schemas/common"; import { GroupLinkSchema, InviteSchema, JoinedSchema, JoinRequestSchema, LinkPreviewSchema, LinkRevocationSchema, LiveLinkSchema, PlaceholderListSchema } from "../schemas/ledger"; @@ -154,7 +155,8 @@ export function inviteRoutes(routes: OpenAPIHono) { const result = await group(c, groupId).join(token, viewer, { ...request, displayName: request.displayName ?? profile?.displayName ?? null }); if (result.ok && !profile?.displayName) { - await c.var.db.update(profiles).set({ displayName: result.value.member.displayName, updatedAt: new Date().toISOString() }).where(eq(profiles.id, viewer)); + const db = c.var.db; + await db.batch([nextProfileVersion(db), db.update(profiles).set({ displayName: result.value.member.displayName, updatedAt: new Date().toISOString(), version: profileVersion }).where(eq(profiles.id, viewer))]); } return respond(c, result); }); diff --git a/server/src/api/ledger.ts b/server/src/api/ledger.ts index d05e974..2f83ab3 100644 --- a/server/src/api/ledger.ts +++ b/server/src/api/ledger.ts @@ -1,14 +1,14 @@ -import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"; +import { createRoute, type OpenAPIHono } from "@hono/zod-openapi"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import { chunked } from "../chunked"; import type { AppEnv } from "../context"; import { memberships, profiles } from "../db/d1/schema"; -import { GroupIdsSchema } from "../schemas/account"; +import type { Profile } from "../schemas/account"; import { jsonBody, jsonResponse } from "../schemas/common"; -import { ChangePageSchema, EntryInputSchema, EntrySchema, GroupInputSchema, GroupSchema, type Member, MemberInputSchema, MemberSchema } from "../schemas/ledger"; -import { EntryPathSchema, GroupPathSchema, group, MemberPathSchema, refusals, refused, respond, SeqQuerySchema, signedIn } from "./routing"; +import { EntryInputSchema, EntrySchema, type GroupChanges, GroupInputSchema, type GroupRefusal, GroupSchema, type Member, MemberInputSchema, MemberSchema, PullRequestSchema, PullSchema } from "../schemas/ledger"; +import { EntryPathSchema, GroupPathSchema, group, MemberPathSchema, refusals, respond, signedIn } from "./routing"; /** * The sync surface. Handlers only route: rules about shape are the Zod @@ -17,32 +17,17 @@ import { EntryPathSchema, GroupPathSchema, group, MemberPathSchema, refusals, re * answers `not_member` itself. */ -const listGroupsRoute = createRoute({ +const pullRoute = createRoute({ ...signedIn, - method: "get", - operationId: "listGroups", - path: "/groups", + method: "post", + operationId: "pull", + path: "/sync", tags: ["sync"], - summary: "The groups this account is still in", - responses: { 200: jsonResponse(GroupIdsSchema, "The group ids"), 401: refusals[401] }, -}); - -const changesRoute = createRoute({ - ...signedIn, - method: "get", - operationId: "getChanges", - path: "/groups/{groupId}/changes", - tags: ["sync"], - summary: "Everything that changed in one group since a cursor", - description: "`limit` counts changes, not rows, and a page is cut only between sequence numbers, so a device sees a whole write or none of it. Send `seq` back as `since` next time.", - request: { - params: GroupPathSchema, - query: z.object({ - since: SeqQuerySchema.default(0), - limit: z.coerce.number().int().min(1).max(500).default(200).openapi({ type: "integer", example: 200 }), - }), - }, - responses: { 200: jsonResponse(ChangePageSchema, "The page, and the cursor to send next time"), ...refusals }, + summary: "Every group this account is in, and what changed in the ones asked for", + description: + "Each group asked for gets its own page past its own cursor, read in parallel; a group that cannot be read is listed in `refusals` and the rest are still answered. `groupIds` names every group the account is in now, so one with no cursor yet is asked for next time, from 0. A page is cut only between sequence numbers, so a device sees a whole write or none of it; send each page's `seq` back as that group's `since`.", + request: { body: jsonBody(PullRequestSchema) }, + responses: { 200: jsonResponse(PullSchema, "The account's groups, and a page or a refusal for each group asked for"), 400: refusals[400], 401: refusals[401] }, }); const putGroupRoute = createRoute({ @@ -76,27 +61,36 @@ const putEntryRoute = createRoute({ path: "/groups/{groupId}/entries/{entryId}", tags: ["entries"], summary: "Record, edit, delete or restore an expense, whole", - description: "A stale `baseSeq` is refused only when the write would move money, so two people fixing a typo never arbitrate. Deleting and restoring always move money.", + description: "`baseSeq` must be the version stored now (null for a new expense), or the write is refused as `stale_base`: the expense is replaced whole, so a write composed on an older version would undo the edit in between. Sending the stored contents again is answered with the stored row, whatever the base.", request: { params: EntryPathSchema, body: jsonBody(EntryInputSchema) }, responses: { 200: jsonResponse(EntrySchema, "The expense as stored"), ...refusals }, }); export function ledgerRoutes(routes: OpenAPIHono) { - routes.openapi(listGroupsRoute, async (c) => { - const rows = await c.var.db - .select({ groupId: memberships.groupId }) - .from(memberships) - .where(and(eq(memberships.profileId, c.var.session.userId), isNull(memberships.leftAt))) - .all(); - return c.json({ groupIds: rows.map((row) => row.groupId) }, 200); - }); + routes.openapi(pullRoute, async (c) => { + const { groups, limit } = c.req.valid("json"); + const viewer = c.var.session.userId; + const [groupIds, results] = await Promise.all([groupsOf(c.var.db, viewer), Promise.all(groups.map(({ groupId, since }) => group(c, groupId).changes(viewer, since, limit)))]); - routes.openapi(changesRoute, async (c) => { - const { groupId } = c.req.valid("param"); - const { since, limit } = c.req.valid("query"); - const result = await group(c, groupId).changes(c.var.session.userId, since, limit); - if (!result.ok) return refused(c, result.error); - return c.json({ ...result.value, profiles: await profilesOf(c.var.db, result.value.members) }, 200); + const pages: GroupChanges[] = []; + const refused: GroupRefusal[] = []; + results.forEach((result, index) => { + if (result.ok) pages.push(result.value); + else refused.push({ groupId: groups[index]?.groupId ?? "", ...result.error }); + }); + + const profilesById = await profilesOf( + c.var.db, + pages.flatMap((page) => page.members), + ); + return c.json( + { + groupIds, + pages: pages.map((page) => ({ ...page, profiles: currentProfiles(page.members).flatMap((id) => profilesById.get(id) ?? []) })), + refusals: refused, + }, + 200, + ); }); routes.openapi(putGroupRoute, async (c) => { @@ -115,9 +109,23 @@ export function ledgerRoutes(routes: OpenAPIHono) { }); } -/** The accounts holding the page's current places: ids the group named, never ones a client asked for. */ -async function profilesOf(db: DrizzleD1Database, members: Member[]) { - const ids = [...new Set(members.flatMap((member) => (member.profileId !== null && member.leftAt === null ? [member.profileId] : [])))]; - const pages = await Promise.all(chunked(ids).map((chunk) => db.select().from(profiles).where(inArray(profiles.id, chunk)).all())); - return pages.flat(); +/** The groups an account is in now, from D1's index of them. */ +async function groupsOf(db: DrizzleD1Database, profileId: string): Promise { + const rows = await db + .select({ groupId: memberships.groupId }) + .from(memberships) + .where(and(eq(memberships.profileId, profileId), isNull(memberships.leftAt))) + .all(); + return rows.map((row) => row.groupId); +} + +/** The accounts holding a page's current places: ids the group named, never ones a client asked for. */ +function currentProfiles(members: Member[]): string[] { + return [...new Set(members.flatMap((member) => (member.profileId !== null && member.leftAt === null ? [member.profileId] : [])))]; +} + +/** Every profile any page names, read once for the whole pull. */ +async function profilesOf(db: DrizzleD1Database, members: Member[]): Promise> { + const pages = await Promise.all(chunked(currentProfiles(members)).map((chunk) => db.select().from(profiles).where(inArray(profiles.id, chunk)).all())); + return new Map(pages.flat().map((profile) => [profile.id, profile])); } diff --git a/server/src/auth.ts b/server/src/auth.ts index 4b94cd8..d9c3716 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -6,6 +6,7 @@ import { emailOTP } from "better-auth/plugins/email-otp"; import { drizzle } from "drizzle-orm/d1"; import * as authSchema from "./auth-schema"; +import { nextProfileVersion, profileVersion } from "./db/d1/profile-version"; import { profiles } from "./db/d1/schema"; import { createEmailSender } from "./email/sender"; import { signInCodeMessage } from "./email/sign-in-code"; @@ -102,7 +103,8 @@ export function build(env: Env) { */ async function createProfile(env: Env, user: { id: string; name?: string | null; isAnonymous?: boolean | null }): Promise { const chosen = user.isAnonymous ? null : user.name?.trim() || null; - await drizzle(env.DB).insert(profiles).values({ id: user.id, displayName: chosen, upiVpa: null, updatedAt: new Date().toISOString() }).onConflictDoNothing(); + const db = drizzle(env.DB); + await db.batch([nextProfileVersion(db), db.insert(profiles).values({ id: user.id, displayName: chosen, upiVpa: null, updatedAt: new Date().toISOString(), version: profileVersion }).onConflictDoNothing()]); } export type Auth = ReturnType; diff --git a/server/src/chunked.ts b/server/src/chunked.ts index 7a33505..7de4f98 100644 --- a/server/src/chunked.ts +++ b/server/src/chunked.ts @@ -1,6 +1,24 @@ -/** D1 and Durable Object SQLite bind at most 100 parameters per statement, so lists go in chunks below that. */ +/** + * D1 and Durable Object SQLite bind at most 100 values a statement. Every + * value a query sends is one: each column of each row in an insert, each id in + * an `IN (…)` list, each value put into a `sql` template. + */ +export const MAX_BOUND_VALUES = 100; + +/** A list in pieces no longer than [size]. The default leaves room for a few other values in the same `IN (…)` query. */ export function chunked(items: readonly T[], size = 90): T[][] { const chunks: T[][] = []; for (let start = 0; start < items.length; start += size) chunks.push(items.slice(start, start + size)); return chunks; } + +/** + * Rows for a multi-row insert, in pieces that each fit one statement: as many + * rows as the limit allows at this many columns a row. Inside a Durable Object + * the pieces run in the same transaction and the same process, so splitting + * costs nothing. + */ +export function insertable(rows: readonly T[]): T[][] { + const columns = Math.max(1, ...rows.map((row) => Object.keys(row).length)); + return chunked(rows, Math.floor(MAX_BOUND_VALUES / columns)); +} diff --git a/server/src/context.ts b/server/src/context.ts index c06c005..b7f441a 100644 --- a/server/src/context.ts +++ b/server/src/context.ts @@ -11,6 +11,18 @@ export interface Session { createdAt: number; } +/** How recently somebody must have proved who they are to change who they are or end the account: Better Auth's `freshAge`, for our routes. */ +export const REAUTH_WINDOW = 10 * 60 * 1000; + +/** + * Whether this session may change the identity behind it. A session lasts a + * year, so holding one is not proof enough; a guest has nothing to confirm + * with, and attaching an identity is how a guest keeps their groups. + */ +export function mayChangeIdentity(session: Session): boolean { + return session.isAnonymous || Date.now() - session.createdAt <= REAUTH_WINDOW; +} + interface Services { db: DrizzleD1Database; auth: Auth; diff --git a/server/src/db/d1/profile-version.ts b/server/src/db/d1/profile-version.ts new file mode 100644 index 0000000..5fe9cae --- /dev/null +++ b/server/src/db/d1/profile-version.ts @@ -0,0 +1,22 @@ +import { eq, sql } from "drizzle-orm"; +import type { DrizzleD1Database } from "drizzle-orm/d1"; + +import { counters } from "./schema"; + +/** + * Every write to `profiles` goes in one `db.batch` (a transaction) behind + * `nextProfileVersion`, and sets `version: profileVersion`: + * + * ```ts + * await db.batch([nextProfileVersion(db), db.update(profiles).set({ ...changes, version: profileVersion })]); + * ``` + */ +export function nextProfileVersion(db: DrizzleD1Database) { + return db + .insert(counters) + .values({ name: "profiles", value: 1 }) + .onConflictDoUpdate({ target: counters.name, set: { value: sql`${counters.value} + 1` } }); +} + +/** The version `nextProfileVersion` just took, read inside the same batch. */ +export const profileVersion = sql`(select ${counters.value} from ${counters} where ${eq(counters.name, "profiles")})`; diff --git a/server/src/db/d1/schema.ts b/server/src/db/d1/schema.ts index d8d0a75..8380282 100644 --- a/server/src/db/d1/schema.ts +++ b/server/src/db/d1/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { avatarChecks, avatarColumns } from "../appearance"; @@ -25,10 +25,27 @@ export const profiles = sqliteTable( updatedAt: text("updated_at").notNull(), /** Set by account deletion; the row stays so history still resolves. */ deletedAt: text("deleted_at"), + /** + * The profile feed's cursor: the `profiles` counter's value when this row + * was last written, taken in the same transaction (`profileVersion`). + * D1 commits one write at a time, so versions follow commit order, which + * a timestamp from the Worker's clock does not. + */ + version: integer("version").notNull(), }, - (table) => [index("profiles_updated").on(table.updatedAt, table.id), ...avatarChecks("profiles", table)], + (table) => [uniqueIndex("profiles_version").on(table.version), ...avatarChecks("profiles", table)], ); +/** + * Named counters that only go up, for rows D1 numbers itself. A counter + * rather than `max(version) + 1`, which would hand a deleted row's number out + * again, behind a cursor that has already passed it. + */ +export const counters = sqliteTable("counters", { + name: text("name").primaryKey(), + value: integer("value").notNull(), +}); + /** * Which groups a person is in. Derived from the group objects, which send * every change in order and retry until D1 takes it. diff --git a/server/src/db/group/schema.ts b/server/src/db/group/schema.ts index 6b822db..f8887c8 100644 --- a/server/src/db/group/schema.ts +++ b/server/src/db/group/schema.ts @@ -72,6 +72,7 @@ export const meta = sqliteTable( .notNull() .references(() => members.id), createdAt: text("created_at").notNull(), + lastActivityAt: text("last_activity_at").notNull(), archivedAt: text("archived_at"), updatedAt: text("updated_at").notNull(), seq: integer("seq").notNull(), diff --git a/server/src/do/fx/index.ts b/server/src/do/fx/index.ts index f0c623b..370f80d 100644 --- a/server/src/do/fx/index.ts +++ b/server/src/do/fx/index.ts @@ -1,11 +1,12 @@ import { DurableObject } from "cloudflare:workers"; -import { and, asc, between, eq, gte, inArray, lte, sql } from "drizzle-orm"; +import { and, asc, between, eq, gte, inArray, sql } from "drizzle-orm"; import { type DrizzleSqliteDODatabase, drizzle } from "drizzle-orm/durable-sqlite"; import { migrate } from "drizzle-orm/durable-sqlite/migrator"; +import { insertable } from "../../chunked"; import * as schema from "../../db/fx/schema"; import { type MonthBlob, monthKey } from "../../fx/blob"; -import { type FxProvider, providers } from "../../fx/providers"; +import { type FxProvider, type FxSnapshot, providers } from "../../fx/providers"; import { supportedCurrencies } from "../../reference"; import type { FxRate } from "../../schemas/reference"; import migrations from "./migrations/migrations"; @@ -70,6 +71,39 @@ export class Fx extends DurableObject { return true; } + /** + * Keeps the last [HISTORY_DAYS] free of any stretch longer than + * [STALE_AFTER_DAYS] without a publication, since a rate that old does not + * answer for a day. Each such stretch is fetched whole, in one request, from + * the first provider that can answer for a range. Run daily: on a new + * deployment the first run fills the year, and every run after finds + * nothing to do unless a provider was down for a week. + */ + async fillHistory(now: number = Date.now()): Promise { + const gaps = this.gaps(now); + const months = new Set(); + let stored = 0; + + for (const gap of gaps) { + for (const provider of providers) { + if (!provider.fetchRange) continue; + const { snapshots, failure } = await this.attemptRange(provider, gap); + this.recordHealth(provider.name, now, failure); + if (!snapshots) continue; + + for (const snapshot of snapshots) { + const written = this.store(snapshot, provider.name, now); + stored += written.length; + if (written.length > 0) months.add(snapshot.asOf.slice(0, 7)); + } + break; + } + } + + await this.publish([...months]); + return { gaps, stored, months: [...months].sort() }; + } + /** Rates on or after a day, straight from storage. */ async since(asOf: string, limit: number): Promise { return this.db.select(rateColumns).from(schema.fxRates).where(gte(schema.fxRates.asOf, asOf)).orderBy(asc(schema.fxRates.asOf), asc(schema.fxRates.currency)).limit(limit).all(); @@ -106,21 +140,72 @@ export class Fx extends DurableObject { this.recordHealth(provider.name, now, failure); if (!snapshot) continue; - const rows = Object.entries(snapshot.rates) - .filter(([code]) => outstanding.has(code)) - .map(([currency, rate]) => ({ asOf: snapshot.asOf, currency, rate, source: provider.name, createdAt: new Date(now).toISOString() })); - if (rows.length === 0) continue; + const answered = Object.entries(snapshot.rates).filter(([code]) => outstanding.has(code)); + const written = this.store({ asOf: snapshot.asOf, rates: Object.fromEntries(answered) }, provider.name, now); + // Covered whether written now or already held: a second run on the same day asks nobody else for it. + for (const [currency] of answered) outstanding.delete(currency); + if (written.length === 0) continue; - // Never overwritten: the `source` is stamped on expenses converted with the rate. - const written = this.db.insert(schema.fxRates).values(rows).onConflictDoNothing().returning({ currency: schema.fxRates.currency }).all(); stored += written.length; months.add(snapshot.asOf.slice(0, 7)); - for (const { currency } of written) outstanding.delete(currency); } return { stored, covered: wanted.filter((code) => !outstanding.has(code)), missing: [...outstanding], months: [...months] }; } + /** + * Stores one day's rates and returns the currencies newly written. Never + * overwrites: the `source` is stamped on expenses converted with a rate. + * In pieces, since one statement carries at most 100 values. + */ + private store(snapshot: FxSnapshot, source: string, now: number): string[] { + const createdAt = new Date(now).toISOString(); + const rows = Object.entries(snapshot.rates).map(([currency, rate]) => ({ asOf: snapshot.asOf, currency, rate, source, createdAt })); + return insertable(rows).flatMap((chunk) => + this.db + .insert(schema.fxRates) + .values(chunk) + .onConflictDoNothing() + .returning({ currency: schema.fxRates.currency }) + .all() + .map((row) => row.currency), + ); + } + + /** + * Stretches of the last year where some day has no publication within + * [STALE_AFTER_DAYS] before it, each as the run of days between the + * publications either side. The edges of the year count as publications. + */ + private gaps(now: number): Gap[] { + const today = isoDay(now); + const floor = shiftDay(today, -HISTORY_DAYS); + const published = this.db + .selectDistinct({ asOf: schema.fxRates.asOf }) + .from(schema.fxRates) + .where(between(schema.fxRates.asOf, floor, today)) + .orderBy(asc(schema.fxRates.asOf)) + .all() + .map((row) => row.asOf); + + const gaps: Gap[] = []; + let before = shiftDay(floor, -1); + for (const after of [...published, shiftDay(today, 1)]) { + if (daysBetween(before, after) > STALE_AFTER_DAYS + 1) gaps.push({ from: shiftDay(before, 1), to: shiftDay(after, -1) }); + before = after; + } + return gaps; + } + + private async attemptRange(provider: FxProvider, { from, to }: Gap) { + try { + const snapshots = (await provider.fetchRange?.({ from, to, currencies: supportedCurrencies, env: this.env })) ?? null; + return { snapshots, failure: snapshots === null ? "no usable response for a range" : null }; + } catch (cause) { + return { snapshots: null, failure: String(cause) }; + } + } + private async attempt(provider: FxProvider, asOf: string | null, currencies: string[]) { try { const snapshot = await provider.fetch({ asOf, currencies, env: this.env }); @@ -143,12 +228,16 @@ export class Fx extends DurableObject { .run(); } - /** Currencies with no rate on or before a day: a Friday rate answers a Sunday. */ + /** + * Currencies with no rate that answers for a day: one published on it or in + * the [STALE_AFTER_DAYS] before, so Friday's answers a Sunday and last + * spring's does not answer today. + */ private missingFor(asOf: string): string[] { const held = this.db .selectDistinct({ currency: schema.fxRates.currency }) .from(schema.fxRates) - .where(and(lte(schema.fxRates.asOf, asOf), inArray(schema.fxRates.currency, supportedCurrencies))) + .where(and(between(schema.fxRates.asOf, shiftDay(asOf, -STALE_AFTER_DAYS), asOf), inArray(schema.fxRates.currency, supportedCurrencies))) .all(); const covered = new Set(held.map((row) => row.currency)); return supportedCurrencies.filter((code) => !covered.has(code)); @@ -168,6 +257,28 @@ export class Fx extends DurableObject { } } +/** How far back history is kept complete: the client's own rate window. */ +export const HISTORY_DAYS = 365; + +/** + * How old a rate may be and still answer for a day. A week covers every + * weekend and every ECB holiday. The app holds the same rule, in + * `lib/domain/fx/fx_quote.dart`, for the rates it looks up itself. + */ +export const STALE_AFTER_DAYS = 7; + +/** A run of days, `YYYY-MM-DD`, inclusive. */ +export interface Gap { + from: string; + to: string; +} + +export interface HistoryOutcome { + gaps: Gap[]; + stored: number; + months: string[]; +} + const rateColumns = { asOf: schema.fxRates.asOf, currency: schema.fxRates.currency, rate: schema.fxRates.rate, source: schema.fxRates.source }; export interface RunOutcome { @@ -181,4 +292,7 @@ const BACKFILL_COOLDOWN = 60 * 60 * 1000; /** After this many, a date is accepted as one no provider will answer. */ const BACKFILL_ATTEMPTS = 5; +const DAY = 24 * 60 * 60 * 1000; const isoDay = (at: number) => new Date(at).toISOString().slice(0, 10); +const shiftDay = (day: string, by: number) => isoDay(Date.parse(day) + by * DAY); +const daysBetween = (from: string, to: string) => Math.round((Date.parse(to) - Date.parse(from)) / DAY); diff --git a/server/src/do/group/balances.ts b/server/src/do/group/balances.ts index ee3fc62..ddbb0b2 100644 --- a/server/src/do/group/balances.ts +++ b/server/src/do/group/balances.ts @@ -38,3 +38,12 @@ export function isSettled(tx: Tx, memberId: string): boolean { export function isGroupSettled(tx: Tx): boolean { return outstanding(tx).length === 0; } + +/** Every open position, by member and then currency: positive is owed, negative owes. */ +export function positionsByMember(tx: Tx): Map> { + const byMember = new Map>(); + for (const { memberId, currency, balanceMinor } of outstanding(tx)) { + byMember.set(memberId, (byMember.get(memberId) ?? new Map()).set(currency, balanceMinor)); + } + return byMember; +} diff --git a/server/src/do/group/changes.ts b/server/src/do/group/changes.ts index 7c92fad..9bc5e40 100644 --- a/server/src/do/group/changes.ts +++ b/server/src/do/group/changes.ts @@ -55,7 +55,8 @@ export function changesSince(tx: Tx, profileId: string, since: number, limit: nu purgedAt: null, // The group and member rows changed since the cursor ride on every page, even ahead of it: // this page's entries and events may name them, and a device's foreign keys need them first. - group: meta.seq > since ? meta : null, + // Not past the ceiling: somebody who left does not hear what the group is called now. + group: meta.seq > since && meta.seq <= ceiling ? meta : null, members: tx .select() .from(schema.members) diff --git a/server/src/do/group/index.ts b/server/src/do/group/index.ts index a1908ac..73de5ed 100644 --- a/server/src/do/group/index.ts +++ b/server/src/do/group/index.ts @@ -16,7 +16,7 @@ import { pendingNotices } from "./notices"; import { attempt, type Result } from "./refusal"; import { createGroup, forgetProfile, handOver, putMember, updateGroup } from "./roster"; import { currentSeq, findMeta, findTombstone, type GroupDb, nowIso, requireActiveMember, requireMeta, type Tx, type WriteContext } from "./store"; -import { backoffOutbox, clearOutbox, nextDue, type OutboxRow, pendingOutbox, runDormancy, type UpkeepOutcome } from "./upkeep"; +import { backoffOutbox, clearOutbox, nextDue, type OutboxRow, pendingOutbox, runDormancy, touchDormancy, type UpkeepOutcome } from "./upkeep"; /** * One group's ledger and its authorization boundary. The object runs one @@ -131,7 +131,8 @@ export class Group extends DurableObject { /** * One change: run it, flush what it owes D1, and wake the other members if it * committed something. A write that changed nothing spends no sequence - * number and so notifies nobody, which is what keeps retries quiet. + * number, so it notifies nobody and does not count as use of the group, + * which is what keeps retries quiet. */ private async write(profileId: string, body: (tx: Tx, now: string) => T): Promise> { const now = nowIso(); @@ -140,6 +141,7 @@ export class Group extends DurableObject { const before = currentSeq(tx); const value = body(tx, now); const after = currentSeq(tx); + if (after > before) touchDormancy(tx, now); return { value, spent: after > before ? after : null }; }), ); diff --git a/server/src/do/group/invites.ts b/server/src/do/group/invites.ts index 0ca450e..b38cfad 100644 --- a/server/src/do/group/invites.ts +++ b/server/src/do/group/invites.ts @@ -84,8 +84,8 @@ export function peek(tx: Tx, token: string, viewer: string | null, now: string): const meta = requireMeta(tx); const nameOf = (memberId: string) => tx.select({ name: schema.members.displayName }).from(schema.members).where(eq(schema.members.id, memberId)).get()?.name ?? null; const memberCount = tx.select({ count: sql`count(*)` }).from(schema.members).where(isNull(schema.members.leftAt)).get()?.count ?? 0; - const isMember = viewer !== null && activeMemberFor(tx, viewer) !== undefined; - const shared = { groupId: meta.id, groupName: meta.name, memberCount, isMember }; + const place = viewer === null ? undefined : placeOf(tx, viewer); + const shared = { groupId: meta.id, groupName: meta.name, memberCount, isMember: place !== undefined && place.leftAt === null, hasLeft: place?.leftAt != null }; const invite = tx.select().from(schema.invites).where(eq(schema.invites.token, token)).get(); if (invite) { @@ -129,23 +129,26 @@ export function placeholders(tx: Tx, token: string, now: string): Placeholder[] /** * Walking in: redeem an invite, claim a placeholder behind an open link, or - * arrive as somebody new. Never a second place for one account. + * arrive as somebody new. Never a second place for one account: somebody who + * left gets their own place back, whatever the link offered. */ export function join(tx: Tx, token: string, profileId: string, request: JoinRequest, now: string): Member { requireMeta(tx); - if (tx.select().from(schema.members).where(eq(schema.members.profileId, profileId)).get()) { - refuse("already_member", "You are already in this group."); - } + const previous = placeOf(tx, profileId); + if (previous && previous.leftAt === null) refuse("already_member", "You are already in this group."); const invite = tx.select().from(schema.invites).where(eq(schema.invites.token, token)).get(); if (invite) { if (invite.redeemedAt !== null) refuse("invite_spent", "This invite link has already been used."); if (invite.expiresAt < now) refuse("invite_expired", "This invite link has expired."); + // The invite names somebody else's place, so it stays unspent for them. + if (previous) return rejoin(tx, previous, now); tx.update(schema.invites).set({ redeemedAt: now, redeemedBy: profileId }).where(eq(schema.invites.token, token)).run(); return claim(tx, invite.memberId, profileId, now); } requireLiveLink(tx, token, now); + if (previous) return rejoin(tx, previous, now); if (request.memberId !== null) return claim(tx, request.memberId, profileId, now); const name = request.displayName?.trim() ?? ""; @@ -159,6 +162,15 @@ export function join(tx: Tx, token: string, profileId: string, request: JoinRequ return requireMember(tx, id); } +/** Coming back to the place you left, balances and history as they were. */ +function rejoin(tx: Tx, member: MemberRow, now: string): Member { + const seq = nextSeq(tx); + tx.update(schema.members).set({ leftAt: null, updatedAt: now, seq }).where(eq(schema.members.id, member.id)).run(); + append(tx, { seq, now, actorId: null, kind: "member_joined", subjectId: member.id, member: { displayName: member.displayName, previousName: null } }); + if (member.profileId !== null) stageMembership(tx, member.profileId, null, now); + return requireMember(tx, member.id); +} + /** Taking over a placeholder sets one column; no balance moves. Nobody did it to them, so no actor. */ function claim(tx: Tx, memberId: string, profileId: string, now: string): Member { const member = requireMember(tx, memberId); @@ -179,10 +191,7 @@ function requireLiveLink(tx: Tx, token: string, now: string): void { if (link.expiresAt < now) refuse("invite_expired", "This invite link has expired."); } -function activeMemberFor(tx: Tx, profileId: string): MemberRow | undefined { - return tx - .select() - .from(schema.members) - .where(and(eq(schema.members.profileId, profileId), isNull(schema.members.leftAt))) - .get(); +/** The account's place here, current or left. One account never holds two. */ +function placeOf(tx: Tx, profileId: string): MemberRow | undefined { + return tx.select().from(schema.members).where(eq(schema.members.profileId, profileId)).get(); } diff --git a/server/src/do/group/ledger.ts b/server/src/do/group/ledger.ts index 048649a..ee3517c 100644 --- a/server/src/do/group/ledger.ts +++ b/server/src/do/group/ledger.ts @@ -1,12 +1,12 @@ -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, isNotNull } from "drizzle-orm"; -import { chunked } from "../../chunked"; +import { chunked, insertable } from "../../chunked"; import * as schema from "../../db/group/schema"; import { type Entry, type EntryInput, editableEntryColumns } from "../../schemas/ledger"; +import { positionsByMember } from "./balances"; import { appendSnapshot, byMember, moneyRows } from "./events"; import { refuse } from "./refusal"; import { type EntryRow, nextSeq, requireMeta, type Tx, type WriteContext } from "./store"; -import { touchDormancy } from "./upkeep"; /** * The expense write path. Every rule is checked here against the finished @@ -54,12 +54,13 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: assertBalanced(input); assertMembersExist(tx, input); - // The id is client-minted, so a retry after a lost response finds its own row here. + // The id is client-minted, so a retry after a lost response finds its own row here, unchanged. const stored = readEntry(tx, id); if (stored) { - assertBaseIsCurrent(stored, input); if (JSON.stringify(comparable(stored)) === JSON.stringify(comparable(input))) return stored; + assertBaseIsCurrent(stored, input); } + assertDepartedOnlySettle(tx, stored, input); const seq = nextSeq(tx); @@ -85,16 +86,16 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: tx.delete(schema.entryPayers).where(eq(schema.entryPayers.entryId, id)).run(); tx.delete(schema.entryShares).where(eq(schema.entryShares.entryId, id)).run(); - tx.insert(schema.entryPayers) - .values(input.payers.map(({ memberId, amountMinor }) => ({ entryId: id, memberId, amountMinor }))) - .run(); - tx.insert(schema.entryShares) - .values(input.shares.map(({ memberId, amountMinor, weightMicros }) => ({ entryId: id, memberId, amountMinor, weightMicros }))) - .run(); + // A crowd's worth of payers or shares is more than one statement can carry. + for (const payers of insertable(input.payers.map(({ memberId, amountMinor }) => ({ entryId: id, memberId, amountMinor })))) { + tx.insert(schema.entryPayers).values(payers).run(); + } + for (const shares of insertable(input.shares.map(({ memberId, amountMinor, weightMicros }) => ({ entryId: id, memberId, amountMinor, weightMicros })))) { + tx.insert(schema.entryShares).values(shares).run(); + } const entry = requireEntry(tx, id); appendSnapshot(tx, entry, entry.payers, entry.shares, { seq, now, actorId: actor.id }); - touchDormancy(tx, now); return entry; } @@ -121,16 +122,59 @@ function assertMembersExist(tx: Tx, input: EntryInput): void { } /** - * A stale base is refused only when the write would move money; two people - * fixing a typo never arbitrate. A null base claims no version. + * Somebody who has left cannot see this group, so no write may change what + * they owe or are owed, except to settle it: a balance they left with only + * moves toward zero. Editing a description, or a split among the people still + * here, moves none of their money and goes through. */ -function assertBaseIsCurrent(stored: Entry, input: EntryInput): void { - if (input.baseSeq === null || stored.seq === input.baseSeq) return; - if (JSON.stringify(money(stored)) !== JSON.stringify(money(input))) { - refuse("stale_base", "This expense changed since you opened it."); +function assertDepartedOnlySettle(tx: Tx, stored: Entry | undefined, input: EntryInput): void { + const departed = new Map( + tx + .select({ id: schema.members.id, name: schema.members.displayName }) + .from(schema.members) + .where(isNotNull(schema.members.leftAt)) + .all() + .map((member) => [member.id, member.name]), + ); + if (departed.size === 0) return; + + // Per member, per currency: what this write moves, the stored version taken back out. + const moved = new Map>(); + const count = (entry: Pick, sign: 1 | -1) => { + if (entry.deletedAt !== null) return; + const add = (memberId: string, amount: number) => { + if (!departed.has(memberId)) return; + const byCurrency = moved.get(memberId) ?? new Map(); + byCurrency.set(entry.currency, (byCurrency.get(entry.currency) ?? 0) + sign * amount); + moved.set(memberId, byCurrency); + }; + for (const payer of entry.payers) add(payer.memberId, payer.amountMinor); + for (const share of entry.shares) add(share.memberId, -share.amountMinor); + }; + if (stored) count(stored, -1); + count(input, 1); + + const held = positionsByMember(tx); + for (const [memberId, byCurrency] of moved) { + for (const [currency, delta] of byCurrency) { + if (delta === 0) continue; + const before = held.get(memberId)?.get(currency) ?? 0; + const after = before + delta; + const settles = before !== 0 && Math.sign(after) !== -Math.sign(before) && Math.abs(after) < Math.abs(before); + if (!settles) refuse("forbidden", `${departed.get(memberId)} has left this group, so this expense cannot change what they owe.`); + } } } +/** + * An edit replaces the whole expense, so it must be composed on the version + * stored now; anything else would silently undo the edit in between. A null + * base claims the row is new, which a stored row contradicts. + */ +function assertBaseIsCurrent(stored: Entry, input: EntryInput): void { + if (stored.seq !== input.baseSeq) refuse("stale_base", "This expense changed since you opened it."); +} + type Comparable = Omit; /** What moves a balance. Deleting or restoring moves every payer and share at once. */ diff --git a/server/src/do/group/migrations/20261002071250_fair_hemingway/migration.sql b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql similarity index 99% rename from server/src/do/group/migrations/20261002071250_fair_hemingway/migration.sql rename to server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql index e5be1c4..cddfc77 100644 --- a/server/src/do/group/migrations/20261002071250_fair_hemingway/migration.sql +++ b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql @@ -116,6 +116,7 @@ CREATE TABLE `meta` ( `cover_photo` text, `created_by` text NOT NULL, `created_at` text NOT NULL, + `last_activity_at` text NOT NULL, `archived_at` text, `updated_at` text NOT NULL, `seq` integer NOT NULL, diff --git a/server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json similarity index 99% rename from server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json rename to server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json index 9e0c3a8..d37270f 100644 --- a/server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json +++ b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json @@ -1,7 +1,7 @@ { "version": "7", "dialect": "sqlite", - "id": "f612090c-d005-4921-9693-cb1974b0d8db", + "id": "09fbe554-2e51-414e-935c-d7812dbbe577", "prevIds": [ "00000000-0000-0000-0000-000000000000" ], @@ -794,6 +794,16 @@ "entityType": "columns", "table": "meta" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_activity_at", + "entityType": "columns", + "table": "meta" + }, { "type": "text", "notNull": false, diff --git a/server/src/do/group/migrations/migrations.js b/server/src/do/group/migrations/migrations.js index 8152145..d6d50d0 100644 --- a/server/src/do/group/migrations/migrations.js +++ b/server/src/do/group/migrations/migrations.js @@ -1,8 +1,8 @@ -import m0000 from './20261002071250_fair_hemingway/migration.sql'; +import m0000 from './20261005061632_gigantic_mother_askani/migration.sql'; export default { migrations: { - "20261002071250_fair_hemingway": m0000 + "20261005061632_gigantic_mother_askani": m0000 } } \ No newline at end of file diff --git a/server/src/do/group/roster.ts b/server/src/do/group/roster.ts index a62bb56..65e6ef7 100644 --- a/server/src/do/group/roster.ts +++ b/server/src/do/group/roster.ts @@ -6,7 +6,7 @@ import { isSettled } from "./balances"; import { append } from "./events"; import { refuse } from "./refusal"; import { findMemberByProfile, findMeta, nextSeq, purge, requireMember, requireMeta, type Tx, type WriteContext } from "./store"; -import { stageMembership, stagePurge, touchDormancy } from "./upkeep"; +import { stageMembership, stagePurge } from "./upkeep"; /** * The group and its people. Membership is the only permission: no owner, no @@ -27,13 +27,13 @@ export function createGroup(tx: Tx, groupId: string, input: GroupInput, profileI ...appearanceOf(input), createdBy: input.creatorId, createdAt: now, + lastActivityAt: now, updatedAt: now, seq, }) .run(); stageMembership(tx, profileId, null, now); - touchDormancy(tx, now); return requireMeta(tx); } @@ -89,27 +89,38 @@ function addMember(tx: Tx, memberId: string, input: MemberInput, { now, actor }: /** * The column rules. Your own row and any placeholder are editable; another * account holder's name and payment handle are not, since a handle redirects - * money. Leaving is always yours; removing somebody else requires them settled. + * money. Nobody leaves or is removed owing or owed anything, so a balance is + * never stranded with somebody who can no longer see the group. A placeholder + * can be brought back by anybody; an account holder only comes back + * themselves, with a link. */ function updateMember(tx: Tx, memberId: string, input: MemberInput, { now, actor }: WriteContext): Member { const target = requireMember(tx, memberId); - const { displayName, upiVpa, leftAt } = input; - - const mineOrPlaceholder = target.profileId === null || target.id === actor.id; - if ((displayName !== target.displayName || upiVpa !== target.upiVpa) && !mineOrPlaceholder) { + const { displayName, upiVpa } = input; + // The device says whether; the server's clock says when, once. + const leftAt = input.leftAt === null ? null : (target.leftAt ?? now); + const isSelf = target.id === actor.id; + const leaving = leftAt !== null && target.leftAt === null; + const returning = leftAt === null && target.leftAt !== null; + + if ((displayName !== target.displayName || upiVpa !== target.upiVpa) && target.profileId !== null && !isSelf) { refuse("forbidden", `Only ${target.displayName} can change their own name or payment handle.`); } - if (leftAt !== target.leftAt && target.id !== actor.id && !isSettled(tx, target.id)) { - refuse("not_settled", `${target.displayName} is not settled up in this group, so they cannot be removed from it.`); + if (leaving && !isSettled(tx, target.id)) { + refuse("not_settled", isSelf ? "You are not settled up in this group yet, so you cannot leave it." : `${target.displayName} is not settled up in this group, so they cannot be removed from it.`); + } + if (returning && target.profileId !== null) { + refuse("forbidden", `${target.displayName} left this group, so only they can come back, with a link.`); } - if (displayName === target.displayName && upiVpa === target.upiVpa && leftAt === target.leftAt) return target; + if (displayName === target.displayName && upiVpa === target.upiVpa && !leaving && !returning) return target; const seq = nextSeq(tx); tx.update(schema.members).set({ displayName, upiVpa, leftAt, updatedAt: now, seq }).where(eq(schema.members.id, memberId)).run(); - // A payment handle and a rejoin are deliberately silent. + // A payment handle is deliberately silent. const written = { seq, now, actorId: actor.id, subjectId: memberId }; - if (leftAt !== null && target.leftAt === null) append(tx, { ...written, kind: "member_left", member: { displayName, previousName: null } }); + if (leaving) append(tx, { ...written, kind: "member_left", member: { displayName, previousName: null } }); + if (returning) append(tx, { ...written, kind: "member_added", member: { displayName, previousName: null } }); if (displayName !== target.displayName) append(tx, { ...written, kind: "member_renamed", member: { displayName, previousName: target.displayName } }); if (target.profileId !== null) stageMembership(tx, target.profileId, leftAt, now); diff --git a/server/src/do/group/upkeep.ts b/server/src/do/group/upkeep.ts index c5338c4..207db15 100644 --- a/server/src/do/group/upkeep.ts +++ b/server/src/do/group/upkeep.ts @@ -41,22 +41,13 @@ export function nextDue(tx: Tx): number | null { ); } -/** Called on every ledger write: a group in use is not dormant. */ +/** Called in the transaction of every change a member makes: a group in use is not dormant. */ export function touchDormancy(tx: Tx, now: string): void { + tx.update(schema.meta).set({ lastActivityAt: now }).run(); scheduleAt(tx, "archive", Date.parse(now) + DORMANCY.archiveAfter); unschedule(tx, "purge"); } -/** Last genuine use, recomputed from the ledger so an early alarm cannot archive a busy group. */ -function lastActivity(tx: Tx, createdAt: string): number { - const latest = - tx - .select({ at: sql`max(${schema.entries.createdAt})` }) - .from(schema.entries) - .get()?.at ?? createdAt; - return Date.parse(latest); -} - export interface UpkeepOutcome { archived: boolean; purged: boolean; @@ -68,7 +59,7 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { const meta = findMeta(tx); if (!meta) return outcome; - const quietSince = lastActivity(tx, meta.createdAt); + const quietSince = Date.parse(meta.lastActivityAt); if (meta.archivedAt === null) { if (quietSince + DORMANCY.archiveAfter > now) { @@ -81,8 +72,10 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { tx.update(schema.meta).set({ archivedAt: at, updatedAt: at, seq }).where(eq(schema.meta.id, meta.id)).run(); append(tx, { seq, now: at, actorId: null, kind: "group_archived", group: { name: meta.name, previousName: null } }); outcome.archived = true; - unschedule(tx, "archive"); } + // Archived, by this run or by a member: only collecting is left to wait for, and a spent + // archive deadline left behind would hold the alarm in the past. + unschedule(tx, "archive"); // Nobody left with an account: collect as soon as it is quiet. const due = hasAccountHolders(tx) ? quietSince + DORMANCY.purgeAfter : quietSince; @@ -99,7 +92,6 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { purge(tx, nowIso(now)); stagePurge(tx, meta.id, nowIso(now)); unschedule(tx, "purge"); - unschedule(tx, "archive"); outcome.purged = true; return outcome; } diff --git a/server/src/forget.ts b/server/src/forget.ts index ec7dd20..b60f90c 100644 --- a/server/src/forget.ts +++ b/server/src/forget.ts @@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/d1"; import { user } from "./auth-schema"; import { defaultAvatar } from "./db/appearance"; +import { nextProfileVersion, profileVersion } from "./db/d1/profile-version"; import { deviceTokens, memberships, profiles } from "./db/d1/schema"; /** @@ -35,10 +36,13 @@ export async function forgetAccount(env: Env, profileId: string, { heir }: { hei // Kept, emptied, so co-members' history still resolves. const now = new Date().toISOString(); - await db - .update(profiles) - .set({ displayName: null, upiVpa: null, ...defaultAvatar, deletedAt: now, updatedAt: now }) - .where(eq(profiles.id, profileId)); + await db.batch([ + nextProfileVersion(db), + db + .update(profiles) + .set({ displayName: null, upiVpa: null, ...defaultAvatar, deletedAt: now, updatedAt: now, version: profileVersion }) + .where(eq(profiles.id, profileId)), + ]); return { forgotten, purged }; } diff --git a/server/src/fx/providers.ts b/server/src/fx/providers.ts index db18c9a..15f4a95 100644 --- a/server/src/fx/providers.ts +++ b/server/src/fx/providers.ts @@ -24,6 +24,14 @@ export interface FetchOptions { env: Env; } +export interface RangeOptions { + /** The first and last day wanted, inclusive, `YYYY-MM-DD`. */ + from: string; + to: string; + currencies: string[]; + env: Env; +} + /** A rate source; everything provider-specific stays inside the adapter. */ export interface FxProvider { readonly name: string; @@ -33,6 +41,12 @@ export interface FxProvider { /** Null for any failure: a provider being down is ordinary. */ fetch(options: FetchOptions): Promise; + + /** + * Every publication between two days in one request, for filling a long + * stretch of history. Only some providers can; null for any failure. + */ + fetchRange?(options: RangeOptions): Promise; } /** A fetch that cannot hang the run. */ @@ -83,6 +97,24 @@ export const frankfurter: FxProvider = { clean.USD = 1; return { asOf: date, rates: clean }; }, + + async fetchRange({ from, to, currencies }: RangeOptions): Promise { + const symbols = currencies.filter((code) => code !== "USD"); + if (symbols.length === 0) return null; + + // A year of ECB's thirty-odd currencies is a few hundred kilobytes. + const body = await getJson(`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=${symbols.join(",")}`, 30_000); + if (typeof body !== "object" || body === null) return null; + + const { rates } = body as { rates?: unknown }; + if (typeof rates !== "object" || rates === null) return null; + + const snapshots = Object.entries(rates as Record).flatMap(([asOf, day]) => { + const clean = sanitise(day, symbols); + return /^\d{4}-\d{2}-\d{2}$/.test(asOf) && Object.keys(clean).length > 0 ? [{ asOf, rates: { ...clean, USD: 1 } }] : []; + }); + return snapshots.length > 0 ? snapshots : null; + }, }; /** diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index 0510f32..9482903 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -6,7 +6,7 @@ import type { Context } from "hono"; import { clientIp, exhausted, maybeSignedIn, rateLimited, signedIn, tooMany } from "../api/routing"; import { user } from "../auth-schema"; -import type { AppEnv } from "../context"; +import { type AppEnv, mayChangeIdentity, type Session } from "../context"; import { apiError, errorResponse, jsonBody, jsonResponse } from "../schemas/common"; import { EmailStartRequestSchema, EmailStartResponseSchema, EmailVerifyRequestSchema, GoogleIdentityRequestSchema, GoogleRedirectRequestSchema, GoogleRedirectSchema, IdentityOutcomeSchema, ReauthStartSchema, ReauthVerifyRequestSchema, SessionSchema } from "../schemas/identity"; import { outcomeFor, toAccount } from "./outcome"; @@ -22,6 +22,13 @@ import { outcomeFor, toAccount } from "./outcome"; * strand a ledger until the caller says it has warned the person. */ +/** + * Attaching an address or a Google account to an account that already has + * one is how a session somebody else got hold of would become theirs for + * good, so it needs the same fresh sign-in as deleting the account. + */ +const reauthRequired = errorResponse("This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`."); + const guestRoute = createRoute({ ...maybeSignedIn, method: "post", @@ -63,6 +70,7 @@ const googleRoute = createRoute({ request: { body: jsonBody(GoogleIdentityRequestSchema) }, responses: { 200: jsonResponse(IdentityOutcomeSchema, "Linked, or signed in"), + 403: reauthRequired, 409: errorResponse("That Google account already has an account of its own, and allowSignIn was not set."), }, }); @@ -76,7 +84,7 @@ const googleRedirectRoute = createRoute({ summary: "Where to send the browser to link or sign in with Google", description: "Links to the session in hand unless allowSignIn is set or there is none. Afterwards, read `GET /identity/session`.", request: { body: jsonBody(GoogleRedirectRequestSchema) }, - responses: { 200: jsonResponse(GoogleRedirectSchema, "The URL to visit"), 400: errorResponse("The callback URL is not on this origin.") }, + responses: { 200: jsonResponse(GoogleRedirectSchema, "The URL to visit"), 400: errorResponse("The callback URL is not on this origin."), 403: reauthRequired }, }); const emailStartRoute = createRoute({ @@ -87,7 +95,7 @@ const emailStartRoute = createRoute({ tags: ["identity"], summary: "Send a sign-in code, and say which flow it started", request: { body: jsonBody(EmailStartRequestSchema) }, - responses: { 200: jsonResponse(EmailStartResponseSchema, "A code is on its way"), ...rateLimited }, + responses: { 200: jsonResponse(EmailStartResponseSchema, "A code is on its way"), 403: reauthRequired, ...rateLimited }, }); const emailVerifyRoute = createRoute({ @@ -102,6 +110,7 @@ const emailVerifyRoute = createRoute({ 200: jsonResponse(IdentityOutcomeSchema, "Attached, or signed in"), 400: errorResponse("The code is wrong or has expired."), 401: errorResponse("That code was for a flow this session cannot finish."), + 403: reauthRequired, ...rateLimited, }, }); @@ -139,6 +148,12 @@ const reauthVerifyRoute = createRoute({ }, }); +function staleForIdentityChange(session: Session | null): boolean { + return session !== null && !mayChangeIdentity(session); +} + +const confirmFirst = () => apiError("reauth_required", "Confirm it is you before changing how this account signs in."); + /** Better Auth checks the token's nonce claim against `nonce` exactly, so the app gives Google the same value it sends here. */ const google = (idToken: string, nonce: string | null) => ({ provider: "google" as const, idToken: { token: idToken, nonce: nonce ?? undefined } }); @@ -198,8 +213,11 @@ export function identityRoutes(routes: OpenAPIHono) { const { auth, session } = c.var; const { idToken, nonce, allowSignIn } = c.req.valid("json"); const before = session?.userId ?? null; + // An old session may still sign in somewhere else; it may not attach an identity to itself. + const mayLink = !staleForIdentityChange(session); + if (!mayLink && !allowSignIn) return c.json(confirmFirst(), 403); - if (before !== null) { + if (before !== null && mayLink) { try { const linked = await auth.api.linkSocialAccount({ body: google(idToken, nonce), headers: c.req.raw.headers, returnHeaders: true }); await settle(c.var.db, before); @@ -223,6 +241,7 @@ export function identityRoutes(routes: OpenAPIHono) { if (new URL(callbackUrl).origin !== new URL(c.env.APP_ORIGIN).origin) return c.json(apiError("malformed", "The callback URL must be on this site."), 400); const body = { provider: "google" as const, callbackURL: callbackUrl, errorCallbackURL: callbackUrl, disableRedirect: true }; const link = c.var.session !== null && !allowSignIn; + if (link && staleForIdentityChange(c.var.session)) return c.json(confirmFirst(), 403); const started = link ? await c.var.auth.api.linkSocialAccount({ body, headers: c.req.raw.headers, returnHeaders: true }) : await c.var.auth.api.signInSocial({ body, headers: c.req.raw.headers, returnHeaders: true }); // The OAuth state cookie has to reach the browser for the callback to verify. @@ -242,6 +261,7 @@ export function identityRoutes(routes: OpenAPIHono) { return c.json({ flow: "signInPending" as const }, 200); } + if (staleForIdentityChange(session)) return c.json(confirmFirst(), 403); await auth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: c.req.raw.headers }); return c.json({ flow: "linkPending" as const }, 200); }); @@ -255,6 +275,7 @@ export function identityRoutes(routes: OpenAPIHono) { if (flow === "linkPending") { if (before === null) return c.json(apiError("no_session", "That code was for attaching an address to a session, and there is no session to attach it to."), 401); + if (staleForIdentityChange(session)) return c.json(confirmFirst(), 403); const changed = await auth.api.changeEmailEmailOTP({ body: { newEmail: email, otp: code }, headers: c.req.raw.headers, returnHeaders: true }); await settle(c.var.db, before); return c.json(outcomeFor(await accountById(c.var.db, before), before, forward(c, changed.headers)), 200); diff --git a/server/src/scheduled.ts b/server/src/scheduled.ts index 33c1ab4..f166444 100644 --- a/server/src/scheduled.ts +++ b/server/src/scheduled.ts @@ -9,10 +9,15 @@ import { finishDeletions } from "./forget"; /** The two crons. Dormant groups are not swept: each group object arms its own alarm. */ export async function refreshRates(env: Env): Promise { - const outcome = await env.FX.getByName("global").refresh(); + const fx = env.FX.getByName("global"); + const outcome = await fx.refresh(); // Logged, not alerted: a missing rate is a missing estimate, never a wrong balance. if (outcome.missing.length > 0) console.warn("[fx] uncovered after the waterfall", outcome.missing.join(",")); console.log("[fx] stored", outcome.stored, "months", outcome.months.join(",")); + + // After today's, so a gap reaching up to today is measured against it. Nothing to do on almost every run. + const history = await fx.fillHistory(); + if (history.gaps.length > 0) console.log("[fx] filled", history.gaps.map((gap) => `${gap.from}..${gap.to}`).join(","), "stored", history.stored); } const DAY = 24 * 60 * 60 * 1000; diff --git a/server/src/schemas/account.ts b/server/src/schemas/account.ts index 2415252..fad4deb 100644 --- a/server/src/schemas/account.ts +++ b/server/src/schemas/account.ts @@ -2,7 +2,7 @@ import { z } from "@hono/zod-openapi"; import { deviceTokens, platforms, profiles } from "../db/d1/schema"; import { avatarFields, avatarRefinements, refineAvatar } from "./appearance"; -import { createSelectSchema, IdSchema, NameSchema, TimestampSchema, UpiVpaSchema } from "./common"; +import { createSelectSchema, IdSchema, NameSchema, SeqSchema, TimestampSchema, UpiVpaSchema } from "./common"; /** The person behind an account, their devices, and deleting it. None of it is group-scoped. */ @@ -15,6 +15,7 @@ const profileRow = createSelectSchema(profiles, { ...avatarRefinements, updatedAt: () => TimestampSchema, deletedAt: () => TimestampSchema, + version: () => SeqSchema, }); /** A null `displayName` means nobody chose one; a set `deletedAt` means the account is gone. */ @@ -26,22 +27,16 @@ export const ProfileUpdateSchema = profileRow .superRefine(refineAvatar) .openapi("ProfileUpdate"); -/** - * One page of the profile feed, the one feed cursored on time: D1 has no - * single writer to hand out a sequence number. - */ +/** One page of the profile feed, oldest version first. */ export const ProfilePageSchema = z .object({ profiles: z.array(ProfileSchema), - /** Opaque. Send it back as `after`; null when the page is empty. */ - cursor: z.string().nullable(), + /** The newest version on the page, or the cursor sent when it is empty. Send it back as `since`. */ + seq: SeqSchema, hasMore: z.boolean(), }) .openapi("ProfilePage"); -/** The groups this account is still in. */ -export const GroupIdsSchema = z.object({ groupIds: z.array(IdSchema) }).openapi("GroupIds"); - /** An FCM registration token. Registering claims it for this session, since phones change hands. */ export const DeviceSchema = createSelectSchema(deviceTokens, { token: () => z.string().min(1).max(4096), platform: () => PlatformSchema }) .pick({ token: true, platform: true }) @@ -61,6 +56,5 @@ export const AccountDeletionSchema = z export type Profile = z.infer; export type ProfileUpdate = z.infer; export type ProfilePage = z.infer; -export type GroupIds = z.infer; export type Device = z.infer; export type AccountDeletion = z.infer; diff --git a/server/src/schemas/common.ts b/server/src/schemas/common.ts index 14f9c81..739fc8f 100644 --- a/server/src/schemas/common.ts +++ b/server/src/schemas/common.ts @@ -62,7 +62,7 @@ export const refusalCodes = [ "no_such_member", /** `sum(payers) = sum(shares) = amount` does not hold. */ "unbalanced", - /** Composed against a version that has since moved money. */ + /** Composed against a version that has since changed. */ "stale_base", /** A column rule: whose name, whose payment handle. */ "forbidden", diff --git a/server/src/schemas/ledger.ts b/server/src/schemas/ledger.ts index 5a4b0d8..304f01a 100644 --- a/server/src/schemas/ledger.ts +++ b/server/src/schemas/ledger.ts @@ -4,7 +4,7 @@ import { linkKinds } from "../db/d1/schema"; import * as tables from "../db/group/schema"; import { ProfileSchema } from "./account"; import { avatarFields, avatarRefinements, coverFields, coverRefinements, refineAvatar, refineCover } from "./appearance"; -import { CurrencyCodeSchema, createSelectSchema, DateSchema, IdSchema, NameSchema, SeqSchema, TimestampSchema, TimeZoneSchema, UpiVpaSchema } from "./common"; +import { CurrencyCodeSchema, createSelectSchema, DateSchema, ErrorCodeSchema, IdSchema, NameSchema, SeqSchema, TimestampSchema, TimeZoneSchema, UpiVpaSchema } from "./common"; /** * The ledger on the wire. Rows are the group object's tables; request bodies @@ -112,6 +112,7 @@ const groupRow = createSelectSchema(tables.meta, { createdAt: () => TimestampSchema, archivedAt: () => TimestampSchema, updatedAt: () => TimestampSchema, + lastActivityAt: () => TimestampSchema, seq: () => SeqSchema, }); @@ -162,6 +163,8 @@ export const LinkPreviewSchema = z isRevoked: z.boolean(), /** Whether the caller already holds a place in this group. */ isMember: z.boolean(), + /** Whether the caller held a place here and left it. Joining brings that same place back. */ + hasLeft: z.boolean(), }) .openapi("LinkPreview"); @@ -228,6 +231,35 @@ export const ChangePageSchema = z /** A page as the group's object answers it; the Worker adds the profiles, which live in D1. */ export type GroupChanges = Omit; +/** Where a device stands in one group's history. */ +export const GroupCursorSchema = z.object({ groupId: IdSchema, since: SeqSchema }).openapi("GroupCursor"); + +/** The most groups one pull reads. A device with more asks again with the rest. */ +export const MAX_GROUPS_PER_PULL = 50; + +/** Groups to read past their cursors, each its own page. */ +export const PullRequestSchema = z + .object({ + groups: z.array(GroupCursorSchema).max(MAX_GROUPS_PER_PULL), + /** Per group, counted in changes as on a page. */ + limit: z.int().min(1).max(500), + }) + .refine((request) => new Set(request.groups.map((cursor) => cursor.groupId)).size === request.groups.length, { path: ["groups"], message: "Each group is asked for once." }) + .openapi("PullRequest"); + +/** A group in the request that could not be read, and why. Every other group in the same pull is still answered. */ +export const GroupRefusalSchema = z.object({ groupId: IdSchema, code: ErrorCodeSchema, message: z.string() }).openapi("GroupRefusal"); + +export const PullSchema = z + .object({ + /** Every group this account is in now, asked for or not: how a device finds a group joined elsewhere. */ + groupIds: z.array(IdSchema), + /** One page per group read, in the order asked. */ + pages: z.array(ChangePageSchema), + refusals: z.array(GroupRefusalSchema), + }) + .openapi("Pull"); + /** The kinds that wake a device: an expense, somebody arriving, somebody leaving. */ export const notifiableKinds = ["entry", "member_joined", "member_left"] as const satisfies readonly (typeof tables.eventKinds)[number][]; @@ -265,4 +297,8 @@ export type GroupEventPayload = z.infer; export type LinkEventPayload = z.infer; export type Event = z.infer; export type ChangePage = z.infer; +export type GroupCursor = z.infer; +export type PullRequest = z.infer; +export type GroupRefusal = z.infer; +export type Pull = z.infer; export type PushData = z.infer; diff --git a/server/test/account.test.ts b/server/test/account.test.ts index 3a63c4e..09d05d3 100644 --- a/server/test/account.test.ts +++ b/server/test/account.test.ts @@ -3,9 +3,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; -import type { AccountDeletion, ChangePage, GroupIds, GroupLink, Joined, LinkPreview, Profile, ProfilePage } from "./api-types"; +import type { AccountDeletion, GroupLink, Joined, LinkPreview, Profile, ProfilePage } from "./api-types"; import { freshId } from "./group"; -import { type Guest, signInAsGuest } from "./session"; +import { changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; /** The person, rather than the ledger. */ @@ -166,7 +166,7 @@ describe("the profile feed", () => { expect(other.profiles.map((row) => row.id)).toContain(host.id); }); - it("pages on the pair, so two renames in one millisecond cannot hide one", async () => { + it("pages on the version, delivering every row once", async () => { const host = await signInAsGuest(); const friends = [await signInAsGuest(), await signInAsGuest(), await signInAsGuest()]; @@ -178,17 +178,16 @@ describe("the profile feed", () => { } const collected: string[] = []; - let cursor: ProfilePage = { profiles: [], cursor: null, hasMore: true }; + let page: ProfilePage = { profiles: [], seq: 0, hasMore: true }; let pages = 0; - while (cursor.hasMore && pages < 10) { - const query = cursor.cursor ? `?limit=2&after=${encodeURIComponent(cursor.cursor)}` : "?limit=2"; - cursor = await json(await call(`/api/profiles${query}`, host)); - collected.push(...cursor.profiles.map((row) => row.id)); + while (page.hasMore && pages < 10) { + page = await json(await call(`/api/profiles?limit=2&since=${page.seq}`, host)); + collected.push(...page.profiles.map((row) => row.id)); pages += 1; } - expect(cursor.hasMore).toBe(false); + expect(page.hasMore).toBe(false); expect(new Set(collected)).toEqual(new Set([host.id, ...friends.map((friend) => friend.id)])); // No row twice. @@ -204,7 +203,7 @@ describe("a group's change page", () => { await named(stranger, "Zara"); const { groupId } = await share(host, friend, "Priya"); - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, host)); + const page = await changes(host, groupId); expect(page.profiles.map((row) => row.id).sort()).toEqual([host.id, friend.id].sort()); }); @@ -220,7 +219,7 @@ describe("a group's change page", () => { await call(`/api/links/${link.token}/join`, guest, { method: "POST", body: JSON.stringify({ memberId: null, displayName: `Friend ${index}` }) }); } - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, host)); + const page = await changes(host, groupId); expect(page.profiles).toHaveLength(121); }, 30_000); @@ -239,7 +238,7 @@ describe("a group's change page", () => { const { groupId } = await share(host, friend, "Priya"); - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, host)); + const page = await changes(host, groupId); expect(page.profiles).toContainEqual(expect.objectContaining({ id: friend.id, displayName: "Priya" })); }); }); @@ -283,7 +282,7 @@ describe("deleting an account", () => { // The group is untouched for everybody else, and the member row keeps its // name: money Ravi paid is a fact about Priya's group as much as his. - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, staying)); + const page = await changes(staying, groupId); const row = page.members.find((each) => each.id === member.id); expect(row?.displayName).toBe("Ravi"); @@ -300,7 +299,7 @@ describe("deleting an account", () => { await call("/api/account", leaving, { method: "DELETE" }); - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, staying)); + const page = await changes(staying, groupId); expect(page.members.find((each) => each.id === member.id)?.displayName).toBe("Ravi Kumar"); }); @@ -314,7 +313,7 @@ describe("deleting an account", () => { // Holding somebody's expense descriptions forever in a group with no living // reader is the opposite of what deleting an account asks for. const after = await signInAsGuest(); - const grave = await json(await call(`/api/groups/${groupId}/changes?since=0`, after)); + const grave = await changes(after, groupId); expect(grave.purgedAt).not.toBeNull(); expect(grave.members).toEqual([]); @@ -339,10 +338,10 @@ describe("deleting an account", () => { // The session is gone with the account, so the device is signed out by // consequence rather than by a second call. - expect((await call("/api/groups", going)).status).toBe(401); + expect((await pullResponse(going, [])).status).toBe(401); // The place is a placeholder now, so the group no longer names the account at all. - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, friend)); + const page = await changes(friend, groupId); expect(page.profiles.map((row) => row.id)).not.toContain(going.id); }); @@ -354,7 +353,7 @@ describe("deleting an account", () => { describe("a guest who never became anybody", () => { it("still gets a profile, no groups and an empty feed", async () => { const guest = await signInAsGuest(); - expect(await json(await call("/api/groups", guest))).toEqual({ groupIds: [] }); + expect((await pull(guest, [])).groupIds).toEqual([]); // Visible only to themselves, which is the degenerate case of the rule // rather than a special one: nobody shares a group with them yet. diff --git a/server/test/activity.test.ts b/server/test/activity.test.ts index 3467126..5727734 100644 --- a/server/test/activity.test.ts +++ b/server/test/activity.test.ts @@ -52,7 +52,7 @@ describe("recording an expense", () => { const { groupId, ravi, priya } = await makeGroup(); const id = freshId("e"); - ok(await saveEntry(stub(groupId), evenly(id, ravi.id, [ravi.id, priya.id], 1000), RAVI)); + const first = ok(await saveEntry(stub(groupId), evenly(id, ravi.id, [ravi.id, priya.id], 1000), RAVI)); const before = (await eventsOf(groupId, RAVI)).filter((event) => event.kind === "entry").length; ok( @@ -60,6 +60,7 @@ describe("recording an expense", () => { stub(groupId), expense({ id, + baseSeq: first.seq, amountMinor: 1000, payers: [{ memberId: ravi.id, amountMinor: 1000 }], shares: [ diff --git a/server/test/api-types.ts b/server/test/api-types.ts index e6b76ad..80dc2a2 100644 --- a/server/test/api-types.ts +++ b/server/test/api-types.ts @@ -1,3 +1,3 @@ /** The wire types, as a client sees them. */ -export type { AccountDeletion, GroupIds, Profile, ProfilePage } from "../src/schemas/account"; +export type { AccountDeletion, Profile, ProfilePage } from "../src/schemas/account"; export type { ChangePage, Entry, Group, GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, Member, PlaceholderList } from "../src/schemas/ledger"; diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 23f82ed..75dddd7 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -3,9 +3,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; -import type { ChangePage, Entry, Group, GroupIds, Member } from "./api-types"; +import type { Entry, Group, Member } from "./api-types"; import { freshId } from "./group"; -import { type Guest, signInAsGuest } from "./session"; +import { changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; /** The sync surface over HTTP. */ @@ -72,30 +72,27 @@ beforeAll(async () => { describe("without a session", () => { it("refuses every route in the sync surface", async () => { - const paths = ["/api/groups", `/api/groups/${freshId()}/changes`]; - - for (const path of paths) { - const response = await call(path, null); - expect(response.status, path).toBe(401); - expect((await json(response)).error.code).toBe("no_session"); - } + const pulled = await pullResponse(null, [{ groupId: freshId(), since: 0 }]); + expect(pulled.status).toBe(401); + expect((await json(pulled)).error.code).toBe("no_session"); const write = await call(`/api/groups/${freshId()}`, null, { method: "PUT", body: JSON.stringify({}) }); expect(write.status).toBe(401); }); }); -describe("listing groups", () => { - it("names the groups the account is still in", async () => { +describe("finding the account's groups", () => { + it("names the groups the account is still in, though none was asked for", async () => { const { id } = await makeGroup(ravi); - const body = await json(await call("/api/groups", ravi)); + const body = await pull(ravi, []); expect(body.groupIds).toContain(id); + expect(body.pages).toEqual([]); }); it("does not name somebody else's groups", async () => { const { id } = await makeGroup(ravi); - const body = await json(await call("/api/groups", zara)); + const body = await pull(zara, []); expect(body.groupIds).not.toContain(id); }); @@ -104,39 +101,66 @@ describe("listing groups", () => { describe("the change feed", () => { it("answers with a cursor, and pages on it", async () => { const { id, group } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes?since=0`, ravi)); + const page = await changes(ravi, id); expect(page.group?.id).toBe(group.id); expect(page.members).toHaveLength(1); expect(page.hasMore).toBe(false); - const nothing = await json(await call(`/api/groups/${id}/changes?since=${page.seq}`, ravi)); + const nothing = await changes(ravi, id, page.seq); expect(nothing.seq).toBe(page.seq); expect(nothing.members).toHaveLength(0); }); - it("refuses a stranger with 403, and says the refusal is permanent", async () => { + it("refuses a stranger one group, and still answers the rest of the pull", async () => { const { id } = await makeGroup(ravi); - const response = await call(`/api/groups/${id}/changes`, zara); + const { id: zaras } = await makeGroup(zara); + const body = await pull(zara, [ + { groupId: id, since: 0 }, + { groupId: zaras, since: 0 }, + ]); + + expect(body.refusals).toEqual([{ groupId: id, code: "not_member", message: expect.any(String) }]); + expect(body.pages.map((page) => page.groupId)).toEqual([zaras]); + }); - expect(response.status).toBe(403); - const body = await json(response); - expect(body.error.code).toBe("not_member"); - expect(body.error.retry).toBe("permanent"); + it("tells a device that a group id names nothing", async () => { + const ghost = freshId("ghost"); + const body = await pull(ravi, [{ groupId: ghost, since: 0 }]); + + expect(body.refusals).toEqual([{ groupId: ghost, code: "no_group", message: expect.any(String) }]); }); - it("tells a device that a group id names nothing, rather than refusing it", async () => { - const response = await call(`/api/groups/${freshId("ghost")}/changes`, ravi); + it("reads many groups in one request, each past its own cursor", async () => { + const first = await makeGroup(ravi); + const second = await makeGroup(ravi); + const read = await changes(ravi, first.id); - expect(response.status).toBe(404); - expect((await json(response)).error.code).toBe("no_group"); + const body = await pull(ravi, [ + { groupId: first.id, since: read.seq }, + { groupId: second.id, since: 0 }, + ]); + + expect(body.pages.map((page) => [page.groupId, page.members.length])).toEqual([ + [first.id, 0], + [second.id, 1], + ]); + }); + + it("refuses a pull that asks for one group twice", async () => { + const { id } = await makeGroup(ravi); + const response = await pullResponse(ravi, [ + { groupId: id, since: 0 }, + { groupId: id, since: 0 }, + ]); + expect(response.status).toBe(400); }); }); describe("recording an expense over HTTP", () => { it("lands, and comes back in the feed", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -147,7 +171,7 @@ describe("recording an expense over HTTP", () => { expect(entry.amountMinor).toBe(1000); expect(entry.createdBy).toBe(me.id); - const after = await json(await call(`/api/groups/${id}/changes?since=${page.seq}`, ravi)); + const after = await changes(ravi, id, page.seq); expect(after.entries.map((row) => row.id)).toContain(entry.id); }); @@ -158,7 +182,7 @@ describe("recording an expense over HTTP", () => { */ it("is refused with 422 when it does not add up", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -174,9 +198,9 @@ describe("recording an expense over HTTP", () => { * The one refusal a device should act on by re-reading and composing again, * and the only one of six 409s that says so. */ - it("is refused with 409 and retry 'stale' when somebody else moved the money", async () => { + it("is refused with 409 and retry 'stale' when somebody else changed it first", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -184,7 +208,7 @@ describe("recording an expense over HTTP", () => { const entryId = freshId("api-e"); const first = await json(await putEntry(id, body(1000))); - await putEntry(id, body(2000)); + await putEntry(id, { ...body(2000), baseSeq: first.seq }); const stale = await putEntry(id, { ...body(1500), baseSeq: first.seq }); @@ -204,7 +228,7 @@ describe("recording an expense over HTTP", () => { it("keeps when and where it happened, both or neither", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); const post = (moment: Record) => putEntry(id, expense({ payers: [{ memberId: me.id, amountMinor: 1000 }], shares: [{ memberId: me.id, amountMinor: 1000, weightMicros: null }], ...moment })); @@ -218,7 +242,7 @@ describe("recording an expense over HTTP", () => { it("soft-deletes and restores by writing the row, and stamps the server's own time", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -265,9 +289,9 @@ describe("the roster over HTTP", () => { it("changes how a group looks without telling the activity feed", async () => { const { id } = await makeGroup(ravi); - const look = (changes: Record) => call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi", ...changes }) }); + const look = (look: Record) => call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi", ...look }) }); - const events = async () => (await json(await call(`/api/groups/${id}/changes?since=0`, ravi))).events.length; + const events = async () => (await changes(ravi, id)).events.length; const before = await events(); const iconed = await look({ avatarKind: "icon", avatarIcon: "beach_access", avatarColor: "amber" }); @@ -299,7 +323,7 @@ describe("which group a page is about", () => { */ it("is on the page, and not repeated on every row", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); expect(page.groupId).toBe(id); @@ -312,7 +336,7 @@ describe("which group a page is about", () => { const { id } = await makeGroup(ravi); await env.GROUP.getByName(id).runUpkeep(Date.now() + 800 * 24 * 60 * 60 * 1000); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); expect(page.groupId).toBe(id); expect(page.purgedAt).not.toBeNull(); expect(page.group).toBeNull(); diff --git a/server/test/dormancy.test.ts b/server/test/dormancy.test.ts index ca838f2..ae6e35f 100644 --- a/server/test/dormancy.test.ts +++ b/server/test/dormancy.test.ts @@ -1,7 +1,10 @@ +import { runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; -import { deleteEntry, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub } from "./group"; +import { defaultGroupLook } from "../src/db/appearance"; +import type { Group } from "../src/do/group"; +import { deleteEntry, draftOf, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub } from "./group"; /** Dormancy and account deletion: what an idle group does to itself, and what survives somebody deleting the account that made it. */ @@ -42,6 +45,55 @@ describe("a group that goes quiet", () => { expect(ok(await object.changes(RAVI, 0, 500)).group?.archivedAt).toBeNull(); }); + /** + * Quiet is measured from the last change anybody made, not from the newest + * expense: a group whose people spend months correcting old expenses, or + * only joining and renaming, is in use. + */ + it("counts an edit to an old expense as use", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); + + // Recorded, and last touched, half a year ago. + const longAgo = Date.now() - 180 * DAYS; + await runInDurableObject(object, async (_instance: Group, state) => { + state.storage.sql.exec("update entries set created_at = ?", new Date(longAgo).toISOString()); + state.storage.sql.exec("update meta set last_activity_at = ?", new Date(longAgo).toISOString()); + }); + ok(await saveEntry(object, draftOf(entry, { description: "Dinner, corrected" }), RAVI)); + + expect((await object.runUpkeep(Date.now() + 10 * DAYS)).archived).toBe(false); + }); + + it("does not count being archived as use", async () => { + const { groupId } = await makeGroup(); + const object = stub(groupId); + + await object.runUpkeep(Date.now() + 100 * DAYS); + const activity = await runInDurableObject(object, async (_instance: Group, state) => state.storage.sql.exec<{ last_activity_at: string }>("select last_activity_at from meta").one().last_activity_at); + expect(Date.parse(activity)).toBeLessThan(Date.now() + DAYS); + }); + + /** A deadline already spent would hold the object's one alarm in the past, firing again as soon as it returns. */ + it("leaves no archive deadline behind once it is archived, by anybody", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); + ok(await object.putGroup(groupId, { name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: new Date().toISOString(), ...defaultGroupLook, creatorId: ravi.id, creatorName: "Ravi" }, RAVI)); + // Deleting is a change, so it arms the archive deadline again, in an archived group. + ok(await deleteEntry(object, entry, RAVI)); + + await object.runUpkeep(Date.now() + 100 * DAYS); + const chores = await runInDurableObject(object, async (_instance: Group, state) => + state.storage.sql + .exec<{ name: string }>("select name from schedule") + .toArray() + .map((row) => row.name), + ); + expect(chores).not.toContain("archive"); + }); + it("tells the record that nobody archived it", async () => { const { groupId } = await makeGroup(); await stub(groupId).runUpkeep(Date.now() + 100 * DAYS); diff --git a/server/test/fx.test.ts b/server/test/fx.test.ts index 28edbed..3f0a217 100644 --- a/server/test/fx.test.ts +++ b/server/test/fx.test.ts @@ -126,6 +126,20 @@ describe("the waterfall", () => { expect(stored.find((rate) => rate.currency === "EUR")?.rate).toBe(0.92); }); + it("counts a rate it already holds as covered, so a second run asks nobody else", async () => { + stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-09-24", Object.fromEntries(supportedCurrencies.map((code) => [code, 3]))) : null)); + const object = fx(); + await object.refresh(); + + vi.unstubAllGlobals(); + const calls = stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-09-24", Object.fromEntries(supportedCurrencies.map((code) => [code, 3]))) : null)); + const again = await object.refresh(); + + expect(again.missing).toEqual([]); + expect(again.stored).toBe(0); + expect(calls.filter((url) => url.includes("exchangerate-api"))).toEqual([]); + }); + it("skips a provider with no key rather than failing the run", async () => { // How a fork, or a local `wrangler dev`, runs on Frankfurter alone without // editing anything. An unconfigured source is not a broken one. @@ -203,6 +217,72 @@ describe("a backfill", () => { }); }); +/** Frankfurter's range shape: one map of rates per business day. */ +function frankfurterRange(url: string, skip: (day: string) => boolean = () => false) { + const match = /\/v1\/(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(url); + if (!match) return null; + const [, from = "", to = ""] = match; + const rates: Record> = {}; + for (let at = Date.parse(from); at <= Date.parse(to); at += 24 * 60 * 60 * 1000) { + const day = new Date(at).toISOString().slice(0, 10); + const weekday = new Date(at).getUTCDay(); + if (weekday !== 0 && weekday !== 6 && !skip(day)) rates[day] = { EUR: 0.9, GBP: 0.8 }; + } + return { amount: 1, base: "USD", start_date: from, end_date: to, rates }; +} + +describe("the year of history", () => { + const now = Date.parse("2026-09-24T12:00:00Z"); + + it("is filled in one request on a new deployment, and left alone after", async () => { + const calls = stubFetch((url) => (url.includes("frankfurter") ? frankfurterRange(url) : null)); + const object = fx(); + + const first = await object.fillHistory(now); + expect(first.gaps).toEqual([{ from: "2025-09-24", to: "2026-09-24" }]); + expect(calls).toHaveLength(1); + // Two currencies and USD on each of about 260 business days, written in + // chunks, since one statement binds at most 100 parameters. + expect(first.stored).toBeGreaterThan(700); + expect(first.months).toContain("2025-12"); + expect((await env.CACHE.get(monthKey("2025-12"), "json"))?.rates.length).toBeGreaterThan(0); + + const second = await object.fillHistory(now); + expect(second.gaps).toEqual([]); + expect(calls).toHaveLength(1); + }); + + it("does not count a weekend or a holiday as missing, but does a week of outage", async () => { + // Christmas, and a provider down from 2 to 13 March. + const holiday = (day: string) => day === "2025-12-25" || day === "2025-12-26"; + const outage = (day: string) => day >= "2026-03-02" && day <= "2026-03-13"; + stubFetch((url) => (url.includes("frankfurter") ? frankfurterRange(url, (day) => holiday(day) || outage(day)) : null)); + const object = fx(); + await object.fillHistory(now); + + vi.unstubAllGlobals(); + const calls = stubFetch((url) => (url.includes("frankfurter") ? frankfurterRange(url) : null)); + const outcome = await object.fillHistory(now); + + // Every day between the Friday before and the Monday after, in one request. + expect(outcome.gaps).toEqual([{ from: "2026-02-28", to: "2026-03-15" }]); + expect(calls).toHaveLength(1); + expect(outcome.stored).toBeGreaterThan(0); + }); + + it("is what a day's rate is looked for in, a week back at most", async () => { + stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-01-05", { EUR: 0.9 }) : null)); + const object = fx(); + await object.refresh("2026-01-05"); + + // Last January's rate does not answer for September, so asking is worth it. + stubFetch(() => null); + expect(await object.backfill("2026-09-21", "EUR", now)).toBe(true); + // But it does answer for the weekend after it. + expect(await object.backfill("2026-01-10", "EUR", now)).toBe(false); + }); +}); + describe("the month blobs", () => { it("are written whole, from what the object holds", async () => { stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-09-24", { EUR: 0.92, GBP: 0.78 }) : null)); diff --git a/server/test/group.ts b/server/test/group.ts index c9cd14c..107c522 100644 --- a/server/test/group.ts +++ b/server/test/group.ts @@ -14,7 +14,11 @@ export const ZARA = "99999999-9999-4999-8999-999999999999"; /** A real account (a `user` row) at a fresh id: D1 refuses memberships for any other kind. */ export async function makeAccount(displayName: string | null = null): Promise { const id = crypto.randomUUID(); - await env.DB.batch([env.DB.prepare("insert into user (id, name, email, updated_at) values (?, ?, ?, ?)").bind(id, "", `${id}@fixture.invalid`, Date.now()), env.DB.prepare("insert into profiles (id, display_name, updated_at) values (?, ?, ?)").bind(id, displayName, new Date().toISOString())]); + await env.DB.batch([ + env.DB.prepare("insert into user (id, name, email, updated_at) values (?, ?, ?, ?)").bind(id, "", `${id}@fixture.invalid`, Date.now()), + env.DB.prepare("insert into counters (name, value) values ('profiles', 1) on conflict (name) do update set value = value + 1"), + env.DB.prepare("insert into profiles (id, display_name, updated_at, version) values (?, ?, ?, (select value from counters where name = 'profiles'))").bind(id, displayName, new Date().toISOString()), + ]); return id; } diff --git a/server/test/ledger.test.ts b/server/test/ledger.test.ts index 0e6fff7..c75ecbc 100644 --- a/server/test/ledger.test.ts +++ b/server/test/ledger.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { deleteEntry, draftOf, editGroup, editMember, evenly, expense, freshId, makeGroup, ok, PRIYA, RAVI, refusal, restoreEntry, saveEntry, stub, sumOf } from "./group"; -/** The invariant and the write path: an expense that does not add up, a hard delete, an idempotent retry, and the stale-base predicate. */ +/** The invariant and the write path: an expense that does not add up, a hard delete, an idempotent retry, and the stale-base rule. */ describe("an expense has to add up", () => { it("accepts one that does", async () => { const { groupId, ravi, priya } = await makeGroup(); @@ -106,8 +106,8 @@ describe("writing an expense twice", () => { const object = stub(groupId); const id = freshId("e"); - ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1200), ravi.profileId ?? "")); - const edited = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1600), ravi.profileId ?? "")); + const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1200), ravi.profileId ?? "")); + const edited = ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1600), baseSeq: first.seq }, ravi.profileId ?? "")); expect(edited.amountMinor).toBe(1600); expect(edited.shares).toHaveLength(2); @@ -139,7 +139,7 @@ describe("writing an expense twice", () => { // Priya is a placeholder with no account, so Ravi is the only possible // author. The point is that there is no parameter to say otherwise. - const edited = ok(await saveEntry(object, evenly(id, priya.id, [ravi.id, priya.id], 800), ravi.profileId ?? "")); + const edited = ok(await saveEntry(object, { ...evenly(id, priya.id, [ravi.id, priya.id], 800), baseSeq: first.seq }, ravi.profileId ?? "")); expect(edited.createdBy).toBe(first.createdBy); expect(edited.createdAt).toBe(first.createdAt); @@ -159,62 +159,58 @@ describe("an edit composed against a version that has moved", () => { }); /** - * Two people fixing a typo should not have to arbitrate. The predicate is - * about money, not about staleness. + * The write replaces the whole expense, so a stale one carries the old + * value of every field it did not touch. Accepting it would silently undo + * the other edit, whatever the edit was about. */ - it("is applied when it is stale but changes nothing about the money", async () => { + it("is refused when it is stale, even if it changes nothing about the money", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); const id = freshId("e"); const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); - ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Dinner at Britto's" }, ravi.profileId ?? "")); + ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), notes: "Paid on the card", baseSeq: first.seq }, ravi.profileId ?? "")); - const late = ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Dinner, Britto's", baseSeq: first.seq }, ravi.profileId ?? "")); + const late = refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Dinner, Britto's", baseSeq: first.seq }, ravi.profileId ?? "")); + expect(late.code).toBe("stale_base"); - expect(late.description).toBe("Dinner, Britto's"); + const page = ok(await object.changes(ravi.profileId ?? "", 0, 100)); + expect(page.entries.find((entry) => entry.id === id)?.notes).toBe("Paid on the card"); }); - it("is refused when it would move money away from where the server has it", async () => { + it("is refused when it claims to be new but the row exists", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); const id = freshId("e"); - const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); - ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 2000), ravi.profileId ?? "")); + ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); + expect(refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Lunch" }, ravi.profileId ?? "")).code).toBe("stale_base"); + }); - const late = refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), baseSeq: first.seq }, ravi.profileId ?? "")); - expect(late.code).toBe("stale_base"); + it("is answered with the stored row when it sends what is already stored, whatever its base", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const input = evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 1000); - // And the correction somebody else made is still standing. - const page = ok(await object.changes(ravi.profileId ?? "", 0, 100)); - expect(page.entries.find((entry) => entry.id === id)?.amountMinor).toBe(2000); + // A retry whose first response was lost still holds no base. + const first = ok(await saveEntry(object, input, ravi.profileId ?? "")); + expect(ok(await saveEntry(object, input, ravi.profileId ?? "")).seq).toBe(first.seq); }); - it("is refused when only the shares moved, even though the total did not", async () => { + it("is refused when it would move money away from where the server has it", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); const id = freshId("e"); const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); + ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 2000), baseSeq: first.seq }, ravi.profileId ?? "")); + + const late = refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), baseSeq: first.seq }, ravi.profileId ?? "")); + expect(late.code).toBe("stale_base"); - ok( - await saveEntry( - object, - expense({ - id, - amountMinor: 1000, - payers: [{ memberId: ravi.id, amountMinor: 1000 }], - shares: [ - { memberId: ravi.id, amountMinor: 900, weightMicros: null }, - { memberId: priya.id, amountMinor: 100, weightMicros: null }, - ], - }), - ravi.profileId ?? "", - ), - ); - - expect(refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), baseSeq: first.seq }, ravi.profileId ?? "")).code).toBe("stale_base"); + // And the correction somebody else made is still standing. + const page = ok(await object.changes(ravi.profileId ?? "", 0, 100)); + expect(page.entries.find((entry) => entry.id === id)?.amountMinor).toBe(2000); }); }); @@ -244,7 +240,7 @@ describe("deleting an expense", () => { const id = freshId("e"); const entry = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 600), ravi.profileId ?? "")); - ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 900), ravi.profileId ?? "")); + ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 900), baseSeq: entry.seq }, ravi.profileId ?? "")); expect(refusal(await deleteEntry(object, entry, ravi.profileId ?? "")).code).toBe("stale_base"); }); @@ -379,3 +375,29 @@ describe("a large page", () => { expect(page.entries.every((entry) => entry.payers.length === 1 && entry.shares.length === 2)).toBe(true); }); }); + +/** + * One expense among more people than one SQL statement can name: a share is + * four bound values and a payer three, against a limit of 100 a statement. + */ +describe("an expense shared by a crowd", () => { + it("is stored whole, every share and every payer", async () => { + const { groupId, ravi } = await makeGroup(); + const object = stub(groupId); + const people = [ravi.id]; + for (let index = 0; index < 39; index++) { + people.push(ok(await object.putMember(freshId("m"), { displayName: `Guest ${index}`, upiVpa: null, leftAt: null }, RAVI)).id); + } + + const wedding = expense({ + id: freshId("e"), + amountMinor: 4000, + payers: people.map((memberId) => ({ memberId, amountMinor: 100 })), + shares: people.map((memberId) => ({ memberId, amountMinor: 100, weightMicros: 1_000_000 })), + }); + const stored = ok(await saveEntry(object, wedding, RAVI)); + + expect(stored.payers).toHaveLength(40); + expect(stored.shares).toHaveLength(40); + }); +}); diff --git a/server/test/links.test.ts b/server/test/links.test.ts index b51240f..144ef88 100644 --- a/server/test/links.test.ts +++ b/server/test/links.test.ts @@ -3,9 +3,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; -import type { ChangePage, GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, PlaceholderList } from "./api-types"; +import type { GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, PlaceholderList } from "./api-types"; import { freshId } from "./group"; -import { type Guest, signInAsGuest } from "./session"; +import { changes, type Guest, signInAsGuest } from "./session"; /** Arriving, over HTTP. */ @@ -216,7 +216,7 @@ describe("the name, travelling the other way", () => { // Somebody arriving on an invite signed in as a guest a moment earlier and // has no name at all, while the group already knows them as whatever was // typed on the placeholder. - const { profiles } = await json(await call(`/api/groups/${id}/changes?since=0`, ravi)); + const { profiles } = await changes(ravi, id); expect(profiles).toContainEqual(expect.objectContaining({ id: arriving.id, displayName: "Priya" })); }); @@ -231,7 +231,7 @@ describe("the name, travelling the other way", () => { // The slot keeps the name the group knows, and the account keeps its own. expect(claimed.member.displayName).toBe("P"); - const { profiles } = await json(await call(`/api/groups/${id}/changes?since=0`, ravi)); + const { profiles } = await changes(ravi, id); expect(profiles.find((row) => row.id === arriving.id)?.displayName).toBe("Priya Sharma"); }); diff --git a/server/test/reauth.test.ts b/server/test/reauth.test.ts index aa2b397..ab7cdb8 100644 --- a/server/test/reauth.test.ts +++ b/server/test/reauth.test.ts @@ -100,7 +100,60 @@ describe("deleting an account with an address", () => { }); }); +/** + * A session somebody else got hold of must not be able to point the account + * at their own address or Google account: after that, the confirmation above + * would go to them. + */ +describe("changing how an account with an address signs in", () => { + it("is refused for an address once the sign-in is old", async () => { + const account = await googleAccount("relink-stale-email"); + await age(account.id); + + const response = await call("/identity/email", account.token, { method: "POST", body: { email: "thief@example.com" } }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ error: { code: "reauth_required" } }); + }); + + it("is refused for a Google account once the sign-in is old", async () => { + const account = await googleAccount("relink-stale-google"); + await age(account.id); + + const linked = await call("/identity/google", account.token, { method: "POST", body: { idToken: await googleIdToken({ sub: "thief", email: "thief@example.com" }), nonce: null, allowSignIn: false } }); + expect(linked.status).toBe(403); + expect(await linked.json()).toMatchObject({ error: { code: "reauth_required" } }); + + const redirect = await call("/identity/google/redirect", account.token, { method: "POST", body: { callbackUrl: "http://localhost:8787/app/welcome", allowSignIn: false } }); + expect(redirect.status).toBe(403); + }); + + it("still lets an old session sign in to a different account", async () => { + const account = await googleAccount("relink-stale-switch"); + await age(account.id); + + const response = await call("/identity/google", account.token, { method: "POST", body: { idToken: await googleIdToken({ sub: "relink-other", email: "other@example.com" }), nonce: null, allowSignIn: true } }); + expect(response.status).toBe(200); + const outcome = (await response.json()) as IdentityOutcome; + expect(outcome.outcome).toBe("replaced"); + expect(outcome.strandedUserId).toBe(account.id); + }); + + it("goes ahead straight after signing in", async () => { + const account = await googleAccount("relink-fresh"); + const response = await call("/identity/email", account.token, { method: "POST", body: { email: "relink-fresh-new@example.com" } }); + expect(await response.json()).toEqual({ flow: "linkPending" }); + }); +}); + describe("a guest", () => { + it("attaches an address however old the session, since that is how it keeps its groups", async () => { + const guest = await signInAsGuest(); + await age(guest.id); + + const response = await call("/identity/email", guest.token, { method: "POST", body: { email: "old-guest@example.com" } }); + expect(await response.json()).toEqual({ flow: "linkPending" }); + }); + /** Nothing identifies a guest but the session, so there is nothing more to ask for. */ it("deletes without confirming, however old the session", async () => { const guest = await signInAsGuest(); diff --git a/server/test/roster.test.ts b/server/test/roster.test.ts index 995812e..19da10b 100644 --- a/server/test/roster.test.ts +++ b/server/test/roster.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { defaultGroupLook } from "../src/db/appearance"; -import { deleteEntry, editGroup, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub, ZARA } from "./group"; +import { deleteEntry, draftOf, editGroup, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub, ZARA } from "./group"; /** What one member of a group can do to another, which is a different question from what a stranger can do and has a much less obvious answer. */ @@ -54,14 +54,28 @@ describe("what a member may change about somebody else", () => { }); describe("leaving, and being removed", () => { - it("is always yours to do, settled or not", async () => { + it("is yours to do once you are settled", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + + const left = ok(await editMember(groupId, ravi.id, RAVI, { leftAt: new Date().toISOString() })); + expect(left.leftAt).not.toBeNull(); + }); + + /** Otherwise the balance stays behind with somebody who can no longer see it. */ + it("but not while you still owe or are owed", async () => { const { groupId, ravi, priya } = await makeGroupOfTwo(); const object = stub(groupId); ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 1000), RAVI)); - const left = ok(await editMember(groupId, ravi.id, RAVI, { leftAt: new Date().toISOString() })); - expect(left.leftAt).not.toBeNull(); + const refused = refusal(await editMember(groupId, ravi.id, RAVI, { leftAt: new Date().toISOString() })); + expect(refused.code).toBe("not_settled"); + }); + + it("is stamped with the server's own time, whatever the device sent", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + const left = ok(await editMember(groupId, ravi.id, RAVI, { leftAt: "2001-01-01T00:00:00.000Z" })); + expect(Date.parse(left.leftAt ?? "")).toBeGreaterThan(Date.parse("2026-01-01")); }); /** @@ -112,6 +126,15 @@ describe("leaving, and being removed", () => { expect(after.hasMore).toBe(false); }); + it("including what the group is called after they went", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, RAVI, { leftAt: new Date().toISOString() })); + ok(await editGroup(groupId, RAVI, { name: "Somewhere new" })); + + expect(ok(await object.changes(PRIYA, 0, 500)).group?.name).not.toBe("Somewhere new"); + }); + it("and stops them writing to it at all", async () => { const { groupId, ravi, priya } = await makeGroupOfTwo(); const object = stub(groupId); @@ -121,6 +144,89 @@ describe("leaving, and being removed", () => { }); }); +describe("coming back", () => { + /** Being pulled back into a group is not something somebody else gets to decide for you. */ + it("is not for anybody else to decide, for somebody with an account", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); + + const refused = refusal(await editMember(groupId, priya.id, RAVI, { leftAt: null })); + expect(refused.code).toBe("forbidden"); + }); + + it("but a removed placeholder can be put back, on the record", async () => { + const { groupId, priya } = await makeGroup(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, RAVI, { leftAt: new Date().toISOString() })); + + const back = ok(await editMember(groupId, priya.id, RAVI, { leftAt: null })); + expect(back.leftAt).toBeNull(); + + const events = ok(await object.changes(RAVI, 0, 500)).events; + expect(events.at(-1)).toMatchObject({ kind: "member_added", subjectId: priya.id }); + }); + + it("is a link away for somebody who left, into the same place", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); + + const link = ok(await object.createLink(RAVI)); + expect(ok(await object.peekLink(link.token, PRIYA))).toMatchObject({ isMember: false, hasLeft: true }); + + // Even asking to arrive as somebody new: one account, one place. + const { member } = ok(await object.join(link.token, PRIYA, { memberId: null, displayName: "Priya again" })); + expect(member.id).toBe(priya.id); + expect(member.leftAt).toBeNull(); + expect(ok(await object.changes(PRIYA, 0, 500)).events.at(-1)).toMatchObject({ kind: "member_joined", subjectId: priya.id }); + }); + + it("leaves an invite for somebody else's place unspent", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); + + const zara = ok(await object.putMember(freshId("m"), { displayName: "Zara", upiVpa: null, leftAt: null }, RAVI)); + const invite = ok(await object.createInvite(zara.id, RAVI)); + + expect(ok(await object.join(invite.token, PRIYA, { memberId: null, displayName: null })).member.id).toBe(priya.id); + expect(ok(await object.join(invite.token, ZARA, { memberId: null, displayName: null })).member.id).toBe(zara.id); + }); +}); + +/** + * Somebody who has left cannot see the group, so nothing written after they + * go may change what they owe. Their past expenses stay editable in every + * other respect. + */ +describe("the money of somebody who has left", () => { + async function departedPlaceholder() { + const fixture = await makeGroup(); + const object = stub(fixture.groupId); + const dinner = ok(await saveEntry(object, evenly(freshId("e"), fixture.ravi.id, [fixture.ravi.id, fixture.priya.id], 1000), RAVI)); + const settled = ok(await saveEntry(object, { ...evenly(freshId("e"), fixture.priya.id, [fixture.ravi.id], 500), kind: "settlement" }, RAVI)); + ok(await editMember(fixture.groupId, fixture.priya.id, RAVI, { leftAt: new Date().toISOString() })); + return { ...fixture, object, dinner, settled }; + } + + it("cannot be charged again", async () => { + const { object, ravi, priya } = await departedPlaceholder(); + const refused = refusal(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 600), RAVI)); + expect(refused.code).toBe("forbidden"); + expect(refused.message).toContain("Priya"); + }); + + it("is not reopened by deleting an expense they were part of", async () => { + const { object, dinner } = await departedPlaceholder(); + expect(refusal(await deleteEntry(object, dinner, RAVI)).code).toBe("forbidden"); + }); + + it("leaves the rest of that expense editable", async () => { + const { object, dinner } = await departedPlaceholder(); + expect(ok(await saveEntry(object, draftOf(dinner, { description: "Dinner at Thalassa" }), RAVI)).description).toBe("Dinner at Thalassa"); + }); +}); + describe("the group itself", () => { it("can be renamed by any member, including one who did not create it", async () => { const { groupId } = await makeGroupOfTwo(); diff --git a/server/test/session.ts b/server/test/session.ts index 8a81901..70a94e7 100644 --- a/server/test/session.ts +++ b/server/test/session.ts @@ -1,6 +1,7 @@ import { exports as workerExports } from "cloudflare:workers"; import type { IdentityOutcome } from "../src/schemas/identity"; +import type { ChangePage, GroupCursor, Pull } from "../src/schemas/ledger"; /** A guest account and its bearer token. */ export interface Guest { @@ -22,3 +23,28 @@ export async function signInAsGuest(): Promise { export function randomIp(): string { return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join("."); } + +const ORIGIN = "https://opensplit.test"; + +/** The sync route, as the app calls it. */ +export function pullResponse(guest: Guest | null, groups: GroupCursor[], limit = 200): Promise { + const headers = new Headers({ "Content-Type": "application/json" }); + if (guest) headers.set("Authorization", `Bearer ${guest.token}`); + return workerExports.default.fetch(`${ORIGIN}/api/sync`, { method: "POST", headers, body: JSON.stringify({ groups, limit }) }); +} + +export async function pull(guest: Guest, groups: GroupCursor[], limit = 200): Promise { + const response = await pullResponse(guest, groups, limit); + if (response.status !== 200) throw new Error(`Pull failed with ${response.status}: ${await response.text()}`); + return (await response.json()) as Pull; +} + +/** One group's page past `since`, failing the test with the refusal's own words if it is refused. */ +export async function changes(guest: Guest, groupId: string, since = 0, limit = 200): Promise { + const { pages, refusals } = await pull(guest, [{ groupId, since }], limit); + const [refused] = refusals; + if (refused) throw new Error(`Refused with ${refused.code}: ${refused.message}`); + const [page] = pages; + if (!page) throw new Error("The pull answered no page."); + return page; +} diff --git a/server/wrangler.jsonc b/server/wrangler.jsonc index eeea514..7cddb23 100644 --- a/server/wrangler.jsonc +++ b/server/wrangler.jsonc @@ -51,7 +51,7 @@ { "binding": "DB", "database_name": "opensplit", - "database_id": "db2d89ad-d9cb-4608-bd8d-25642aaa7cb7", + "database_id": "a25350b6-ac42-474b-b14c-4535b7448ee5", "migrations_dir": "migrations", "migrations_pattern": "migrations/*/migration.sql" } diff --git a/test/application/forms_test.dart b/test/application/forms_test.dart new file mode 100644 index 0000000..7a53b92 --- /dev/null +++ b/test/application/forms_test.dart @@ -0,0 +1,271 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:opensplit/application/entry_form.dart'; +import 'package:opensplit/application/ledger_providers.dart' + show GroupLedger, currenciesProvider, groupLedgerProvider; +import 'package:opensplit/application/local_providers.dart'; +import 'package:opensplit/application/preferences_providers.dart'; +import 'package:opensplit/application/session_providers.dart'; +import 'package:opensplit/application/settlement_form.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/domain/entry_draft.dart'; +import 'package:opensplit/domain/split/splitter.dart'; +import 'package:opensplit_api/opensplit_api.dart' show EntryKind, SplitKind; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../harness.dart'; + +/// The two forms that record money, driven the way their screens drive them: +/// one intent at a time, then read back. +void main() { + final now = DateTime.utc(2026, 8, 27); + late AppDatabase db; + late ProviderContainer container; + + setUp(() async { + db = await testDatabase(NativeDatabase.memory()); + await db + .into(db.groups) + .insert( + GroupsCompanion.insert( + id: 'g1', + name: 'Goa', + defaultCurrency: 'INR', + createdAt: now, + ), + ); + await db.batch((batch) { + batch.insertAll(db.members, [ + MembersCompanion.insert( + id: 'm-ravi', + groupId: 'g1', + profileId: const Value(testAccountId), + displayName: 'Ravi', + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-priya', + groupId: 'g1', + displayName: 'Priya', + upiVpa: const Value('priya@okaxis'), + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-arun', + groupId: 'g1', + displayName: 'Arun', + upiVpa: const Value('arun@okicici'), + joinedAt: now, + ), + ]); + }); + + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + container = ProviderContainer.test( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + appDatabaseProvider.overrideWithValue(db), + currentAccountIdProvider.overrideWithValue(testAccountId), + signedInProvider.overrideWithValue(true), + deviceZoneProvider.overrideWith((ref) async => testZone), + ], + ); + }); + + tearDown(() => db.close()); + + /// The ledger, resolved, which every form reads when it acts. + Future ledger() async { + container.listen(groupLedgerProvider('g1'), (_, _) {}); + container.listen(currenciesProvider, (_, _) {}); + for (var i = 0; i < 100; i++) { + final resolved = container.read(groupLedgerProvider('g1')); + if (resolved != null) return resolved; + await Future.delayed(const Duration(milliseconds: 5)); + } + throw StateError('the ledger never resolved'); + } + + group('settling up', () { + Future< + ({ + SettlementForm form, + SettlementFormState Function() state, + GroupLedger ledger, + }) + > + open({String? toId, int? amountMinor}) async { + final provider = settlementFormProvider( + 'g1', + toId: toId, + amountMinor: amountMinor, + ); + container.listen(provider, (_, _) {}); + await container.read(provider.future); + return ( + form: container.read(provider.notifier), + state: () => container.read(provider).requireValue, + ledger: await ledger(), + ); + } + + test('opens on the suggested payment, paid by this account', () async { + final settle = await open(toId: 'm-priya', amountMinor: 125050); + + expect(settle.state().fromId, 'm-ravi'); + expect(settle.state().toId, 'm-priya'); + expect(settle.state().amount, '1250.50'); + expect(settle.state().handleIn(settle.ledger), 'priya@okaxis'); + }); + + test('never pays a handle typed for somebody else', () async { + final settle = await open(toId: 'm-priya'); + + settle.form.setHandle('priya.new@okhdfc'); + expect(settle.state().handleIn(settle.ledger), 'priya.new@okhdfc'); + + settle.form.choosePayee('m-arun'); + expect(settle.state().handleIn(settle.ledger), 'arun@okicici'); + + // What was typed for Priya is still hers. + settle.form.choosePayee('m-priya'); + expect(settle.state().handleIn(settle.ledger), 'priya.new@okhdfc'); + }); + + test('choosing the payee as the payer leaves nobody being paid', () async { + final settle = await open(toId: 'm-priya'); + + settle.form.choosePayer('m-priya'); + + expect(settle.state().fromId, 'm-priya'); + expect(settle.state().toId, isNull); + }); + + test('records what was typed, decimal comma and all', () async { + final settle = await open(toId: 'm-priya'); + + settle.form.setAmount('12,50'); + expect(await settle.form.record(), isTrue); + + final recorded = await db.select(db.entries).getSingle(); + expect(recorded.kind, EntryKind.settlement); + expect(recorded.amountMinor, 1250); + }); + + test('says what is missing rather than recording nothing', () async { + final settle = await open(); + + settle.form.setAmount('100'); + expect(await settle.form.record(), isFalse); + expect(settle.state().error, 'Choose who is paying whom.'); + + // Any change is a new attempt, so the complaint goes. + settle.form.choosePayee('m-priya'); + expect(settle.state().error, isNull); + }); + }); + + group('an expense', () { + Future<({EntryForm form, EntryFormState Function() state})> open({ + String? entryId, + }) async { + await ledger(); + final provider = entryFormProvider('g1', entryId: entryId); + container.listen(provider, (_, _) {}); + await container.read(provider.future); + return ( + form: container.read(provider.notifier), + state: () => container.read(provider).requireValue, + ); + } + + Currency inr() => container.read(currenciesProvider).requireValue['INR']!; + + test('starts split between everyone, paid by this account', () async { + final expense = await open(); + expense.form.setAmount('300'); + + final check = expense.state().check(inr(), groupId: 'g1'); + final draft = (check as DraftReady).draft; + expect(draft.payerAmounts, {'m-ravi': 30000}); + expect(draft.split, isA()); + expect(expense.state().preview(inr()), { + 'm-arun': 10000, + 'm-priya': 10000, + 'm-ravi': 10000, + }); + }); + + test('takes percentages typed with a decimal comma', () async { + final expense = await open(); + expense.form + ..setAmount('200') + ..chooseSplitKind(SplitKind.percent) + ..setInSplit('m-arun', included: false) + ..setPercent('m-ravi', '50,5') + ..setPercent('m-priya', '49,5'); + + final draft = + (expense.state().check(inr(), groupId: 'g1') as DraftReady).draft; + expect(draft.split.resolve(20000), [ + (memberId: 'm-priya', amountMinor: 9900, weightMicros: 49500000), + (memberId: 'm-ravi', amountMinor: 10100, weightMicros: 50500000), + ]); + }); + + test('says what is missing before anything is written', () async { + final expense = await open(); + expense.form + ..setAmount('300') + ..chooseSplitKind(SplitKind.exact); + + expect(await expense.form.save(), isFalse); + expect( + expense.state().error, + 'Check the split — some amounts are missing.', + ); + expect(await db.select(db.entries).get(), isEmpty); + }); + + test('going back to one payer keeps the first of several', () async { + final expense = await open(); + expense.form + ..setSeveralPayers(true) + ..setPaying('m-priya', paying: true) + ..setSeveralPayers(false); + + expect(expense.state().payers, {'m-ravi'}); + }); + + test( + 'is not saved over a version somebody else changed meanwhile', + () async { + final created = await open(); + created.form + ..setAmount('300') + ..setDescription('Dinner'); + expect(await created.form.save(), isTrue); + final id = (await db.select(db.entries).getSingle()).id; + + final editing = await open(entryId: id); + editing.form.setDescription('Dinner at Thalassa'); + + // A sync lands underneath the open editor. + await (db.update(db.entries)..where((t) => t.id.equals(id))).write( + const EntriesCompanion(amountMinor: Value(40000)), + ); + + expect(await editing.form.save(), isFalse); + expect( + editing.state().error, + contains('changed while you were editing'), + ); + // And the editor kept what was typed. + expect(editing.state().description, 'Dinner at Thalassa'); + }, + ); + }); +} diff --git a/test/application/sync_coordinator_test.dart b/test/application/sync_coordinator_test.dart index 3a329f4..9586ff6 100644 --- a/test/application/sync_coordinator_test.dart +++ b/test/application/sync_coordinator_test.dart @@ -22,8 +22,8 @@ void main() { fullRuns++; return first.future; }, - syncGroup: (id) async { - groups.add(id); + syncGroups: (ids) async { + groups.addAll(ids); return _clean; }, ); @@ -50,7 +50,7 @@ void main() { var fullRuns = 0; final coordinator = SyncCoordinator( syncAll: () async => ++fullRuns == 1 ? await first.future : _clean, - syncGroup: (_) async => throw StateError('full sync should cover this'), + syncGroups: (_) async => throw StateError('full sync should cover this'), ); addTearDown(coordinator.dispose); @@ -66,13 +66,72 @@ void main() { expect(coordinator.status.error, isNull); }); + testWidgets('a local write sends and reads back only what it touched', ( + tester, + ) async { + final writes = StreamController(); + addTearDown(writes.close); + var fullRuns = 0; + final partialRuns = >[]; + final coordinator = SyncCoordinator( + syncAll: () async { + fullRuns++; + return _clean; + }, + syncGroups: (ids) async { + partialRuns.add(ids); + return _clean; + }, + writes: writes.stream, + ); + addTearDown(coordinator.dispose); + coordinator.start(); + await tester.pump(); + expect(fullRuns, 1); + + writes + ..add(null) + ..add(null); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(fullRuns, 1); + expect(partialRuns, [{}], reason: 'two writes, one debounced run'); + }); + + testWidgets('groups asked for during a run go out together', (tester) async { + final first = Completer(); + final partialRuns = >[]; + final coordinator = SyncCoordinator( + syncAll: () async => _clean, + syncGroups: (ids) async { + partialRuns.add(ids); + return partialRuns.length == 1 ? await first.future : _clean; + }, + ); + addTearDown(coordinator.dispose); + await coordinator.syncAll(); + + final running = coordinator.syncGroup('home'); + await tester.pump(); + final second = coordinator.syncGroup('trip'); + final third = coordinator.syncGroup('flat'); + first.complete(_clean); + await Future.wait([running, second, third]); + + expect(partialRuns, [ + {'home'}, + {'trip', 'flat'}, + ]); + }); + testWidgets('a failure stays visible and retries without another trigger', ( tester, ) async { var attempts = 0; final coordinator = SyncCoordinator( syncAll: () async => ++attempts == 1 ? _failed : _clean, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); addTearDown(coordinator.dispose); @@ -100,7 +159,7 @@ void main() { attempts++; return fail ? _failed : _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); await coordinator.syncAll(); for (final seconds in [5, 10, 20, 40, 80, 160, 300, 300]) { @@ -131,10 +190,17 @@ void main() { failed: 0, nextPushAt: now.add(const Duration(seconds: 30)), ); - var attempts = 0; + var fullRuns = 0; + final partialRuns = >[]; final coordinator = SyncCoordinator( - syncAll: () async => ++attempts == 1 ? waiting : _clean, - syncGroup: (_) async => _clean, + syncAll: () async { + fullRuns++; + return waiting; + }, + syncGroups: (ids) async { + partialRuns.add(ids); + return _clean; + }, clock: () => now, ); addTearDown(coordinator.dispose); @@ -144,9 +210,11 @@ void main() { expect(coordinator.status.hasCompletedFullSync, isTrue); expect(coordinator.status.retryAt, waiting.nextPushAt); await tester.pump(const Duration(seconds: 29)); - expect(attempts, 1); + expect(partialRuns, isEmpty); await tester.pump(const Duration(seconds: 1)); - expect(attempts, 2); + // Only the write is owed, so only the write is retried. + expect(partialRuns, [{}]); + expect(fullRuns, 1); expect(coordinator.status.retryAt, isNull); }); @@ -155,7 +223,7 @@ void main() { ) async { final coordinator = SyncCoordinator( syncAll: () async => const SyncReport(pushed: 0, pulled: 1, failed: 1), - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); addTearDown(coordinator.dispose); await coordinator.syncAll(); @@ -172,7 +240,7 @@ void main() { var groupRuns = 0; final coordinator = SyncCoordinator( syncAll: () async => fail ? _failed : _clean, - syncGroup: (_) async { + syncGroups: (_) async { groupRuns++; return _failed; }, @@ -203,7 +271,7 @@ void main() { attempts++; return attempts == 1 ? _failed : await pending.future; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); var updates = 0; coordinator.addListener(() => updates++); @@ -226,7 +294,7 @@ void main() { ) async { final coordinator = SyncCoordinator( syncAll: () => throw StateError('database unavailable'), - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); await coordinator.syncAll(); expect(coordinator.status.error, isA()); diff --git a/test/application/sync_triggers_test.dart b/test/application/sync_triggers_test.dart index 2aafcf3..9fdde51 100644 --- a/test/application/sync_triggers_test.dart +++ b/test/application/sync_triggers_test.dart @@ -12,6 +12,9 @@ void main() { late List runs; late StreamController online; late StreamController writes; + + /// Partial runs: what a local write sends, and the groups it touched. + late List> partial; late DateTime now; late SyncCoordinator scheduler; @@ -31,6 +34,7 @@ void main() { setUp(() { runs = []; + partial = []; online = StreamController.broadcast(); writes = StreamController.broadcast(); now = DateTime.utc(2026, 8, 27, 9); @@ -39,7 +43,10 @@ void main() { runs.add(now); return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (ids) async { + partial.add(ids); + return _clean; + }, online: online.stream, writes: writes.stream, clock: () => now, @@ -154,7 +161,8 @@ void main() { writes.add(null); await settleWrite(); - expect(runs, hasLength(2)); + expect(runs, hasLength(1)); + expect(partial, [{}], reason: 'the write, sent and read back'); }); test('one action that queues several rows is one sync', () async { @@ -169,8 +177,8 @@ void main() { await settleWrite(); expect( - runs, - hasLength(2), + partial, + hasLength(1), reason: 'a burst from one user action collects into a single sync', ); }); @@ -184,7 +192,11 @@ void main() { await gate.future; return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async { + started.add(started.length); + await gate.future; + return _clean; + }, online: online.stream, writes: writes.stream, clock: () => now, @@ -226,7 +238,7 @@ void main() { await gate.future; return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, online: online.stream, writes: writes.stream, clock: () => now, @@ -255,7 +267,7 @@ void main() { attempts++; throw StateError('offline'); }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, online: online.stream, writes: writes.stream, clock: () => now, @@ -280,7 +292,7 @@ void main() { runs.add(now); return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, online: online.stream, clock: () => now, ); diff --git a/test/data/apply_changes_test.dart b/test/data/apply_changes_test.dart index cc4f778..97d3000 100644 --- a/test/data/apply_changes_test.dart +++ b/test/data/apply_changes_test.dart @@ -15,25 +15,27 @@ void main() { setUp(() async => db = await testDatabase()); tearDown(() => db.close()); - api.Group group({DateTime? archivedAt, int seq = 1}) => api.Group( - avatarKind: api.AvatarKind.initials, - avatarColor: null, - avatarEmoji: null, - avatarIcon: null, - avatarPhoto: null, - coverKind: api.CoverKind.generated, - coverPhoto: null, - id: 'g', - name: 'Goa', - defaultCurrency: 'INR', - isDirect: false, - simplifyDebts: true, - createdBy: 'm', - createdAt: at, - archivedAt: archivedAt, - updatedAt: at, - seq: seq, - ); + api.Group group({String id = 'g', DateTime? archivedAt, int seq = 1}) => + api.Group( + avatarKind: api.AvatarKind.initials, + avatarColor: null, + avatarEmoji: null, + avatarIcon: null, + avatarPhoto: null, + coverKind: api.CoverKind.generated, + coverPhoto: null, + id: id, + name: 'Goa', + defaultCurrency: 'INR', + isDirect: false, + simplifyDebts: true, + createdBy: 'm', + createdAt: at, + archivedAt: archivedAt, + updatedAt: at, + lastActivityAt: at, + seq: seq, + ); api.Member member({String? upiVpa, DateTime? leftAt, int seq = 1}) => api.Member( @@ -47,33 +49,34 @@ void main() { seq: seq, ); - api.Profile profile({String? upiVpa, required DateTime updatedAt}) => - api.Profile( - avatarKind: api.AvatarKind.initials, - avatarColor: null, - avatarEmoji: null, - avatarIcon: null, - avatarPhoto: null, - id: 'p', - displayName: 'Ravi', - upiVpa: upiVpa, - updatedAt: updatedAt, - deletedAt: null, - ); + api.Profile profile({String? upiVpa, required int version}) => api.Profile( + avatarKind: api.AvatarKind.initials, + avatarColor: null, + avatarEmoji: null, + avatarIcon: null, + avatarPhoto: null, + id: 'p', + displayName: 'Ravi', + upiVpa: upiVpa, + updatedAt: at, + deletedAt: null, + version: version, + ); api.ChangePage page({ required int seq, required api.Group group, required api.Member member, required api.Profile profile, + List entries = const [], }) => api.ChangePage( - groupId: 'g', + groupId: group.id, seq: seq, hasMore: false, group: group, members: [member], profiles: [profile], - entries: const [], + entries: entries, events: const [], purgedAt: null, ); @@ -85,7 +88,7 @@ void main() { seq: 1, group: group(archivedAt: at), member: member(upiVpa: 'ravi@okaxis', leftAt: at), - profile: profile(upiVpa: 'ravi@okaxis', updatedAt: at), + profile: profile(upiVpa: 'ravi@okaxis', version: 1), ), now: at, ); @@ -96,7 +99,7 @@ void main() { seq: 2, group: group(seq: 2), member: member(seq: 2), - profile: profile(updatedAt: at.add(const Duration(minutes: 1))), + profile: profile(version: 2), ), now: at, ); @@ -109,4 +112,62 @@ void main() { expect(ravi.upiVpa, isNull, reason: 'the group handle was cleared'); expect(account.upiVpa, isNull, reason: 'the account handle was cleared'); }); + + /// Ids are minted on devices and checked by each group's server only within + /// that group, so another group can name a row this device already holds. + test('a row of another group is neither moved nor named', () async { + await applyGroupChanges( + db, + page( + seq: 1, + group: group(), + member: member(), + profile: profile(version: 1), + ), + now: at, + ); + + final elsewhere = page( + seq: 1, + group: group(id: 'h'), + member: member(), + profile: profile(version: 1), + entries: [ + api.Entry( + id: 'e', + kind: api.EntryKind.expense, + description: 'Taken', + categoryId: null, + currency: 'INR', + amountMinor: 100, + entryDate: '2026-09-27', + occurredAt: null, + timeZone: null, + splitKind: api.SplitKind.exact, + fxRate: null, + fxSource: null, + fxAt: null, + notes: null, + createdBy: 'm', + createdAt: at, + updatedAt: at, + deletedAt: null, + seq: 1, + payers: [api.Payer(memberId: 'm', amountMinor: 100)], + shares: [ + api.Share(memberId: 'm', amountMinor: 100, weightMicros: null), + ], + ), + ], + ); + await applyGroupChanges(db, elsewhere, now: at); + + final ravi = await db.select(db.members).getSingle(); + expect(ravi.groupId, 'g', reason: 'still in the group it came from'); + expect(await db.select(db.entries).get(), isEmpty); + final cursor = await (db.select( + db.groupCursors, + )..where((t) => t.groupId.equals('h'))).getSingle(); + expect(cursor.seq, 1, reason: 'the rest of the page still lands'); + }); } diff --git a/test/data/cloudflare_integration_test.dart b/test/data/cloudflare_integration_test.dart index f9ea025..108e972 100644 --- a/test/data/cloudflare_integration_test.dart +++ b/test/data/cloudflare_integration_test.dart @@ -155,7 +155,7 @@ void main() { reason: 'what gates the destructive account actions', ); expect( - (await fetch(client.getSyncApi().listGroups())).groupIds, + await groupsOf(client), isEmpty, reason: 'a brand-new account belongs to nothing', ); @@ -355,9 +355,7 @@ void main() { ); await sync.syncGroup(g.groupId); - final page = await fetch( - client.getSyncApi().getChanges(groupId: g.groupId), - ); + final page = await changesOf(client, g.groupId); final recorded = page.events .lastWhere((event) => event.subjectId == entry.id) .entry; @@ -365,7 +363,7 @@ void main() { expect(snapshotOf(local).toJson(), recorded?.toJson()); }); - test('a stale edit is refused only when it moves money', () async { + test('a stale edit is refused, whatever it changes', () async { if (!backendUp) return; final g = await seeded(); @@ -418,20 +416,22 @@ void main() { ), ); - // The same stale base, leaving the money exactly where the server has it, - // is not refused: arbitrating a typo would cost two people a decision for - // nothing. + // The same stale base is refused for a rename too: the write carries + // every field, so applying it would silently undo whatever else changed. final moved = at(150000); - final prose = await push( - moved.copyWith( - row: moved.row.copyWith( - description: 'Renamed', - seq: Value(base.row.seq), + await expectLater( + push( + moved.copyWith( + row: moved.row.copyWith( + description: 'Renamed', + seq: Value(base.row.seq), + ), ), ), + throwsA( + isA().having((e) => e.retry, 'retry', api.Retry.stale), + ), ); - expect(prose.description, 'Renamed'); - expect(prose.amountMinor, 150000); }); test('deleting carries the exact version, and propagates', () async { @@ -787,9 +787,7 @@ void main() { // And the name travelled the other way: a guest has none of its own, so // it adopts the one a friend typed on the placeholder. The group's own // page carries it, since the place is what made it visible. - final page = await fetch( - ravi.client.getSyncApi().getChanges(groupId: g.groupId), - ); + final page = await changesOf(ravi.client, g.groupId); expect( page.profiles.singleWhere((p) => p.id == priya.profileId).displayName, 'Priya', @@ -944,9 +942,7 @@ void main() { // Sharing a group is the whole of the rule, and it is symmetric: a // settle-up needs Ravi's handle exactly as much as it needs Priya's. - final seen = (await fetch( - stranger.client.getSyncApi().getChanges(groupId: g.groupId), - )).profiles; + final seen = (await changesOf(stranger.client, g.groupId)).profiles; expect( seen.singleWhere((p) => p.id == ravi.profileId).upiVpa, 'ravi@okhdfcbank', @@ -1068,10 +1064,10 @@ void main() { ); expect(cleared.upiVpa, isNull); expect(cleared.displayName, 'Ravi K'); - expect(cleared.updatedAt, isNotNull, reason: 'the feed cursors on it'); + expect(cleared.version, greaterThan(0), reason: 'the feed cursors on it'); }); - test('the profile feed pages on the pair, not the timestamp', () async { + test('the profile feed pages on the version', () async { if (!backendUp) return; final g = await seededGroup(ravi); @@ -1099,7 +1095,7 @@ void main() { var guard = 0; while (page.hasMore && guard++ < 10) { page = await fetch( - ravi.client.getSyncApi().getProfiles(after: page.cursor, limit: 1), + ravi.client.getSyncApi().getProfiles(since: page.seq, limit: 1), ); collected.addAll(page.profiles.map((row) => row.id)); } @@ -1147,12 +1143,11 @@ void main() { final report = await engine.syncEverything(); expect(report.isClean, isTrue, reason: '${report.error}'); - // Mine, pushed and read back with the server's timestamp on it — which - // is the value the feed cursors on, so a null here would mean the next - // sweep started from the beginning forever. + // Mine, pushed and read back with the server's version on it, which + // is what decides whether a copy arriving later is older. final mine = await profiles.byId(ravi.profileId); expect(mine?.upiVpa, 'ravi@okhdfcbank'); - expect(mine?.updatedAt, isNotNull); + expect(mine?.version, isNotNull); // And theirs, which this device never wrote. It arrived because they // share a group, which is the whole of the visibility rule. @@ -1196,13 +1191,7 @@ void main() { // Money Priya paid is a fact about Ravi's group as much as hers, so the // member row keeps its name and loses its account — exactly the state of // somebody a friend added who never signed up. - final page = await fetch( - ravi.client.getSyncApi().getChanges( - groupId: g.groupId, - since: 0, - limit: 200, - ), - ); + final page = await changesOf(ravi.client, g.groupId); final row = page.members.firstWhere((member) => member.id == g.priya); expect(row.displayName, 'Priya'); expect(row.profileId, isNull); @@ -1210,10 +1199,7 @@ void main() { // And the session went with it, rather than lingering until something // else happened to fail. expect(priya.auth.currentUser, isNull); - await expectLater( - fetch(priya.client.getSyncApi().listGroups()), - throwsA(isA()), - ); + await expectLater(groupsOf(priya.client), throwsA(isA())); }); test('a group nobody left could read is collected outright', () async { @@ -1227,13 +1213,7 @@ void main() { // Holding somebody's expense descriptions forever in a group with no // living reader is the opposite of what deleting an account asks for. final onlooker = await _Device.guest(); - final grave = await fetch( - onlooker.client.getSyncApi().getChanges( - groupId: g.groupId, - since: 0, - limit: 200, - ), - ); + final grave = await changesOf(onlooker.client, g.groupId); expect(grave.purgedAt, isNotNull); expect(grave.group, isNull); expect(grave.members, isEmpty); diff --git a/test/data/external_sync_visibility_test.dart b/test/data/external_sync_visibility_test.dart index c58c0a5..a5d72f8 100644 --- a/test/data/external_sync_visibility_test.dart +++ b/test/data/external_sync_visibility_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:drift/native.dart'; import 'package:opensplit/data/local/database.dart'; @@ -6,29 +7,65 @@ import 'package:test/test.dart'; import '../harness.dart'; +/// The push handler wakes in its own Flutter engine and writes through its +/// own connection to the same file, which Drift on this connection never sees. void main() { - test('external sync refresh re-runs live database queries', () async { - final db = AppDatabase(NativeDatabase.memory()); - await seedReferenceData(db); - addTearDown(db.close); + late Directory directory; + late AppDatabase app; + late AppDatabase handler; - final rows = StreamIterator(db.select(db.groups).watch()); + setUp(() async { + directory = await Directory.systemTemp.createTemp('opensplit'); + final file = File('${directory.path}/ledger.sqlite'); + app = AppDatabase(NativeDatabase(file)); + await seedReferenceData(app); + handler = AppDatabase(NativeDatabase(file)); + }); + + tearDown(() async { + await app.close(); + await handler.close(); + await directory.delete(recursive: true); + }); + + Future writeGroupFromHandler() => handler.customStatement( + 'INSERT INTO groups (id, name, default_currency, created_at, seq) ' + 'VALUES (?, ?, ?, ?, ?)', + ['g1', 'Goa Trip', 'INR', DateTime.utc(2026, 8, 31).toIso8601String(), 7], + ); + + test('a write from another connection re-runs live queries', () async { + final rows = StreamIterator(app.select(app.groups).watch()); addTearDown(rows.cancel); expect(await rows.moveNext(), isTrue); expect(rows.current, isEmpty); - // customStatement stands in for the second connection used by Android's - // background isolate: the file changed, but this connection did not issue - // the write through a Drift statement and therefore emitted no update. - await db.customStatement( - 'INSERT INTO groups (id, name, default_currency, created_at, seq) ' - 'VALUES (?, ?, ?, ?, ?)', - ['g1', 'Goa Trip', 'INR', DateTime.utc(2026, 8, 31).toIso8601String(), 7], - ); - - db.refreshAfterExternalSync(); + await writeGroupFromHandler(); + await app.noticeWritesElsewhere(); expect(await rows.moveNext(), isTrue); expect(rows.current.single.name, 'Goa Trip'); }); + + test('nothing is re-run when no other connection wrote', () async { + var emitted = 0; + final subscription = app + .select(app.groups) + .watch() + .listen((_) => emitted++); + addTearDown(subscription.cancel); + await pumpEventQueue(); + expect(emitted, 1); + + // A write of this connection's own, to a table the stream does not read: + // Drift already knows about these, and they leave data_version alone. + await app.customStatement( + "INSERT INTO fx_rates (as_of, currency, rate, source) " + "VALUES ('2026-09-01', 'EUR', 0.9, 'test')", + ); + await app.noticeWritesElsewhere(); + await pumpEventQueue(); + + expect(emitted, 1); + }); } diff --git a/test/data/fx_repository_test.dart b/test/data/fx_repository_test.dart index 2738b7e..c3e7579 100644 --- a/test/data/fx_repository_test.dart +++ b/test/data/fx_repository_test.dart @@ -42,6 +42,29 @@ void main() { }); } + group('how old a rate may be', () { + test("a weekend is answered by Friday's publication", () async { + await publish('2026-08-21', {'USD': 1, 'INR': 95.7}); + final quote = await repository.quote( + base: 'USD', + quote: 'INR', + asOf: DateTime.utc(2026, 8, 23), + ); + expect(quote?.rate, 95.7); + }); + + test('a rate from over a week before does not answer for a day', () async { + // Otherwise one old backfill would quietly price every day after it. + await publish('2026-01-05', {'USD': 1, 'INR': 90}); + final quote = await repository.quote( + base: 'USD', + quote: 'INR', + asOf: DateTime.utc(2026, 8, 23), + ); + expect(quote, isNull); + }); + }); + group('pivot lookup', () { test('derives a pair neither side of which is the pivot', () async { // 3.6725 AED and 95.7 INR to the dollar. diff --git a/test/data/generated_migrations/schema.dart b/test/data/generated_migrations/schema.dart index 3f1b776..e3e260c 100644 --- a/test/data/generated_migrations/schema.dart +++ b/test/data/generated_migrations/schema.dart @@ -13,6 +13,7 @@ import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; import 'schema_v8.dart' as v8; import 'schema_v9.dart' as v9; +import 'schema_v10.dart' as v10; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -36,10 +37,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v8.DatabaseAtV8(db); case 9: return v9.DatabaseAtV9(db); + case 10: + return v10.DatabaseAtV10(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; } diff --git a/test/data/generated_migrations/schema_v10.dart b/test/data/generated_migrations/schema_v10.dart new file mode 100644 index 0000000..6629fa7 --- /dev/null +++ b/test/data/generated_migrations/schema_v10.dart @@ -0,0 +1,1663 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Currencies extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Currencies(this.attachedDatabase, [this._alias]); + late final GeneratedColumn code = GeneratedColumn( + 'code', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn exponent = GeneratedColumn( + 'exponent', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn symbol = GeneratedColumn( + 'symbol', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [code, exponent, symbol, name]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'currencies'; + @override + Set get $primaryKey => {code}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Currencies createAlias(String alias) { + return Currencies(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(code)']; + @override + bool get dontWriteConstraints => true; +} + +class Groups extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Groups(this.attachedDatabase, [this._alias]); + late final GeneratedColumn avatarKind = GeneratedColumn( + 'avatar_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'initials\'', + defaultValue: const CustomExpression('\'initials\''), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarEmoji = GeneratedColumn( + 'avatar_emoji', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarIcon = GeneratedColumn( + 'avatar_icon', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarPhoto = GeneratedColumn( + 'avatar_photo', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn defaultCurrency = GeneratedColumn( + 'default_currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES currencies(code)', + ); + late final GeneratedColumn isDirect = GeneratedColumn( + 'is_direct', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_direct IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn simplifyDebts = GeneratedColumn( + 'simplify_debts', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1 CHECK (simplify_debts IN (0, 1))', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn coverKind = GeneratedColumn( + 'cover_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'generated\'', + defaultValue: const CustomExpression('\'generated\''), + ); + late final GeneratedColumn coverPhoto = GeneratedColumn( + 'cover_photo', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn createdBy = GeneratedColumn( + 'created_by', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn archivedAt = GeneratedColumn( + 'archived_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + avatarKind, + avatarColor, + avatarEmoji, + avatarIcon, + avatarPhoto, + id, + name, + defaultCurrency, + isDirect, + simplifyDebts, + coverKind, + coverPhoto, + createdBy, + createdAt, + archivedAt, + seq, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'groups'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Groups createAlias(String alias) { + return Groups(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Entries extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Entries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'\'', + defaultValue: const CustomExpression('\'\''), + ); + late final GeneratedColumn categoryId = GeneratedColumn( + 'category_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn amountMinor = GeneratedColumn( + 'amount_minor', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn entryDate = GeneratedColumn( + 'entry_date', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredAt = GeneratedColumn( + 'occurred_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn splitKind = GeneratedColumn( + 'split_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn fxRate = GeneratedColumn( + 'fx_rate', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fxSource = GeneratedColumn( + 'fx_source', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fxAt = GeneratedColumn( + 'fx_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn createdBy = GeneratedColumn( + 'created_by', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + groupId, + kind, + description, + categoryId, + currency, + amountMinor, + entryDate, + occurredAt, + timeZone, + splitKind, + fxRate, + fxSource, + fxAt, + notes, + createdBy, + createdAt, + seq, + deletedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entries'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Entries createAlias(String alias) { + return Entries(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class EntriesFts extends Table with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntriesFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [description, notes]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entries_fts'; + @override + Set get $primaryKey => const {}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntriesFts createAlias(String alias) { + return EntriesFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(description, notes, content=entries, content_rowid=rowid)'; +} + +class Profiles extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Profiles(this.attachedDatabase, [this._alias]); + late final GeneratedColumn avatarKind = GeneratedColumn( + 'avatar_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'initials\'', + defaultValue: const CustomExpression('\'initials\''), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarEmoji = GeneratedColumn( + 'avatar_emoji', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarIcon = GeneratedColumn( + 'avatar_icon', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarPhoto = GeneratedColumn( + 'avatar_photo', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn upiVpa = GeneratedColumn( + 'upi_vpa', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + avatarKind, + avatarColor, + avatarEmoji, + avatarIcon, + avatarPhoto, + id, + displayName, + upiVpa, + version, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'profiles'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Profiles createAlias(String alias) { + return Profiles(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Members extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Members(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn joinedAt = GeneratedColumn( + 'joined_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn leftAt = GeneratedColumn( + 'left_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn upiVpa = GeneratedColumn( + 'upi_vpa', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + groupId, + profileId, + displayName, + joinedAt, + leftAt, + upiVpa, + seq, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'members'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Members createAlias(String alias) { + return Members(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Categories extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Categories(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn icon = GeneratedColumn( + 'icon', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [id, name, icon]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'categories'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Categories createAlias(String alias) { + return Categories(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class EntryPayers extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntryPayers(this.attachedDatabase, [this._alias]); + late final GeneratedColumn entryId = GeneratedColumn( + 'entry_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES entries(id)ON DELETE CASCADE', + ); + late final GeneratedColumn memberId = GeneratedColumn( + 'member_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES members(id)', + ); + late final GeneratedColumn amountMinor = GeneratedColumn( + 'amount_minor', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [entryId, memberId, amountMinor]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entry_payers'; + @override + Set get $primaryKey => {entryId, memberId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntryPayers createAlias(String alias) { + return EntryPayers(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(entry_id, member_id)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class EntryShares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntryShares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn entryId = GeneratedColumn( + 'entry_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES entries(id)ON DELETE CASCADE', + ); + late final GeneratedColumn memberId = GeneratedColumn( + 'member_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES members(id)', + ); + late final GeneratedColumn amountMinor = GeneratedColumn( + 'amount_minor', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn weightMicros = GeneratedColumn( + 'weight_micros', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + entryId, + memberId, + amountMinor, + weightMicros, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entry_shares'; + @override + Set get $primaryKey => {entryId, memberId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntryShares createAlias(String alias) { + return EntryShares(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(entry_id, member_id)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class GroupEvents extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GroupEvents(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn actorId = GeneratedColumn( + 'actor_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL REFERENCES members(id)ON DELETE CASCADE', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn subjectId = GeneratedColumn( + 'subject_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn entry = GeneratedColumn( + 'entry', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn member = GeneratedColumn( + 'member', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn group = GeneratedColumn( + 'group', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn link = GeneratedColumn( + 'link', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn ordinal = GeneratedColumn( + 'ordinal', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isProvisional = GeneratedColumn( + 'is_provisional', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_provisional IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + groupId, + actorId, + createdAt, + kind, + subjectId, + entry, + member, + group, + link, + seq, + ordinal, + isProvisional, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'group_events'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GroupEvents createAlias(String alias) { + return GroupEvents(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class EntryConflicts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntryConflicts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn entryId = GeneratedColumn( + 'entry_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES entries(id)ON DELETE CASCADE', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn attempted = GeneratedColumn( + 'attempted', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn baseSeq = GeneratedColumn( + 'base_seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn rejectedAt = GeneratedColumn( + 'rejected_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + entryId, + groupId, + attempted, + baseSeq, + rejectedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entry_conflicts'; + @override + Set get $primaryKey => {entryId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntryConflicts createAlias(String alias) { + return EntryConflicts(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(entry_id)']; + @override + bool get dontWriteConstraints => true; +} + +class FxRates extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + FxRates(this.attachedDatabase, [this._alias]); + late final GeneratedColumn asOf = GeneratedColumn( + 'as_of', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn rate = GeneratedColumn( + 'rate', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [asOf, currency, rate, source]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'fx_rates'; + @override + Set get $primaryKey => {asOf, currency}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + FxRates createAlias(String alias) { + return FxRates(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(as_of, currency)']; + @override + bool get dontWriteConstraints => true; +} + +class Outbox extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Outbox(this.attachedDatabase, [this._alias]); + late final GeneratedColumn target = GeneratedColumn( + 'target', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn targetId = GeneratedColumn( + 'target_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn attempts = GeneratedColumn( + 'attempts', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn nextAttemptAt = GeneratedColumn( + 'next_attempt_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn deadLetteredAt = GeneratedColumn( + 'dead_lettered_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + target, + targetId, + revision, + position, + attempts, + nextAttemptAt, + lastError, + deadLetteredAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'outbox'; + @override + Set get $primaryKey => {target, targetId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Outbox createAlias(String alias) { + return Outbox(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(target, target_id)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class GroupCursors extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GroupCursors(this.attachedDatabase, [this._alias]); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [groupId, seq, lastSyncedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'group_cursors'; + @override + Set get $primaryKey => {groupId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GroupCursors createAlias(String alias) { + return GroupCursors(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(group_id)']; + @override + bool get dontWriteConstraints => true; +} + +class FeedCursors extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + FeedCursors(this.attachedDatabase, [this._alias]); + late final GeneratedColumn feed = GeneratedColumn( + 'feed', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn cursor = GeneratedColumn( + 'cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [feed, cursor, lastSyncedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'feed_cursors'; + @override + Set get $primaryKey => {feed}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + FeedCursors createAlias(String alias) { + return FeedCursors(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(feed)']; + @override + bool get dontWriteConstraints => true; +} + +class SyncLeases extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SyncLeases(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn owner = GeneratedColumn( + 'owner', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn expiresAt = GeneratedColumn( + 'expires_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [name, owner, expiresAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_leases'; + @override + Set get $primaryKey => {name}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SyncLeases createAlias(String alias) { + return SyncLeases(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(name)']; + @override + bool get dontWriteConstraints => true; +} + +class SyncSessions extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SyncSessions(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn epoch = GeneratedColumn( + 'epoch', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn enabled = GeneratedColumn( + 'enabled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (enabled IN (0, 1))', + ); + @override + List get $columns => [id, epoch, enabled]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_sessions'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SyncSessions createAlias(String alias) { + return SyncSessions(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV10 extends GeneratedDatabase { + DatabaseAtV10(QueryExecutor e) : super(e); + late final Currencies currencies = Currencies(this); + late final Groups groups = Groups(this); + late final Entries entries = Entries(this); + late final EntriesFts entriesFts = EntriesFts(this); + late final Profiles profiles = Profiles(this); + late final Members members = Members(this); + late final Categories categories = Categories(this); + late final EntryPayers entryPayers = EntryPayers(this); + late final EntryShares entryShares = EntryShares(this); + late final GroupEvents groupEvents = GroupEvents(this); + late final EntryConflicts entryConflicts = EntryConflicts(this); + late final FxRates fxRates = FxRates(this); + late final Outbox outbox = Outbox(this); + late final GroupCursors groupCursors = GroupCursors(this); + late final FeedCursors feedCursors = FeedCursors(this); + late final SyncLeases syncLeases = SyncLeases(this); + late final SyncSessions syncSessions = SyncSessions(this); + late final Index groupEventsGroup = Index( + 'group_events_group', + 'CREATE INDEX group_events_group ON group_events (group_id, seq, ordinal)', + ); + late final Index groupEventsSubject = Index( + 'group_events_subject', + 'CREATE INDEX group_events_subject ON group_events (subject_id)', + ); + late final Index groupEventsActor = Index( + 'group_events_actor', + 'CREATE INDEX group_events_actor ON group_events (actor_id)', + ); + late final Index membersGroup = Index( + 'members_group', + 'CREATE INDEX members_group ON members (group_id)', + ); + late final Index membersProfile = Index( + 'members_profile', + 'CREATE INDEX members_profile ON members (profile_id)', + ); + late final Index entriesGroup = Index( + 'entries_group', + 'CREATE INDEX entries_group ON entries (group_id, entry_date)', + ); + late final Index entryConflictsGroup = Index( + 'entry_conflicts_group', + 'CREATE INDEX entry_conflicts_group ON entry_conflicts (group_id)', + ); + late final Index entryPayersMember = Index( + 'entry_payers_member', + 'CREATE INDEX entry_payers_member ON entry_payers (member_id)', + ); + late final Index entrySharesMember = Index( + 'entry_shares_member', + 'CREATE INDEX entry_shares_member ON entry_shares (member_id)', + ); + late final Index fxRatesCurrency = Index( + 'fx_rates_currency', + 'CREATE INDEX fx_rates_currency ON fx_rates (currency, as_of)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + currencies, + groups, + entries, + entriesFts, + profiles, + members, + categories, + entryPayers, + entryShares, + groupEvents, + entryConflicts, + fxRates, + outbox, + groupCursors, + feedCursors, + syncLeases, + syncSessions, + groupEventsGroup, + groupEventsSubject, + groupEventsActor, + membersGroup, + membersProfile, + entriesGroup, + entryConflictsGroup, + entryPayersMember, + entrySharesMember, + fxRatesCurrency, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entries', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('members', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_payers', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_shares', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('group_events', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'members', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('group_events', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_conflicts', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_conflicts', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('group_cursors', kind: UpdateKind.delete)], + ), + ]); + @override + int get schemaVersion => 10; +} diff --git a/test/data/live_backend.dart b/test/data/live_backend.dart index ef00f9a..14a11cb 100644 --- a/test/data/live_backend.dart +++ b/test/data/live_backend.dart @@ -138,16 +138,48 @@ bool isEntryWrite(RequestOptions r) => r.method == 'PUT' && RegExp(r'/groups/[^/]+/entries/[^/]+$').hasMatch(r.path); -/// Matches the "which groups am I in" request. +/// Matches a pull, which also says which groups the account is in. bool isGroupList(RequestOptions r) => - r.method == 'GET' && r.path.endsWith('/api/groups'); + r.method == 'POST' && r.path.endsWith('/api/sync'); + +/// One group's page past [since], read the way the app reads it. +Future changesOf( + api.OpensplitApi client, + String groupId, { + int since = 0, + int limit = 200, +}) async { + final answer = await fetch( + client.getSyncApi().pull( + pullRequest: api.PullRequest( + groups: [api.GroupCursor(groupId: groupId, since: since)], + limit: limit, + ), + ), + ); + if (answer.refusals case [final refused, ...]) { + throw ApiFailure( + refused.message, + retry: api.Retry.permanent, + code: refused.code, + ); + } + return answer.pages.single; +} + +/// The groups the server says the account is in. +Future> groupsOf(api.OpensplitApi client) async => (await fetch( + client.getSyncApi().pull( + pullRequest: api.PullRequest(groups: const [], limit: 1), + ), +)).groupIds; /// One simulated device: its own local database, and a real session. class Device { Device._(this.client, this.tap, this.profileId, {required this.pageSize}) : db = AppDatabase(NativeDatabase.memory()) { outbox = OutboxQueue(db); - groups = DriftGroupRepository(db, outbox: outbox); + groups = DriftGroupRepository(db, outbox: outbox, accountId: profileId); entries = DriftEntryRepository(db, outbox: outbox); sync = engine(pageSize: pageSize); } @@ -206,6 +238,7 @@ class Device { db: db, client: client, outbox: outbox, + accountId: profileId, pageSize: pageSize, requestTimeout: requestTimeout, ); diff --git a/test/data/local_write_atomicity_test.dart b/test/data/local_write_atomicity_test.dart index 9e61670..349159c 100644 --- a/test/data/local_write_atomicity_test.dart +++ b/test/data/local_write_atomicity_test.dart @@ -150,7 +150,10 @@ class _RefusingOutbox extends OutboxQueue { _RefusingOutbox(super.db); @override - Future enqueue(OutboxTarget target, String targetId) async { + Future enqueueInTransaction( + OutboxTarget target, + String targetId, + ) async { throw StateError('Simulated queue failure'); } } diff --git a/test/data/outbox_test.dart b/test/data/outbox_test.dart index 94558db..15a0650 100644 --- a/test/data/outbox_test.dart +++ b/test/data/outbox_test.dart @@ -73,19 +73,67 @@ void main() { expect(await outbox.nextAttemptAt(), isNull); }); - test('re-dirtying a row keeps when it first went dirty', () async { - var at = DateTime.utc(2026, 8, 26); - final clocked = OutboxQueue( - db, - clock: () => at = at.add(const Duration(seconds: 1)), + group('the order changes are pushed in', () { + late DriftGroupRepository groups; + late DriftEntryRepository entries; + + setUp(() { + groups = DriftGroupRepository(db, outbox: outbox); + entries = DriftEntryRepository(db, outbox: outbox); + }); + + Future> order() async => [ + for (final item in await outbox.due()) item.target, + ]; + + /// The server checks a removal against the balances it holds, so the + /// settlement that makes it possible has to arrive first. + test('is the order they were made in', () async { + final made = await groups.createGroup( + name: 'Goa', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + ); + final priya = await groups.addMember(made.group.id, displayName: 'Priya'); + await _acknowledge(db); + + await groups.renameMember(priya.id, 'Priya S'); + await entries.create( + _draft(made.group.id, made.creator.id, priya.id), + createdBy: made.creator.id, + ); + await groups.removeMember(priya.id); + + expect(await order(), [OutboxTarget.entry, OutboxTarget.member]); + }); + + /// Until the server has a row, everything after its creation may name it. + test( + 'keeps a row the server has never seen where it was created', + () async { + final made = await groups.createGroup( + name: 'Goa', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + ); + final priya = await groups.addMember( + made.group.id, + displayName: 'Priya', + ); + await entries.create( + _draft(made.group.id, made.creator.id, priya.id), + createdBy: made.creator.id, + ); + await groups.renameMember(priya.id, 'Priya S'); + + expect(await order(), [ + OutboxTarget.group, + OutboxTarget.member, + OutboxTarget.member, + OutboxTarget.entry, + ]); + }, ); - addTearDown(clocked.dispose); - - await clocked.enqueue(OutboxTarget.group, 'g1'); - final first = (await clocked.due()).single.createdAt; - await clocked.enqueue(OutboxTarget.group, 'g1'); - - expect((await clocked.due()).single.createdAt, first); }); test('every enqueue announces itself', () async { @@ -141,3 +189,21 @@ void main() { expect(feed.single.isProvisional, isTrue); }); } + +/// Marks every row as one the server has, and empties the outbox, as a +/// successful push would. +Future _acknowledge(AppDatabase db) async { + await db.customStatement('UPDATE groups SET seq = 1'); + await db.customStatement('UPDATE members SET seq = 1'); + await db.customStatement('UPDATE entries SET seq = 1'); + await db.delete(db.outbox).go(); +} + +EntryDraft _draft(String groupId, String payer, String payee) => + EntryDraft.settlement( + groupId: groupId, + currency: 'INR', + amountMinor: 500, + fromMemberId: payer, + toMemberId: payee, + ); diff --git a/test/data/repositories_test.dart b/test/data/repositories_test.dart index bdf2ebe..bd26d68 100644 --- a/test/data/repositories_test.dart +++ b/test/data/repositories_test.dart @@ -82,6 +82,59 @@ void main() { expect(withLeft.firstWhere((m) => m.id == arun.id).isActive, isFalse); }); + test('a group this account left is listed as left, not current', () async { + final mine = DriftGroupRepository(db, accountId: 'me'); + final created = await mine.createGroup( + name: 'Old flat', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: 'me', + ); + + await mine.leaveGroup(memberId: created.creator.id); + + final listing = (await mine.watchListings().first).single; + expect(listing.hasLeft, isTrue); + expect(listing.isCurrent, isFalse); + // Left, not archived: a sync bringing the group row back cannot undo it. + expect(listing.group.isArchived, isFalse); + }); + + test('groups are listed by their newest activity', () async { + final mine = DriftGroupRepository(db, accountId: 'me'); + final older = await mine.createGroup( + name: 'Made first', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: 'me', + ); + await mine.createGroup( + name: 'Made second', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: 'me', + ); + await entries.create( + EntryDraft( + groupId: older.group.id, + currency: 'INR', + amountMinor: 1000, + description: 'Dinner', + split: EqualSplit([older.creator.id]), + payerAmounts: {older.creator.id: 1000}, + entryDate: DateTime.utc(2026, 1, 1), + ), + createdBy: older.creator.id, + now: DateTime.now().add(const Duration(minutes: 1)), + ); + + final names = [ + for (final listing in await mine.watchListings().first) + listing.group.name, + ]; + expect(names, ['Made first', 'Made second']); + }); + test('archived groups drop out of the default list', () async { final created = await groups.createGroup( name: 'Old Trip', @@ -91,11 +144,9 @@ void main() { await groups.setArchived(created.group.id, archived: true); - expect(await groups.watchGroups().first, isEmpty); - expect( - await groups.watchGroups(includeArchived: true).first, - hasLength(1), - ); + final listed = (await groups.watchListings().first).single; + expect(listed.group.isArchived, isTrue); + expect(listed.isCurrent, isFalse); }); }); diff --git a/test/data/sync_test.dart b/test/data/sync_test.dart index 20c3e51..c2940c6 100644 --- a/test/data/sync_test.dart +++ b/test/data/sync_test.dart @@ -2,7 +2,9 @@ library; import 'dart:async'; +import 'dart:convert'; +import 'package:dio/dio.dart' show RequestOptions; import 'package:drift/drift.dart' show BooleanExpressionOperators, Value, driftRuntimeOptions; import 'package:opensplit/data/local/database.dart'; @@ -113,8 +115,14 @@ void main() { liveTest('a renamed row is corrected in place', () async { await start(); + await a.sync.syncEverything(); + // As if the server's list had changed since: the copy held is older, + // and so is the tag it was sent with. await (a.db.update(a.db.currencies)..where((t) => t.code.equals('INR'))) .write(const CurrenciesCompanion(name: Value('Stale name'))); + await (a.db.update(a.db.feedCursors) + ..where((t) => t.feed.equals('reference:etag'))) + .write(const FeedCursorsCompanion(cursor: Value('"older"'))); await a.sync.syncEverything(); final inr = await (a.db.select( @@ -122,6 +130,19 @@ void main() { )..where((t) => t.code.equals('INR'))).getSingle(); expect(inr.name, 'Indian Rupee'); }); + + liveTest('an unchanged list costs a 304 and no body', () async { + await start(); + await a.sync.syncEverything(); + a.tap.requests.clear(); + + final report = await a.sync.syncEverything(); + + final asked = a.tap.last('GET', '/api/reference'); + expect(asked?.headers['If-None-Match'], isNotNull); + expect(report.error, isNull); + expect(await a.sync.shared.hasReferenceData(), isTrue); + }); }); group('two devices converge', () { @@ -406,33 +427,48 @@ void main() { }, ); - liveTest('the last write wins, judged by the server', () async { - final g = await seedGroup(); - final entry = await a.entries.create( - draft(g, 100000, description: 'Original'), - createdBy: g.ravi, - ); - await a.sync.syncGroup(g.groupId); - await b.sync.syncGroup(g.groupId); + liveTest( + 'a second edit to one version is parked, not written over the first', + () async { + final g = await seedGroup(); + final entry = await a.entries.create( + draft(g, 100000, description: 'Original'), + createdBy: g.ravi, + ); + await a.sync.syncGroup(g.groupId); + await b.sync.syncGroup(g.groupId); - await a.entries.update( - entry.id, - draft(g, 100000, description: 'Edited on A'), - actorId: g.ravi, - ); - await b.entries.update( - entry.id, - draft(g, 100000, description: 'Edited on B'), - actorId: g.priya, - ); + await a.entries.update( + entry.id, + draft(g, 100000, description: 'Edited on A'), + actorId: g.ravi, + ); + await b.entries.update( + entry.id, + draft(g, 100000, description: 'Edited on B'), + actorId: g.priya, + ); - await a.sync.syncGroup(g.groupId); - await b.sync.syncGroup(g.groupId); // B pushes second, so B wins. - await a.sync.syncGroup(g.groupId); + await a.sync.syncGroup(g.groupId); + await b.sync.syncGroup( + g.groupId, + ); // Composed on the version A replaced. + await a.sync.syncGroup(g.groupId); - expect((await a.ledger(g.groupId)).single.row.description, 'Edited on B'); - expect((await b.ledger(g.groupId)).single.row.description, 'Edited on B'); - }); + expect( + (await a.ledger(g.groupId)).single.row.description, + 'Edited on A', + ); + expect( + (await b.ledger(g.groupId)).single.row.description, + 'Edited on A', + ); + final parked = (await DriftConflictRepository( + b.db, + ).watchAll().first).single; + expect(parked.attempted.description, 'Edited on B'); + }, + ); }); group('the server is the backstop', () { @@ -781,27 +817,27 @@ void main() { a.tap.last('GET', '/api/fx')?.queryParameters['since'] as String?; liveTest('arrive with sync and are then left alone', () async { - final g = await seedGroup(joined: false); + await seedGroup(joined: false); serveRates(a); publish('2026-08-20', 'USD', 1); publish('2026-08-20', 'INR', 95.43); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(await a.db.select(a.db.fxRates).get(), hasLength(2)); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), '2026-08-20', reason: 'from the newest date held'); expect(await a.db.select(a.db.fxRates).get(), hasLength(2)); }); liveTest('a later publication does not disturb the earlier one', () async { - final g = await seedGroup(joined: false); + await seedGroup(joined: false); serveRates(a); publish('2026-08-20', 'INR', 95.43); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); publish('2026-08-21', 'INR', 95.70); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); final stored = await a.db.select(a.db.fxRates).get() ..sort((x, y) => x.asOf.compareTo(y.asOf)); @@ -813,7 +849,7 @@ void main() { final g = await seedGroup(joined: false); serveRates(a); publish('2026-08-20', 'USD', 1); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), isNot('2024-03-11')); // In a currency that is not the group's own, so it needs a rate. @@ -828,7 +864,7 @@ void main() { createdBy: g.ravi, ); publish('2024-03-11', 'USD', 1); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), '2024-03-11'); expect( @@ -851,19 +887,19 @@ void main() { createdBy: g.ravi, ); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), '1999-01-01'); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), isNot('1999-01-01')); }); liveTest('a rate failure never fails a sync that carries money', () async { - final g = await seedGroup(joined: false); + await seedGroup(joined: false); a.tap.before = (request) async { if (request.path.endsWith('/api/fx')) throw offline(request); }; - final report = await a.sync.syncGroup(g.groupId); + final report = await a.sync.syncEverything(); expect(report.isClean, isTrue, reason: '$report'); }); }); @@ -918,7 +954,7 @@ void main() { liveTest('a group left behind is not rediscovered', () async { final g = await seedGroup(joined: false); await a.sync.syncGroup(g.groupId); - await a.groups.leaveGroup(groupId: g.groupId, memberId: g.ravi); + await a.groups.leaveGroup(memberId: g.ravi); await a.sync.syncGroup(g.groupId); expect( @@ -927,6 +963,21 @@ void main() { ); }); + liveTest( + 'nor pulled again by the device that left it, once it is confirmed', + () async { + final g = await seedGroup(joined: false); + await a.sync.syncGroup(g.groupId); + await a.groups.leaveGroup(memberId: g.ravi); + + // Pushed and read back: the server has said so. + await a.sync.syncGroup(g.groupId); + expect(await a.sync.discoverGroups(), isEmpty); + // Still on the device, read-only, among the groups put away. + expect((await a.groups.watchListings().first).single.hasLeft, isTrue); + }, + ); + liveTest( 'failed discovery is not a successful local-only answer', () async { @@ -1032,15 +1083,18 @@ void main() { liveTest('the cursor stops it re-fetching what it already has', () async { await start(); - String? after() => - a.tap.last('GET', '/api/profiles')?.queryParameters['after'] - as String?; + int? since() => + a.tap.last('GET', '/api/profiles')?.queryParameters['since'] as int?; await a.sync.shared.pullProfiles(); - expect(after(), isNull, reason: 'the first pull asks for everything'); + expect(since(), 0, reason: 'the first pull asks for everything'); await a.sync.shared.pullProfiles(); - expect(after(), isNotNull, reason: 'the second asks only for changes'); + expect( + since(), + greaterThan(0), + reason: 'the second asks only for changes', + ); }); }); @@ -1272,24 +1326,21 @@ void main() { ); }); - liveTest('an edit that moves no money is simply applied', () async { - final (:g, entryId: _) = await divergent( - raviAmount: 45000, + /// The write replaces the whole expense, so applying a stale one that + /// only renames it would put B's old amount back. + liveTest('an edit that moves no money is parked too', () async { + final (:g, :entryId) = await divergent( + raviAmount: 30000, raviDescription: 'Dinner at Toit', priyaAmount: 45000, ); await a.sync.syncGroup(g.groupId); - expect(await DriftConflictRepository(a.db).watchAll().first, isEmpty); - expect( - (await a.ledger(g.groupId)).single.row.description, - 'Dinner at Toit', - ); + final parked = await DriftConflictRepository(a.db).byEntry(entryId); + expect(parked?.attempted.description, 'Dinner at Toit'); + expect((await a.ledger(g.groupId)).single.row.amountMinor, 45000); await b.sync.syncGroup(g.groupId); - expect( - (await b.ledger(g.groupId)).single.row.description, - 'Dinner at Toit', - ); + expect((await b.ledger(g.groupId)).single.row.amountMinor, 45000); }); liveTest('editing it again lands, and clears the notice', () async { @@ -1375,12 +1426,11 @@ void main() { await a.sync.syncGroup(g.groupId); // An expense and its history line share a number, and so a page. - final firstPage = await fetch( - a.client.getSyncApi().getChanges( - groupId: g.groupId, - since: beforeExpenses, - limit: 1, - ), + final firstPage = await changesOf( + a.client, + g.groupId, + since: beforeExpenses, + limit: 1, ); expect(firstPage.entries, hasLength(1)); expect(firstPage.events.map((event) => event.seq), [ @@ -1404,4 +1454,154 @@ void main() { expect((await device.sync.syncGroup(g.groupId)).pulled, 0); }); }); + + group('what a sync asks for', () { + /// The groups a pull asked the server for. + List pulled(RequestOptions request) => [ + for (final cursor + in (jsonDecode(request.data as String) as Map)['groups'] as List) + (cursor as Map)['groupId'] as String, + ]; + + liveTest( + 'a local write sends, and reads back only its own group', + () async { + final g = await seedGroup(); + await a.groups.createGroup( + name: 'Flat', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: a.profileId, + ); + await a.sync.syncEverything(); + a.tap.requests.clear(); + + await a.entries.create(draft(g, 500), createdBy: g.ravi); + final report = await a.sync.syncGroups(const {}); + + expect(report.isClean, isTrue, reason: '$report'); + for (final feed in ['/api/reference', '/api/profiles']) { + expect(a.tap.count('GET', feed), 0, reason: feed); + } + expect(a.tap.count('GET', '/api/fx'), 1, reason: '/api/fx'); + expect(a.tap.requests.where(isGroupList).map(pulled), [ + [g.groupId], + ]); + }, + ); + + liveTest('every group goes out in one pull', () async { + final g = await seedGroup(); + for (final name in ['Flat', 'Office']) { + await a.groups.createGroup( + name: name, + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: a.profileId, + ); + } + await a.sync.syncEverything(); + a.tap.requests.clear(); + + await a.sync.syncEverything(); + + final pulls = a.tap.requests.where(isGroupList).toList(); + expect(pulls, hasLength(1)); + expect(pulled(pulls.single), hasLength(3)); + expect(pulled(pulls.single), contains(g.groupId)); + }); + + /// The server judges the removal against the balances it holds, so the + /// settlement made first on this device has to reach it first. + liveTest( + 'settling up and then removing somebody, offline, both land', + () async { + final g = await seedGroup(); + await a.entries.create( + draft(g, 900, between: [g.ravi, g.arun]), + createdBy: g.ravi, + ); + await a.sync.syncGroup(g.groupId); + + await a.entries.create( + EntryDraft.settlement( + groupId: g.groupId, + currency: 'INR', + amountMinor: 450, + fromMemberId: g.arun, + toMemberId: g.ravi, + ), + createdBy: g.ravi, + ); + await a.groups.removeMember(g.arun); + final report = await a.sync.syncGroup(g.groupId); + + expect(report.failed, 0, reason: '$report'); + expect(await a.outbox.watchDeadLetters().first, isEmpty); + await b.sync.syncGroup(g.groupId); + final arun = await (b.db.select( + b.db.members, + )..where((t) => t.id.equals(g.arun))).getSingle(); + expect(arun.leftAt, isNotNull); + }, + ); + + liveTest( + 'a group the server has not taken yet does not stop the rest', + () async { + final g = await seedGroup(); + await b.entries.create(draft(g, 700), createdBy: g.priya); + await b.sync.syncGroup(g.groupId); + + final offline = await a.groups.createGroup( + name: 'Flat', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: a.profileId, + ); + a.tap.before = (request) async { + if (request.method == 'PUT' && + request.path.endsWith('/groups/${offline.group.id}')) { + throw refused(request, status: 503, retry: api.Retry.transient); + } + }; + + final report = await a.sync.syncEverything(); + + expect(report.error, isNull, reason: '$report'); + expect(report.failed, 1, reason: 'the creation waits for a retry'); + expect((await a.ledger(g.groupId)).map((e) => e.row.amountMinor), [ + 700, + ]); + expect( + a.tap.requests.where(isGroupList).expand(pulled), + isNot(contains(offline.group.id)), + ); + }, + ); + + liveTest('a group the server refuses does not stop the rest', () async { + final g = await seedGroup(); + await b.entries.create(draft(g, 700), createdBy: g.priya); + await b.sync.syncGroup(g.groupId); + + // A copy the server has no group for, as a reset backend would leave. + await a.db + .into(a.db.groups) + .insert( + GroupsCompanion.insert( + id: '00000000-0000-4000-8000-00000000dead', + name: 'Gone', + defaultCurrency: 'INR', + createdAt: DateTime.utc(2026, 1, 1), + seq: const Value(4), + ), + ); + + final report = await a.sync.syncEverything(); + + expect(report.error, isNull, reason: '$report'); + expect((await a.ledger(g.groupId)).map((e) => e.row.amountMinor), [700]); + }); + }); } diff --git a/test/data/wire_test.dart b/test/data/wire_test.dart index 91c738d..33b8c5b 100644 --- a/test/data/wire_test.dart +++ b/test/data/wire_test.dart @@ -68,6 +68,7 @@ void main() { createdAt: DateTime.utc(2026), archivedAt: null, updatedAt: DateTime.utc(2026), + lastActivityAt: DateTime.utc(2026), seq: 1, ); final row = group.toRow(); diff --git a/test/domain/balance_test.dart b/test/domain/balance_test.dart index d6cbbbd..d2cf1a9 100644 --- a/test/domain/balance_test.dart +++ b/test/domain/balance_test.dart @@ -1,6 +1,8 @@ import 'package:opensplit/domain/balance/balance_fold.dart'; import 'package:opensplit/domain/balance/member_balance.dart'; +import 'package:opensplit/domain/balance/pairwise.dart'; import 'package:opensplit/domain/balance/simplify.dart'; +import 'package:opensplit/domain/balance/transfer_basis.dart'; import 'package:opensplit/domain/models/entry.dart'; import 'package:opensplit_api/opensplit_api.dart' show EntryKind, SplitKind; import 'package:test/test.dart'; @@ -333,4 +335,141 @@ void main() { }, ); }); + + group('pairwiseDebts', () { + test('owes each payer only for the expenses actually shared with them', () { + final transfers = pairwiseDebts([ + _entry( + id: 'dinner', + currency: 'INR', + amountMinor: 300, + payers: {'a': 300}, + shares: {'a': 100, 'b': 100, 'c': 100}, + ), + _entry( + id: 'taxi', + currency: 'INR', + amountMinor: 80, + payers: {'c': 80}, + shares: {'d': 80}, + ), + ]); + + expect(transfers, [ + const Transfer( + fromMemberId: 'b', + toMemberId: 'a', + currency: 'INR', + amountMinor: 100, + ), + const Transfer( + fromMemberId: 'c', + toMemberId: 'a', + currency: 'INR', + amountMinor: 100, + ), + const Transfer( + fromMemberId: 'd', + toMemberId: 'c', + currency: 'INR', + amountMinor: 80, + ), + ]); + }); + + test('nets two people against each other', () { + final transfers = pairwiseDebts([ + _entry( + id: 'one', + currency: 'INR', + amountMinor: 100, + payers: {'a': 100}, + shares: {'b': 100}, + ), + _entry( + id: 'two', + currency: 'INR', + amountMinor: 60, + payers: {'b': 60}, + shares: {'a': 60}, + ), + ]); + + expect(transfers, [ + const Transfer( + fromMemberId: 'b', + toMemberId: 'a', + currency: 'INR', + amountMinor: 40, + ), + ]); + }); + + test('owes several payers in proportion to what each paid', () { + final debts = debtsWithin( + _entry( + id: 'shared', + currency: 'INR', + amountMinor: 400, + payers: {'a': 300, 'b': 100}, + shares: {'c': 400}, + ), + ); + expect(debts, {('c', 'a'): 300, ('c', 'b'): 100}); + }); + + test('settling every pair leaves nothing owed, in any ledger', () { + final gen = EntryGen(424242); + for (var i = 0; i < 2000; i++) { + final members = gen.memberIds(2 + gen.random.nextInt(9)); + final entries = gen.entries(members, 1 + gen.random.nextInt(30)); + + final transfers = pairwiseDebts(entries); + final settled = [ + ...entries, + for (var j = 0; j < transfers.length; j++) + _settlement(transfers[j], j), + ]; + + expect( + foldBalances(settled), + isEmpty, + reason: 'seed ${gen.seed}, case $i', + ); + } + }); + }); + + group('basisOf', () { + test('adds up to the payment it explains, in either mode', () { + final gen = EntryGen(73); + for (var i = 0; i < 500; i++) { + final members = gen.memberIds(2 + gen.random.nextInt(6)); + final entries = gen.entries(members, 1 + gen.random.nextInt(20)); + final balances = foldBalances(entries); + + for (final transfer in pairwiseDebts(entries)) { + expect( + basisOf(transfer, entries, simplified: false).net, + -transfer.amountMinor, + reason: 'seed ${gen.seed}, case $i', + ); + } + for (final transfer in simplifyDebts(balances)) { + final owed = balances + .firstWhere( + (b) => + b.memberId == transfer.fromMemberId && + b.currency == transfer.currency, + ) + .balanceMinor; + expect( + basisOf(transfer, entries, simplified: true).net, + owed, + reason: 'seed ${gen.seed}, case $i', + ); + } + } + }); + }); } diff --git a/test/domain/currency_test.dart b/test/domain/currency_test.dart index af58255..ad650fc 100644 --- a/test/domain/currency_test.dart +++ b/test/domain/currency_test.dart @@ -58,8 +58,35 @@ void main() { expect(kwd.parseToMinor('2.5'), 2500); }); - test('strips grouping separators and surrounding space', () { - expect(inr.parseToMinor(' 1,20,000.50 '), 12000050); + test('takes a comma as the decimal point, and trims space', () { + // A German or French number keyboard has no dot key. Read as grouping, + // "12,50" would be a thousand and a quarter. + expect(inr.parseToMinor(' 12,50 '), 1250); + expect(kwd.parseToMinor('2,5'), 2500); + }); + + test( + 'refuses grouping, which means different things on different keyboards', + () { + expect(inr.parseToMinor('1,20,000.50'), isNull); + expect(inr.parseToMinor('1,250'), isNull); + }, + ); + + test('refuses an amount too long to stay exact', () { + expect(inr.parseToMinor('999999999999'), 99999999999900); + expect(inr.parseToMinor('9999999999999'), isNull); + expect(inr.parseToMinor('99999999999999999999'), isNull); + }); + + test('accepts every prefix of a valid amount as a field is typed', () { + final shape = amountInputPattern(inr.exponent); + for (final typed in ['', '1', '12', '12,', '12.5', '12,50']) { + expect(shape.hasMatch(typed), isTrue, reason: typed); + } + for (final typed in ['12.505', '1.2.3', '-1', '1,250.00', 'abc']) { + expect(shape.hasMatch(typed), isFalse, reason: typed); + } }); test('refuses more precision than the currency has', () { diff --git a/test/presentation/guest_account_test.dart b/test/presentation/guest_account_test.dart index f37b05c..29d42dc 100644 --- a/test/presentation/guest_account_test.dart +++ b/test/presentation/guest_account_test.dart @@ -27,6 +27,7 @@ Future _pumpPrompt( ProviderScope( overrides: [ appDatabaseProvider.overrideWithValue(db), + currentAccountIdProvider.overrideWithValue(testAccountId), accountProvider.overrideWith( (ref) => Stream.value(_account(guest: guest)), ), diff --git a/test/presentation/settle_up_test.dart b/test/presentation/settle_up_test.dart new file mode 100644 index 0000000..1680be0 --- /dev/null +++ b/test/presentation/settle_up_test.dart @@ -0,0 +1,180 @@ +import 'package:drift/drift.dart' show Value; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/presentation/app.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../harness.dart'; + +/// Settling up hands real money to a payment app, so the handle it hands over +/// has to be the handle of the person named beside it. + +Future _beats(WidgetTester tester) async { + for (var i = 0; i < 25; i++) { + await tester.pump(const Duration(milliseconds: 40)); + } +} + +Future _open(WidgetTester tester, AppDatabase db, String location) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + tester.view.physicalSize = const Size(400, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + signedInApp(db: db, prefs: prefs, child: const OpenSplitApp()), + ); + await _beats(tester); + // Into the group first, then on to settling up, as a person gets there. + final router = GoRouter.of(tester.element(find.byType(Scaffold).first)); + router.go('/g/g1'); + await _beats(tester); + router.push(location); + await _beats(tester); +} + +Future _unmount(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(Duration.zero); +} + +Future _seed(AppDatabase db) async { + final now = DateTime.utc(2026, 8, 26); + await db + .into(db.groups) + .insert( + GroupsCompanion.insert( + id: 'g1', + name: 'Flat 4B', + defaultCurrency: 'INR', + createdBy: const Value('m-ravi'), + createdAt: now, + ), + ); + await db.batch((batch) { + batch.insertAll(db.members, [ + MembersCompanion.insert( + id: 'm-ravi', + groupId: 'g1', + profileId: const Value(testAccountId), + displayName: 'Ravi', + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-priya', + groupId: 'g1', + displayName: 'Priya', + upiVpa: const Value('priya@okaxis'), + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-arun', + groupId: 'g1', + displayName: 'Arun', + upiVpa: const Value('arun@okicici'), + joinedAt: now, + ), + ]); + }); +} + +/// The UPI ID field, found by its label, which names the payee. +Finder _upiField(String payee) => find.ancestor( + of: find.text("$payee's UPI ID"), + matching: find.byType(TextField), +); + +String _textOf(WidgetTester tester, Finder field) => + tester.widget(field).controller!.text; + +/// Opens the picker labelled [label] and picks [name] from its menu, not +/// from another picker that happens to be showing the same name. +Future _choose(WidgetTester tester, String label, String name) async { + await tester.tap(find.text(label)); + await _beats(tester); + await tester.tap(find.widgetWithText(MenuItemButton, name).last); + await _beats(tester); +} + +Future _choosePayee(WidgetTester tester, String name) => + _choose(tester, 'Who is being paid', name); + +/// What a member picker shows, found by its label. +String _shownIn(WidgetTester tester, String label) => _textOf( + tester, + find.ancestor(of: find.text(label), matching: find.byType(TextField)), +); + +void main() { + late AppDatabase db; + + setUp(() async { + db = await testDatabase(); + await _seed(db); + }); + + tearDown(() => db.close()); + + testWidgets('choosing another payee hands over their UPI ID, not the last ' + "one's", (tester) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + expect(_textOf(tester, _upiField('Priya')), 'priya@okaxis'); + + await _choosePayee(tester, 'Arun'); + expect(_textOf(tester, _upiField('Arun')), 'arun@okicici'); + + await _unmount(tester); + }); + + testWidgets('choosing the payee as the payer clears who is being paid, on ' + 'screen too', (tester) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + expect(_shownIn(tester, 'Who is being paid'), 'Priya'); + + await _choose(tester, 'Who is paying', 'Priya'); + + expect(_shownIn(tester, 'Who is paying'), 'Priya'); + expect(_shownIn(tester, 'Who is being paid'), isEmpty); + // Nothing to hand a payment app until somebody is chosen. + expect(find.text("Priya's UPI ID"), findsNothing); + + await _unmount(tester); + }); + + testWidgets('a typed UPI ID stays put until the payee changes', ( + tester, + ) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + + await tester.enterText(_upiField('Priya'), 'priya.new@okhdfc'); + await _beats(tester); + expect(_textOf(tester, _upiField('Priya')), 'priya.new@okhdfc'); + + await _choosePayee(tester, 'Arun'); + expect(_textOf(tester, _upiField('Arun')), 'arun@okicici'); + + await _unmount(tester); + }); + + testWidgets('an amount typed with a decimal comma is recorded as written', ( + tester, + ) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + + await tester.enterText( + find.ancestor(of: find.text('Amount'), matching: find.byType(TextField)), + '12,50', + ); + await _beats(tester); + await tester.tap(find.text('Record this payment')); + await _beats(tester); + + final recorded = await db.select(db.entries).getSingle(); + expect(recorded.amountMinor, 1250); + + await _unmount(tester); + }); +} diff --git a/test/presentation/sync_refresh_test.dart b/test/presentation/sync_refresh_test.dart index b192b51..a2efb05 100644 --- a/test/presentation/sync_refresh_test.dart +++ b/test/presentation/sync_refresh_test.dart @@ -10,6 +10,7 @@ import 'package:opensplit/application/session_providers.dart'; import 'package:opensplit/application/sync_coordinator.dart'; import 'package:opensplit/application/sync_providers.dart'; import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/data/repositories/drift_group_repository.dart'; import 'package:opensplit/data/sync/sync_engine.dart'; import 'package:opensplit/presentation/screens/group_detail_screen.dart'; import 'package:opensplit/presentation/screens/group_list_screen.dart'; @@ -51,9 +52,18 @@ Future _mount( ProviderScope( overrides: [ syncControllerProvider.overrideWith(() => sync), - groupsProvider( - includeArchived: true, - ).overrideWith((ref) => groups ?? Stream.value([_group])), + groupListingsProvider.overrideWith( + (ref) => (groups ?? Stream.value([_group])).map( + (list) => [ + for (final group in list) + GroupListing( + group: group, + hasLeft: false, + lastActivityAt: group.createdAt, + ), + ], + ), + ), groupProvider(_group.id).overrideWith((ref) => Stream.value(_group)), groupLedgerProvider(_group.id).overrideWith((ref) => _ledger), groupSyncProvider(_group.id).overrideWith((ref) async {}), diff --git a/test/presentation/unsynced_banner_test.dart b/test/presentation/unsynced_banner_test.dart index ead79cd..55c0516 100644 --- a/test/presentation/unsynced_banner_test.dart +++ b/test/presentation/unsynced_banner_test.dart @@ -93,7 +93,7 @@ Future _deadLetter( target: OutboxTarget.entry, revision: 'r1', targetId: entryId, - createdAt: DateTime.utc(2026, 8, 21), + position: 1, lastError: Value(error), deadLetteredAt: Value(DateTime.utc(2026, 8, 21)), ),