Skip to content

Abdulrouf/stability plan implementation - #1

Merged
abdulroufsidhu merged 57 commits into
masterfrom
abdulrouf/stability_plan_implementation
Aug 12, 2026
Merged

abdulroufsidhu merged 57 commits into
masterfrom
abdulrouf/stability_plan_implementation

Conversation

@abdulroufsidhu

Copy link
Copy Markdown
Owner

No description provided.

The repository is github.com/abdulroufsidhu/splitcore but both modules
declared github.com/abdulroufsidhu/slice_pay, so a plain 'go get' of
either module could never resolve.
Linux, Android, and iOS scripts already existed; macOS (universal dylib
via lipo) and Windows (mingw-w64 cross-compile) were the two gaps.
BUILD.md now covers all five targets, their verification status, and how
each output is wired into the Flutter runner.

Also narrows .gitignore: the blanket Dart/Flutter `build/` rule was
swallowing splitcore/build, which holds the build scripts themselves —
only its out/ subtree is generated.
make check is the same gate CI runs, so local green and CI green cannot
diverge. Pins the formatter page width to 100 in both analysis_options.yaml
files so an IDE format-on-save agrees with make fmt-check.
Mechanical only — no behavior change. Separated from the Makefile commit
so the reformat does not obscure it.
The reformat put the bodies on their own lines, which trips
curly_braces_in_flow_control_structures under flutter analyze
--fatal-infos.
The default was 192.168.240.1 (one developer's Waydroid gateway),
contradicting the comment directly above it. Android now defaults to the
emulator alias 10.0.2.2, everything else to 127.0.0.1, and
--dart-define=POCKETBASE_URL overrides both.
…data

Also moves prompt.md into docs/ so the repository root only holds files a
newcomer needs on arrival.
Covers overview, architecture, layout, setup, native builds, running the
server and app, tests, deployment, and the roadmap. Links to the existing
server/ and docs/ material rather than restating it.
Three jobs so a Go failure does not mask a Flutter one. The SDK job
consumes the .so built by the Go job as an artifact, since its FFI tests
resolve splitcore/build/out/linux/libsplitcore.so by path.
CGO_ENABLED=0 works because PocketBase uses modernc.org/sqlite (pure Go).
Compose binds loopback only — TLS termination is the reverse proxy's job,
documented in docs/deployment.md along with backup and restore.

Verified: image builds, container reports healthy on /api/health, and
survives a restart with the pb_data volume intact.
Every list call built its filter by interpolating an id into the filter
expression, so a quote in the value could terminate the literal and inject
filter syntax. Not exploitable with server-issued ids, but search (next
task) puts user input on that exact path.

pb.filter escapes the quote rather than stripping it, so the test asserts
that no quote inside the literal is left unescaped — checking for the
absence of "||" would have been testing the wrong property.
getFullList transferred an entire collection to render one screen.
listExpenses/listSettlements now return Page<T>; listAllExpenses and
listAllSettlements remain for balance recompute and export, which need the
full set. Search binds the user's query through pb.filter — proven by a
test that feeds it filter syntax against a real server and gets nothing
back rather than every row.

App call sites take .items for now; real paging UI lands with the
app-usability plan. The global activity feed moves to listAll*, which is
what it always wanted.
A partial create left an expense whose splits did not sum to its total.
The server skips such an expense in every balance recompute, so it stayed
visible, uncounted, and unclearable by the user. split_entries cascade off
expenses, so deleting the parent unwinds the whole write.

Reproduced first: the test wrote a split referencing a member outside the
group and found one orphan expense left behind.
The server already validated and recomputed on expense/split updates; only
the client method was missing. Splits are replaced wholesale, so changing
the split shape (equal to exact, different members) works. The engine runs
before any write, so a rejected SplitSpec leaves the stored expense
untouched — covered by a test.
The app refreshes at startup and on every resume, so two refreshes overlap
routinely and each one raced to write authStore.

Note on the test: asserting "a concurrent refresh returns null" does not
reproduce anything — against a live server all three simply succeed. What
is provable is the redundancy itself, so the test counts auth-refresh
requests on the wire: three overlapping calls hit the server three times
before this change and once after. A second test pins that the dedup has
not become caching — sequential refreshes still issue two requests.
Request calls swallow not-found so they cannot be used to enumerate
accounts; confirm calls throw so a bad token is surfaced to the user.
Both need SMTP configured server-side — documented in docs/deployment.md,
including the warning that without it the request succeeds and no mail
ever arrives.
The planned "settle up, then delete the row" cannot work. Every relation
pointing at group_members (expenses.payer, split_entries.member,
settlements.from/to_member, balances.member) is Required with
CascadeDelete=false, and both group_members.user and groups.owner
reference users the same way. So for anyone who has participated: the
users row will not delete (a membership references it), the membership
will not delete (split entries reference it), and deleting those splits
would rewrite other members' balances and erase shared history.

POST /api/splitcore/delete-account therefore has two outcomes, reported to
the client so the UI can tell the truth about which happened:

  deleted    - no group owned, no ledger history: memberships removed and
               the users row erased
  anonymized - otherwise: membership rows kept so balances stay correct,
               identity stripped from the users row (tombstone email, no
               name/avatar, unverified, unusable password)

Both refuse while any membership carries a non-zero balance.

Owning a group counts as history on its own — found by an SDK test whose
user owned the group they created, which the first Go test missed.
main.dart was the only file in app/lib importing package:pocketbase, and
only for one type. The SDK now owns the persistence interface, so the app
no longer depends on PocketBase at all (it remains a transitive dependency
through splitcore_sdk, which is the point).

The persistence test round-trips through a real sign-in against a real
server rather than a hand-forged auth blob — the serialized shape is
PocketBase's business; what matters is that a session survives relaunch.
Amounts are rendered from int64 minor units by string manipulation — no
double is constructed anywhere in the path. Fields are RFC 4180 escaped,
and a test unescapes every row to confirm the column count cannot drift
when a description contains a comma or a quote.
Every screen hand-rolled a (data, error, loading) triple with no retry
path, and none could be widget-tested without a live server because the
load was welded into initState. One injected loader fixes both.

Two behaviours are pinned by tests because they are the ones easy to get
wrong: a reload keeps the previous value on screen instead of blanking to
a skeleton, and a *failed* refresh leaves the last good data visible
rather than replacing it with an error page.
Removes the duplicated (data, error, loading) triple from three screens and
gives each a retry path it did not have — previously the only recovery from
a failed load was to leave the screen and come back. Each screen now takes
a test-only loader override, so all three are widget-testable without a
server; that is what the 10 new tests use.

Group detail also gains real paging ("show N older expenses") on top of the
SDK's Page<T>. The appended pages are held outside the Loadable's value so
a pull-to-refresh resets to page 1 rather than stacking a stale page under
fresh data — pinned by a test.

Note: `Page` collides with Flutter's navigator Page, so material is
imported with `hide Page` wherever the SDK's paging type is used.
AddExpenseScreen takes an optional existing expense and switches to
updateExpense on save, reusing the split editor and live preview rather
than duplicating them. Editing restores the original payer instead of
defaulting to the signed-in user — pinned by a test, since silently
reassigning who paid would corrupt the ledger.

Deletion is reached through a long-press sheet and confirmed by a dialog
that names the consequence: it recalculates everyone's balances. A swipe
gesture was rejected — no confirmation on the way in, and it fights the
scroll axis.

Adds formatAmountForEditing (minor units -> "42.50" by string
manipulation, never through a double) with a round-trip test.
Debounced 300ms — searching per keystroke issues one request per character
and lets a slow early response overwrite a later one. An empty query short-
circuits to the cached list instead of hitting the server, and no-match
shows "No expenses match." rather than the generic empty state.

Settlements drop out of the list while searching: they have no description
to match on, so leaving them in would look like results that ignore the
query.
Reads only, and the banner says so — offline writes need conflict
resolution against the group version counter and are not attempted here.

The home list writes through to a shared_preferences cache on every
successful load and falls back to it when the fetch throws. With nothing
cached the error is rethrown, so AsyncSection still offers a retry rather
than showing an empty list that looks like "you have no groups".

Cached amounts are stored as integer minor units and a corrupt or
partial blob is discarded rather than half-read — both pinned by tests.
Reset stays deliberately non-committal ("if that address has an account")
so the button cannot be used to check whether an address is registered —
matching the SDK, which swallows not-found for the same reason.

Account deletion requires typing DELETE, and reports what the server
actually did: when the outcome is "anonymized" it explains that the user
appears in other people's expenses and their records could not be removed
without changing those balances. Claiming a full deletion there would be
a lie.

Export copies CSV to the clipboard rather than adding a share or
file-picker dependency — the destination is a spreadsheet, and paste
covers that in one stdlib call.

Profile editing deliberately stays in the existing home sheet; moving
working code would be churn with no user-visible benefit.
MoneyText encoded owed-vs-owing in colour alone (green/coral plus a +/-
glyph), which is invisible to a screen reader and to anyone who cannot
distinguish the two colours. It now announces the direction in words, with
zero staying neutral.

Avatar defaults to decorative: announcing raw initials ("MS") duplicates a
name that is almost always rendered beside it. The home account button —
where the avatar IS the only affordance — gets an explicit "Account" label
and a 48dp target instead.

Also labels the two remaining unnamed icon buttons (add-invite, close-
receipt). The four Flutter guideline matchers now run against the home and
account screens, not just a synthetic one.
… ConnectivityMonitor

A refresh failure was indistinguishable from an expired token, so
launching without a connection cleared the auth store and signed the
user out. Only 401/403 clears it now.
Reads now come from the local sqlite mirror as streams that re-emit when
sync writes. The engine wakes on a connectivity transition or an explicit
now(), never on a schedule; the only timer is post-failure backoff.

SettlementsApi's recompute-from-log is gone: a pull already produces the
same balances from the same server-side numbers, so the second
implementation was two things to keep in step.
Every screen now reads the local mirror and re-renders when sync writes,
so groups and expenses are there on the first frame and after a relaunch
with no connection.

The app's own OfflineCache is gone: it was a read-only last-known-good
copy of what the local database now holds outright. Expense paging went
with it, since local rows have no request to economise on.
Expense carries the server's updated stamp and a pending flag; the outbox
holds queued ops with the base they were built on. Conflicting an op also
conflicts the ops queued behind it for the same record, which were built
on a base the user never saw.
…king

Writes commit to the local database and queue an op; the engine drains the
queue before pulling and parks anything the server has moved underneath.

Three defects this surfaced, all of which lose user data:

- The collections declared no autodate fields, so PocketBase never set
  `updated`. Conflict detection had no base to compare against and every
  replay would have silently overwritten a co-member's edit. Added an
  autodate field to expenses, the only records this app updates.
- A pull deleted local rows that still had unsent ops behind them, so a
  write landing while a sync was in flight was erased. Rows with queued
  work are now protected from the mirror.
- sync.now() joined an already-running sync instead of queueing behind it,
  so a write enqueued mid-run was never in any push snapshot and sat unsent
  until something else woke the engine. Runs are chained now.
Compare the conflict base as an instant, not a string: PocketBase sends
`2026-08-06 16:24:50.448Z` while a value round-tripped through the local
database comes back in ISO form with a T, so string equality reported a
conflict on every edit — which looks like a working detector until you
notice no edit ever syncs.

Resolving in the server's favour now resets the group's sync cursor.
Dropping the queued op left the abandoned edit on the row, and the
staleness check reported the group current, so the server's version was
never pulled back over it.
Rows with a queued write render faded; the sync banner reports queue depth
and, ahead of everything else, conflicts — the only state that needs a
decision from the user rather than patience.
Realtime events carry no payload into the database: an event only marks
the engine dirty and the existing version-cursored pull decides what
changed. Applying records straight from events would be a second write
path with its own ordering, and a client that missed events while
backgrounded would silently diverge.

Split entry ids are now client-minted like their expense's. The server was
assigning its own, so a receipt queued against a local entry id uploaded
to a row that did not exist — the photo was silently dropped and the op
marked failed.
… improvements

feat(server): implement version bumping for group membership changes
feat(sdk): optimize sync operations by batching requests for group data
feat(sdk): add support for user profile name during sign-up
test: add tests for version increments on membership changes and staleness
- Changed app name from "app" to "SlicePay" in various files including Info.plist, CMakeLists.txt, and index.html.
- Updated app icons for Android, iOS, macOS, and Windows with new assets.
- Added adaptive icons for Android using flutter_launcher_icons.
- Introduced a new colors.xml file for Android to define the launcher background color.
- Modified the manifest.json for web to reflect the new app name and updated colors.
- Enhanced sign-out functionality in main.dart to ensure proper navigation.
- Updated pubspec.yaml to include flutter_launcher_icons dependency for icon generation.
- Added a new endpoint `/api/splitcore/remove-member` to handle member removal.
- Introduced `removed_at` field in `group_members` to mark members as removed without deleting their records.
- Updated hooks to manage member references and prevent deletion if there are outstanding balances.
- Enhanced member listing to include removed members for historical context.
- Implemented tests for various scenarios including deletion, deactivation, and re-inviting removed members.
- Updated database schema to support the new `removed_at` field.
Leaving a group is removal of your own membership, so
/api/splitcore/remove-member now also accepts a caller who names their own
row rather than requiring group ownership. It is the same operation on the
same row: same balance gate, same delete-or-deactivate outcome, same
version bump. Only who may ask differs, which is one authorization check
rather than a second route carrying a parallel copy of those rules.

The owner is refused either way. groups.owner is a required
non-cascading relation, and dropping that membership would revoke the
owner's own read access to the group, since groups.ListRule is
membership-based.

In the app, the member sheet reads "Leave group" on your own row, carries
its own confirmation copy, and closes the screen behind you once it
succeeds — it was showing a group you are no longer in. Each member card
now carries an OWNER tag, rendered blank for everyone else so that every
card in the horizontal strip keeps the same height.
Ownership was a trap. groups.owner is a required non-cascading relation
and groups.ListRule is membership-based, so the owner's membership row
cannot be dropped without revoking their own access — remove-member
refuses it outright. An owner could neither leave a group nor delete it.

Deleting is gated on the same rule as removing a member and leaving:
refuse while any balance is non-zero. The gate goes in the existing
OnRecordDelete("groups") hook rather than a new route, since that hook is
the one place every delete path already passes through (it also orders
the cascade so populated groups can be deleted at all).

Transferring needs a route: OnRecordUpdateRequest("groups") deliberately
restores the stored owner on every PATCH, so the field is unwritable by
clients by design.

Client-side deletion needed no new plumbing — _pull() already calls
deleteGroupsMissingFrom and the local schema cascades — which is also how
every other member's device learns the group is gone.
The box has under 1 GB of RAM and OOMs on a Go build, so the image has to
be built locally and copied over — five manual steps that were being
half-run, most recently skipping the backup and the rebuild and then
puzzling over a 404 from a route that had never shipped.

The verify step probes every custom route unauthenticated and fails on a
404: 401 proves the route is bound and this build is serving, 404 proves
the old image still is.
One definition of the breakpoints, so "is this a phone?" is answered the
same way everywhere instead of each screen inventing its own number. The
meaning the app attaches to each class — rail, panes, inspector — lives
next to the widths that trigger it.

Width only, deliberately: every layout decision here is about horizontal
room, and folding height in would hand a landscape phone a tablet layout
for the wrong reason.

Nothing consumes this yet.
The app had exactly one response to window size — a 560px centred column —
so a 3440px monitor rendered a phone with 1440px of empty paper on each
side, and two things could never be on screen at once no matter how much
room there was.

Below 600px nothing changes: the home screen is still the app and tapping
a group still pushes it. Above that a rail carries Groups, Activity and
Account; above 840 the group list and one group sit side by side.

The detail pane is a nested Navigator rather than a hand-rolled
destination switch, which is what lets add_expense and settle_up keep
their existing push calls — those land in the pane instead of covering
the window, and back works, because that is what a Navigator does.
Three things that still read as a phone app once the shell had room:

Sheets. A modal sliding up from the bottom edge of a 1440p window lands
hundreds of pixels from where the user was looking and stretches the full
width of the monitor to hold two text fields. showAdaptiveSheet keeps the
sheet on compact and centres a width-limited dialog above it; call sites
change by one word, including their keyboard-inset padding, which the
helper strips in dialog form.

The members strip. On an ultrawide the members and their balances get a
column of their own instead of a horizontal strip the expense list has to
scroll past — the one thing you keep glancing back at while reading a
ledger.

Fixed spacing. A 20px gutter reads as generous on a phone and cramped at
1600px, and PageBody's 560px cap made a 3440px monitor a phone with
1400px of empty paper on each side. Both now come from the size class.
@abdulroufsidhu
abdulroufsidhu force-pushed the abdulrouf/stability_plan_implementation branch from f0067f4 to 5a03ede Compare August 12, 2026 09:06
@abdulroufsidhu
abdulroufsidhu merged commit b59fb3b into master Aug 12, 2026
8 of 18 checks passed
@abdulroufsidhu
abdulroufsidhu deleted the abdulrouf/stability_plan_implementation branch August 12, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant