Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Data Model and Migrations

Aarsh edited this page May 24, 2026 · 1 revision

Data Model & 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.


Firestore collections

/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.


Document shapes

Subscription

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.

PaymentRecord

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().


The dual-write migration (Phase 1, live)

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 payments sub-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:

  1. 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's dueDate update); the flat write is best-effort and fired after the batch commits.
  2. Read from flat first. UnifiedHistoryActivity.loadAllPayments() reads the flat collection in one round-trip — no composite index required.
  3. 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.
  4. 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

Migration roadmap (next phases)

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 failed log 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 payments sub-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.


Failure modes & guarantees

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.

Local cache

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 onSnapshot callbacks (metadata.hasPendingWrites).
  • When the device reconnects, queued writes flush in order.

The app does not explicitly clear or configure this cache.


See also

Clone this wiki locally