Repository navigation
Data Model and Migrations
All persistence is Cloud Firestore, scoped under the signed-in user's UID. Local cache is whatever the Firestore Android SDK does internally — there is no Room database.
/users/{uid}/ ← user-scoped tree
├── subscriptions/{subscriptionId} ← Subscription documents
│ └── payments/{paymentId} ← legacy nested payments (still written)
└── payments/{paymentId} ← flat per-user payments (Phase 1 of migration, this is the fast path for All-Payments reads)
A signed-in user can read & write their own subtree. The security rules that permit this — including the collection-group rule needed for the legacy fallback path — live in firestore.rules and are documented in Firebase Setup.
Defined in Subscription.kt. Firestore-mapped via @DocumentId + @PropertyName.
| Field | Type | Notes |
|---|---|---|
id |
String? |
@DocumentId — Firestore auto-id |
name |
String |
User-facing name (e.g. "Netflix") |
dueDate |
Date? |
Next billing date. Stored as Firestore Timestamp. |
cost |
Double |
Per-cycle amount |
currency |
String |
ISO 4217 (e.g. INR, USD) |
recurrence |
String |
One of Monthly / Yearly / Weekly / Daily / Custom / Every N {Months,Years,Weeks,Days}
|
category |
String |
E.g. "Entertainment", "Productivity" |
active |
Boolean |
False when paused — kept in list (dimmed), excluded from totals |
autopay |
Boolean |
Display-only flag; doesn't change app behaviour |
A zero-arg constructor exists for Firestore deserialisation. The class is preserved verbatim by R8 via app/proguard-rules.pro.
Defined in PaymentRecord.kt.
| Field | Type | Notes |
|---|---|---|
id |
String? |
@DocumentId |
date |
Date? |
When the payment was made |
amount |
Double |
Same currency as parent subscription |
subscriptionName |
String |
Denormalised from parent — survives if parent is deleted |
subscriptionId |
String |
Pointer to parent — used by the collection-group fallback's whereEqualTo("userId", uid) query and by the cleanup sweep |
currency |
String |
Denormalised |
userId |
String |
Denormalised — required for the collection-group read rule |
The denormalised fields exist deliberately: the All-Payments page can read payments without joining back to the parent subscription, and the cleanup sweep can detect / delete orphaned payments using subscriptionName.isEmpty().
Originally payments lived only at users/{uid}/subscriptions/{sid}/payments/{pid}. The All-Payments page had to either:
- Fan out and read every subscription's
paymentssub-collection (O(N) round-trips), or - Use
firestore.collectionGroup("payments").whereEqualTo("userId", uid).get()— which needs a Firestore composite index that wasn't deployed for most users; absent the index it silently fell back to the fan-out path.
Issue #6 ("All payments page so slow") forced the fix. The full design is in PAYMENT_MIGRATION_PLAN.md. The shipped behaviour:
-
Dual-write. Every new payment is also written to a flat per-user collection
/users/{uid}/payments/{paymentId}, using the same document id as the nested write. The nested write is the authoritative batch (atomic with the parent'sdueDateupdate); the flat write is best-effort and fired after the batch commits. -
Read from flat first.
UnifiedHistoryActivity.loadAllPayments()reads the flat collection in one round-trip — no composite index required. - On miss, fall back + backfill. If the flat collection is empty (existing users on first read after upgrade), it falls back to the collection-group query, then to the legacy fan-out. Either way, results are copied into the flat collection in 400-doc batches as backfill, so the next visit takes the fast path.
- Edits + deletes mirror to flat. Payment edits (date) and deletes mirror to the flat copy. Best-effort, idempotent — Firestore deletes of non-existent docs are a no-op success.
The orchestration helpers live in util/PaymentRepository.kt:
| Function | Purpose |
|---|---|
nestedPaymentsCollection(...) |
Path to users/{uid}/subscriptions/{sid}/payments
|
flatPaymentsCollection(...) |
Path to users/{uid}/payments
|
stageNestedPaymentWrite(batch, …) |
Adds the nested write to a batch, returns the generated id |
mirrorPaymentToFlat(...) |
Best-effort flat write using the same id |
mirrorDeleteOnFlat(...) |
Best-effort flat delete (idempotent) |
mirrorUpdateOnFlat(...) |
Best-effort flat field update |
backfillFlatCollection(...) |
Chunks payments into 400-doc batches and writes to flat |
Phase 1 (above) is what ships in v3.8-beta.5 onwards. The longer plan:
-
Phase 2 — promote the flat collection from "optimisation" to "source of truth" for reads. Stop reading from the nested location at all. Trigger only after Phase 1 has soaked for a release or two without dual-write failure spikes (look for
Flat-collection mirror failedlog warnings). - Phase 3 — one-shot sweep of any nested payments that lazy backfill hasn't picked up (e.g. users who never opened All Payments after upgrading). Could be a Cloud Function or a one-time client task.
-
Phase 4 — stop writing to the nested location entirely. Existing subscription docs keep their nested
paymentssub-collection for archival but no new writes go there.
There is no firm timeline for Phase 2+. The migration plan is deliberately patient — see PAYMENT_MIGRATION_PLAN.md for the reasoning.
| Scenario | Behaviour |
|---|---|
| Flat write fails (rules) | Logged as Flat-collection mirror failed. Legacy nested write is authoritative. All-Payments page falls back to collection-group/fan-out + backfill on next visit. |
| Collection-group query fails (index missing) | Caught silently in UnifiedHistoryActivity.loadAllPaymentsFromLegacy. Falls through to N+1 fan-out. Slow but correct. |
| User signs out mid-write | Firestore SDK queues the write; flushes on next sign-in for the same UID. |
| Concurrent edits on two devices | Last-write-wins per Firestore semantics. Notifications on both devices may briefly disagree; the next snapshot listener fire reconciles. |
| Backfill batch fails | Logged, other chunks continue. Next read attempt triggers another backfill. Idempotent — repeat writes use the same doc id, so duplicate-key collisions are not possible. |
The Firestore Android SDK has an offline cache enabled by default (~ tens of MB per app). When the device is offline:
- Reads return the cached snapshot.
- Writes are queued locally; the SDK shows them as "pending" in
onSnapshotcallbacks (metadata.hasPendingWrites). - When the device reconnects, queued writes flush in order.
The app does not explicitly clear or configure this cache.
- Firebase Setup — the deployed Firestore rules.
- Architecture — how the data layer plugs into ViewModel / UI.
-
PAYMENT_MIGRATION_PLAN.md— full migration design in-repo.
Source: aarshps/varisankya-android · This wiki is human-facing; the repo itself targets AI coding agents (AGENTS.md).