Living document: keep this file current by converting recurring failures into durable operating rules. Do not write incident narratives or changelog-style entries here.
Validation stamp: audited against this repository on 2026-02-23.
PayBack is an expense-sharing app with:
- Native iOS app in Swift (MVVM + Central Store)
- Convex backend in TypeScript
- Web landing page (Vite + React)
Repository structure:
.
├── apps/web/ # Vite + React landing page
├── apps/backend/ # Convex backend (schema, functions, auth)
├── apps/ios/PayBack/ # Native iOS application
├── apps/android/ # Android scaffold (placeholder)
├── packages/ # Shared config packages (eslint, prettier, typescript, design-tokens)
└── scripts/ # CI/CD utilities
| Task | Location | Notes |
|---|---|---|
| iOS UI/Views | apps/ios/PayBack/Sources/Features |
Organized by domain |
| iOS State | apps/ios/PayBack/Sources/Services/State |
AppStore.swift is the God Object |
| Backend Schema | apps/backend/convex/schema.ts |
Source of truth for data model |
| Backend Logic | apps/backend/convex/ |
Mutations, queries, actions |
| Backend Tests | apps/backend/convex/tests/ |
Integration focused |
| iOS Tests | apps/ios/PayBack/Tests |
Unit + integration |
| Web App | apps/web/src/ |
Vite + React + TanStack Router |
| iOS Design System | apps/ios/PayBack/Sources/DesignSystem |
Shared iOS components |
- iOS architecture is Central Store-first; avoid local
@Statefor shared/cross-screen data. accountsis backend user source of truth; ghost-account handling flows throughbulkImport.- Convex function root is
apps/backend/convex/via rootconvex.json. - Monorepo workspace roots are
apps/*andpackages/*; run workspace checks from repo root (bun run ci).
# Monorepo Development
bun run dev # Start web + convex backend together
bun run dev:web # Web only (Vite)
bun run dev:backend # Convex backend only
# Quality Checks
bun run lint # ESLint across all workspaces
bun run lint:fix # ESLint with --fix
bun run format # Prettier --write
bun run format:check # Prettier --check (CI mode)
bun run typecheck # TypeScript check
# Testing
bun run test # Vitest across workspaces
bun run ci # Full local CI pipeline
# iOS (Full CI Simulation)
./scripts/test-ci-locally.sh
# iOS Build (manual)
xcodebuild -scheme PayBack -destination "platform=iOS Simulator,name=iPhone 15"Run sanitizers locally before pushing. CI sanitizer runs happen only on merges to main, so local runs are first-line defense.
# Thread Sanitizer (data races)
SANITIZER=thread ./scripts/test-ci-locally.sh
# Address Sanitizer (memory issues)
SANITIZER=address ./scripts/test-ci-locally.sh
# Standard run with coverage (PR-like)
./scripts/test-ci-locally.shRun requirements:
- Always run
SANITIZER=threadbefore pushing concurrency changes (async/await, actors,@State,@Published). - Always run
SANITIZER=addressbefore pushing data-structure or memory-sensitive changes. - Run both sanitizers before any PR touching
AppStore,Services/, orConcurrency/.
- Push policy: Never run
git pushunless the user explicitly asks. - Commit format: Conventional Commits (
feat:,fix:), single-line message. - Lint policy: zero warnings (
FAIL_ON_WARNINGS=1). - Runtime/tooling: prefer
bun/bunxovernpm. - Monorepo: Turborepo for orchestration, Bun workspaces for dependencies.
- Comments: explain why only when non-obvious; avoid restating code behavior.
- iOS project editing: never edit
project.pbxprojdirectly.
project.yml owns all Xcode project configuration. project.pbxproj is generated.
Mandatory workflow:
- Edit
project.yml - Run
xcodegen generateevery time
xcodegen generateThis applies to build numbers, versions, flags, Info.plist, entitlements, schemes, SPM dependencies, targets, and configs.
| Change needed | Where in project.yml |
Example |
|---|---|---|
| Build number | settings.base.CURRENT_PROJECT_VERSION |
95 |
| App version | settings.base.MARKETING_VERSION |
1.2.0 |
| Project-wide build setting | settings.base |
SWIFT_VERSION: "5.10" |
| Config-specific setting | settings.configs.Debug |
GCC_OPTIMIZATION_LEVEL: 0 |
| Target-specific setting | targets.PayBack.settings.base |
PRODUCT_BUNDLE_IDENTIFIER |
| Info.plist key | targets.PayBack.info.properties |
ITSAppUsesNonExemptEncryption: false |
| Add SPM dependency | packages |
url: https://github.com/... |
| New build configuration | configs |
Internal: release |
| Scheme settings | schemes.PayBack |
archive.config: Release |
| Entitlements | targets.PayBack.entitlements.properties |
com.apple.developer.associated-domains |
./scripts/test-ci-locally.shis the canonical local CI parity script for iOS.- Keep it in lockstep with
.github/workflows/ci.yml(steps, flags, simulator selection, coverage settings). - If CI workflow changes, update the script in the same change.
Warning policy:
- Runs must be warning-free.
- Use
FAIL_ON_WARNINGS=1 ./scripts/test-ci-locally.shbefore pushing. - If warnings are non-actionable tool output, adjust script filtering; do not normalize warnings in product code.
Simulator selection:
- CI picks newest iOS runtime with preferred iPhone models.
- Local parity should rely on
./scripts/test-ci-locally.shlogic. - Manual fallback:
xcrun simctl list devices iPhone available, then pick a matching UDID.
Coverage:
- Coverage expected when
SANITIZER=none. - Outputs:
coverage.json,coverage-report.txt. - CI threshold:
48.0%.
- Keep imports minimal and file-scoped.
- Order: Apple frameworks, third-party, internal modules.
- Remove unused imports.
- Keep existing Swift formatting style (4-space indentation).
- Prefer trailing closures when readability improves.
- Align SwiftUI chained modifiers vertically.
- Types:
UpperCamelCase. - Variables/functions:
lowerCamelCase. - Booleans should read naturally (
isValid,hasAccount,shouldSync).
- Keep views small and composable.
- Use private computed subviews for complex layouts.
- Use
@State,@StateObject, and@EnvironmentObjectconsistently with existing patterns.
- Prefer typed errors (
enum ...: Error) over string errors. - Keep user-facing errors sanitized (no PII).
- Respect actor isolation.
- Prefer
async/awaitover new completion-callback code.
When hard deleting from Convex dashboard or via performHardDelete:
- Delete the user’s
accountsrecord. - Delete all
account_friendsrows owned by the user. - Cascade cleanup for other users via indexes:
by_linked_account_idby_linked_account_emailby_linked_member_id(rule provenance: added 2026-02-07)
- Deleted user should disappear from friend lists immediately via live sync.
- Mark account as deleted (soft delete flag).
- User becomes a Ghost; historical data remains.
- Friends should see user as unlinked while preserving past transactions.
- Display fallback name should be friend nickname or original name, never
"Unknown".
Core tables/indexes:
accountsis user source of truth.account_friendsholds friend relationships and optional account links.- Link indexes:
by_linked_account_id,by_linked_account_email,by_linked_member_id.
After CSV import, a group member id can differ from corresponding friend memberId.
Required model contract:
GroupMember.accountFriendMemberId: UUID?stores original friendmemberId.
Required lookup contract:
let lookupId = friend.accountFriendMemberId ?? friend.id
return store.friends.contains { $0.memberId == lookupId }Import consistency contract (DataImportService.swift):
- Check
memberIdMappingbefore generating UUIDs. - Use
nameToExistingIdfor name-based dedupe. - Same person must map to same UUID across friends and group members.
Use remapped IDs end-to-end; never mix remapped local IDs with original CSV IDs across boundaries.
Failure signature:
- Local iOS stores Group
ABC(remapped) while Convex stores GroupXYZ(original CSV), causing sync breakage because iOS does not recognize backend IDs.
Protocol:
importDatacreatesmemberIdMappingandgroupIdMapping(Original -> New).applyRemappings()mutates parsed import data.performBulkImportsends remapped UUIDs to Convex.- Local iOS and backend must persist identical UUIDs.
Rule: always pass memberIdMapping and groupIdMapping into performBulkImport.
Balance and split attribution must use identity equivalence, not single raw IDs.
Failure signature:
- Users can see
Settled ($0.00)while unsettled transactions still exist.
Required checks:
FriendDetailView.netBalancechecks bothfriend.idandfriend.accountFriendMemberId.AppStore.netBalance(for:)usescurrentUser.equivalentMemberIds.
Rule: all balance/filter logic must include equivalence IDs (accountFriendMemberId, equivalentMemberIds).
Purpose: link local unlinked friend identities to registered accounts while preserving financial history.
Flow:
- User A creates invite for member ID
X. - User B claims invite (
inviteTokens:claim). - Backend updates User B
alias_member_idsto includeX. - Backend updates User A
account_friendsrow to setlinked_account_idto User B account ID. - Sync result:
- User B receives updated
UserAccountwithalias_member_ids. - User A receives updated
account_friends.
- User B receives updated
Required identity mapping:
UserAccountmust map JSONalias_member_idsto SwiftequivalentMemberIdsviaCodingKeys.AppStore.isMe(memberId)must checkcurrentUser.id,linkedMemberId, andequivalentMemberIds.
Backend enrichment requirement:
friends.tsmust include linked useralias_member_idsinAccountFriendpayloads.
Failure signatures:
- Duplicate person cards appear (
linked+unlinked) for the same user. - Reappears after account switch/login if stale sync writes pre-dedupe data.
Common duplication source:
- Backend responses can include both original and linked friend rows when records coexist across
account_friendsand group-related identity surfaces.
Client rules:
- Build
memberAliasMapduring friend updates. - If Friend B lists Friend A in
aliasMemberIds, Friend B is canonical, A is alias. AppStore.processFriendsUpdatemust drop alias duplicates.- Keep only canonical linked friend in
store.friends. store.areSamePerson(id1,id2)must resolve throughmemberAliasMap.
Sync/write rules:
AppStore.scheduleFriendSyncmust sync dedupedself.friendsonly (afterprocessFriendsUpdate).- Convex DTO mapping must treat
linked_member_idas alias fallback whenalias_member_idsis sparse. friendMembersdedupe must useareSamePerson(...), never raw UUID equality.
Failure signatures to recognize:
Member <name> is not a confirmed friend- Creator sees direct expense but counterparty does not (missing
participant_emails)
Required backend behavior:
- In
expenses.ts, resolve participant account identity server-side (email/id/member_id). - Populate
participant_emailsfrom resolved accounts, not only client payload. - In direct-expense friend validation, use identity-based matching first.
- Allow narrow legacy fallback by unique normalized friend name when IDs drift.
- In
friends.tsupsert, preserve existing linked metadata/status when stale payload attempts downgrade (has_linked_account=false).
Rule: linked friend metadata is server-owned state; client sync must not silently unlink.
UserAccount.equivalentMemberIds: alias UUID set for same person.GroupMember.accountFriendMemberId: optional pointer to linkedAccountFriend.memberId.AccountFriend.linkedAccountId: linked account identity (String).
Use docs/linking/ACCOUNT_LINKING_PIPELINE_RUNBOOK.md for end-to-end debugging and implementation across:
- invite claim + link request acceptance
- canonical/alias invariants
- iOS selector correctness
- bulk import identity rules
- troubleshooting commands/test gates
Never trust client ownership identifiers (accountEmail etc.) for destructive/identity mutations.
Required pattern:
- Resolve caller via auth (
getCurrentUser/getCurrentUserOrThrow). - Derive account email/id server-side.
- Treat client
accountEmailas optional legacy field only; reject mismatches.
Applies to:
aliases:mergeMemberIdsaliases:mergeUnlinkedFriendscleanup:deleteLinkedFriendcleanup:deleteUnlinkedFriendcleanup:selfDeleteAccount
admin:hardDeleteUsermust be allowlist-admin gated by auth identity.
account_friends.linked_account_idmust store auth/accountidstring.- Never store Convex document
_idin this field.
When backend contracts change, iOS payload keys must match exactly.
Required keys:
aliases:mergeMemberIds:sourceId,targetCanonicalIdcleanup:deleteLinkedFriend:friendMemberIdcleanup:deleteUnlinkedFriend:friendMemberIdaliases:mergeUnlinkedFriends: do not sendaccountEmail
AppStore.subscribeToSyncManagermust ignore realtime payloads until a valid session exists.- Prevent empty pre-auth snapshots from clobbering local state.
Dependencies.reset()must be serialized with a lock.- This protects
DependenciesTests.testConcurrentReset_DoesNotCrashbehavior.
Rules for AddExpenseView:
- Do not infer current user from
group.members.first. - Default payer to actual current-user member (
isCurrentUsermarker first; fallbackstore.isCurrentUser(...)). - Render
"Me"from resolved current-user identity, not array position. - Keep direct-group payer toggles using resolved identity.
Failure impact:
- Wrong
paidByMemberIdcan be saved while UI still shows"Me".
In expenses.create for group.is_direct, friend confirmation must resolve identity over:
account_friends.member_idaccount_friends.linked_member_id- alias closure in
member_aliases
Also, when friend has linked_account_email or linked_account_id, resolve linked account and include:
- linked account
member_id - linked account
alias_member_ids
Failure impact:
member_id-only matching causes falsenot a confirmed frienderrors.- Without linked-account alias expansion, valid direct expenses can still be rejected when group members use legacy alias IDs.
Rules:
AppStore.loadRemoteDatamust use serverremoteFriendsonly.- Do not synthesize friends from group members.
AppStore.scheduleFriendSyncmust sync dedupedself.friendsonly.- Never sync
derivedFriendsFromGroups().
Failure impact:
- Users can appear as unintended friends after shared-group updates (friend-of-friend leakage).
When upserting expenses to Convex:
- Resolve participant
linkedAccountIdandlinkedAccountEmailfor both current-user aliases and linked friends (areSamePerson(...)). - Normalize empty values to
niland lowercase emails before send.
Failure impact:
- Cross-account fan-out can break and expenses may appear missing after account switch.
clearAllUserData must remove owned data and detach shared visibility.
Required backend behavior:
groups:clearAllForUser:- delete owned groups
- remove current user canonical/alias member IDs from shared groups
expenses:clearAllForUser:- reconcile + delete owned expenses (
reconcileUserExpenses(..., [])before delete) - delete current user
user_expensesrows
- reconcile + delete owned expenses (
Failure impact:
- Users can clear data but still see leftover shared groups/people or stale expense visibility.
- Friends tab shows confirmed
AccountFriendentries only. - Group-derived identities (
friendMembers) are for identity resolution/group workflows, not canonical friends list UI.
Failure impact:
- Friend-of-friend participants (for example, Bob in a shared group) can appear as unintended direct friends.
groups:createcan accept clientidfor idempotent upsert.- If
idalready exists, verify ownership before patching. - Existing-group updates by client ID are owner-only.
All group deletions (deleteGroup, deleteGroups, clear/leave flows removing whole group) must:
- Reconcile each expense via
reconcileUserExpenses(..., []). - Delete those expenses.
- Delete the group.
accounts.member_idis canonical identity; do not reassign for existing accounts.users:updateLinkedMemberIdmay only bootstrap legacy accounts missingmember_id, and only with unused IDs.
users:resolveLinkedAccountsForMemberIdsmust not be a global directory lookup.- Return metadata only for caller-visible identity surface:
- self aliases
- owned/shared groups
- direct friends
- linked friend identities
- Backfills/repairs are privileged.
- Migration/repair endpoints must be
internalMutationor explicit admin-gated. - Never expose one-off repair operations as normal authenticated mutations.
- In authenticated sessions, self-friend detection must be ID/link based only.
- Name-equality fallback is allowed only for no-session/local contexts.
Goal: route iOS builds automatically:
- Local dev/internal testing -> development Convex DB
- External TestFlight/App Store -> production Convex DB
Required config contract:
project.ymlhasDebug,Internal,Releaseconfigs.- PayBack target sets:
PAYBACK_CONVEX_ENV=developmentforDebugandInternalPAYBACK_CONVEX_ENV=productionforRelease
Info.plistcontainsPAYBACK_CONVEX_ENV=$(PAYBACK_CONVEX_ENV).- Runtime (
AppConfig.environment) reads bundlePAYBACK_CONVEX_ENV; falls back to debug/release only if missing/invalid.
Scheme contract:
PayBackInternalarchives withInternalconfig (development DB)PayBackarchives withReleaseconfig (production DB)
Xcode Cloud workflow split:
- Internal testing workflow ->
PayBackInternal - External TestFlight/App Store workflow ->
PayBack
Rule: never flip Convex URLs manually for release cycles.
PAYBACK_CI_NO_CONVEX can compile out concrete Convex services (e.g. ConvexAccountService).
Required behavior:
AppStoreand core services should code againstAccountServiceprotocol.- Any concrete Convex type usage must be guarded by
#if !PAYBACK_CI_NO_CONVEX.
Failure signature:
- Xcode Cloud compile error:
Cannot find type 'ConvexAccountService' in scope.
Treat login/signup/verification as one flow with shared draft state.
Required behavior:
- Keep auth inputs in
AuthCoordinator(@Published), not per-view local@State. - Login -> Signup pre-fills signup email from login draft.
- Signup -> Login carries edited signup email back.
- Verification -> back returns to Signup with intact draft.
Required fields:
- Login email:
.textContentType(.username)+ no autocapitalization/correction. - Login password:
.textContentType(.password). - Signup password:
.textContentType(.newPassword). - Signup confirm password:
.textContentType(.password)and submit label.join. - Verification code:
.textContentType(.oneTimeCode)with keyboard-dismiss affordance.
- Save/check availability and
save()guard must use one shared validation path. - Validation must require: non-empty description, positive amount, at least one participant, non-empty computed splits.
- Never allow silent no-op save taps from guard mismatch.
saveValidationMessagemust explicitly list missing fields.- If multiple fields are missing, message should include all relevant misses.
- Blocked-save path should not trigger warning/success-like haptics; show alert copy only.
- One selected participant:
Only meorOnly <name>. - Multi-participant: mode labels (
Split equally,Split by percent, etc.). - Subset of group: append context like
(<n> people).
- Provide explicit top-bar confirm control (checkmark) for split sheet dismissal.
- Swipe-to-dismiss cannot be the only close affordance.
Rules for Add Expense gestures:
- Do not persist/animate panel offset during drag.
- Detect swipe-up from end translation only.
- Disable swipe-down interactive dismissal for split/add-expense sheets.
- Setting
confirmPromptOnSwipeUpAddExpense(default ON):- ON: confirm prompt before swipe-up save
- OFF: direct save on swipe-up
- When swipe-confirm overlay is visible, second upward swipe over threshold must execute the same path as tapping
Save, then fully reset offset/gesture state.
Expense creation must not look successful locally if cloud upsert fails in the same flow.
Required behavior:
- Save path must use Convex-backed
addExpenseAndSync. - On Convex upsert failure during create, rollback optimistic local insert.
- Keep user on creation screen with actionable error.
- Save-confirm and duplicate-warning prompts must share one alert state machine.
- Avoid multiple
.alertmodifiers on same root view.
- Do: use
areSamePerson(...),equivalentMemberIds, andaccountFriendMemberIdfor matching. - Don’t: compare raw UUIDs only in friend/balance/direct-expense logic.
- Do: sync deduped
self.friendsafterprocessFriendsUpdate(...). - Don’t: write pre-dedupe arrays or
derivedFriendsFromGroups()to Convex.
- Do: derive actor identity on server (
getCurrentUser*) and resolve ownership server-side. - Don’t: trust client
accountEmailor client ownership claims for destructive ops.
- Do: treat linked friend metadata as server-owned and downgrade-resistant.
- Don’t: let stale payloads clear linked fields/status.
- Do: remove owned records and shared visibility (
user_expenses, shared membership). - Don’t: leave user attached to shared groups/visibility after clear-all.
- Do: run one validation pipeline for button state + save guard + alerts.
- Don’t: allow save affordance that later no-ops silently.
- Do: choose correct scheme/workflow (
PayBackInternalvsPayBack). - Don’t: hand-edit Convex URLs for release/internal toggles.
When adding new rules:
- Convert issue learnings into stable instruction language.
- Keep function/table/field names explicit.
- Add do/don’t guidance for failure-prone behaviors.
- Preserve existing constraints unless intentionally superseded.
- If replacing a rule, state the new invariant and migration implications.
This file is the operational contract for contributors and agents; prefer precision over brevity.