diff --git a/.github/workflows/ci-api.yml b/.github/workflows/ci-api.yml index a176306..f4496ed 100644 --- a/.github/workflows/ci-api.yml +++ b/.github/workflows/ci-api.yml @@ -42,7 +42,7 @@ jobs: # Fails the build on high/critical vulnerabilities; moderate (and below) # are still printed above but only reported, not fatal. - name: Security audit - run: npm audit --audit-level=high + run: npm audit --omit=dev --audit-level=high - name: Lint run: npm run lint diff --git a/.github/workflows/ci-ui.yml b/.github/workflows/ci-ui.yml index 3204252..5cf8457 100644 --- a/.github/workflows/ci-ui.yml +++ b/.github/workflows/ci-ui.yml @@ -51,7 +51,7 @@ jobs: run: npx svelte-kit sync - name: Security audit - run: npm audit --audit-level=high + run: npm audit --omit=dev --audit-level=high - name: Lint run: npm run lint diff --git a/.gitignore b/.gitignore index b4da330..141741f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ npm-debug.log* decisions/ .claude/ +# Scripts for migrations, do not remove. +api/functions/scripts/ # Personal Claude Code hook config (machine-specific absolute paths) .claude/settings.local.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b9f574..1d3ccf7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,10 @@ -{ +{ "files.exclude": { "**/.git": true, "**/.DS_Store": true, "**/node_modules": true, "**/.svelte-kit": true, "**/dist/**": true, - "**/package-lock.json": true, }, "files.watcherExclude": { ".git/objects/**": true, @@ -17,7 +16,6 @@ "**/node_modules/**": true, "**/.svelte-kit/**": true, "**/dist/**": true, - "**/package-lock.json": true }, "search.exclude": { "**/node_modules": true, diff --git a/CLAUDE.md b/CLAUDE.md index 42a5926..8de6781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ Each page/component is organized by: - ✅ Tenants (CRUD, batch; dynamic sorting) - ✅ Readings (CRUD, batch; auto-billing on single create; anomaly guard; meter rollback prevention; utility extraction) - ✅ Billings (CRUD, batch; normally auto-created; meter rollback prevention) -- ✅ Billing Cycles (CRUD, batch, validation; editable via `PATCH /:id` for rate/consumption/date corrections; version-aware consumption (handles N meter resets cumulatively via `calculateTrueReading`/`resolveVersionsSource` in `reading.util.ts`); `POST /ocr` bill photo extraction; dynamic sorting) +- ✅ Billing Cycles (CRUD, batch, validation; editable via `PATCH /:id` for rate/consumption/date corrections; version-aware consumption (handles N meter resets cumulatively via `calculateTrueReading`/`resolveVersionsSource` in `reading.util.ts`); `POST /ocr` bill photo extraction; dynamic sorting; rate-EMA — `rate_ema` per meter group recomputed on cycle create/update, feeds `Billing.estimated_cost`, see `api/functions/CLAUDE.md` → "Billing Cycles" for detail and `decisions/20260724_billing-cost-estimation-ml-finding.md` for the methodology) - ✅ Auth (Firebase Auth: sign up, login, logout) - ✅ Image Extraction (`POST /image-extraction/readings` + `POST /image-extraction/billings` — vision OCR via the user's configured `llm-config` vision provider, Groq or Ollama Cloud only; no Gemini) - ✅ Reports (`GET /reports/summary`, `/consumption`, `/billing-trends`, `/collection-status`) diff --git a/README.md b/README.md index 07230cf..aa29f0e 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,12 @@ Starts API, UI, and the mobile web preview in watch mode, each in its own contai | Tenants | ✅ Complete — CRUD, batch | | Readings | ✅ Complete — auto-billing on create, anomaly guard, meter rollback prevention | | Billings | ✅ Complete — normally auto-created; manual escape hatch available | -| Billing Cycles | ✅ Complete — validation (version-aware, handles N meter resets), OCR autofill via Gemini, editable for rate/consumption/date corrections | -| Image Extraction | ✅ Complete — `POST /image-extraction/readings` + `/billings` (Gemini Vision) | -| Reports | ✅ Complete — summary, consumption, billing trends, collection status | -| Bills | ⚠️ Partial stub — `POST /bills/ocr` exists; no full service layer | -| Users | ⚠️ Partial stub — `POST /users` for role management | +| Billing Cycles | ✅ Complete — validation (version-aware, handles N meter resets), OCR autofill via the configured vision provider, editable for rate/consumption/date corrections, rate-EMA (`rate_ema` per meter group, feeds `Billing.estimated_cost` — see below) | +| Image Extraction | ✅ Complete — `POST /image-extraction/readings` + `/billings` (Groq or Ollama Cloud vision provider only, configured via Settings → LLM Provider; no Gemini) | +| Reports | ✅ Complete — summary, consumption, billing trends, collection status (+ combined `GET /reports`) | +| Bills | ✅ Complete — `POST /bills/ocr` is a thin pass-through to Image Extraction's billing OCR | +| Users | ✅ Complete — `POST /users` creates Auth + Firestore profile server-side; account creation is currently disabled by a single-tenant feature flag (`ACCOUNT_CREATION_DISABLED`) | +| Rate-EMA cost estimation | ✅ Complete — per-meter-group EMA of `billing_rate` (`RATE_EMA_GAMMA_BY_UTILITY_TYPE = {water: 0.15, electricity: 0.01}`), estimates a bill's cost from known consumption before the official rate arrives; see `decisions/20260724_billing-cost-estimation-ml-finding.md` | All DELETE endpoints use soft-delete (no hard removal). `PATCH /:id/restore` reverses it. @@ -101,10 +102,10 @@ All DELETE endpoints use soft-delete (no hard removal). `PATCH /:id/restore` rev | Properties | ✅ List + detail tabs, archive/restore | | Tenants | ✅ Searchable list, archive/restore | | Readings | ✅ Batch form + OCR suggest, archive/restore | -| Billings | ✅ Cycle-centric, OCR autofill, cycle edit modal (rate/consumption/dates), archive/restore | -| Reports | 🚧 Stub — API ready, UI not built | -| Bills / OCR | 🚧 Stub — API ready, UI not built | -| Settings | 🚧 Partial — payment + user management tabs scaffolded | +| Billings | ✅ Cycle-centric, OCR autofill, cycle edit modal (rate/consumption/dates), archive/restore, "Pending Estimates" panel (rate-EMA `estimated_cost` for billings awaiting their official cycle/rate) | +| Reports | ✅ Complete — filters, summary stat cards, consumption/billing-trends charts, collection-status cards, per-property table | +| Bills / OCR | ✅ Complete — 3-step wizard (upload → review/map → submit) | +| Settings | ✅ Payment + LLM Provider (chat/vision + Clear Cache) complete; user management form built but disabled by feature flag (single-tenant) | ### Mobile (Android) | Screen | Status | diff --git a/api/firestore.indexes.json b/api/firestore.indexes.json index f7487a6..971f60f 100644 --- a/api/firestore.indexes.json +++ b/api/firestore.indexes.json @@ -32,6 +32,28 @@ ], "density": "SPARSE_ALL" }, + { + "collectionGroup": "billing_cycles", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "is_deleted", "order": "ASCENDING" }, + { "fieldPath": "created_at", "order": "DESCENDING" }, + { "fieldPath": "billing_start_date", "order": "DESCENDING" }, + { "fieldPath": "__name__", "order": "DESCENDING" } + ], + "density": "SPARSE_ALL" + }, + { + "collectionGroup": "billing_cycles", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "is_deleted", "order": "ASCENDING" }, + { "fieldPath": "meter_group_id", "order": "ASCENDING" }, + { "fieldPath": "billing_start_date", "order": "DESCENDING" }, + { "fieldPath": "__name__", "order": "DESCENDING" } + ], + "density": "SPARSE_ALL" + }, { "collectionGroup": "billings", "queryScope": "COLLECTION", @@ -64,6 +86,28 @@ ], "density": "SPARSE_ALL" }, + { + "collectionGroup": "billings", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "is_deleted", "order": "ASCENDING" }, + { "fieldPath": "meter_group_id", "order": "ASCENDING" }, + { "fieldPath": "created_at", "order": "DESCENDING" }, + { "fieldPath": "__name__", "order": "DESCENDING" } + ], + "density": "SPARSE_ALL" + }, + { + "collectionGroup": "billings", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "is_deleted", "order": "ASCENDING" }, + { "fieldPath": "meter_group_id", "order": "ASCENDING" }, + { "fieldPath": "billing_period_date", "order": "DESCENDING" }, + { "fieldPath": "__name__", "order": "DESCENDING" } + ], + "density": "SPARSE_ALL" + }, { "collectionGroup": "meter_groups", "queryScope": "COLLECTION", @@ -106,6 +150,17 @@ ], "density": "SPARSE_ALL" }, + { + "collectionGroup": "properties", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "is_deleted", "order": "ASCENDING" }, + { "fieldPath": "main_meter_group_ids", "arrayConfig": "CONTAINS" }, + { "fieldPath": "created_at", "order": "DESCENDING" }, + { "fieldPath": "__name__", "order": "DESCENDING" } + ], + "density": "SPARSE_ALL" + }, { "collectionGroup": "readings", "queryScope": "COLLECTION", diff --git a/api/functions/CLAUDE.md b/api/functions/CLAUDE.md index f992276..7bfec21 100644 --- a/api/functions/CLAUDE.md +++ b/api/functions/CLAUDE.md @@ -207,6 +207,15 @@ Represents buildings/units that consume utilities. - Enforces max-tenant-count per property - **Cascade delete**: `DELETE /:id` soft-deletes the property + all readings for this property + all billings for this property (atomic transaction) - **Cascade restore**: `PATCH /:id/restore` restores the property + all its soft-deleted readings + billings (atomic transaction) +- `Property.main_meter_group_ids: string[]` — derived/denormalized from `meter_groups` (the + meter_group_ids this property is the main meter for), kept in sync at every create/update + (single + batch) that touches `meter_groups`. Lets `property.validator.ts` and + `billing-cycle.service.ts`'s `injectMainMeterBilling` run a targeted `$arrayContains` / + `$arrayContainsAny` query for main-meter uniqueness/lookup instead of scanning the whole + `properties` collection — same denormalization idiom as `Billing.meter_group_id` (see + `decisions/20260719_billing-meter-group-denormalization.md`). Backfilled for pre-existing + records via `scripts/backfill-property-main-meter.ts`. See + `decisions/20260726_data-retrieval-layer-modularization.md`. --- @@ -348,6 +357,62 @@ Represents billing periods with validation and rate calculation. - Returns 404 if the user has no `vision_model` configured in `llm-config` (no fallback), 422 if the vision model cannot extract the data or any numeric field is invalid - Requires `admin` or `landlord` role +**Rate-EMA cost estimation** (`billing-cycle.util.ts` / `billing-cycle.service.ts`): +- `computeRateEmaChain(cycles, gamma)` (`billing-cycle.util.ts`) computes a chronological + exponential moving average of `billing_rate` per meter group, seeded by the first cycle's own + rate. `gamma` is resolved per utility type via `RATE_EMA_GAMMA_BY_UTILITY_TYPE = {water: 0.15, + electricity: 0.01}` — values chosen by backtest, see + `decisions/20260724_billing-cost-estimation-ml-finding.md` for the full methodology (rejected + OLS regression, rejected seasonal-naive/sibling-consumption/total-intake forecasting attempts). +- `recomputeRateEmaForMeterGroup()` (`billing-cycle.service.ts`) runs after every cycle + create/batch-create/update, looks up the meter group's gamma, writes the result onto + `BillingCycle.rate_ema`, then calls `billingService.recomputePendingEstimates()` for the same + meter group so any still-pending `Billing.estimated_cost` picks up the new rate_ema too. + Since EMA is causal (`ema[i]` only depends on `ema[i-1]`, never on anything after it), the + common single-cycle create/update path passes an `anchorDate` (the earliest + `billing_start_date` the operation could have affected) so this only fetches the nearest + earlier cycle (to seed the chain) plus cycles on/after the anchor — not the meter group's + entire history. Falls back to a full-history recompute (bounded to 1000 cycles) when no clean + anchor applies: a batch update touching several cycles at once, or cleaning up the *old* + meter group's chain after a cycle's `meter_group_id` changed. This bounded-range fetch relies + on a `billing_cycles` composite index on `(meter_group_id, is_deleted, billing_start_date)` — + see `api/firestore.indexes.json`. See + `decisions/20260726_billing-cycle-ema-recompute-scaling.md` for why this changed (a live + `POST /billing-cycles` was taking 9+ seconds) and what else was fixed alongside it + (`CachedRepository`'s batch cache writes were also fully sequential). The seed-plus-affected + fetch itself goes through the shared `fetchSeedAndAffected()` in + `src/utils/anchor-fetch.util.ts` — a generic "nearest-before + on/after-anchor" bounded read + over any per-user `CachedRepository`, reused by `recomputePendingEstimates()` below rather than + each reimplementing the same shape. +- `Billing.estimated_cost` (`billing.model.ts`) is set at auto-billing time as + `consumption × latest closed cycle's rate_ema` (`billing.service.ts`), then **kept live**: + `recomputePendingEstimates()` recomputes it for every billing with no closed `BillingCycle` + referencing it yet, whenever that meter group's `rate_ema` chain changes — a new cycle, a + `PATCH /billing-cycles/:id` rate correction, or a `rate_ema` backfill. Once a cycle picks up a + billing (referenced in its `billing_ids`), the billing is no longer "pending" and its estimate + stops being touched. See `decisions/20260724_billing-cost-estimation-ml-finding.md`'s "frozen + snapshot" follow-up for the history of this gap and why it was closed this way rather than via + a separate compute pipeline. Like the cycle-chain recompute above, this is anchor-bounded via + `fetchSeedAndAffected()` on `Billing.billing_period_date` (`billing_period_date >= anchorDate`) + instead of scanning every billing in the meter group — the caller + (`recomputeRateEmaForMeterGroup`) only passes an `anchorDate` through when a cycle already + existed before it (i.e. the bounded cycle fetch found a seed cycle); on a meter group's + *very first* cycle ever, it's omitted and this falls back to a full scan (bounded to 1000 + billings), since older still-pending billings that were stuck at `estimated_cost: null` (no + rate_ema existed at all yet) would otherwise be wrongly excluded by an anchor that only looks + forward. Relies on a `billings` composite index on + `(is_deleted, meter_group_id, billing_period_date)` — see `api/firestore.indexes.json`. +- `scripts/backfill-rate-ema.ts` — one-time idempotent migration for `BillingCycle` documents that + predate `rate_ema`, or that need re-backfilling after a gamma change (`--dry-run` writes a + report, `--apply` writes for real; safe to re-run). Since it writes `BillingCycle` documents + directly via raw Firestore batches (bypassing `recomputeRateEmaForMeterGroup`), it also runs its + own second pass that mirrors `recomputePendingEstimates()` against raw Firestore, so a rerun + after a gamma change fixes pending `Billing.estimated_cost` values too, not just `rate_ema`. +- `scripts/rate-ema-backtest.js`, `regression-coefficients.js`, `seasonal-naive-backtest.js`, + `sibling-consumption-backtest.js`, `total-intake-backtest.js` are one-off analysis/backtest + scripts that produced this design — not part of the runtime app, kept for reproducibility. Full + detail in the decision doc above; not duplicated here. + --- ### Image Extraction (`/image-extraction` — protected) @@ -379,11 +444,15 @@ Read-only analytics endpoints for billing summaries and trends. Accepts optional | Method | Path | Purpose | |--------|------|---------| +| GET | `/reports` | Combined fetch — summary + consumption + billing-trends + collection-status from one shared join. Used by the Reports UI page (single HTTP call instead of four) | | GET | `/reports/summary` | Key billing metrics: total revenue, collection rate, payment status breakdown | | GET | `/reports/consumption` | Consumption breakdown by month and by property | | GET | `/reports/billing-trends` | Billing amounts (billed, collected, pending, overdue) grouped by month | | GET | `/reports/collection-status` | Billing counts and amounts grouped by payment status | +The four granular endpoints remain available for other callers (e.g. the chatbot's tool-calling) +even though the Reports UI page only calls the combined `GET /reports`. + **Query params** (all optional, all endpoints): - `startDate` / `endDate` — ISO 8601 filter on billing cycle dates - `meterGroupId` — filter by specific meter group @@ -417,12 +486,10 @@ Restricted to the `admin` role (`requireRole("admin")` in `chatbot.route.ts`) ### Stub & Incomplete Features -The following feature folders exist but are **not fully implemented**: - | Folder | Status | Notes | |--------|--------|-------| -| `bills/` | ⚠️ Partial | `POST /bills/ocr` — OCR via `llm-config` `vision_model`; no model/service/repository. Functionally overlaps with `image-extraction/billings` | -| `user/` | ⚠️ Partial | `POST /users` — create user record; no model/service/repository. Auth covered by `auth/` | +| `bills/` | ✅ Complete (intentionally thin) | `POST /bills/ocr` — no model/service/repository by design; delegates to `ImageExtractionService.extractBillingFromImage()` rather than duplicating it. Not a stub — it's a deliberate pass-through | +| `user/` | ✅ Complete | `POST /users` — creates both the Firebase Auth account and Firestore profile server-side via the Admin SDK in one call; no model/service/repository since it's a single-purpose admin action, not CRUD. Account creation is currently disabled client-side via the UI's `ACCOUNT_CREATION_DISABLED` flag (single-tenant), not an API limitation | | `audit/` | ❌ Stub | `audit.model.ts` only — not mounted | --- @@ -650,6 +717,9 @@ api/functions/src/ ├── pagination.util.ts → PaginatedResult, cursor-based pagination ├── error.util.ts → AppError, handleValidationError() ├── logger.util.ts → Pino logger instance + ├── cascade-delete.util.ts → Cross-cutting cascade delete/restore/purge (meter-group, property, reading) + ├── list-cache.util.ts → Per-user list-cache primitives (loadAll, paginate, listAppendMany/listUpdateMany/listRemoveMany — batched to avoid races from concurrent single-item calls on the same cache key) + ├── anchor-fetch.util.ts → fetchSeedAndAffected() — shared bounded "seed nearest-before + affected on/after anchor" read, see "Rate-EMA cost estimation" below └── ... (sanitize, firestore conversion) ``` @@ -860,6 +930,30 @@ Methods: hard-deleted in a transaction. Public `DELETE /:id` endpoints map to `softDelete`, per the soft-delete-only decision (D1). +`SearchOptions.filters`' `RangeFilter` supports `$gte`/`$lte`/`$gt`/`$lt` and, as of +`decisions/20260726_data-retrieval-layer-modularization.md`, `$arrayContains` / +`$arrayContainsAny` (native Firestore `array-contains` / `array-contains-any`; the latter throws +if given more than 10 values, matching this codebase's existing batch-size cap). + +### CachedRepository +Wraps `Repository` with a two-tier cache (per-item + per-user list). Located in +`src/lib/cached-repository.lib.ts`; every feature constructs one per-request via a local +`repoFor(userId)` helper. + +- `search(options)` / `searchAll(options)` — serve active-item reads from the user's cached full + list, filtered/paginated in memory. **Only equality filters work here** — + `applyFilters()` throws on any range- or array-shaped filter value, since the in-memory list + cache can't express them. This is the right tool for simple paginated list endpoints, where + loading the whole collection once and slicing in memory is the point. +- `searchDirect(options: SearchOptions)` → `PaginatedResult` — thin pass-through to + `this.repo.search(options)`, bypassing the list cache entirely. **The sanctioned tool for + correctness-critical or narrowly-scoped reads** (recompute functions, validators) where loading + a user's entire collection into memory would be wasteful or wrong — supports the full range and + array-contains filter set `Repository.search()` does. Prefer this over `search()`/`searchAll()` + any time the caller isn't serving a simple paginated list endpoint; don't reach around + `CachedRepository` to the raw `Repository`/`Firestore` for this — that was the one-off shape + this method replaced. See `decisions/20260726_data-retrieval-layer-modularization.md`. + ### AppError Custom error class with HTTP status. Located in `src/utils/error.util.ts`. diff --git a/api/functions/package-lock.json b/api/functions/package-lock.json index 38acddb..2bedb3e 100644 --- a/api/functions/package-lock.json +++ b/api/functions/package-lock.json @@ -19,7 +19,7 @@ "pino-http": "^11.0.0", "rate-limit-redis": "^5.0.0", "redis": "^4.7.0", - "sharp": "^0.33.5", + "sharp": "^0.35.3", "swagger-ui-express": "^5.0.1", "zod": "^4.4.3" }, @@ -606,6 +606,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -684,9 +685,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1033,9 +1034,9 @@ "license": "MIT" }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1078,10 +1079,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1091,19 +1101,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1113,19 +1123,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1139,9 +1168,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1155,9 +1184,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1174,9 +1203,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1192,10 +1221,48 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1212,9 +1279,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1231,9 +1298,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1250,9 +1317,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1269,9 +1336,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1284,19 +1351,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1309,19 +1376,69 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1334,19 +1451,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1359,19 +1476,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1384,19 +1501,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1409,38 +1526,83 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1450,16 +1612,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1469,7 +1631,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -3881,9 +4043,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3920,16 +4082,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -4296,23 +4458,11 @@ "dev": true, "license": "MIT" }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4325,18 +4475,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true, "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -4375,15 +4516,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", - "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -5217,9 +5358,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5341,9 +5482,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6401,9 +6542,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -10003,9 +10144,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -10180,9 +10321,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10313,42 +10454,52 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -10375,9 +10526,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -10472,21 +10623,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -11040,9 +11176,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -11356,9 +11492,9 @@ "license": "MIT" }, "node_modules/ts-node-dev/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/api/functions/package.json b/api/functions/package.json index 567b344..1b835ff 100644 --- a/api/functions/package.json +++ b/api/functions/package.json @@ -36,7 +36,7 @@ "pino-http": "^11.0.0", "rate-limit-redis": "^5.0.0", "redis": "^4.7.0", - "sharp": "^0.33.5", + "sharp": "^0.35.3", "swagger-ui-express": "^5.0.1", "zod": "^4.4.3" }, diff --git a/api/functions/src/config/swagger.config.ts b/api/functions/src/config/swagger.config.ts index c7b74c0..3c04c4e 100644 --- a/api/functions/src/config/swagger.config.ts +++ b/api/functions/src/config/swagger.config.ts @@ -567,6 +567,14 @@ const swaggerSpec = { format: "date-time", description: "ISO 8601 timestamp when billing was marked as paid", }, + estimated_cost: { + type: ["number", "null"], + description: "Cost estimate (known consumption x the meter group's rate EMA), " + + "computed at auto-billing time before the official billing cycle/rate lands. " + + "null on manually-created billings, cold-start meter groups with no cycle " + + "history yet, or readings that cross a meter-version reset. Frozen at " + + "creation — never updated by later billing-cycle corrections.", + }, }, required: ["property_id", "previous_reading_id", "current_reading_id", "payment_status"], }, @@ -667,6 +675,14 @@ const swaggerSpec = { overdue_date: { $ref: "#/components/schemas/Timestamp", }, + rate_ema: { + type: "number", + description: "Exponential moving average of billing_rate for this meter " + + "group, as of this cycle (inclusive). An immutable per-cycle snapshot, " + + "recomputed for the whole meter-group history on every create/rate-or-date " + + "correction. Absent on cycles created before this field existed, until " + + "scripts/backfill-rate-ema.ts runs.", + }, }, required: [ "billing_ids", diff --git a/api/functions/src/features/billing-cycle/billing-cycle.model.ts b/api/functions/src/features/billing-cycle/billing-cycle.model.ts index 15f7b45..058ef72 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.model.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.model.ts @@ -9,4 +9,17 @@ export interface BillingCycle extends BaseModel { billing_start_date: Timestamp; billing_end_date: Timestamp; overdue_date?: Timestamp; + /** + * Exponential moving average of billing_rate for this meter_group_id, as of this cycle + * (inclusive) — an immutable-per-cycle snapshot, not a mutable running value. Recomputed by + * `recomputeRateEmaForMeterGroup` (billing-cycle.service.ts) each time a cycle is created or + * a rate/date-affecting field is corrected — but only over the bounded range of cycles that + * could actually change (from the earliest affected billing_start_date onward, seeded from + * the nearest earlier cycle's own rate_ema), not the meter group's entire history; EMA is + * causal, so nothing before that point is ever touched. Missing on cycles created before + * this feature shipped, until scripts/backfill-rate-ema.ts runs (which still recomputes the + * full history, since it has no bounded range to anchor from) — callers must treat undefined + * the same as "no estimate available". + */ + rate_ema?: number; } diff --git a/api/functions/src/features/billing-cycle/billing-cycle.service.ts b/api/functions/src/features/billing-cycle/billing-cycle.service.ts index cc65bd3..3b0f5f5 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.service.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.service.ts @@ -4,16 +4,21 @@ import {CreateBillingCycleDTO} from "./billing-cycle.dto"; import {PaginatedResult} from "../../utils/pagination.util"; import {BillingCycleValidator} from "./billing-cycle.validator"; import {AppError} from "../../utils/error.util"; -import {propertyService} from "../property/property.service"; +import {propertyRepository} from "../property/property.repository"; import {readingService} from "../reading/reading.service"; import {billingRepository} from "../billing/billing.repository"; +import {billingService} from "../billing/billing.service"; import {findPreviousMonthReading, findCurrentMonthReading} from "../reading/reading.util"; import {CachedRepository} from "../../lib/cached-repository.lib"; import {firestore} from "../../config/firebase.config"; import {COLLECTIONS} from "../../constants/collection.constants"; -import {snapshotToModel} from "../../utils/firestore.util"; +import {snapshotToModel, parseTimestamp} from "../../utils/firestore.util"; import {BatchCreateResult} from "../../utils/batch-result.util"; import {applyDateRangeFilter} from "../../utils/date-range-filter.util"; +import {computeRateEmaChain, findLatestByStartDate, RATE_EMA_GAMMA_BY_UTILITY_TYPE} from "./billing-cycle.util"; +import {meterGroupRepository} from "../meter-group/meter-group.repository"; +import {UTILITY_TYPES} from "../../constants/utility.constants"; +import {fetchSeedAndAffected} from "../../utils/anchor-fetch.util"; const validator = new BillingCycleValidator(); const CACHE_TTL = 15 * 60; // 15 minutes @@ -22,21 +27,176 @@ function repoFor(userId: string): CachedRepository { return new CachedRepository(billingCycleRepository, userId, "billing-cycles", CACHE_TTL); } +/** + * True when a cycle update touches a field that can change the EMA chain's values or ordering + * (billing_rate, either date) or which chain it belongs to (meter_group_id). Shared by update() + * and updateBatch() so the two can't drift apart on what counts as "chain-affecting." + */ +function chainAffectingFieldsChanged(data: Partial): boolean { + return ( + data.billing_rate !== undefined || + data.billing_start_date !== undefined || + data.billing_end_date !== undefined || + data.meter_group_id !== undefined + ); +} + +/** + * Anchor = the earliest billing_start_date that could have shifted in the (new) group's chain: + * for a same-group rate/date correction, that's the earlier of the cycle's old and new date (it + * may have moved position); for a cross-group move, the cycle is a fresh insertion into its new + * group, so just its own date. + */ +function computeAnchorDate(existing: BillingCycle | null, updated: BillingCycle): Date { + const groupUnchanged = existing !== null && existing.meter_group_id === updated.meter_group_id; + if (!groupUnchanged) { + return parseTimestamp(updated.billing_start_date).toDate(); + } + return new Date(Math.min( + parseTimestamp(existing!.billing_start_date).toMillis(), + parseTimestamp(updated.billing_start_date).toMillis() + )); +} + +/** + * Merges a just-written cycle into a fetched set, unless it's already present (the anchor-bounded + * fetch typically already includes it, since it was written before this call). + */ +function mergeFreshCycle(cycles: BillingCycle[], freshCycle?: BillingCycle): BillingCycle[] { + if (freshCycle && !cycles.some((c) => c.id === freshCycle.id)) { + return [...cycles, freshCycle]; + } + return cycles; +} + +/** + * Recomputes billing_rate's EMA (rate_ema) for cycles belonging to a meter group, writing back + * only the cycles whose value actually changed. + * + * EMA is causal — `ema[i]` only depends on `ema[i-1]` and cycle `i`'s own rate, never on + * anything chronologically after it. So when `anchorDate` is given (the common single-cycle + * create/update path), this fetches just two bounded slices instead of the meter group's + * entire history: the single nearest cycle strictly before `anchorDate` (to seed the chain — + * see `computeRateEmaChain`'s `seedEma` param) and every cycle on/after `anchorDate` (the only + * ones whose EMA can possibly change) — via the shared `fetchSeedAndAffected` helper. `anchorDate` + * must be the earliest billing_start_date among cycles affected by this operation — for a + * straight append (new cycle after all existing ones) that's just the new cycle's own date; for + * a correction that moves a cycle earlier/later, it's the min of its old and new date, so + * anything in between is re-swept too. + * + * When `anchorDate` is omitted, falls back to a full recompute over the meter group's entire + * cycle history (bounded to 1000 — meter groups here are submeter-scoped and stay in the + * dozens of cycles) — used for the rarer cases where a clean single anchor doesn't apply: a + * batch update touching several cycles at once, or cleaning up the *old* meter group's chain + * after a cycle's meter_group_id changed (no freshCycle to anchor from there — the cycle no + * longer belongs to this group at all, so everything from its old position forward needs a + * clean full re-derivation). + * + * Called after create() and after any update() that could change the chain's values or + * ordering (billing_rate, billing_start_date, billing_end_date). Writes go through the same + * user-scoped CachedRepository as every other mutation in this service, so corrected cycles + * don't serve a stale cached rate_ema. + */ +async function recomputeRateEmaForMeterGroup( + userId: string, + meterGroupId: string, + freshCycle?: BillingCycle, + anchorDate?: Date | null +): Promise { + const cachedRepo = repoFor(userId); + + let cycles: BillingCycle[]; + let seedEma: number | null = null; + let meterGroup; + // Only safe to anchor-bound recomputePendingEstimates's billing query the same way if a cycle + // already existed before this anchor. If not (no seed cycle), this could be the meter group's + // very first cycle ever — in which case older still-pending billings (previously stuck at + // estimated_cost: null, since no rate_ema existed at all yet) would be wrongly excluded by an + // anchor bound that only looks forward from this cycle's own date. Falls back to an unbounded + // recompute in that one-time case; see billing.service.ts's recomputePendingEstimates. + let pendingEstimatesAnchor: Date | undefined; + + if (anchorDate) { + const [{seed, affected}, fetchedMeterGroup] = await Promise.all([ + fetchSeedAndAffected(cachedRepo, {meter_group_id: meterGroupId}, "billing_start_date", anchorDate), + meterGroupRepository.getById(meterGroupId), + ]); + cycles = mergeFreshCycle(affected, freshCycle); + seedEma = seed?.rate_ema ?? null; + meterGroup = fetchedMeterGroup; + pendingEstimatesAnchor = seed ? anchorDate : undefined; + } else { + // limit: 1000 — a meter group's cycle count stays in the dozens (one per month). Uses + // searchDirect (bounded, Firestore-side-filtered, bypasses the list cache) rather than + // search()/the in-memory list cache, since this full-history fallback path is exactly the + // correctness-critical bounded-read shape searchDirect exists for. + const [{data}, fetchedMeterGroup] = await Promise.all([ + cachedRepo.searchDirect({ + limit: 1000, + orderBy: "billing_start_date", + orderDirection: "asc", + filters: {meter_group_id: meterGroupId}, + }), + meterGroupRepository.getById(meterGroupId), + ]); + cycles = mergeFreshCycle(data, freshCycle); + meterGroup = fetchedMeterGroup; + pendingEstimatesAnchor = undefined; + } + + const gamma = meterGroup ? + RATE_EMA_GAMMA_BY_UTILITY_TYPE[meterGroup.utility_type] : + RATE_EMA_GAMMA_BY_UTILITY_TYPE[UTILITY_TYPES.WATER]; + + const emaByCycleId = computeRateEmaChain(cycles, gamma, seedEma); + const updates = cycles + .filter((c) => emaByCycleId.get(c.id) !== c.rate_ema) + .map((c) => ({id: c.id, data: {rate_ema: emaByCycleId.get(c.id)!}})); + + if (updates.length > 0) { + await cachedRepo.updateBatch(updates); + } + + // Propagate the (possibly changed) rate_ema chain into any still-pending Billing.estimated_cost + // in this meter group — see billing.service.ts's recomputePendingEstimates doc comment. Safe to + // derive "latest cycle" from `cycles` alone even in the bounded-range path: the meter group's + // true latest cycle can never predate `anchorDate` (anchorDate is itself some real cycle's + // date), so it's always included in the `affected` slice. + const cycledBillingIds = new Set(cycles.flatMap((c) => Object.keys(c.billing_ids))); + const latestCycle = findLatestByStartDate(cycles); + const latestRateEma = latestCycle ? emaByCycleId.get(latestCycle.id) ?? null : null; + await billingService.recomputePendingEstimates( + userId, + meterGroupId, + latestRateEma, + cycledBillingIds, + pendingEstimatesAnchor + ); +} + async function injectMainMeterBilling( userId: string, data: CreateBillingCycleDTO ): Promise { - // limit: 1000 — meter groups are not expected to exceed this property count - const allProperties = await propertyService.search(userId, { - meterGroupId: data.meter_group_id, - limit: 1000, + // Targeted array-contains query on the denormalized Property.main_meter_group_ids instead of + // loading every property for this meter group — see Property.main_meter_group_ids's doc + // comment. limit 2, not 1: a second match indicates the main-meter uniqueness invariant is + // already violated, which should surface loudly rather than silently taking the first match. + const {data: mainMeterCandidates} = await propertyRepository.search({ + limit: 2, + orderBy: "created_at", + filters: {main_meter_group_ids: {$arrayContains: data.meter_group_id}}, }); - const mainMeterProperty = allProperties.data.find((p) => - Object.values(p.meter_groups).some( - (e) => e.meter_group_id === data.meter_group_id && e.is_main_meter - ) - ); + if (mainMeterCandidates.length > 1) { + throw new AppError( + 500, + `Meter group ${data.meter_group_id} has more than one main meter property — ` + + "data integrity violation." + ); + } + + const mainMeterProperty = mainMeterCandidates[0]; if (!mainMeterProperty) return data; @@ -136,7 +296,14 @@ export const billingCycleService = { const enrichedData = await injectMainMeterBilling(userId, data); await validator.validateCreate(enrichedData); const cachedRepo = repoFor(userId); - return cachedRepo.create(enrichedData); + const created = await cachedRepo.create(enrichedData); + await recomputeRateEmaForMeterGroup( + userId, + created.meter_group_id, + created, + parseTimestamp(created.billing_start_date).toDate() + ); + return created; }, /** @@ -189,6 +356,16 @@ export const billingCycleService = { if (toCreate.length > 0) { const cachedRepo = repoFor(userId); created = await cachedRepo.createBatch(toCreate); + // seenMeterGroups already guarantees one cycle per meter group in this batch, so each + // created cycle needs exactly one independent recompute of its own group's chain. + await Promise.all( + created.map((cycle) => recomputeRateEmaForMeterGroup( + userId, + cycle.meter_group_id, + cycle, + parseTimestamp(cycle.billing_start_date).toDate() + )) + ); } failures.sort((a, b) => a.index - b.index); @@ -257,13 +434,44 @@ export const billingCycleService = { ): Promise { await validator.validateUpdate(id, data); const cachedRepo = repoFor(userId); - return cachedRepo.update(id, data); + const existing = await cachedRepo.getById(id); + const updated = await cachedRepo.update(id, data); + + if (chainAffectingFieldsChanged(data)) { + const newGroupAnchor = computeAnchorDate(existing, updated); + await recomputeRateEmaForMeterGroup(userId, updated.meter_group_id, updated, newGroupAnchor); + if (existing && existing.meter_group_id !== updated.meter_group_id) { + // The cycle no longer belongs to its old group at all — everything from its old + // position forward needs recomputing there, with no freshCycle to merge in. + await recomputeRateEmaForMeterGroup( + userId, + existing.meter_group_id, + undefined, + parseTimestamp(existing.billing_start_date).toDate() + ); + } + } + + return updated; }, async updateBatch(userId: string, updates: {id: string, data: Partial}[]): Promise { await validator.validateUpdateBatch(updates); const cachedRepo = repoFor(userId); - return cachedRepo.updateBatch(updates); + const updated = await cachedRepo.updateBatch(updates); + + // Recomputes each affected cycle's (new) meter group only — unlike the single update(), + // this doesn't also chase a changed meter_group_id back to its old group, since correcting + // meter_group_id via batch update is not a supported UI flow today. + const meterGroupIds = new Set(); + updates.forEach((u, i) => { + if (chainAffectingFieldsChanged(u.data)) meterGroupIds.add(updated[i].meter_group_id); + }); + await Promise.all( + [...meterGroupIds].map((meterGroupId) => recomputeRateEmaForMeterGroup(userId, meterGroupId)) + ); + + return updated; }, async delete(userId: string, id: string): Promise { diff --git a/api/functions/src/features/billing-cycle/billing-cycle.test.ts b/api/functions/src/features/billing-cycle/billing-cycle.test.ts index 345ece7..cfc6870 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.test.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.test.ts @@ -1,18 +1,22 @@ jest.mock('./billing-cycle.repository'); jest.mock('./billing-cycle.validator'); -jest.mock('../property/property.service'); +jest.mock('../property/property.repository'); jest.mock('../reading/reading.service'); jest.mock('../billing/billing.repository'); jest.mock('../reading/reading.util'); +jest.mock('../meter-group/meter-group.repository'); +jest.mock('../reading/reading.repository'); import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { billingCycleService } from './billing-cycle.service'; import { billingCycleRepository } from './billing-cycle.repository'; import { BillingCycleValidator } from './billing-cycle.validator'; -import { propertyService } from '../property/property.service'; +import { propertyRepository } from '../property/property.repository'; import { readingService } from '../reading/reading.service'; import { billingRepository } from '../billing/billing.repository'; import { findPreviousMonthReading } from '../reading/reading.util'; +import { meterGroupRepository } from '../meter-group/meter-group.repository'; +import { readingRepository } from '../reading/reading.repository'; import { CreateBillingCycleDTOSchema, UpdateBillingCycleDTOSchema, BillingCycleByIdParamsDTOSchema, CreateBillingCycleBatchDTOSchema, UpdateBillingCycleBatchDTOSchema } from './billing-cycle.dto'; import { AppError } from '../../utils/error.util'; import { Timestamp } from 'firebase-admin/firestore'; @@ -46,11 +50,35 @@ describe('billingCycleService', () => { beforeEach(() => { jest.clearAllMocks(); // Default: no main meter property, so injectMainMeterBilling is a no-op for existing tests - jest.mocked(propertyService.search).mockResolvedValue({ + jest.mocked(propertyRepository.search).mockResolvedValue({ data: [], hasMore: false, nextCursor: null, }); + // Default: no other cycles for the meter group, so recomputeRateEmaForMeterGroup's chain + // is just the one cycle under test — tests that don't specifically exercise the rate-EMA + // cascade don't need to know about it to avoid crashing on an unmocked search/updateBatch. + jest.mocked(billingCycleRepository.search).mockResolvedValue({ + data: [], + hasMore: false, + nextCursor: null, + }); + jest.mocked(billingCycleRepository.updateBatch).mockResolvedValue([]); + // Default: water meter group, so recomputeRateEmaForMeterGroup can resolve a gamma without + // every test needing to mock this explicitly. + jest.mocked(meterGroupRepository.getById).mockResolvedValue({ + id: 'mg-1', + utility_type: 'water', + } as any); + // Default: no pending billings in the meter group, so recomputePendingEstimates (called at + // the end of recomputeRateEmaForMeterGroup on every create/chain-affecting update) is a + // no-op for tests that don't specifically exercise the pending-estimate cascade. + jest.mocked(billingRepository.search).mockResolvedValue({ + data: [], + hasMore: false, + nextCursor: null, + }); + jest.mocked(readingRepository.getByIds).mockResolvedValue([]); }); // Create a new billing cycle @@ -206,6 +234,94 @@ describe('billingCycleService', () => { }); }); + // Bounded-range EMA recompute (see decisions/20260726_billing-cycle-ema-recompute-scaling.md) + describe('recomputeRateEmaForMeterGroup - bounded range', () => { + it('seeds the EMA chain from the nearest earlier cycle instead of reading the full history', async () => { + const { start, end } = makeTimestamps(); + jest.mocked(BillingCycleValidator.prototype.validateCreate).mockResolvedValue(undefined); + const newCycle = mockBillingCycle({ id: 'cycle-new', meter_group_id: 'mg-1', billing_rate: 5 }); + jest.mocked(billingCycleRepository.create).mockResolvedValue(newCycle); + + jest.mocked(billingCycleRepository.search) + .mockResolvedValueOnce({ + // seed query: nearest cycle strictly before the anchor + data: [{ id: 'cycle-prev', rate_ema: 8 } as any], + hasMore: false, + nextCursor: null, + }) + .mockResolvedValueOnce({ + // affected-range query: nothing else on/after the anchor besides the new cycle itself + data: [], + hasMore: false, + nextCursor: null, + }); + + await billingCycleService.create(TEST_USER_ID, { + meter_group_id: 'mg-1', + billing_ids: { 'billing-1': 100 }, + billing_rate: 5, + billing_consumption: 100, + billing_start_date: start, + billing_end_date: end, + }); + + expect(billingCycleRepository.updateBatch).toHaveBeenCalledTimes(1); + const updates = jest.mocked(billingCycleRepository.updateBatch).mock.calls[0][0]; + expect(updates).toHaveLength(1); + expect(updates[0].id).toBe('cycle-new'); + // alpha = 1 - water gamma (0.15) = 0.85; resumes from the seed's rate_ema (8) rather than + // starting fresh from the new cycle's own rate. + expect(updates[0].data.rate_ema).toBeCloseTo(0.85 * 5 + 0.15 * 8); + + // First search call = seed (nearest earlier cycle, descending, limit 1). + expect(billingCycleRepository.search).toHaveBeenNthCalledWith(1, expect.objectContaining({ + limit: 1, + orderDirection: 'desc', + filters: expect.objectContaining({ + meter_group_id: 'mg-1', + billing_start_date: { $lt: expect.any(Date) }, + }), + })); + // Second search call = affected suffix (on/after the anchor, ascending). + expect(billingCycleRepository.search).toHaveBeenNthCalledWith(2, expect.objectContaining({ + limit: 1000, + orderDirection: 'asc', + filters: expect.objectContaining({ + meter_group_id: 'mg-1', + billing_start_date: { $gte: expect.any(Date) }, + }), + })); + }); + }); + + describe('recomputeRateEmaForMeterGroup - old-group cleanup on meter_group_id change', () => { + it("anchors the old group's recompute on the cycle's prior billing_start_date, with no freshCycle to merge in", async () => { + const { start } = makeTimestamps(); + const existing = mockBillingCycle({ id: 'billing-cycle-1', meter_group_id: 'mg-old', billing_start_date: start }); + const updated = mockBillingCycle({ id: 'billing-cycle-1', meter_group_id: 'mg-new', billing_start_date: start }); + + jest.mocked(BillingCycleValidator.prototype.validateUpdate).mockResolvedValue(undefined); + jest.mocked(billingCycleRepository.getById).mockResolvedValue(existing); + jest.mocked(billingCycleRepository.update).mockResolvedValue(updated); + + await billingCycleService.update(TEST_USER_ID, 'billing-cycle-1', { meter_group_id: 'mg-new' }); + + const searchCalls = jest.mocked(billingCycleRepository.search).mock.calls; + // Calls 1-2 are the new group's recompute; calls 3-4 are the old group's cleanup + // (seed then affected), anchored on the cycle's own prior date. + expect(searchCalls[2][0]).toMatchObject({ + limit: 1, + orderDirection: 'desc', + filters: { meter_group_id: 'mg-old', billing_start_date: { $lt: expect.any(Date) } }, + }); + expect(searchCalls[3][0]).toMatchObject({ + limit: 1000, + orderDirection: 'asc', + filters: { meter_group_id: 'mg-old', billing_start_date: { $gte: expect.any(Date) } }, + }); + }); + }); + // Batch create describe('createBatch', () => { // It should create multiple billing cycles in a batch. @@ -546,10 +662,32 @@ const baseInput = { }; describe('billingCycleService.create - main meter injection', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(meterGroupRepository.getById).mockResolvedValue({ + id: 'mg-1', + utility_type: 'water', + } as any); + jest.mocked(billingCycleRepository.search).mockResolvedValue({ + data: [], + hasMore: false, + nextCursor: null, + }); + // Default: no pending billings to recompute — individual tests that mock + // billingRepository.search for injectMainMeterBilling's own lookup also feed + // recomputePendingEstimates the same result, but that billing is always already + // referenced in the newly created cycle's billing_ids, so it's filtered out as + // "not pending" before readingRepository.getByIds would ever be reached. + jest.mocked(billingRepository.search).mockResolvedValue({ + data: [], + hasMore: false, + nextCursor: null, + }); + jest.mocked(readingRepository.getByIds).mockResolvedValue([]); + }); it('should inject derived billing for main meter property and pass to repository', async () => { - jest.mocked(propertyService.search).mockResolvedValue({ + jest.mocked(propertyRepository.search).mockResolvedValue({ data: [{ id: 'prop-100', room_name: 'Unit 100', @@ -558,6 +696,7 @@ describe('billingCycleService.create - main meter injection', () => { electricity: { meter_group_id: 'mg-1', is_main_meter: true }, water: { meter_group_id: 'mg-water', is_main_meter: false }, }, + main_meter_group_ids: ['mg-1'], created_at: startDate, updated_at: startDate, is_deleted: false, @@ -624,7 +763,7 @@ describe('billingCycleService.create - main meter injection', () => { }); it('should throw 400 when main meter property has no seed reading', async () => { - jest.mocked(propertyService.search).mockResolvedValue({ + jest.mocked(propertyRepository.search).mockResolvedValue({ data: [{ id: 'prop-100', room_name: 'Unit 100', @@ -633,6 +772,7 @@ describe('billingCycleService.create - main meter injection', () => { electricity: { meter_group_id: 'mg-1', is_main_meter: true }, water: { meter_group_id: 'mg-water', is_main_meter: false }, }, + main_meter_group_ids: ['mg-1'], created_at: startDate, updated_at: startDate, is_deleted: false, @@ -651,20 +791,12 @@ describe('billingCycleService.create - main meter injection', () => { }); it('should skip injection when no main meter property exists for meter group', async () => { - jest.mocked(propertyService.search).mockResolvedValue({ - data: [{ - id: 'prop-101', - room_name: 'Unit 101', - tenant_amount: 1, - meter_groups: { - electricity: { meter_group_id: 'mg-1', is_main_meter: false }, - water: { meter_group_id: 'mg-water', is_main_meter: false }, - }, - created_at: startDate, - updated_at: startDate, - is_deleted: false, - deleted_at: null, - }], + // No candidates for the targeted main_meter_group_ids array-contains query — the query is + // mocked wholesale, so an empty result is how this test simulates "no main meter owns this + // meter group" (a property present but with is_main_meter: false would never actually + // surface from the real Firestore query, since its main_meter_group_ids would be empty). + jest.mocked(propertyRepository.search).mockResolvedValue({ + data: [], hasMore: false, nextCursor: null, }); @@ -686,3 +818,105 @@ describe('billingCycleService.create - main meter injection', () => { expect(Object.keys(repoCallArg.billing_ids)).toHaveLength(2); }); }); + +describe('billingCycleService.create - pending estimate propagation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(meterGroupRepository.getById).mockResolvedValue({ + id: 'mg-1', + utility_type: 'water', + } as any); + jest.mocked(propertyRepository.search).mockResolvedValue({ data: [], hasMore: false, nextCursor: null }); + jest.mocked(billingCycleRepository.search).mockResolvedValue({ data: [], hasMore: false, nextCursor: null }); + jest.mocked(BillingCycleValidator.prototype.validateCreate).mockResolvedValue(undefined); + }); + + it("recomputes a pending billing's estimated_cost using the newly created cycle's rate_ema", async () => { + const newCycle = mockBillingCycle({ + id: 'cycle-new', + meter_group_id: 'mg-1', + billing_ids: { 'billing-cycled': 100 }, + billing_rate: 20, + }); + jest.mocked(billingCycleRepository.create).mockResolvedValue(newCycle); + + jest.mocked(billingRepository.search).mockResolvedValue({ + data: [{ + id: 'billing-pending', + property_id: 'prop-1', + previous_reading_id: 'r-prev', + current_reading_id: 'r-curr', + meter_group_id: 'mg-1', + payment_status: 'pending', + estimated_cost: 500, // stale — computed under an earlier rate_ema + created_at: now, + updated_at: now, + is_deleted: false, + deleted_at: null, + }], + hasMore: false, + nextCursor: null, + }); + jest.mocked(readingRepository.getByIds).mockResolvedValue([ + { id: 'r-prev', reading_amount: 100, meter_version: 1 } as any, + { id: 'r-curr', reading_amount: 150, meter_version: 1 } as any, + ]); + jest.mocked(billingRepository.updateBatch).mockResolvedValue([]); + + await billingCycleService.create(TEST_USER_ID, { + meter_group_id: 'mg-1', + billing_ids: { 'billing-cycled': 100 }, + billing_rate: 20, + billing_consumption: 100, + billing_start_date: startDate, + billing_end_date: endDate, + }); + + // Only one cycle exists for this meter group, so its own rate (20) is the chain's EMA. + expect(billingRepository.updateBatch).toHaveBeenCalledWith([ + { id: 'billing-pending', data: { estimated_cost: (150 - 100) * 20 } }, + ]); + }); + + it("leaves an already-cycled billing's estimated_cost untouched", async () => { + const newCycle = mockBillingCycle({ + id: 'cycle-new', + meter_group_id: 'mg-1', + billing_ids: { 'billing-already-cycled': 50 }, + billing_rate: 20, + }); + jest.mocked(billingCycleRepository.create).mockResolvedValue(newCycle); + + jest.mocked(billingRepository.search).mockResolvedValue({ + data: [{ + id: 'billing-already-cycled', + property_id: 'prop-1', + previous_reading_id: 'r-prev', + current_reading_id: 'r-curr', + meter_group_id: 'mg-1', + payment_status: 'pending', + estimated_cost: 999, + created_at: now, + updated_at: now, + is_deleted: false, + deleted_at: null, + }], + hasMore: false, + nextCursor: null, + }); + + await billingCycleService.create(TEST_USER_ID, { + meter_group_id: 'mg-1', + billing_ids: { 'billing-already-cycled': 50 }, + billing_rate: 20, + billing_consumption: 50, + billing_start_date: startDate, + billing_end_date: endDate, + }); + + // The billing is already referenced in the new cycle's billing_ids, so it's excluded + // from "pending" and neither readings nor a batch write should be attempted for it. + expect(readingRepository.getByIds).not.toHaveBeenCalled(); + expect(billingRepository.updateBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/api/functions/src/features/billing-cycle/billing-cycle.util.test.ts b/api/functions/src/features/billing-cycle/billing-cycle.util.test.ts new file mode 100644 index 0000000..1570dd4 --- /dev/null +++ b/api/functions/src/features/billing-cycle/billing-cycle.util.test.ts @@ -0,0 +1,81 @@ +import {describe, it, expect} from '@jest/globals'; +import {Timestamp} from 'firebase-admin/firestore'; +import {computeRateEmaChain, RATE_EMA_GAMMA_BY_UTILITY_TYPE} from './billing-cycle.util'; +import type {BillingCycle} from './billing-cycle.model'; + +type CycleInput = Pick; + +const TEST_GAMMA = RATE_EMA_GAMMA_BY_UTILITY_TYPE.water; + +const cycle = (id: string, billing_rate: number, monthOffset: number): CycleInput => ({ + id, + billing_rate, + billing_start_date: Timestamp.fromMillis(monthOffset * 30 * 24 * 60 * 60 * 1000), +}); + +describe('computeRateEmaChain', () => { + it('seeds the first cycle with its own rate (no prior history)', () => { + const result = computeRateEmaChain([cycle('a', 10, 0)], TEST_GAMMA); + expect(result.get('a')).toBe(10); + }); + + it('applies the standard EMA update for a second cycle', () => { + const alpha = 1 - TEST_GAMMA; + const result = computeRateEmaChain([cycle('a', 10, 0), cycle('b', 20, 1)], TEST_GAMMA); + expect(result.get('a')).toBe(10); + expect(result.get('b')).toBeCloseTo(alpha * 20 + (1 - alpha) * 10); + }); + + it('is order-independent given unsorted input (sorts by billing_start_date internally)', () => { + const sorted = computeRateEmaChain([cycle('a', 10, 0), cycle('b', 20, 1), cycle('c', 15, 2)], TEST_GAMMA); + const shuffled = computeRateEmaChain([cycle('c', 15, 2), cycle('a', 10, 0), cycle('b', 20, 1)], TEST_GAMMA); + expect(shuffled.get('a')).toBe(sorted.get('a')); + expect(shuffled.get('b')).toBe(sorted.get('b')); + expect(shuffled.get('c')).toBe(sorted.get('c')); + }); + + it('recomputes the full downstream chain when an out-of-order cycle is inserted', () => { + // b (month 2) exists first; inserting a (month 0, before b) after the fact must shift + // b's EMA, since it now has a predecessor it didn't have before. + const beforeInsertion = computeRateEmaChain([cycle('b', 20, 2)], TEST_GAMMA); + const afterInsertion = computeRateEmaChain([cycle('b', 20, 2), cycle('a', 10, 0)], TEST_GAMMA); + expect(beforeInsertion.get('b')).toBe(20); + expect(afterInsertion.get('b')).not.toBe(20); + expect(afterInsertion.get('a')).toBe(10); + }); + + it('returns an empty map for no cycles', () => { + expect(computeRateEmaChain([], TEST_GAMMA).size).toBe(0); + }); + + describe('seedEma', () => { + it('resumes the chain from seedEma instead of the first cycle\'s own rate', () => { + const alpha = 1 - TEST_GAMMA; + const seeded = computeRateEmaChain([cycle('b', 20, 1)], TEST_GAMMA, 10); + expect(seeded.get('b')).toBeCloseTo(alpha * 20 + (1 - alpha) * 10); + }); + + it('produces identical results to a full-history recompute (bounded-range fetch is a pure optimization)', () => { + const full = computeRateEmaChain( + [cycle('a', 10, 0), cycle('b', 20, 1), cycle('c', 15, 2), cycle('d', 18, 3)], + TEST_GAMMA + ); + // Simulate recomputeRateEmaForMeterGroup's bounded fetch: seed from 'b' (the cycle + // immediately before the affected range) and only recompute 'c' and 'd'. + const bounded = computeRateEmaChain( + [cycle('c', 15, 2), cycle('d', 18, 3)], + TEST_GAMMA, + full.get('b')! + ); + expect(bounded.get('c')).toBeCloseTo(full.get('c')!); + expect(bounded.get('d')).toBeCloseTo(full.get('d')!); + }); + + it('defaults to null (seeds from the first cycle\'s own rate) when omitted', () => { + const withDefault = computeRateEmaChain([cycle('a', 10, 0)], TEST_GAMMA); + const withExplicitNull = computeRateEmaChain([cycle('a', 10, 0)], TEST_GAMMA, null); + expect(withDefault.get('a')).toBe(withExplicitNull.get('a')); + expect(withDefault.get('a')).toBe(10); + }); + }); +}); diff --git a/api/functions/src/features/billing-cycle/billing-cycle.util.ts b/api/functions/src/features/billing-cycle/billing-cycle.util.ts new file mode 100644 index 0000000..8e611fa --- /dev/null +++ b/api/functions/src/features/billing-cycle/billing-cycle.util.ts @@ -0,0 +1,63 @@ +import {parseTimestamp} from "../../utils/firestore.util"; +import {UTILITY_TYPES, type UtilityType} from "../../constants/utility.constants"; +import type {BillingCycle} from "./billing-cycle.model"; + +/** + * Smoothing factor for the rate EMA, chosen via walk-forward backtest against + * decisions/20260724_billing-cost-estimation-ml-finding.md's dataset. A finer/wider gamma sweep + * (see the doc's "gamma=0.8 was not actually the sweet spot" follow-up) found the two utility + * types don't share an optimum: water's MAPE has a genuine minimum around gamma=0.15, while + * electricity's keeps improving down to gamma≈0.01 with no interior minimum in the range tested. + * Falls back to water's value for any utility type not in this map (there are only two today). + */ +export const RATE_EMA_GAMMA_BY_UTILITY_TYPE: Record = { + [UTILITY_TYPES.WATER]: 0.15, + [UTILITY_TYPES.ELECTRICITY]: 0.01, +}; + +/** + * Chronological EMA of billing_rate, one value per cycle (inclusive of that cycle's own rate). + * Pure function — no I/O, no mutation. `cycles` need not be pre-sorted; this sorts by + * billing_start_date ascending internally so callers can pass raw query results directly. + * + * `seedEma` lets a caller resume the chain partway through instead of always starting from + * `cycles[0]`'s own rate: since each value only depends on the previous one, a caller that + * already knows the EMA immediately before `cycles[0]` (e.g. the meter group's nearest earlier + * cycle) can pass it in and get identical results to running the full history through this + * function, without needing every prior cycle in `cycles` — see + * `recomputeRateEmaForMeterGroup`'s bounded-range fetch in `billing-cycle.service.ts`. + */ +export function computeRateEmaChain( + cycles: Pick[], + gamma: number, + seedEma: number | null = null +): Map { + const alpha = 1 - gamma; + const sorted = [...cycles].sort( + (a, b) => parseTimestamp(a.billing_start_date).toMillis() - parseTimestamp(b.billing_start_date).toMillis() + ); + + const emaByCycleId = new Map(); + let ema: number | null = seedEma; + for (const cycle of sorted) { + ema = ema === null ? cycle.billing_rate : alpha * cycle.billing_rate + (1 - alpha) * ema; + emaByCycleId.set(cycle.id, ema); + } + return emaByCycleId; +} + +/** + * Latest item by billing_start_date, without assuming pre-sorted input. Shared so callers + * needing "the latest cycle in this set" (e.g. to read its resulting rate_ema) don't each + * re-derive their own sort/compare — see `recomputeRateEmaForMeterGroup` in + * `billing-cycle.service.ts`. + */ +export function findLatestByStartDate>( + items: T[] +): T | null { + return items.reduce((latest, item) => { + if (!latest) return item; + return parseTimestamp(item.billing_start_date).toMillis() > + parseTimestamp(latest.billing_start_date).toMillis() ? item : latest; + }, null); +} diff --git a/api/functions/src/features/billing/billing.model.ts b/api/functions/src/features/billing/billing.model.ts index 1ef00c0..ac2a38f 100644 --- a/api/functions/src/features/billing/billing.model.ts +++ b/api/functions/src/features/billing/billing.model.ts @@ -9,4 +9,15 @@ export interface Billing extends BaseModel { billing_period_date: Timestamp; payment_status: "pending" | "paid"; paid_at?: string; + /** + * Cost estimate (known consumption x the meter group's latest BillingCycle.rate_ema), before + * the official cycle/rate lands. null when no prior cycle exists yet for the meter group, or + * when the reading pair crosses a meter-version reset (raw amount diff isn't meaningful + * without full offset resolution). Set at auto-billing time, then kept live — recomputed by + * billing.service.ts's recomputePendingEstimates whenever this meter group's rate_ema chain + * changes (a new cycle, a PATCH rate correction, or a gamma backfill), for as long as this + * billing has no closed BillingCycle referencing it yet. Once a cycle picks it up, it stops + * being "pending" and this field is no longer touched. + */ + estimated_cost: number | null; } diff --git a/api/functions/src/features/billing/billing.service.test.ts b/api/functions/src/features/billing/billing.service.test.ts new file mode 100644 index 0000000..6c85ff1 --- /dev/null +++ b/api/functions/src/features/billing/billing.service.test.ts @@ -0,0 +1,113 @@ +jest.mock('./billing.repository'); +jest.mock('../reading/reading.repository'); + +import {describe, it, expect, jest, beforeEach} from '@jest/globals'; +import {computeEstimatedCost, billingService} from './billing.service'; +import {billingRepository} from './billing.repository'; +import {readingRepository} from '../reading/reading.repository'; + +describe('computeEstimatedCost', () => { + it('multiplies raw consumption by the rate EMA when versions match', () => { + expect(computeEstimatedCost(150, 100, 1, 1, 12.5)).toBeCloseTo(50 * 12.5); + }); + + it('returns null when there is no prior cycle yet (cold start)', () => { + expect(computeEstimatedCost(150, 100, 1, 1, null)).toBeNull(); + }); + + it('returns null across a meter-version reset, even with a valid rate EMA', () => { + expect(computeEstimatedCost(20, 950, 2, 1, 12.5)).toBeNull(); + }); +}); + +describe('billingService.recomputePendingEstimates', () => { + const TEST_USER_ID = 'user-1'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('no-ops without querying billings when latestRateEma is null', async () => { + await billingService.recomputePendingEstimates(TEST_USER_ID, 'mg-1', null, new Set()); + + expect(billingRepository.search).not.toHaveBeenCalled(); + }); + + it('skips billings already referenced by a cycle (cycledBillingIds)', async () => { + jest.mocked(billingRepository.search).mockResolvedValue({ + data: [{ + id: 'billing-cycled', + property_id: 'prop-1', + previous_reading_id: 'r-prev', + current_reading_id: 'r-curr', + meter_group_id: 'mg-1', + payment_status: 'pending', + estimated_cost: 999, + created_at: new Date() as any, + updated_at: new Date() as any, + is_deleted: false, + deleted_at: null, + }], + hasMore: false, + nextCursor: null, + }); + + await billingService.recomputePendingEstimates( + TEST_USER_ID, + 'mg-1', + 20, + new Set(['billing-cycled']) + ); + + expect(readingRepository.getByIds).not.toHaveBeenCalled(); + expect(billingRepository.updateBatch).not.toHaveBeenCalled(); + }); + + it('only writes pending billings whose recomputed estimate actually changed', async () => { + jest.mocked(billingRepository.search).mockResolvedValue({ + data: [ + { + id: 'billing-stale', + property_id: 'prop-1', + previous_reading_id: 'r-prev-1', + current_reading_id: 'r-curr-1', + meter_group_id: 'mg-1', + payment_status: 'pending', + estimated_cost: 500, + created_at: new Date() as any, + updated_at: new Date() as any, + is_deleted: false, + deleted_at: null, + }, + { + id: 'billing-already-current', + property_id: 'prop-2', + previous_reading_id: 'r-prev-2', + current_reading_id: 'r-curr-2', + meter_group_id: 'mg-1', + payment_status: 'pending', + estimated_cost: (80 - 30) * 20, // already matches what the new rate_ema would produce + created_at: new Date() as any, + updated_at: new Date() as any, + is_deleted: false, + deleted_at: null, + }, + ], + hasMore: false, + nextCursor: null, + }); + jest.mocked(readingRepository.getByIds).mockResolvedValue([ + {id: 'r-prev-1', reading_amount: 100, meter_version: 1} as any, + {id: 'r-curr-1', reading_amount: 150, meter_version: 1} as any, + {id: 'r-prev-2', reading_amount: 30, meter_version: 1} as any, + {id: 'r-curr-2', reading_amount: 80, meter_version: 1} as any, + ]); + jest.mocked(billingRepository.updateBatch).mockResolvedValue([]); + + await billingService.recomputePendingEstimates(TEST_USER_ID, 'mg-1', 20, new Set()); + + expect(billingRepository.updateBatch).toHaveBeenCalledWith([ + {id: 'billing-stale', data: {estimated_cost: (150 - 100) * 20}}, + ]); + }); +}); diff --git a/api/functions/src/features/billing/billing.service.ts b/api/functions/src/features/billing/billing.service.ts index 657a66d..72bcc6d 100644 --- a/api/functions/src/features/billing/billing.service.ts +++ b/api/functions/src/features/billing/billing.service.ts @@ -14,6 +14,7 @@ import {cacheSet} from "../../utils/cache.util"; import {listAppend} from "../../utils/list-cache.util"; import {CachedRepository} from "../../lib/cached-repository.lib"; import {readingRepository} from "../reading/reading.repository"; +import {fetchSeedAndAffected} from "../../utils/anchor-fetch.util"; const validator = new BillingValidator(); const CACHE_TTL = 10 * 60; // 10 minutes @@ -38,6 +39,113 @@ function deriveBillingDenormalizedFields( }; } +/** + * cost estimate = raw consumption (currReading - prevReading) x the meter group's latest + * rate EMA — see createFromReadings' doc comment for why this is null across a meter-version + * reset or when no prior cycle exists yet. Extracted as a pure function for unit testing; + * production callers always go through createFromReadings. + */ +export function computeEstimatedCost( + currReadingAmount: number, + prevReadingAmount: number, + currMeterVersion: number, + prevMeterVersion: number, + latestCycleRateEma: number | null +): number | null { + if (latestCycleRateEma === null || currMeterVersion !== prevMeterVersion) { + return null; + } + return (currReadingAmount - prevReadingAmount) * latestCycleRateEma; +} + +/** + * Recomputes estimated_cost for every pending (uncycled) billing in a meter group, using its + * latest rate_ema. Called by billing-cycle.service.ts's recomputeRateEmaForMeterGroup right + * after it recomputes the cycle chain, so a rate correction (or a gamma backfill) that shifts + * rate_ema propagates into every still-pending billing's estimate instead of leaving it stuck + * on the value computed at creation time — see billing.model.ts's estimated_cost doc comment. + * `cycledBillingIds` (billing IDs already referenced by some cycle's billing_ids map) marks + * which billings to leave alone: once a billing has an official cycle, its estimate is no + * longer "pending" and correcting the cycle's own billing_rate replaces it directly. + * + * `anchorDate`, when given, bounds the billings query to `billing_period_date >= anchorDate` + * via the shared `fetchSeedAndAffected` helper (same bounded-fetch shape as + * `recomputeRateEmaForMeterGroup`'s cycle chain), instead of scanning every billing in the + * group. The caller only passes it when a cycle already existed before that anchor — see + * `recomputeRateEmaForMeterGroup`'s `pendingEstimatesAnchor` — so this never has to reason + * about the group's very first cycle here. Omitted, this falls back to today's full scan + * (bounded to 1000, same assumption as `recomputeRateEmaForMeterGroup`'s own fallback). + */ +async function recomputePendingEstimates( + userId: string, + meterGroupId: string, + latestRateEma: number | null, + cycledBillingIds: Set, + anchorDate?: Date +): Promise { + if (latestRateEma === null) return; + + const cachedRepo = repoFor(userId); + + let candidates: Billing[]; + if (anchorDate) { + // Requires a `billings` index on `(is_deleted, meter_group_id, billing_period_date)` — see + // api/firestore.indexes.json. + const {affected} = await fetchSeedAndAffected( + cachedRepo, + {meter_group_id: meterGroupId}, + "billing_period_date", + anchorDate + ); + candidates = affected; + } else { + // limit: 1000 — same bounded-scale assumption as recomputeRateEmaForMeterGroup's cycle fetch. + // searchDirect (bounded, Firestore-side-filtered, bypasses the list cache) instead of + // search() — this recompute needs a correctness-critical, narrowly-scoped read, not the + // full-collection list cache. Requires a `billings` index on + // `(is_deleted, meter_group_id, created_at)` — see api/firestore.indexes.json. + const {data} = await cachedRepo.searchDirect({ + limit: 1000, + orderBy: "created_at", + orderDirection: "asc", + filters: {meter_group_id: meterGroupId}, + }); + candidates = data; + } + + const pending = candidates.filter((b) => !cycledBillingIds.has(b.id)); + if (pending.length === 0) return; + + const readingIds = Array.from( + new Set(pending.flatMap((b) => [b.previous_reading_id, b.current_reading_id])) + ); + const readings = await readingRepository.getByIds(readingIds); + const readingById = new Map( + readings.filter((r): r is NonNullable => r !== null).map((r) => [r.id, r]) + ); + + const updates = pending + .map((b) => { + const prevReading = readingById.get(b.previous_reading_id); + const currReading = readingById.get(b.current_reading_id); + if (!prevReading || !currReading) return null; + + const newEstimate = computeEstimatedCost( + currReading.reading_amount, + prevReading.reading_amount, + currReading.meter_version ?? 1, + prevReading.meter_version ?? 1, + latestRateEma + ); + return newEstimate !== b.estimated_cost ? {id: b.id, data: {estimated_cost: newEstimate}} : null; + }) + .filter((u): u is {id: string; data: {estimated_cost: number | null}} => u !== null); + + if (updates.length > 0) { + await cachedRepo.updateBatch(updates); + } +} + type BillingSearchOptions = { propertyId?: string; meterGroupId?: string; @@ -99,6 +207,10 @@ export const billingService = { ...data, ...deriveBillingDenormalizedFields(currReading as any), payment_status: "pending" as const, + // Manual/correction path — not the auto-billing flow the rate-EMA estimate targets + // (see createFromReadings). No known-consumption-at-creation-time signal exists here + // worth estimating from. + estimated_cost: null, created_at: FieldValue.serverTimestamp(), is_deleted: false, deleted_at: null, @@ -134,6 +246,7 @@ export const billingService = { ...item, ...deriveBillingDenormalizedFields(currReading), payment_status: "pending" as const, + estimated_cost: null, // manual path — see create()'s comment }; }) ); @@ -295,6 +408,13 @@ export const billingService = { * Applies the meter_version rollback bypass: if currReading.meter_version differs * from prevReading.meter_version the reading_amount comparison is not enforced. * + * `latestCycleRateEma` is the meter group's latest closed BillingCycle.rate_ema, resolved by + * the caller *before* this transaction opens (Firestore transactions require all reads + * before any writes — see reading.util.ts's createReadingWithAutoBilling). estimated_cost is + * null when that's unavailable (cold start, no cycle yet) or when the reading pair crosses a + * meter-version reset, since a raw reading_amount diff isn't meaningful without resolving + * the full cumulative-offset chain for that case. + * * Returns the newly generated billing ID for cache population post-transaction. */ createFromReadings( @@ -304,6 +424,7 @@ export const billingService = { currReadingId: string, prevReading: DocumentData, currReading: DocumentData, + latestCycleRateEma: number | null, ): string { if (prevReading.meter_group_id !== currReading.meter_group_id) { throw new AppError(400, "Previous and current readings must belong to the same meter group"); @@ -317,6 +438,14 @@ export const billingService = { currMeterVersion ); + const estimatedCost = computeEstimatedCost( + currReading.reading_amount, + prevReading.reading_amount, + currMeterVersion, + prevMeterVersion, + latestCycleRateEma + ); + const newRef = firestore.collection(COLLECTIONS.BILLINGS).doc(); txn.set(newRef, { property_id: propertyId, @@ -324,10 +453,13 @@ export const billingService = { current_reading_id: currReadingId, ...deriveBillingDenormalizedFields(currReading as any), payment_status: "pending" as const, + estimated_cost: estimatedCost, created_at: FieldValue.serverTimestamp(), is_deleted: false, deleted_at: null, }); return newRef.id; }, + + recomputePendingEstimates, }; diff --git a/api/functions/src/features/billing/billing.validator.ts b/api/functions/src/features/billing/billing.validator.ts index 3c701b4..4269b79 100644 --- a/api/functions/src/features/billing/billing.validator.ts +++ b/api/functions/src/features/billing/billing.validator.ts @@ -3,6 +3,7 @@ import {AppError} from "../../utils/error.util"; import {propertyRepository} from "../property/property.repository"; import {readingRepository} from "../reading/reading.repository"; import {billingRepository} from "./billing.repository"; +import {fetchAllPages} from "../../utils/list-cache.util"; import type {Property} from "../property/property.model"; import type {Reading} from "../reading/reading.model"; @@ -125,14 +126,17 @@ export class BillingValidator { } } - // Batch check for duplicate billings once instead of per-item - const duplicateCheck = await billingRepository.search({ + // Batch check for duplicate billings once instead of per-item. fetchAllPages instead of a + // single limit:1000 page — a landlord past 1000 total billings previously could silently + // miss a duplicate here. + const allBillings = await fetchAllPages((cursor) => billingRepository.search({ limit: 1000, orderBy: "created_at", + cursor, filters: {}, - }); + })); const existingBillings = new Map(); - for (const billing of duplicateCheck.data) { + for (const billing of allBillings) { const key = `${billing.property_id}:${billing.current_reading_id}`; existingBillings.set(key, true); } diff --git a/api/functions/src/features/meter-group/meter-group.validator.ts b/api/functions/src/features/meter-group/meter-group.validator.ts index 3ce921c..6e61c66 100644 --- a/api/functions/src/features/meter-group/meter-group.validator.ts +++ b/api/functions/src/features/meter-group/meter-group.validator.ts @@ -16,9 +16,12 @@ export class MeterGroupValidator { ): Promise { // Use indexed equality query instead of full collection scan const normalizedName = normalizeMeterName(meterName); + // .limit(1000) — defense-in-depth safety net; not expected to bind at this collection's + // scale, but this was previously the one truly uncapped `.get()` in the codebase. const snap = await collectionRef(COLLECTIONS.METER_GROUPS) .where("utility_type", "==", utilityType) .where("is_deleted", "==", false) + .limit(1000) .get(); return snap.docs @@ -44,6 +47,7 @@ export class MeterGroupValidator { const snap = await collectionRef(COLLECTIONS.METER_GROUPS) .where("utility_type", "==", item.utility_type) .where("is_deleted", "==", false) + .limit(1000) .get(); existingByUtilityType.set( item.utility_type, diff --git a/api/functions/src/features/property/property.model.ts b/api/functions/src/features/property/property.model.ts index 15f8655..368f778 100644 --- a/api/functions/src/features/property/property.model.ts +++ b/api/functions/src/features/property/property.model.ts @@ -12,4 +12,11 @@ export interface Property extends BaseModel { room_name: string; tenant_amount: number; meter_groups: Record; + // Derived, denormalized from meter_groups: the meter_group_ids this property is the main + // meter for (usually empty, occasionally one entry). Kept in sync at write time by + // property.service.ts so targeted array-contains queries can replace full-collection scans + // for main-meter lookups — see decisions/20260719_billing-meter-group-denormalization.md for + // the precedent (Billing.meter_group_id) and decisions/20260726_data-retrieval-layer- + // modularization.md for why this field exists. + main_meter_group_ids: string[]; } diff --git a/api/functions/src/features/property/property.service.ts b/api/functions/src/features/property/property.service.ts index 56c1660..cd86245 100644 --- a/api/functions/src/features/property/property.service.ts +++ b/api/functions/src/features/property/property.service.ts @@ -36,15 +36,38 @@ function cleanMeterGroups(meterGroups: Record; } +// Derives the denormalized main_meter_group_ids list from a cleaned meter_groups map — see +// Property.main_meter_group_ids's doc comment. Kept in sync at every write that touches +// meter_groups so targeted array-contains queries can replace full-collection main-meter scans. +function deriveMainMeterGroupIds( + meterGroups: Record +): string[] { + return Object.values(meterGroups) + .filter((e) => e.is_main_meter) + .map((e) => e.meter_group_id); +} + export const propertyService = { async create(userId: string, data: CreatePropertyDTO): Promise { await validator.validateCreate(data); - return repoFor(userId).create({...data, meter_groups: cleanMeterGroups(data.meter_groups)}); + const meterGroups = cleanMeterGroups(data.meter_groups); + return repoFor(userId).create({ + ...data, + meter_groups: meterGroups, + main_meter_group_ids: deriveMainMeterGroupIds(meterGroups), + }); }, async createBatch(userId: string, data: CreatePropertyDTO[]): Promise { await validator.validateBatchCreate(data); - return repoFor(userId).createBatch(data.map((d) => ({...d, meter_groups: cleanMeterGroups(d.meter_groups)}))); + return repoFor(userId).createBatch(data.map((d) => { + const meterGroups = cleanMeterGroups(d.meter_groups); + return { + ...d, + meter_groups: meterGroups, + main_meter_group_ids: deriveMainMeterGroupIds(meterGroups), + }; + })); }, async getById(userId: string, id: string): Promise { @@ -86,16 +109,32 @@ export const propertyService = { const property = await getOrThrow(propertyRepository.getById.bind(propertyRepository), id, "Property"); await validator.validateUpdate(property, data); - const cleanData = data.meter_groups ? {...data, meter_groups: cleanMeterGroups(data.meter_groups)} : (data as any); + let cleanData: any = data; + if (data.meter_groups) { + const meterGroups = cleanMeterGroups(data.meter_groups); + cleanData = { + ...data, + meter_groups: meterGroups, + main_meter_group_ids: deriveMainMeterGroupIds(meterGroups), + }; + } return repoFor(userId).update(id, cleanData); }, async updateBatch(userId: string, updates: { id: string; data: UpdatePropertyDTO }[]): Promise { await validator.validateBatchUpdate(updates); - const cleanUpdates = updates.map((u) => ({ - id: u.id, - data: u.data.meter_groups ? {...u.data, meter_groups: cleanMeterGroups(u.data.meter_groups)} : (u.data as any), - })); + const cleanUpdates = updates.map((u) => { + if (!u.data.meter_groups) return {id: u.id, data: u.data as any}; + const meterGroups = cleanMeterGroups(u.data.meter_groups); + return { + id: u.id, + data: { + ...u.data, + meter_groups: meterGroups, + main_meter_group_ids: deriveMainMeterGroupIds(meterGroups), + } as any, + }; + }); return repoFor(userId).updateBatch(cleanUpdates); }, diff --git a/api/functions/src/features/property/property.test.ts b/api/functions/src/features/property/property.test.ts index c51a399..b553b66 100644 --- a/api/functions/src/features/property/property.test.ts +++ b/api/functions/src/features/property/property.test.ts @@ -58,6 +58,7 @@ describe('propertyService', () => { room_name: 'Room 101', tenant_amount: 2, meter_groups: { electricity: { meter_group_id: 'mg-1', is_main_meter: true } }, + main_meter_group_ids: ['mg-1'], }); expect(result.id).toBe('prop-1'); }); @@ -123,7 +124,9 @@ describe('propertyService', () => { ]; const result = await propertyService.createBatch(TEST_USER_ID, input); - expect(propertyRepository.createBatch).toHaveBeenCalledWith(input); + expect(propertyRepository.createBatch).toHaveBeenCalledWith( + input.map((d) => ({...d, main_meter_group_ids: ['mg-1']})) + ); expect(result).toHaveLength(2); }); diff --git a/api/functions/src/features/property/property.validator.ts b/api/functions/src/features/property/property.validator.ts index 645542a..b9be98a 100644 --- a/api/functions/src/features/property/property.validator.ts +++ b/api/functions/src/features/property/property.validator.ts @@ -59,21 +59,19 @@ export class PropertyValidator { .filter((e) => e.is_main_meter); if (mainMeterEntries.length === 0) return; - // Fetch all properties with cursor-based pagination to avoid the 100-item hard limit - const allProperties = await fetchAllPages((cursor) => propertyRepository.search({ - limit: 1000, - orderBy: "created_at", - cursor, - })); - + // Targeted array-contains query per entry instead of a full-collection scan — see + // Property.main_meter_group_ids. limit 2, not 1: a second match after excluding this + // property indicates the uniqueness invariant is already violated, which should surface + // loudly rather than silently taking the first match. for (const entry of mainMeterEntries) { - const conflict = allProperties.find((p) => { - if (excludePropertyId && p.id === excludePropertyId) return false; - return Object.values(p.meter_groups).some( - (pv) => pv.meter_group_id === entry.meter_group_id && pv.is_main_meter - ); + const {data: candidates} = await propertyRepository.search({ + limit: 2, + orderBy: "created_at", + filters: {main_meter_group_ids: {$arrayContains: entry.meter_group_id}}, }); + const conflict = candidates.find((p) => !excludePropertyId || p.id !== excludePropertyId); + if (conflict) { throw new AppError( 409, @@ -97,9 +95,39 @@ export class PropertyValidator { } } + /** + * Batch-resolves existing main-meter owners for a set of meter_group_ids via targeted + * array-contains-any queries (chunked at Firestore's 10-value cap) instead of a + * full-collection scan — see Property.main_meter_group_ids. + */ + private async queryMainMeterOwners(meterGroupIds: string[]): Promise> { + const owners = new Map(); + if (meterGroupIds.length === 0) return owners; + + const chunkSize = 10; + for (let i = 0; i < meterGroupIds.length; i += chunkSize) { + const chunk = meterGroupIds.slice(i, i + chunkSize); + const {data: candidates} = await propertyRepository.search({ + limit: 1000, + orderBy: "created_at", + filters: {main_meter_group_ids: {$arrayContainsAny: chunk}}, + }); + for (const p of candidates) { + for (const groupId of p.main_meter_group_ids) { + if (chunk.includes(groupId) && !owners.has(groupId)) { + owners.set(groupId, p); + } + } + } + } + + return owners; + } + async validateBatchCreate(data: CreatePropertyDTO[]): Promise { const seenRoomNames = new Set(); const allMeterGroupIds = new Set(); + const mainMeterGroupIds = new Set(); for (const item of data) { const normalizedRoomName = normalizeRoomName(item.room_name); @@ -110,7 +138,10 @@ export class PropertyValidator { seenRoomNames.add(normalizedRoomName); Object.values(item.meter_groups) .filter((e): e is MeterGroupEntry => e !== undefined) - .forEach((e) => allMeterGroupIds.add(e.meter_group_id)); + .forEach((e) => { + allMeterGroupIds.add(e.meter_group_id); + if (e.is_main_meter) mainMeterGroupIds.add(e.meter_group_id); + }); } await this.ensureMeterGroupsExist(Array.from(allMeterGroupIds)); @@ -130,28 +161,12 @@ export class PropertyValidator { } } - // Batch fetch all existing properties once instead of per-item - const allProperties = await fetchAllPages((cursor) => propertyRepository.search({ - limit: 1000, - orderBy: "created_at", - cursor, - })); - - // Pre-index existing properties for O(1) conflict lookups - const mainMeterOwnerByGroupId = new Map(); - const propertyByNormalizedRoomName = new Map(); - for (const p of allProperties) { - propertyByNormalizedRoomName.set(normalizeRoomName(p.room_name), p); - for (const pv of Object.values(p.meter_groups)) { - if (pv.is_main_meter) { - mainMeterOwnerByGroupId.set(pv.meter_group_id, p); - } - } - } + const mainMeterOwnerByGroupId = await this.queryMainMeterOwners(Array.from(mainMeterGroupIds)); - // Validate all items at once using cached property list + // Validate each item: main meter uniqueness via the targeted owner map, room name + // uniqueness via the same indexed equality query validateCreate/validateUpdate use + // (batch is capped at 10 items, so N targeted queries here stays cheap). for (const item of data) { - // Check main meter uniqueness against all properties const mainMeterEntries = Object.values(item.meter_groups) .filter((e): e is MeterGroupEntry => e !== undefined) .filter((e) => e.is_main_meter); @@ -167,10 +182,7 @@ export class PropertyValidator { } } - // Check room name uniqueness - const normalizedRoomName = normalizeRoomName(item.room_name); - const duplicate = propertyByNormalizedRoomName.get(normalizedRoomName); - + const duplicate = await this.findDuplicateProperty(item.room_name); if (duplicate) { logger.warn({room_name: item.room_name}, "Duplicate property batch creation attempt"); throw new AppError(409, "Room name already exists"); @@ -232,32 +244,25 @@ export class PropertyValidator { await this.ensureMeterGroupsExist(Array.from(allMeterGroupIds)); } - // Batch fetch all existing properties for main meter uniqueness check - const allProperties = await fetchAllPages((cursor) => propertyRepository.search({ - limit: 1000, - orderBy: "created_at", - cursor, - })); - - // Pre-index existing properties for O(1) conflict lookups - const mainMeterOwnerByGroupId = new Map(); - const propertyByNormalizedRoomName = new Map(); - for (const p of allProperties) { - propertyByNormalizedRoomName.set(normalizeRoomName(p.room_name), p); - for (const pv of Object.values(p.meter_groups)) { - if (pv.is_main_meter) { - mainMeterOwnerByGroupId.set(pv.meter_group_id, p); - } + // Targeted main-meter-owner lookup instead of a full-collection scan — see + // Property.main_meter_group_ids / queryMainMeterOwners's doc comment. + const mainMeterGroupIds = new Set(); + for (const update of updates) { + if (update.data.meter_groups) { + Object.values(update.data.meter_groups) + .filter((e): e is MeterGroupEntry => e !== undefined) + .filter((e) => e.is_main_meter) + .forEach((e) => mainMeterGroupIds.add(e.meter_group_id)); } } + const mainMeterOwnerByGroupId = await this.queryMainMeterOwners(Array.from(mainMeterGroupIds)); - // Validate each update using cached data + // Validate each update for (let i = 0; i < updates.length; i++) { const property = properties[i]!; const update = updates[i]; if (update.data.meter_groups) { - // Check main meter uniqueness using cached allProperties const mainMeterEntries = Object.values(update.data.meter_groups) .filter((e): e is MeterGroupEntry => e !== undefined) .filter((e) => e.is_main_meter); @@ -275,10 +280,9 @@ export class PropertyValidator { } if (update.data.room_name) { - const normalizedRoomName = normalizeRoomName(update.data.room_name); - const duplicate = propertyByNormalizedRoomName.get(normalizedRoomName); + const duplicate = await this.findDuplicateProperty(update.data.room_name, property.id); - if (duplicate && duplicate.id !== property.id) { + if (duplicate) { logger.warn({room_name: update.data.room_name}, "Duplicate property update attempt"); throw new AppError(409, "Room name already exists"); } diff --git a/api/functions/src/features/reading/reading.dto.ts b/api/functions/src/features/reading/reading.dto.ts index d55c0d7..f9744b1 100644 --- a/api/functions/src/features/reading/reading.dto.ts +++ b/api/functions/src/features/reading/reading.dto.ts @@ -72,20 +72,10 @@ export const GetReadingsQueryDTOSchema = z ), }) .superRefine((value, context) => { - if (value.meterGroupId && value.cursor) { - context.addIssue({ - code: "custom", - message: "cursor cannot be combined with meterGroupId", - path: ["cursor"], - }); - } - if (value.propertyId && value.cursor) { - context.addIssue({ - code: "custom", - message: "cursor cannot be combined with propertyId", - path: ["cursor"], - }); - } + // meterGroupId/propertyId are applied in-memory by CachedRepository.search() + // (load-all-then-filter-then-paginate), so cursor pagination works fine combined + // with them. Only the startDate/endDate path uses a separate Firestore query that + // doesn't accept a resumption cursor (see reading.service.ts search()). if ((value.startDate || value.endDate) && value.cursor) { context.addIssue({ code: "custom", diff --git a/api/functions/src/features/reading/reading.test.ts b/api/functions/src/features/reading/reading.test.ts index 1890ecd..d73c330 100644 --- a/api/functions/src/features/reading/reading.test.ts +++ b/api/functions/src/features/reading/reading.test.ts @@ -229,7 +229,8 @@ describe('readingService', () => { // First collection() call: READINGS query → has previous reading // Second collection() call: PROPERTIES.doc(property_id) → property exists - // Third collection() call: READINGS.doc() for new reading + // Third collection() call: BILLING_CYCLES query (getLatestRateEma) → no cycles yet + // Fourth collection() call: READINGS.doc() for new reading jest.mocked(firestore.collection) .mockReturnValueOnce({ where: jest.fn().mockReturnThis(), @@ -247,6 +248,12 @@ describe('readingService', () => { }), }), }) + .mockReturnValueOnce({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, docs: [] }), + }) .mockReturnValueOnce({ doc: jest.fn().mockReturnValue({ id: 'new-id', diff --git a/api/functions/src/features/reading/reading.util.ts b/api/functions/src/features/reading/reading.util.ts index e58827c..dcfd40a 100644 --- a/api/functions/src/features/reading/reading.util.ts +++ b/api/functions/src/features/reading/reading.util.ts @@ -4,13 +4,15 @@ import {firestore} from "../../config/firebase.config"; import {COLLECTIONS} from "../../constants/collection.constants"; import {snapshotToModel} from "../../utils/firestore.util"; import {cacheSet} from "../../utils/cache.util"; -import {listAppend} from "../../utils/list-cache.util"; +import {listAppend, listAppendMany} from "../../utils/list-cache.util"; import {billingService} from "../billing/billing.service"; import {billingRepository} from "../billing/billing.repository"; +import {billingCycleRepository} from "../billing-cycle/billing-cycle.repository"; import {readingRepository} from "./reading.repository"; import {CachedRepository} from "../../lib/cached-repository.lib"; import type {CreateReadingDTO} from "./reading.dto"; import type {Reading} from "./reading.model"; +import type {Billing} from "../billing/billing.model"; import type {MeterGroup, MeterGroupVersionEntry} from "../meter-group/meter-group.model"; import type {Property} from "../property/property.model"; @@ -224,6 +226,33 @@ export function isAlreadyExistsError(err: unknown): boolean { return code === 6 || code === "already-exists"; } +// Passed to the CachedRepository constructed below — unused by searchDirect (which bypasses +// caching entirely), kept only to match billing-cycle.service.ts's own TTL for this feature. +const BILLING_CYCLE_CACHE_TTL = 15 * 60; + +/** + * Reads the meter group's latest cycle's rate_ema, for estimating a new billing's cost before + * the reading-creation transaction opens (Firestore transactions require all reads before any + * writes). Uses CachedRepository.searchDirect — the sanctioned cache-bypass escape hatch for + * correctness-critical, narrowly-scoped reads (see cached-repository.lib.ts) — constructed + * directly here rather than imported from billing-cycle.service.ts (that service already + * imports from this file, so importing it back here would create a circular dependency; + * searchDirect lives on the generic CachedRepository class, so this needs no import from + * billing-cycle.service.ts at all). + * Returns null when the meter group has no cycles yet, or none have been backfilled/computed + * with a rate_ema (see scripts/backfill-rate-ema.ts). + */ +async function getLatestRateEma(userId: string, meterGroupId: string): Promise { + const cachedRepo = new CachedRepository(billingCycleRepository, userId, "billing-cycles", BILLING_CYCLE_CACHE_TTL); + const {data} = await cachedRepo.searchDirect({ + limit: 1, + orderBy: "billing_start_date", + orderDirection: "desc", + filters: {meter_group_id: meterGroupId}, + }); + return data[0]?.rate_ema ?? null; +} + /** * Runs the write side of "create reading + auto-billing" inside a Firestore * transaction, once a previous-month reading has been found. The `prevReading`/ @@ -241,6 +270,7 @@ async function runCreateReadingTransaction( prevReadingId: string, prevReadingData: any, propertyId: string, + latestCycleRateEma: number | null, ): Promise<{readingRef: FirebaseFirestore.DocumentReference; billingId: string}> { const newReadingRef = firestore.collection(COLLECTIONS.READINGS).doc(); const lockRef = firestore.collection(COLLECTIONS.READING_LOCKS).doc( @@ -281,6 +311,7 @@ async function runCreateReadingTransaction( newReadingRef.id, prevReadingData, newReadingForBilling, + latestCycleRateEma, ); }); } catch (err) { @@ -329,12 +360,15 @@ export async function createReadingWithAutoBilling( return cachedRepo.create(payload); } + const latestCycleRateEma = await getLatestRateEma(userId, data.meter_group_id); + const {readingRef, billingId} = await runCreateReadingTransaction( data, meterVersion, prevReading.id, prevReading.data, propertySnap.id, + latestCycleRateEma, ); const snap = await readingRef.get(); @@ -358,45 +392,61 @@ export async function createReadingWithAutoBilling( * For each reading, if a previous-month reading exists, creates * the reading + Billing atomically in a transaction. * Parallelizes across readings. + * + * Cache population is batched once across the whole call (after every item's write + * settles), not per-item inside the parallel map: the list cache is a single + * read-modify-write key per user, so N concurrent single-item `listAppend` calls on it + * (one per batch item) would race — the same class of bug `listAppendMany` was + * introduced to fix in `CachedRepository.createBatch` (see `cached-repository.lib.ts`). + * Reading/billing writes themselves still go through the raw repositories (not + * `CachedRepository.create()`, which would reintroduce the same per-item list-append). */ export async function createBatchReadingsWithAutoBilling( userId: string, readingsWithVersion: ReadingCreatePayload[], ): Promise { - const cachedRepo = new CachedRepository(readingRepository, userId, "readings", CACHE_TTL); - - const readingPromises = readingsWithVersion.map(async (readingData) => { + const results = await Promise.all(readingsWithVersion.map(async (readingData) => { // Look for previous-month reading scoped to this property const prevReading = await findPreviousMonthReading(readingData.meter_group_id, readingData.property_id, readingData.reading_date); // If no previous reading, just create the reading if (!prevReading) { - return cachedRepo.create(readingData); + const reading = await readingRepository.create(readingData); + return {reading, billing: null as Billing | null}; } + // Not deduplicated per meter_group_id across the batch — batches are capped small and + // this mirrors the existing per-reading Promise.all parallelization, so a handful of + // redundant reads for readings sharing a meter group is a non-issue here. + const latestCycleRateEma = await getLatestRateEma(userId, readingData.meter_group_id); + const {readingRef, billingId} = await runCreateReadingTransaction( readingData, readingData.meter_version, prevReading.id, prevReading.data, readingData.property_id, + latestCycleRateEma, ); const snap = await readingRef.get(); const reading = snapshotToModel(snap); - await cacheSet(`utilitool:readings:id:${reading.id}`, reading, CACHE_TTL); - await listAppend(`utilitool:readings:all:${userId}`, reading, CACHE_TTL); - - if (billingId) { - const billing = await billingRepository.getById(billingId); - if (billing) { - await cacheSet(`utilitool:billings:id:${billing.id}`, billing, 10 * 60); - await listAppend(`utilitool:billings:all:${userId}`, billing, 10 * 60); - } - } + const billing = billingId ? await billingRepository.getById(billingId) : null; - return reading; - }); + return {reading, billing}; + })); + + const readings = results.map((r) => r.reading); + const billings = results + .map((r) => r.billing) + .filter((b): b is Billing => b !== null); + + await Promise.all([ + Promise.all(readings.map((r) => cacheSet(`utilitool:readings:id:${r.id}`, r, CACHE_TTL))), + listAppendMany(`utilitool:readings:all:${userId}`, readings, CACHE_TTL), + Promise.all(billings.map((b) => cacheSet(`utilitool:billings:id:${b.id}`, b, 10 * 60))), + listAppendMany(`utilitool:billings:all:${userId}`, billings, 10 * 60), + ]); - return Promise.all(readingPromises); + return readings; } diff --git a/api/functions/src/features/tenant/tenant.validator.ts b/api/functions/src/features/tenant/tenant.validator.ts index a063f02..3f40fb7 100644 --- a/api/functions/src/features/tenant/tenant.validator.ts +++ b/api/functions/src/features/tenant/tenant.validator.ts @@ -19,9 +19,12 @@ export class TenantValidator { ): Promise { // Indexed query scoped to one property — avoids full collection scan const normalizedTenantName = normalizeTenantName(tenantName); + // .limit(1000) — defense-in-depth; already scoped to one property_id, low risk, but matches + // the same-shape cap applied everywhere else in this codebase. const snap = await collectionRef(COLLECTIONS.TENANTS) .where("property_id", "==", propertyId) .where("is_deleted", "==", false) + .limit(1000) .get(); return snap.docs @@ -83,6 +86,7 @@ export class TenantValidator { collectionRef(COLLECTIONS.TENANTS) .where("property_id", "==", propertyId) .where("is_deleted", "==", false) + .limit(1000) .get() ) ); @@ -186,6 +190,7 @@ export class TenantValidator { const snap = collectionRef(COLLECTIONS.TENANTS) .where("property_id", "==", propertyId) .where("is_deleted", "==", false) + .limit(1000) .get(); return snap; }) diff --git a/api/functions/src/lib/cached-repository.lib.ts b/api/functions/src/lib/cached-repository.lib.ts index 48b10b1..e470709 100644 --- a/api/functions/src/lib/cached-repository.lib.ts +++ b/api/functions/src/lib/cached-repository.lib.ts @@ -2,7 +2,10 @@ import type {BaseModel, WithoutBaseModel} from "../utils/model.util"; import type {PaginatedResult} from "../utils/pagination.util"; import type {Repository, SearchFilter, SearchOptions} from "./repository.lib"; import {cacheGet, cacheSet, cacheDel, cacheDelPattern} from "../utils/cache.util"; -import {loadAll, listAppend, listUpdate, listRemove, paginate, fetchAllPages} from "../utils/list-cache.util"; +import { + loadAll, listAppend, listUpdate, listRemove, listAppendMany, listUpdateMany, listRemoveMany, + paginate, fetchAllPages, +} from "../utils/list-cache.util"; /** * CachedRepository wraps Repository with a two-tier caching strategy: @@ -78,6 +81,18 @@ export class CachedRepository { return result; } + /** + * Bounded, Firestore-side-filtered read that bypasses the list cache entirely — for + * correctness-critical or narrowly-scoped reads (recompute functions, validators) where loading + * the user's entire collection into memory would be wasteful or wrong. Supports the full + * SearchOptions the underlying Repository does, including range and array-contains filters that + * applyFilters() below cannot express in-memory. Prefer this over search()/searchAll() any time + * the caller isn't serving a simple paginated list endpoint. + */ + async searchDirect(options: SearchOptions): Promise> { + return this.repo.search(options); + } + /** * Load ALL items (no pagination), cached by user. * Useful for operations that need the full dataset. @@ -175,11 +190,14 @@ export class CachedRepository { async createBatch(documents: WithoutBaseModel[]): Promise { const created = await this.repo.createBatch(documents); - // Update both cache tiers for each item - for (const item of created) { - await cacheSet(this.idCacheKey(item.id), item, this.cacheTTL); - await listAppend(this.listCacheKey(), item, this.cacheTTL); - } + // ID-cache writes are independent keys, safe in parallel. The list cache is a single + // read-modify-write key shared by every item, so it gets one batched append instead of + // one call per item (both for correctness — parallel single-item calls would race and + // could drop entries — and to cut round-trips). + await Promise.all([ + Promise.all(created.map((item) => cacheSet(this.idCacheKey(item.id), item, this.cacheTTL))), + listAppendMany(this.listCacheKey(), created, this.cacheTTL), + ]); return created; } @@ -199,11 +217,11 @@ export class CachedRepository { async updateBatch(updates: { id: string; data: Partial> }[]): Promise { const updated = await this.repo.updateBatch(updates); - // Update both cache tiers for each item - for (const item of updated) { - await cacheSet(this.idCacheKey(item.id), item, this.cacheTTL); - await listUpdate(this.listCacheKey(), item); - } + // Same reasoning as createBatch: parallel ID-cache writes, one batched list update. + await Promise.all([ + Promise.all(updated.map((item) => cacheSet(this.idCacheKey(item.id), item, this.cacheTTL))), + listUpdateMany(this.listCacheKey(), updated), + ]); return updated; } @@ -214,9 +232,11 @@ export class CachedRepository { async delete(id: string): Promise { await this.repo.delete(id); - // Invalidate both cache tiers - await cacheDel(this.idCacheKey(id)); - await listRemove(this.listCacheKey(), id); + // Invalidate both cache tiers — different keys, safe in parallel. + await Promise.all([ + cacheDel(this.idCacheKey(id)), + listRemove(this.listCacheKey(), id), + ]); } /** @@ -235,11 +255,11 @@ export class CachedRepository { async deleteBatch(ids: string[]): Promise { await this.repo.deleteBatch(ids); - // Invalidate both cache tiers for each item - for (const id of ids) { - await cacheDel(this.idCacheKey(id)); - await listRemove(this.listCacheKey(), id); - } + // Same reasoning as createBatch/updateBatch: parallel ID-cache deletes, one batched list removal. + await Promise.all([ + Promise.all(ids.map((id) => cacheDel(this.idCacheKey(id)))), + listRemoveMany(this.listCacheKey(), ids), + ]); } /** @@ -248,9 +268,11 @@ export class CachedRepository { async softDelete(id: string): Promise { const deleted = await this.repo.softDelete(id); - // Invalidate both cache tiers - await cacheDel(this.idCacheKey(id)); - await listRemove(this.listCacheKey(), id); + // Invalidate both cache tiers — different keys, safe in parallel. + await Promise.all([ + cacheDel(this.idCacheKey(id)), + listRemove(this.listCacheKey(), id), + ]); return deleted; } @@ -261,11 +283,11 @@ export class CachedRepository { async softDeleteBatch(ids: string[]): Promise { const deleted = await this.repo.softDeleteBatch(ids); - // Invalidate both cache tiers for each item - for (const id of ids) { - await cacheDel(this.idCacheKey(id)); - await listRemove(this.listCacheKey(), id); - } + // Same reasoning as createBatch/updateBatch: parallel ID-cache deletes, one batched list removal. + await Promise.all([ + Promise.all(ids.map((id) => cacheDel(this.idCacheKey(id)))), + listRemoveMany(this.listCacheKey(), ids), + ]); return deleted; } diff --git a/api/functions/src/lib/repository.lib.ts b/api/functions/src/lib/repository.lib.ts index b7555c5..7141acb 100644 --- a/api/functions/src/lib/repository.lib.ts +++ b/api/functions/src/lib/repository.lib.ts @@ -24,6 +24,10 @@ export type RangeFilter = { $lte?: FilterValue; $gt?: FilterValue; $lt?: FilterValue; + $arrayContains?: FilterValue; + // Firestore caps array-contains-any at 10 values — matches this codebase's + // existing batch-size cap (e.g. CreateBillingCycleBatchDTOSchema). + $arrayContainsAny?: FilterValue[]; }; export type SearchFilter = { @@ -65,7 +69,11 @@ export class Repository { for (const [field, value] of Object.entries(options.filters)) { if (value !== undefined && value !== null) { // Check if value is a range filter object - if (typeof value === "object" && ("$gte" in value || "$lte" in value || "$gt" in value || "$lt" in value)) { + if ( + typeof value === "object" && + ("$gte" in value || "$lte" in value || "$gt" in value || "$lt" in value || + "$arrayContains" in value || "$arrayContainsAny" in value) + ) { const rangeFilter = value as RangeFilter; if (rangeFilter.$gte !== undefined && rangeFilter.$gte !== null) { query = query.where(field, ">=", rangeFilter.$gte as never); @@ -79,6 +87,18 @@ export class Repository { if (rangeFilter.$lt !== undefined && rangeFilter.$lt !== null) { query = query.where(field, "<", rangeFilter.$lt as never); } + if (rangeFilter.$arrayContains !== undefined && rangeFilter.$arrayContains !== null) { + query = query.where(field, "array-contains", rangeFilter.$arrayContains as never); + } + if (rangeFilter.$arrayContainsAny !== undefined && rangeFilter.$arrayContainsAny !== null) { + if (rangeFilter.$arrayContainsAny.length > 10) { + throw new Error( + `$arrayContainsAny on "${String(field)}" received ${rangeFilter.$arrayContainsAny.length} ` + + "values — Firestore's array-contains-any caps at 10." + ); + } + query = query.where(field, "array-contains-any", rangeFilter.$arrayContainsAny as never); + } } else { // Equality filter query = query.where(field, "==", value as never); diff --git a/api/functions/src/utils/anchor-fetch.util.ts b/api/functions/src/utils/anchor-fetch.util.ts new file mode 100644 index 0000000..b14ba1a --- /dev/null +++ b/api/functions/src/utils/anchor-fetch.util.ts @@ -0,0 +1,42 @@ +import type {BaseModel, WithoutBaseModel} from "./model.util"; +import type {CachedRepository} from "../lib/cached-repository.lib"; +import type {SearchFilter} from "../lib/repository.lib"; + +/** + * Bounded "seed nearest-before + affected on/after anchor" fetch, for any per-user chain of + * records ordered by a single Date-typed field where a later record's derived value only ever + * depends on earlier records, never the reverse — e.g. BillingCycle.rate_ema by + * billing_start_date, or Billing.estimated_cost by billing_period_date. Instead of loading the + * whole chain, this fetches just the single nearest record strictly before `anchorDate` (to seed + * continuation of the chain) plus every record on/after `anchorDate` (the only ones whose + * derived value can possibly change). Both reads run via `CachedRepository.searchDirect` — the + * sanctioned cache-bypass path for correctness-critical, narrowly-scoped reads (see + * cached-repository.lib.ts) — in parallel, since neither depends on the other. + * + * See `recomputeRateEmaForMeterGroup` (billing-cycle.service.ts) and `recomputePendingEstimates` + * (billing.service.ts) for the two current callers. + */ +export async function fetchSeedAndAffected( + cachedRepo: CachedRepository, + baseFilters: SearchFilter>, + anchorField: keyof WithoutBaseModel & string, + anchorDate: Date, + affectedLimit: number = 1000 +): Promise<{seed: T | null; affected: T[]}> { + const [{data: seedItems}, {data: affected}] = await Promise.all([ + cachedRepo.searchDirect({ + limit: 1, + orderBy: anchorField, + orderDirection: "desc", + filters: {...baseFilters, [anchorField]: {$lt: anchorDate}} as SearchFilter>, + }), + cachedRepo.searchDirect({ + limit: affectedLimit, + orderBy: anchorField, + orderDirection: "asc", + filters: {...baseFilters, [anchorField]: {$gte: anchorDate}} as SearchFilter>, + }), + ]); + + return {seed: seedItems[0] ?? null, affected}; +} diff --git a/api/functions/src/utils/list-cache.util.ts b/api/functions/src/utils/list-cache.util.ts index 87738bd..32e4b9e 100644 --- a/api/functions/src/utils/list-cache.util.ts +++ b/api/functions/src/utils/list-cache.util.ts @@ -128,36 +128,67 @@ export async function listAppend( item: T, ttlSeconds: number = 30 * 60 ): Promise { + return listAppendMany(cacheKey, [item], ttlSeconds); +} + +export async function listUpdate( + cacheKey: string, + item: T +): Promise { + return listUpdateMany(cacheKey, [item]); +} + +export async function listRemove( + cacheKey: string, + id: string +): Promise { + return listRemoveMany(cacheKey, [id]); +} + +/** + * Batch variants of listAppend/listUpdate/listRemove: one cacheGet + one cacheSet for the + * whole batch instead of one round-trip pair per item. Also correctness-critical, not just an + * optimization — the list cache is a single read-modify-write key, so calling the single-item + * versions concurrently (e.g. via Promise.all across a batch) would race: two calls can both + * read the same stale array and the second write clobbers the first, silently dropping an item. + */ +export async function listAppendMany( + cacheKey: string, + items: T[], + ttlSeconds: number = 30 * 60 +): Promise { + if (items.length === 0) return; const cached = await cacheGet(cacheKey); if (!cached) return; // Cache miss, let next GET populate it - cached.push(item); + cached.push(...items); await cacheSet(cacheKey, cached, ttlSeconds); } -export async function listUpdate( +export async function listUpdateMany( cacheKey: string, - item: T + items: T[] ): Promise { + if (items.length === 0) return; const cached = await cacheGet(cacheKey); if (!cached) return; - const index = cached.findIndex((i) => i.id === item.id); - if (index !== -1) { - cached[index] = item; - const ttl = 30 * 60; - await cacheSet(cacheKey, cached, ttl); - } + const byId = new Map(items.map((item) => [item.id, item])); + const updated = cached.map((existing) => byId.get(existing.id) ?? existing); + const ttl = 30 * 60; + await cacheSet(cacheKey, updated, ttl); } -export async function listRemove( +export async function listRemoveMany( cacheKey: string, - id: string + ids: string[] ): Promise { + if (ids.length === 0) return; const cached = await cacheGet(cacheKey); if (!cached) return; - const filtered = cached.filter((i) => i.id !== id); + const idSet = new Set(ids); + const filtered = cached.filter((i) => !idSet.has(i.id)); const ttl = 30 * 60; await cacheSet(cacheKey, filtered, ttl); } diff --git a/mobile/src/App.svelte b/mobile/src/App.svelte index 22229c3..14cc000 100644 --- a/mobile/src/App.svelte +++ b/mobile/src/App.svelte @@ -7,16 +7,34 @@ import ReadingHistory from './screens/ReadingHistory.svelte'; import Billings from './screens/Billings.svelte'; import Settings from './screens/Settings.svelte'; + import ConfirmSheet from './components/ConfirmSheet.svelte'; + import Toast from './components/Toast.svelte'; + import { consumeManualSignOutFlag, setSessionExpired } from './lib/stores/auth-notice.svelte'; let currentScreen = $state('login'); let user = $state(auth.currentUser); + let announcement = $state(''); + + const screenTitles: Record = { + home: 'Home', + capture: 'Capture Readings', + history: 'Reading History', + billings: 'Billings', + settings: 'Settings' + }; $effect(() => { const unsubscribe = auth.onAuthStateChanged((newUser) => { + const wasLoggedIn = !!user; user = newUser; if (newUser && currentScreen === 'login') { currentScreen = 'home'; } else if (!newUser) { + if (wasLoggedIn && !consumeManualSignOutFlag()) { + setSessionExpired(); + } else { + consumeManualSignOutFlag(); + } currentScreen = 'login'; } }); @@ -28,6 +46,9 @@ const hash = window.location.hash.slice(2); if (hash && ['home', 'capture', 'history', 'billings', 'settings'].includes(hash)) { currentScreen = hash; + announcement = screenTitles[hash] ?? ''; + } else if (hash) { + window.location.hash = '#/home'; } }; @@ -37,6 +58,8 @@ }); +
{announcement}
+ {#if !user} {:else if currentScreen === 'home'} @@ -50,3 +73,6 @@ {:else if currentScreen === 'settings'} {/if} + + + diff --git a/mobile/src/components/BottomNav.svelte b/mobile/src/components/BottomNav.svelte index 4a611ee..3343d67 100644 --- a/mobile/src/components/BottomNav.svelte +++ b/mobile/src/components/BottomNav.svelte @@ -15,7 +15,7 @@ ] as const; -
+
+ diff --git a/mobile/src/components/ConfirmSheet.svelte b/mobile/src/components/ConfirmSheet.svelte new file mode 100644 index 0000000..d90b30a --- /dev/null +++ b/mobile/src/components/ConfirmSheet.svelte @@ -0,0 +1,44 @@ + + +{#if confirmState.open} + +{/if} diff --git a/mobile/src/components/ErrorBanner.svelte b/mobile/src/components/ErrorBanner.svelte new file mode 100644 index 0000000..ed7c69d --- /dev/null +++ b/mobile/src/components/ErrorBanner.svelte @@ -0,0 +1,26 @@ + + + diff --git a/mobile/src/components/Toast.svelte b/mobile/src/components/Toast.svelte new file mode 100644 index 0000000..a3d5052 --- /dev/null +++ b/mobile/src/components/Toast.svelte @@ -0,0 +1,29 @@ + + +
+ {#each toastState.toasts as toast (toast.id)} +
+

{toast.message}

+ +
+ {/each} +
diff --git a/mobile/src/lib/api/billing-cycles.ts b/mobile/src/lib/api/billing-cycles.ts index f5be6f6..c59019c 100644 --- a/mobile/src/lib/api/billing-cycles.ts +++ b/mobile/src/lib/api/billing-cycles.ts @@ -1,4 +1,4 @@ -import { apiGet } from './client'; +import { apiGet, buildQueryString } from './client'; export interface BillingCycle { id: string; @@ -24,10 +24,5 @@ export async function listBillingCycles(params?: { limit?: number; cursor?: string; }): Promise { - const query = new URLSearchParams(); - if (params?.limit) query.set('limit', params.limit.toString()); - if (params?.cursor) query.set('cursor', params.cursor); - - const path = query.toString() ? `/billing-cycles?${query}` : '/billing-cycles'; - return apiGet(path); + return apiGet(`/billing-cycles${buildQueryString(params)}`); } diff --git a/mobile/src/lib/api/billings.ts b/mobile/src/lib/api/billings.ts index 5c981f2..e46352d 100644 --- a/mobile/src/lib/api/billings.ts +++ b/mobile/src/lib/api/billings.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPatch } from './client'; +import { apiGet, apiPatch, buildQueryString } from './client'; export interface Billing { id: string; @@ -12,12 +12,16 @@ export interface Billing { is_deleted: boolean; } -export async function listBillings(propertyId?: string) { - const params = new URLSearchParams(); - if (propertyId) params.append('propertyId', propertyId); - return apiGet(`/billings${params.toString() ? '?' + params.toString() : ''}`); +export interface BillingsListResponse { + data: Billing[]; + nextCursor?: string | null; + hasMore: boolean; } -export async function updateBillingStatus(id: string, paymentStatus: string) { - return apiPatch(`/billings/${id}`, { payment_status: paymentStatus }); +export async function listBillings(propertyId?: string): Promise { + return apiGet(`/billings${buildQueryString({ propertyId })}`); +} + +export async function updateBillingStatus(id: string, paymentStatus: string): Promise { + return apiPatch(`/billings/${id}`, { payment_status: paymentStatus }); } diff --git a/mobile/src/lib/api/client.ts b/mobile/src/lib/api/client.ts index 336e70b..7dd8404 100644 --- a/mobile/src/lib/api/client.ts +++ b/mobile/src/lib/api/client.ts @@ -42,13 +42,13 @@ async function request(endpoint: string, options: RequestInit = {}) { return response; } -export async function apiGet(endpoint: string) { +export async function apiGet(endpoint: string): Promise { const res = await request(endpoint, { method: 'GET' }); if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`); return res.json(); } -export async function apiPost(endpoint: string, data: any) { +export async function apiPost(endpoint: string, data: any): Promise { const res = await request(endpoint, { method: 'POST', body: JSON.stringify(data) @@ -57,7 +57,7 @@ export async function apiPost(endpoint: string, data: any) { return res.json(); } -export async function apiPatch(endpoint: string, data: any) { +export async function apiPatch(endpoint: string, data: any): Promise { const res = await request(endpoint, { method: 'PATCH', body: JSON.stringify(data) @@ -71,3 +71,17 @@ export async function apiDelete(endpoint: string) { if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`); return res; } + +/** Shared optional-param query-string builder for the feature API modules. */ +export function buildQueryString( + params?: Record +): string { + if (!params) return ''; + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + query.set(key, String(value)); + } + const qs = query.toString(); + return qs ? `?${qs}` : ''; +} diff --git a/mobile/src/lib/api/readings.ts b/mobile/src/lib/api/readings.ts index a4c9830..5657fd4 100644 --- a/mobile/src/lib/api/readings.ts +++ b/mobile/src/lib/api/readings.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from './client'; +import { apiGet, apiPost, buildQueryString } from './client'; export interface Reading { id: string; @@ -28,35 +28,37 @@ export interface BatchCreateResult { failed: { index: number; error: string }[]; } +export interface ReadingsListResponse { + data: Reading[]; + nextCursor?: string | null; + hasMore: boolean; +} + export async function listReadings(options?: { meterGroupId?: string; limit?: number; offset?: number; -}) { - const params = new URLSearchParams(); - if (options?.meterGroupId) params.append('meterGroupId', options.meterGroupId); - if (options?.limit) params.append('limit', String(options.limit)); - if (options?.offset) params.append('offset', String(options.offset)); - return apiGet(`/readings${params.toString() ? '?' + params.toString() : ''}`); +}): Promise { + return apiGet(`/readings${buildQueryString(options)}`); } export async function getReading(id: string): Promise { - return apiGet(`/readings/${id}`); + return apiGet(`/readings/${id}`); } export async function createReadingsBatch(data: BatchReadingRequest): Promise> { if (!data.readings || data.readings.length === 0) { throw new Error('Cannot submit an empty batch — add at least one reading.'); } - return apiPost('/readings/batch', data); + return apiPost>('/readings/batch', data); } -export async function createSeedReading(data: CreateReadingRequest) { - return apiPost('/readings/seed', data); +export async function createSeedReading(data: CreateReadingRequest): Promise { + return apiPost('/readings/seed', data); } export async function ocrReadingImage( imageUrl: string ): Promise<{ suggested_reading_amount: number | null }> { - return apiPost('/readings/ocr', { image_url: imageUrl }); + return apiPost<{ suggested_reading_amount: number | null }>('/readings/ocr', { image_url: imageUrl }); } diff --git a/mobile/src/lib/stores/auth-notice.svelte.ts b/mobile/src/lib/stores/auth-notice.svelte.ts new file mode 100644 index 0000000..8ff7d23 --- /dev/null +++ b/mobile/src/lib/stores/auth-notice.svelte.ts @@ -0,0 +1,25 @@ +let message = $state(null); +let manualSignOut = false; + +export const authNotice = { + get message() { + return message; + }, + clear() { + message = null; + } +}; + +export function markManualSignOut() { + manualSignOut = true; +} + +export function consumeManualSignOutFlag(): boolean { + const was = manualSignOut; + manualSignOut = false; + return was; +} + +export function setSessionExpired() { + message = 'Your session expired — please sign in again.'; +} diff --git a/mobile/src/lib/stores/confirm.svelte.ts b/mobile/src/lib/stores/confirm.svelte.ts new file mode 100644 index 0000000..9b82562 --- /dev/null +++ b/mobile/src/lib/stores/confirm.svelte.ts @@ -0,0 +1,72 @@ +interface ConfirmState { + open: boolean; + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + danger: boolean; +} + +interface ConfirmOptions { + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +let state = $state({ + open: false, + title: '', + message: '', + confirmLabel: 'Confirm', + cancelLabel: 'Cancel', + danger: false +}); + +let resolver: ((value: boolean) => void) | null = null; + +export const confirmState = { + get open() { + return state.open; + }, + get title() { + return state.title; + }, + get message() { + return state.message; + }, + get confirmLabel() { + return state.confirmLabel; + }, + get cancelLabel() { + return state.cancelLabel; + }, + get danger() { + return state.danger; + } +}; + +export function confirmAsync( + title: string, + message: string, + options?: ConfirmOptions +): Promise { + // Resolve any stale pending confirm as cancelled before opening a new one. + resolver?.(false); + state = { + open: true, + title, + message, + confirmLabel: options?.confirmLabel ?? 'Confirm', + cancelLabel: options?.cancelLabel ?? 'Cancel', + danger: options?.danger ?? false + }; + return new Promise((resolve) => { + resolver = resolve; + }); +} + +export function resolveConfirm(result: boolean) { + state = { ...state, open: false }; + resolver?.(result); + resolver = null; +} diff --git a/mobile/src/lib/stores/toast.svelte.ts b/mobile/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..56d8af7 --- /dev/null +++ b/mobile/src/lib/stores/toast.svelte.ts @@ -0,0 +1,28 @@ +export type ToastVariant = 'success' | 'warning' | 'error'; + +export interface ToastMessage { + id: string; + message: string; + variant: ToastVariant; +} + +const AUTO_DISMISS_MS = 4000; +const MAX_STACKED = 2; + +let toasts = $state([]); + +export const toastState = { + get toasts() { + return toasts; + } +}; + +export function pushToast(message: string, variant: ToastVariant = 'success') { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, variant }].slice(-MAX_STACKED); + setTimeout(() => dismissToast(id), AUTO_DISMISS_MS); +} + +export function dismissToast(id: string) { + toasts = toasts.filter((t) => t.id !== id); +} diff --git a/mobile/src/lib/utils/errors.ts b/mobile/src/lib/utils/errors.ts new file mode 100644 index 0000000..8c91cb9 --- /dev/null +++ b/mobile/src/lib/utils/errors.ts @@ -0,0 +1,3 @@ +export function getErrorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; +} diff --git a/mobile/src/lib/utils/focus-trap.ts b/mobile/src/lib/utils/focus-trap.ts new file mode 100644 index 0000000..f169eec --- /dev/null +++ b/mobile/src/lib/utils/focus-trap.ts @@ -0,0 +1,64 @@ +export const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface FocusTrapOptions { + active: boolean; + onEscape?: () => void; + lockScroll?: boolean; +} + +/** Svelte action: traps Tab focus inside `node`, closes on Escape, restores focus on deactivate. */ +export function focusTrap(node: HTMLElement, options: FocusTrapOptions) { + let opts = options; + let previouslyFocused: HTMLElement | null = null; + + function focusFirst() { + node.querySelector(FOCUSABLE_SELECTOR)?.focus(); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Tab') { + const focusable = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } else if (e.key === 'Escape') { + opts.onEscape?.(); + } + } + + function activate() { + previouslyFocused = document.activeElement as HTMLElement | null; + focusFirst(); + node.addEventListener('keydown', handleKeydown); + if (opts.lockScroll) document.body.classList.add('modal-open'); + } + + function deactivate() { + node.removeEventListener('keydown', handleKeydown); + if (opts.lockScroll) document.body.classList.remove('modal-open'); + previouslyFocused?.focus(); + previouslyFocused = null; + } + + if (opts.active) activate(); + + return { + update(newOptions: FocusTrapOptions) { + const wasActive = opts.active; + opts = newOptions; + if (!wasActive && opts.active) activate(); + else if (wasActive && !opts.active) deactivate(); + }, + destroy() { + if (opts.active) deactivate(); + } + }; +} diff --git a/mobile/src/lib/utils/navigation.ts b/mobile/src/lib/utils/navigation.ts new file mode 100644 index 0000000..58e5ca0 --- /dev/null +++ b/mobile/src/lib/utils/navigation.ts @@ -0,0 +1,3 @@ +export function goToHash(hash: string) { + window.location.hash = hash; +} diff --git a/mobile/src/screens/Billings.svelte b/mobile/src/screens/Billings.svelte index d1bf5da..ffdffd7 100644 --- a/mobile/src/screens/Billings.svelte +++ b/mobile/src/screens/Billings.svelte @@ -1,4 +1,5 @@ -
+
- -

New Reading Session

+ +

New Reading Session

+
+ + + Step {step} of 3 +
{#if error} -
- {error} -
+ {/if} @@ -277,6 +316,9 @@

Reading date: {readingDate}

+

+ {filledCount} of {properties.length} properties done +

{#each properties as property (property.id)}
@@ -400,6 +442,5 @@
{/if} - - +
diff --git a/mobile/src/screens/Home.svelte b/mobile/src/screens/Home.svelte index 8dcb0fc..0da7b00 100644 --- a/mobile/src/screens/Home.svelte +++ b/mobile/src/screens/Home.svelte @@ -1,4 +1,5 @@ diff --git a/mobile/src/screens/Login.svelte b/mobile/src/screens/Login.svelte index 68b06c4..79710db 100644 --- a/mobile/src/screens/Login.svelte +++ b/mobile/src/screens/Login.svelte @@ -2,12 +2,16 @@ import { signInWithEmailAndPassword } from 'firebase/auth'; import { auth } from '../firebase'; import { getReadableAuthError } from '../lib/utils/auth-errors'; + import { authNotice } from '../lib/stores/auth-notice.svelte'; + import ErrorBanner from '../components/ErrorBanner.svelte'; let email = $state(''); let password = $state(''); - let error = $state(''); + let error = $state(authNotice.message ?? ''); let loading = $state(false); + authNotice.clear(); + async function handleLogin(e: Event) { e.preventDefault(); loading = true; @@ -62,9 +66,7 @@ {#if error} -
- {error} -
+ {/if} diff --git a/mobile/src/screens/ReadingHistory.svelte b/mobile/src/screens/ReadingHistory.svelte index 2670e77..f4fabfc 100644 --- a/mobile/src/screens/ReadingHistory.svelte +++ b/mobile/src/screens/ReadingHistory.svelte @@ -1,4 +1,5 @@
- -

Settings

+ +

Settings

{#if error} -
- {error} - -
+ (error = null)} /> {/if} -
+

Account

@@ -70,7 +87,7 @@
-
+ diff --git a/ui/CLAUDE.md b/ui/CLAUDE.md index a84e537..b3f7d26 100644 --- a/ui/CLAUDE.md +++ b/ui/CLAUDE.md @@ -135,8 +135,8 @@ ui/src/ │ │ ├── property.types.ts │ │ ├── tenant.types.ts │ │ ├── reading.types.ts -│ │ ├── billing.types.ts -│ │ ├── billing-cycle.types.ts +│ │ ├── billing.types.ts (includes `estimated_cost?: number` — frozen rate-EMA snapshot, see Pending Estimates panel below) +│ │ ├── billing-cycle.types.ts (includes `rate_ema?: number` — per-meter-group EMA of billing_rate) │ │ ├── reports.types.ts (ReportSummary, ConsumptionReport, BillingTrendsReport, CollectionStatusReport, CombinedReportsResponse, ReportQueryParams) │ │ └── llm-config.types.ts (LlmConfigResponse, UpsertLlmConfigRequest) │ │ @@ -278,7 +278,15 @@ ui/src/ - "Manual Billing (Advanced)" collapsed section for corrections - Pencil "Edit" button on each cycle row → opens an `EditModal` for correcting `billing_consumption`, `billing_rate`, `billing_start_date`, `billing_end_date`, `overdue_date` (covers company errors in rate/consumption without needing to delete and recreate the cycle) - **Note**: Billings are auto-created when readings are posted — the cycle form just groups them. OCR autofill is optional; all autofilled fields remain editable. Per-reading consumption previews (discovery, override, gap-fill) use the shared version-aware `readingConsumption()`/`trueReading()` helpers so they stay correct across meter resets. -- **Status**: ✅ Complete (cycle-centric design; auto-billing integration; bill photo OCR) +- **Pending Estimates panel** (`+page.svelte:1146-1195`): lists uncycled billings that already have + a known consumption but no official cycle/rate yet, showing `~{formatCurrency(billing.estimated_cost)}` + with an "estimated" badge (rate-EMA, from the meter group's recent rate history) or a "derived" + badge (main-meter properties, whose billing is computed as total-minus-submeters at the moment + its own cycle is created). A main-meter property only appears here once its cycle exists — its + consumption isn't knowable before that — so a brief absence from this panel is expected, not a + bug. See `api/functions/CLAUDE.md` → "Billing Cycles" → "Rate-EMA cost estimation" for the + underlying computation. +- **Status**: ✅ Complete (cycle-centric design; auto-billing integration; bill photo OCR; rate-EMA pending estimates) #### Archive Pages (`//archive`) diff --git a/ui/package-lock.json b/ui/package-lock.json index 96a8064..409684b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2977,16 +2977,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/chai": { @@ -4386,9 +4386,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4638,9 +4638,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", "dev": true, "funding": [ { @@ -4658,7 +4658,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5354,9 +5354,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/ui/src/lib/api/billing-cycles.ts b/ui/src/lib/api/billing-cycles.ts index 8b14c65..de8f519 100644 --- a/ui/src/lib/api/billing-cycles.ts +++ b/ui/src/lib/api/billing-cycles.ts @@ -1,60 +1,38 @@ -import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import { apiPost } from './client'; +import { createCrudApi } from './crud-api-factory'; import type { BillingCycle, CreateBillingCycleRequest, UpdateBillingCycleRequest } from '$lib/types/billing-cycle.types'; -import type { PaginatedResult, BatchCreateResult } from '$lib/types/api.types'; +import type { BatchCreateResult } from '$lib/types/api.types'; -export async function getBillingCycles(params?: { +interface GetBillingCyclesParams { meterGroupId?: string; billingStartDate?: string; billingEndDate?: string; limit?: number; cursor?: string; archived?: boolean; -}): Promise> { - return apiGet>(`/billing-cycles${toQueryString(params)}`); } -export async function getBillingCycleById(id: string): Promise { - return apiGet(`/billing-cycles/${id}`); -} - -export async function createBillingCycle(data: CreateBillingCycleRequest): Promise { - return apiPost('/billing-cycles', data); -} - -export async function createBillingCyclesBatch( - data: CreateBillingCycleRequest[] -): Promise> { - return apiPost>('/billing-cycles/batch', data); -} - -export async function updateBillingCycle( - id: string, - data: UpdateBillingCycleRequest -): Promise { - return apiPatch(`/billing-cycles/${id}`, data); -} - -export async function updateBillingCyclesBatch( - data: { id: string; data: UpdateBillingCycleRequest }[] -): Promise { - return apiPatch('/billing-cycles/batch', data); -} - -export async function deleteBillingCycle(id: string): Promise { - return apiDelete(`/billing-cycles/${id}`); -} - -export async function softDeleteBillingCycle(id: string): Promise { - return apiDelete(`/billing-cycles/${id}`); -} - -export async function restoreBillingCycle(id: string): Promise { - return apiPatch(`/billing-cycles/${id}/restore`, {}); -} +const billingCyclesApi = createCrudApi< + BillingCycle, + CreateBillingCycleRequest, + UpdateBillingCycleRequest, + GetBillingCyclesParams, + BatchCreateResult +>('/billing-cycles'); + +export const getBillingCycles = billingCyclesApi.get; +export const getBillingCycleById = billingCyclesApi.getById; +export const createBillingCycle = billingCyclesApi.create; +export const createBillingCyclesBatch = billingCyclesApi.createBatch; +export const updateBillingCycle = billingCyclesApi.update; +export const updateBillingCyclesBatch = billingCyclesApi.updateBatch; +export const softDeleteBillingCycle = billingCyclesApi.softDelete; +export const restoreBillingCycle = billingCyclesApi.restore; +export const clearCache = billingCyclesApi.clearCache; export interface BillingCycleOcrResult { billing_start_date: string; @@ -67,7 +45,3 @@ export interface BillingCycleOcrResult { export async function ocrBillingCycle(imageUrl: string): Promise { return apiPost('/billing-cycles/ocr', { image_url: imageUrl }); } - -export async function clearCache(): Promise<{ message: string }> { - return apiPost<{ message: string }>('/billing-cycles/cache/clear', {}); -} diff --git a/ui/src/lib/api/billings.ts b/ui/src/lib/api/billings.ts index 773c5d9..781b6a0 100644 --- a/ui/src/lib/api/billings.ts +++ b/ui/src/lib/api/billings.ts @@ -1,8 +1,8 @@ -import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import { apiGet } from './client'; +import { createCrudApi } from './crud-api-factory'; import type { Billing, CreateBillingRequest, UpdateBillingRequest } from '$lib/types/billing.types'; -import type { PaginatedResult } from '$lib/types/api.types'; -export async function getBillings(params?: { +interface GetBillingsParams { propertyId?: string; meterGroupId?: string; startDate?: string; @@ -10,44 +10,25 @@ export async function getBillings(params?: { limit?: number; cursor?: string; archived?: boolean; -}): Promise> { - return apiGet>(`/billings${toQueryString(params)}`); } -export async function getBillingById(id: string): Promise { - return apiGet(`/billings/${id}`); -} +const billingsApi = createCrudApi< + Billing, + CreateBillingRequest, + UpdateBillingRequest, + GetBillingsParams +>('/billings'); + +export const getBillings = billingsApi.get; +export const getBillingById = billingsApi.getById; +export const createBilling = billingsApi.create; +export const createBillingsBatch = billingsApi.createBatch; +export const updateBilling = billingsApi.update; +export const updateBillingsBatch = billingsApi.updateBatch; +export const softDeleteBilling = billingsApi.softDelete; +export const restoreBilling = billingsApi.restore; +export const clearCache = billingsApi.clearCache; export async function getBillingsByIds(ids: string[]): Promise { return apiGet(`/billings/batch-get?ids=${ids.map(encodeURIComponent).join(',')}`); } - -export async function createBilling(data: CreateBillingRequest): Promise { - return apiPost('/billings', data); -} - -export async function createBillingsBatch(data: CreateBillingRequest[]): Promise { - return apiPost('/billings/batch', data); -} - -export async function updateBilling(id: string, data: UpdateBillingRequest): Promise { - return apiPatch(`/billings/${id}`, data); -} - -export async function updateBillingsBatch( - data: { id: string; data: UpdateBillingRequest }[] -): Promise { - return apiPatch('/billings/batch', data); -} - -export async function softDeleteBilling(id: string): Promise { - return apiDelete(`/billings/${id}`); -} - -export async function restoreBilling(id: string): Promise { - return apiPatch(`/billings/${id}/restore`, {}); -} - -export async function clearCache(): Promise<{ message: string }> { - return apiPost<{ message: string }>('/billings/cache/clear', {}); -} diff --git a/ui/src/lib/api/cache.ts b/ui/src/lib/api/cache.ts index 64abffb..d04cb8b 100644 --- a/ui/src/lib/api/cache.ts +++ b/ui/src/lib/api/cache.ts @@ -1,12 +1,17 @@ -import { apiPost } from './client'; +import { clearCache as clearPropertiesCache } from './properties'; +import { clearCache as clearMeterGroupsCache } from './meter-groups'; +import { clearCache as clearTenantsCache } from './tenants'; +import { clearCache as clearReadingsCache } from './readings'; +import { clearCache as clearBillingsCache } from './billings'; +import { clearCache as clearBillingCyclesCache } from './billing-cycles'; export async function clearAllCaches(): Promise { await Promise.all([ - apiPost<{ message: string }>('/properties/cache/clear', {}), - apiPost<{ message: string }>('/meter-groups/cache/clear', {}), - apiPost<{ message: string }>('/tenants/cache/clear', {}), - apiPost<{ message: string }>('/readings/cache/clear', {}), - apiPost<{ message: string }>('/billings/cache/clear', {}), - apiPost<{ message: string }>('/billing-cycles/cache/clear', {}) + clearPropertiesCache(), + clearMeterGroupsCache(), + clearTenantsCache(), + clearReadingsCache(), + clearBillingsCache(), + clearBillingCyclesCache() ]); } diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 4ee06ec..201f6ed 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -41,16 +41,29 @@ export async function apiRequest(path: string, options: RequestOptions = {}): const url = `${API_BASE_URL}${path}`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 20_000); - - let response: Response; - try { - response = await fetch(url, { ...fetchOptions, headers, signal: controller.signal }); - } finally { - clearTimeout(timeoutId); + // Shared by the initial request and the 401-retry below so timeout/abort/error-shape + // behavior can't silently drift between the two call sites. + async function doFetch(requestHeaders: Headers): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20_000); + try { + return await fetch(url, { + ...fetchOptions, + headers: requestHeaders, + signal: controller.signal + }); + } catch { + throw { + status: 0, + message: 'Could not reach the server. Check your connection and that the API is running.' + } satisfies ApiError; + } finally { + clearTimeout(timeoutId); + } } + let response = await doFetch(headers); + // Handle 401 by force-refreshing token and retrying once if (response.status === 401 && !skipAuth) { const refreshed = await refreshAccessToken(); @@ -59,13 +72,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): if (token) { headers.set('Authorization', `Bearer ${token}`); } - const retryController = new AbortController(); - const retryTimeoutId = setTimeout(() => retryController.abort(), 20_000); - try { - response = await fetch(url, { ...fetchOptions, headers, signal: retryController.signal }); - } finally { - clearTimeout(retryTimeoutId); - } + response = await doFetch(headers); } } diff --git a/ui/src/lib/api/crud-api-factory.ts b/ui/src/lib/api/crud-api-factory.ts new file mode 100644 index 0000000..3de2c5d --- /dev/null +++ b/ui/src/lib/api/crud-api-factory.ts @@ -0,0 +1,50 @@ +import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import type { PaginatedResult } from '$lib/types/api.types'; + +/** + * Shared CRUD shape behind the feature API modules (meter-groups, properties, tenants, + * readings, billings, billing-cycles) — each previously hand-wrote the same + * get/getById/create/createBatch/update/updateBatch/softDelete/restore/clearCache set against + * apiGet/apiPost/apiPatch/apiDelete + toQueryString. Feature-specific extras (OCR endpoints, + * meter-group reset, batch-get-by-ids) stay as additional exports in each module, not here. + * + * `BatchCreateResp` defaults to `T[]` (the common case) — pass e.g. `BatchCreateResult` for + * modules whose batch-create endpoint reports partial failures instead. + */ +export function createCrudApi< + T, + CreateReq, + UpdateReq, + Params extends object = Record, + BatchCreateResp = T[] +>(basePath: string) { + return { + async get(params?: Params): Promise> { + return apiGet>(`${basePath}${toQueryString(params)}`); + }, + async getById(id: string): Promise { + return apiGet(`${basePath}/${id}`); + }, + async create(data: CreateReq): Promise { + return apiPost(basePath, data); + }, + async createBatch(data: CreateReq[]): Promise { + return apiPost(`${basePath}/batch`, data); + }, + async update(id: string, data: UpdateReq): Promise { + return apiPatch(`${basePath}/${id}`, data); + }, + async updateBatch(data: { id: string; data: UpdateReq }[]): Promise { + return apiPatch(`${basePath}/batch`, data); + }, + async softDelete(id: string): Promise { + return apiDelete(`${basePath}/${id}`); + }, + async restore(id: string): Promise { + return apiPatch(`${basePath}/${id}/restore`, {}); + }, + async clearCache(): Promise<{ message: string }> { + return apiPost<{ message: string }>(`${basePath}/cache/clear`, {}); + } + }; +} diff --git a/ui/src/lib/api/meter-groups.ts b/ui/src/lib/api/meter-groups.ts index c51c9ba..c813a09 100644 --- a/ui/src/lib/api/meter-groups.ts +++ b/ui/src/lib/api/meter-groups.ts @@ -1,61 +1,37 @@ -import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import { apiPost } from './client'; +import { createCrudApi } from './crud-api-factory'; import type { MeterGroup, CreateMeterGroupRequest, UpdateMeterGroupRequest } from '$lib/types/meter-group.types'; -import type { PaginatedResult } from '$lib/types/api.types'; -export async function getMeterGroups(params?: { +interface GetMeterGroupsParams { meterName?: string; utilityType?: string; limit?: number; cursor?: string; minimal?: boolean; archived?: boolean; -}): Promise> { - return apiGet>(`/meter-groups${toQueryString(params)}`); } -export async function getMeterGroupById(id: string): Promise { - return apiGet(`/meter-groups/${id}`); -} - -export async function createMeterGroup(data: CreateMeterGroupRequest): Promise { - return apiPost('/meter-groups', data); -} - -export async function createMeterGroupsBatch( - data: CreateMeterGroupRequest[] -): Promise { - return apiPost('/meter-groups/batch', data); -} - -export async function updateMeterGroup( - id: string, - data: UpdateMeterGroupRequest -): Promise { - return apiPatch(`/meter-groups/${id}`, data); -} - -export async function updateMeterGroupsBatch( - data: { id: string; data: UpdateMeterGroupRequest }[] -): Promise { - return apiPatch('/meter-groups/batch', data); -} - -export async function softDeleteMeterGroup(id: string): Promise { - return apiDelete(`/meter-groups/${id}`); -} - -export async function restoreMeterGroup(id: string): Promise { - return apiPatch(`/meter-groups/${id}/restore`, {}); -} +const meterGroupsApi = createCrudApi< + MeterGroup, + CreateMeterGroupRequest, + UpdateMeterGroupRequest, + GetMeterGroupsParams +>('/meter-groups'); + +export const getMeterGroups = meterGroupsApi.get; +export const getMeterGroupById = meterGroupsApi.getById; +export const createMeterGroup = meterGroupsApi.create; +export const createMeterGroupsBatch = meterGroupsApi.createBatch; +export const updateMeterGroup = meterGroupsApi.update; +export const updateMeterGroupsBatch = meterGroupsApi.updateBatch; +export const softDeleteMeterGroup = meterGroupsApi.softDelete; +export const restoreMeterGroup = meterGroupsApi.restore; +export const clearCache = meterGroupsApi.clearCache; export async function recordMeterGroupReset(id: string): Promise { return apiPost(`/meter-groups/${id}/reset`, {}); } - -export async function clearCache(): Promise<{ message: string }> { - return apiPost<{ message: string }>('/meter-groups/cache/clear', {}); -} diff --git a/ui/src/lib/api/properties.ts b/ui/src/lib/api/properties.ts index 8103d3b..190ccbd 100644 --- a/ui/src/lib/api/properties.ts +++ b/ui/src/lib/api/properties.ts @@ -1,50 +1,35 @@ -import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import { apiPost } from './client'; +import { createCrudApi } from './crud-api-factory'; import type { Property, CreatePropertyRequest, UpdatePropertyRequest } from '$lib/types/property.types'; -import type { PaginatedResult } from '$lib/types/api.types'; -export async function getProperties(params?: { +interface GetPropertiesParams { roomName?: string; meterGroupId?: string; limit?: number; cursor?: string; archived?: boolean; -}): Promise> { - return apiGet>(`/properties${toQueryString(params)}`); } -export async function getPropertyById(id: string): Promise { - return apiGet(`/properties/${id}`); -} - -export async function createProperty(data: CreatePropertyRequest): Promise { - return apiPost('/properties', data); -} - -export async function createPropertiesBatch(data: CreatePropertyRequest[]): Promise { - return apiPost('/properties/batch', data); -} - -export async function updateProperty(id: string, data: UpdatePropertyRequest): Promise { - return apiPatch(`/properties/${id}`, data); -} - -export async function updatePropertiesBatch( - data: { id: string; data: UpdatePropertyRequest }[] -): Promise { - return apiPatch('/properties/batch', data); -} - -export async function softDeleteProperty(id: string): Promise { - return apiDelete(`/properties/${id}`); -} - -export async function restoreProperty(id: string): Promise { - return apiPatch(`/properties/${id}/restore`, {}); -} +const propertiesApi = createCrudApi< + Property, + CreatePropertyRequest, + UpdatePropertyRequest, + GetPropertiesParams +>('/properties'); + +export const getProperties = propertiesApi.get; +export const getPropertyById = propertiesApi.getById; +export const createProperty = propertiesApi.create; +export const createPropertiesBatch = propertiesApi.createBatch; +export const updateProperty = propertiesApi.update; +export const updatePropertiesBatch = propertiesApi.updateBatch; +export const softDeleteProperty = propertiesApi.softDelete; +export const restoreProperty = propertiesApi.restore; +export const clearCache = propertiesApi.clearCache; export async function recordPropertyMeterGroupReset( propertyId: string, @@ -52,7 +37,3 @@ export async function recordPropertyMeterGroupReset( ): Promise { return apiPost(`/properties/${propertyId}/meter-groups/${meterGroupId}/reset`, {}); } - -export async function clearCache(): Promise<{ message: string }> { - return apiPost<{ message: string }>('/properties/cache/clear', {}); -} diff --git a/ui/src/lib/api/readings.ts b/ui/src/lib/api/readings.ts index 09cb8f9..d4aa7b3 100644 --- a/ui/src/lib/api/readings.ts +++ b/ui/src/lib/api/readings.ts @@ -1,13 +1,14 @@ -import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import { apiGet, apiPost } from './client'; +import { createCrudApi } from './crud-api-factory'; import type { Reading, CreateReadingRequest, CreateSeedReadingRequest, UpdateReadingRequest } from '$lib/types/reading.types'; -import type { PaginatedResult, BatchCreateResult } from '$lib/types/api.types'; +import type { BatchCreateResult } from '$lib/types/api.types'; -export async function getReadings(params?: { +interface GetReadingsParams { meterGroupId?: string; propertyId?: string; startDate?: string; @@ -15,50 +16,34 @@ export async function getReadings(params?: { limit?: number; cursor?: string; archived?: boolean; -}): Promise> { - return apiGet>(`/readings${toQueryString(params)}`); } -export async function getReadingById(id: string): Promise { - return apiGet(`/readings/${id}`); -} +const readingsApi = createCrudApi< + Reading, + CreateReadingRequest, + UpdateReadingRequest, + GetReadingsParams, + BatchCreateResult +>('/readings'); + +export const getReadings = readingsApi.get; +export const getReadingById = readingsApi.getById; +export const createReading = readingsApi.create; +export const createReadingsBatch = readingsApi.createBatch; +export const updateReading = readingsApi.update; +export const updateReadingsBatch = readingsApi.updateBatch; +export const softDeleteReading = readingsApi.softDelete; +export const restoreReading = readingsApi.restore; +export const clearCache = readingsApi.clearCache; export async function getReadingsByIds(ids: string[]): Promise { return apiGet(`/readings/batch-get?ids=${ids.map(encodeURIComponent).join(',')}`); } -export async function createReading(data: CreateReadingRequest): Promise { - return apiPost('/readings', data); -} - -export async function createReadingsBatch( - data: CreateReadingRequest[] -): Promise> { - return apiPost>('/readings/batch', data); -} - export async function createSeedReading(data: CreateSeedReadingRequest): Promise { return apiPost('/readings/seed', data); } -export async function updateReading(id: string, data: UpdateReadingRequest): Promise { - return apiPatch(`/readings/${id}`, data); -} - -export async function updateReadingsBatch( - data: { id: string; data: UpdateReadingRequest }[] -): Promise { - return apiPatch('/readings/batch', data); -} - -export async function softDeleteReading(id: string): Promise { - return apiDelete(`/readings/${id}`); -} - -export async function restoreReading(id: string): Promise { - return apiPatch(`/readings/${id}/restore`, {}); -} - export async function ocrReadingImage( imageUrl: string ): Promise<{ suggested_reading_amount: number | null }> { @@ -66,7 +51,3 @@ export async function ocrReadingImage( image_url: imageUrl }); } - -export async function clearCache(): Promise<{ message: string }> { - return apiPost<{ message: string }>('/readings/cache/clear', {}); -} diff --git a/ui/src/lib/api/tenants.ts b/ui/src/lib/api/tenants.ts index eccd2fe..b350680 100644 --- a/ui/src/lib/api/tenants.ts +++ b/ui/src/lib/api/tenants.ts @@ -1,47 +1,27 @@ -import { apiGet, apiPost, apiPatch, apiDelete, toQueryString } from './client'; +import { createCrudApi } from './crud-api-factory'; import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '$lib/types/tenant.types'; -import type { PaginatedResult } from '$lib/types/api.types'; -export async function getTenants(params?: { +interface GetTenantsParams { tenantName?: string; propertyId?: string; limit?: number; cursor?: string; archived?: boolean; -}): Promise> { - return apiGet>(`/tenants${toQueryString(params)}`); } -export async function getTenantById(id: string): Promise { - return apiGet(`/tenants/${id}`); -} - -export async function createTenant(data: CreateTenantRequest): Promise { - return apiPost('/tenants', data); -} - -export async function createTenantsBatch(data: CreateTenantRequest[]): Promise { - return apiPost('/tenants/batch', data); -} - -export async function updateTenant(id: string, data: UpdateTenantRequest): Promise { - return apiPatch(`/tenants/${id}`, data); -} - -export async function updateTenantsBatch( - data: { id: string; data: UpdateTenantRequest }[] -): Promise { - return apiPatch('/tenants/batch', data); -} - -export async function softDeleteTenant(id: string): Promise { - return apiDelete(`/tenants/${id}`); -} - -export async function restoreTenant(id: string): Promise { - return apiPatch(`/tenants/${id}/restore`, {}); -} - -export async function clearCache(): Promise<{ message: string }> { - return apiPost<{ message: string }>('/tenants/cache/clear', {}); -} +const tenantsApi = createCrudApi< + Tenant, + CreateTenantRequest, + UpdateTenantRequest, + GetTenantsParams +>('/tenants'); + +export const getTenants = tenantsApi.get; +export const getTenantById = tenantsApi.getById; +export const createTenant = tenantsApi.create; +export const createTenantsBatch = tenantsApi.createBatch; +export const updateTenant = tenantsApi.update; +export const updateTenantsBatch = tenantsApi.updateBatch; +export const softDeleteTenant = tenantsApi.softDelete; +export const restoreTenant = tenantsApi.restore; +export const clearCache = tenantsApi.clearCache; diff --git a/ui/src/lib/components/layout/RightPanel.svelte b/ui/src/lib/components/layout/RightPanel.svelte index 6d43655..2b22091 100644 --- a/ui/src/lib/components/layout/RightPanel.svelte +++ b/ui/src/lib/components/layout/RightPanel.svelte @@ -24,7 +24,7 @@ diff --git a/ui/src/routes/(app)/readings/+page.svelte b/ui/src/routes/(app)/readings/+page.svelte index 3878ffb..e1e0a23 100644 --- a/ui/src/routes/(app)/readings/+page.svelte +++ b/ui/src/routes/(app)/readings/+page.svelte @@ -17,7 +17,7 @@ import type { Property } from '$lib/types/property.types'; import type { PaginatedResult } from '$lib/types/api.types'; import { formatFirestoreDate, formatLongDate, formatReading } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { toDate, toTimestamp } from '$lib/utils/timestamp'; import { compressImage } from '$lib/utils/image-compression'; import { resolveCurrentVersion, getVersionsSource } from '$lib/utils/true-reading'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; @@ -28,6 +28,8 @@ import ImagePreview from '$lib/components/shared/ImagePreview.svelte'; import PhotoDropzone from '$lib/components/shared/PhotoDropzone.svelte'; import { createCrudStore } from '$lib/stores/crud.svelte'; + import { confirmAsync } from '$lib/stores/confirm.svelte'; + import { pushToast } from '$lib/stores/toast.svelte'; import { Archive, Plus, X } from 'lucide-svelte'; const crud = createCrudStore(); @@ -57,7 +59,13 @@ let meterGroups = $state([]); let properties = $state([]); let isLoading = $state(false); + // Scoped per operation instead of one shared string — a background batch/manual-form + // failure can no longer overwrite/mask an unrelated table-filter error (finding #26). let error = $state(''); + let batchFormError = $state(''); + let manualFormError = $state(''); + // Filter-only — the batch form's meter group select uses its own batchMeterGroup state + // below so picking one no longer silently desyncs the other (finding #19). let selectedMeterGroup = $state(''); let selectedProperty = $state(''); let filterStartDate = $state(''); @@ -66,6 +74,9 @@ let readingFormTab = $state<'batch' | 'manual'>('batch'); let manualReadingLoading = $state(false); let manualImageUploading = $state(false); + // Bumped on every manual-form reset so an in-flight OCR suggest from a discarded form + // can't land on the next form instance (finding #7). + let manualFormGeneration = $state(0); let manualReadingForm = $state({ meter_group_id: '', property_id: '', @@ -88,16 +99,24 @@ }); // Batch reading form + let batchMeterGroup = $state(''); let batchDate = $state(new Date().toISOString().split('T')[0]); let batchRows = $state([]); let batchLoading = $state(false); + let batchEmptyReason = $state('No properties found for this meter group'); + + // Parses a `YYYY-MM-DD` value as a local-midnight Date, avoiding the + // UTC-parse/local-display off-by-one `new Date(dateString)` causes. + function parseLocalDateInput(dateString: string): Date { + const [y, m, d] = dateString.split('-').map(Number); + return new Date(y, m - 1, d); + } // "Month day, Year" preview of the batch date, parsed as a local date to avoid // the UTC-midnight/local-timezone off-by-one shift new Date(batchDate) would cause. const batchDateDisplay = $derived.by(() => { if (!batchDate) return ''; - const [y, m, d] = batchDate.split('-').map(Number); - return formatLongDate(new Date(y, m - 1, d)); + return formatLongDate(parseLocalDateInput(batchDate)); }); // Image preview @@ -173,42 +192,69 @@ } } - async function handleMeterGroupChange() { - // Load batch properties for the selected meter group - if (selectedMeterGroup) { + async function handleFilterChange() { + await applyFilters(); + } + + // Separate from the table filter's meter group select (finding #19) — changing this one + // only loads the batch form's property rows, it never touches the filtered table. + async function handleBatchMeterGroupChange() { + if (batchMeterGroup) { await loadBatchProperties(); } else { batchRows = []; } - // Apply filter to readings - await applyFilters(); - } - - async function handleFilterChange() { - await applyFilters(); } function resetReadingForm() { + batchMeterGroup = ''; batchRows = []; batchDate = new Date().toISOString().split('T')[0]; + batchFormError = ''; resetManualReadingForm(); } + function hasUnsavedBatchData() { + return batchRows.some((row) => row.reading_amount !== null || row.image_url); + } + + function hasUnsavedManualData() { + return manualReadingForm.reading_amount !== null || manualReadingForm.image_url !== ''; + } + + async function switchReadingFormTab(tab: 'batch' | 'manual') { + if (tab === readingFormTab) return; + const hasUnsaved = readingFormTab === 'batch' ? hasUnsavedBatchData() : hasUnsavedManualData(); + if (hasUnsaved) { + const confirmed = await confirmAsync( + 'Discard entered readings?', + 'Switching tabs will discard the readings entered here — continue?', + { danger: true, confirmLabel: 'Discard' } + ); + if (!confirmed) return; + } + readingFormTab = tab; + resetReadingForm(); + } + async function loadBatchProperties() { - if (!selectedMeterGroup) { - error = 'Please select a meter group first'; + if (!batchMeterGroup) { + batchFormError = 'Please select a meter group first'; return; } batchLoading = true; - error = ''; + batchFormError = ''; try { - const result = await getProperties({ limit: 100, meterGroupId: selectedMeterGroup }); - const selectedMeter = meterGroups.find((m) => m.id === selectedMeterGroup); + const result = await getProperties({ limit: 100, meterGroupId: batchMeterGroup }); + const selectedMeter = meterGroups.find((m) => m.id === batchMeterGroup); const utilityType = selectedMeter?.utility_type || 'electricity'; if (result.data.length === 0) { - error = 'No properties found for this meter group'; + // No error banner here — the "No properties" EmptyState below already + // communicates this; a red banner on top of it would be redundant and + // wrongly implies a failure rather than an empty selection. + batchEmptyReason = 'No properties found for this meter group'; batchRows = []; } else { const filteredProperties = result.data.filter((property) => { @@ -222,12 +268,13 @@ }); if (filteredProperties.length === 0) { - error = 'No submeter properties found for this meter group (all are main meters)'; + batchEmptyReason = + 'No submeter properties found for this meter group (all are main meters)'; batchRows = []; } else { batchRows = filteredProperties.map((property) => ({ property, - meter_group_id: selectedMeterGroup, + meter_group_id: batchMeterGroup, reading_amount: null, image_url: null, data_url: null, @@ -236,7 +283,7 @@ } } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load properties'; + batchFormError = err instanceof Error ? err.message : 'Failed to load properties'; batchRows = []; } finally { batchLoading = false; @@ -244,6 +291,8 @@ } function resetManualReadingForm() { + manualFormGeneration++; + manualFormError = ''; manualReadingForm = { meter_group_id: '', property_id: '', @@ -263,8 +312,16 @@ const meterGroup = meterGroups.find((g) => g.id === meterGroupId); const currentVersion = meterGroup?.current_version ?? 1; - const existing = await getReadings({ meterGroupId, propertyId, limit: 100 }); - return !existing.data.some((r) => r.meter_version === currentVersion); + // Paginate through every historical reading for this property/meter-group pair — a + // single capped page could silently miss the current-version reading once history + // exceeds 100 rows, mis-categorizing a create as a seed (finding #25). + let cursor: string | undefined; + do { + const page = await getReadings({ meterGroupId, propertyId, limit: 100, cursor }); + if (page.data.some((r) => r.meter_version === currentVersion)) return false; + cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined; + } while (cursor); + return true; } async function handleCreateManualReading() { @@ -273,21 +330,24 @@ !manualReadingForm.property_id || manualReadingForm.reading_amount === null ) { - error = 'Please complete all required fields for the manual reading'; + manualFormError = 'Please complete all required fields for the manual reading'; + return; + } + // A still-resolving OCR suggest could otherwise write into a freshly-reset form after + // this submit completes (finding #7) — block submit until it settles. + if (manualImageUploading) { + manualFormError = 'Please wait for the photo suggestion to finish'; return; } manualReadingLoading = true; - error = ''; + manualFormError = ''; try { const payload = { meter_group_id: manualReadingForm.meter_group_id, property_id: manualReadingForm.property_id, reading_amount: manualReadingForm.reading_amount, - reading_date: { - _seconds: Math.floor(new Date(manualReadingForm.reading_date).getTime() / 1000), - _nanoseconds: 0 - } + reading_date: toTimestamp(parseLocalDateInput(manualReadingForm.reading_date)) } as any; const isSeed = await shouldSeedReading( @@ -303,71 +363,104 @@ readingFormOpen = false; resetManualReadingForm(); await loadData(); - alert( + pushToast( isSeed ? 'Seed reading created successfully — this establishes the baseline for this meter version.' - : 'Manual reading created successfully. If this property has a previous-month reading, the billing was auto-created.' + : 'Manual reading created successfully. If this property has a previous-month reading, the billing was auto-created.', + 'success' ); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create manual reading'; + manualFormError = err instanceof Error ? err.message : 'Failed to create manual reading'; } finally { manualReadingLoading = false; } } + type CompressAndSuggestResult = + { ok: true; imageUrl: string; amount: number | null } | { ok: false; message: string }; + + // Shared by the batch and manual tabs: compress → auto-suggest via OCR — no separate + // Suggest button on either. `onCompressed` fires as soon as the compressed image is ready + // (before the OCR await) so a busy indicator can clear at the same point it always did. + // Extracting this one helper closes the race in finding #8 (only the batch copy guarded + // against a stale row) and, combined with the caller-side generation/identity checks + // below, finding #7. + async function compressAndSuggest( + file: File, + onCompressed?: (imageUrl: string) => void + ): Promise { + let imageUrl: string; + try { + // Compress image to avoid "request entity too large" errors. Photo is only ever + // used transiently for OCR suggest — never persisted. + imageUrl = await compressImage(file, 800, 0.7); + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : 'Failed to process image' }; + } + onCompressed?.(imageUrl); + try { + const result = await ocrReadingImage(imageUrl); + return { ok: true, imageUrl, amount: result.suggested_reading_amount }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : 'Failed to suggest reading' + }; + } + } + async function handleBatchImageUpload(rowIndex: number, file: File | null) { if (!file) return; + // Stable identity across the async gap, not the array index — batchRows can be + // reassigned (meter group change) while this is in flight (finding #8). + const propertyId = batchRows[rowIndex].property.id; + batchRows[rowIndex].is_uploading = true; + + const result = await compressAndSuggest(file, (imageUrl) => { + const current = batchRows[rowIndex]; + if (current && current.property.id === propertyId) { + current.data_url = imageUrl; + current.image_url = imageUrl; + current.is_uploading = false; + } + }); const row = batchRows[rowIndex]; - - row.is_uploading = true; - try { - // Compress image to avoid "request entity too large" errors - const compressedDataUrl = await compressImage(file, 800, 0.7); - - // Photo is only ever used transiently for OCR suggest — never persisted. - row.data_url = compressedDataUrl; - row.image_url = compressedDataUrl; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to process image'; - row.is_uploading = false; + if (!row || row.property.id !== propertyId) return; // stale — row moved on, discard + row.is_uploading = false; + if (!result.ok) { + batchFormError = result.message; return; } - row.is_uploading = false; - - // Auto-suggest a reading value from the photo — no separate Suggest button. - await handleSuggestReading(rowIndex); + if (result.amount !== null) row.reading_amount = result.amount; } async function handleManualImageUpload(file: File | null) { if (!file) return; - + // Captured before the async gap — a reset (tab switch, successful submit) bumps this, + // so a stale suggestion can no longer write into the next form instance (finding #7). + const generation = manualFormGeneration; manualImageUploading = true; - try { - // Compress image to avoid "request entity too large" errors - const compressedDataUrl = await compressImage(file, 800, 0.7); - manualReadingForm.image_url = compressedDataUrl; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to process image'; - manualImageUploading = false; - return; - } - manualImageUploading = false; - // Auto-suggest a reading value from the photo — no separate Suggest button. - try { - const result = await ocrReadingImage(manualReadingForm.image_url); - if (result.suggested_reading_amount !== null) { - manualReadingForm.reading_amount = result.suggested_reading_amount; + const result = await compressAndSuggest(file, (imageUrl) => { + if (generation === manualFormGeneration) { + manualReadingForm.image_url = imageUrl; + manualImageUploading = false; } - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to suggest reading'; + }); + + if (generation !== manualFormGeneration) return; // stale — form was reset, discard + manualImageUploading = false; + if (!result.ok) { + manualFormError = result.message; + return; } + if (result.amount !== null) manualReadingForm.reading_amount = result.amount; } async function handleCreateBatch() { - if (!selectedMeterGroup || !batchDate) { - error = 'Please select a meter group and reading date'; + if (!batchMeterGroup || !batchDate) { + batchFormError = 'Please select a meter group and reading date'; return; } @@ -375,45 +468,45 @@ (r) => r.reading_amount === null || r.reading_amount === undefined ); if (invalidRows.length > 0) { - error = `Please enter reading amounts for all properties (${invalidRows.length} missing)`; + batchFormError = `Please enter reading amounts for all properties (${invalidRows.length} missing)`; return; } batchLoading = true; - error = ''; + batchFormError = ''; try { - const dateObj = new Date(batchDate); + const readingDate = toTimestamp(parseLocalDateInput(batchDate)); const readingsData = batchRows.map((row) => ({ meter_group_id: row.meter_group_id, property_id: row.property.id, reading_amount: row.reading_amount!, - reading_date: { - _seconds: Math.floor(dateObj.getTime() / 1000), - _nanoseconds: 0 - } + reading_date: readingDate })); const result = await createReadingsBatch(readingsData); readingFormOpen = false; batchRows = []; + batchMeterGroup = ''; batchDate = new Date().toISOString().split('T')[0]; - await handleMeterGroupChange(); + await applyFilters(); if (result.failed.length > 0) { - const failedSummary = result.failed.map((f) => `Row ${f.index + 1}: ${f.error}`).join('\n'); - alert( + const failedSummary = result.failed.map((f) => `Row ${f.index + 1}: ${f.error}`).join('; '); + pushToast( `${result.created.length} of ${result.created.length + result.failed.length} readings created. ` + - `${result.failed.length} skipped:\n${failedSummary}` + `${result.failed.length} skipped — ${failedSummary}`, + 'warning' ); } else { - alert( - 'Readings created successfully! If a previous-month reading exists for this meter group, billings have been auto-created for each property.' + pushToast( + 'Readings created successfully! If a previous-month reading exists for this meter group, billings have been auto-created for each property.', + 'success' ); } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create readings'; + batchFormError = err instanceof Error ? err.message : 'Failed to create readings'; } finally { batchLoading = false; } @@ -425,7 +518,7 @@ try { await updateReading(crud.editingItem.id, crud.editFormData as UpdateReadingRequest); crud.closeEditModal(); - await handleMeterGroupChange(); + await applyFilters(); } catch (err) { error = err instanceof Error ? err.message : 'Failed to update reading'; } finally { @@ -439,29 +532,12 @@ function canBatchSubmit(): boolean { return ( - selectedMeterGroup.length > 0 && + batchMeterGroup.length > 0 && batchDate.length > 0 && batchRows.length > 0 && batchRows.every((r) => r.reading_amount !== null && r.reading_amount !== undefined) ); } - - async function handleSuggestReading(rowIndex: number) { - const row = batchRows[rowIndex]; - if (!row.image_url) { - error = 'Please upload an image first'; - return; - } - - try { - const result = await ocrReadingImage(row.image_url); - if (result.suggested_reading_amount !== null) { - row.reading_amount = result.suggested_reading_amount; - } - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to suggest reading'; - } - }
@@ -509,10 +585,7 @@
{#if readingFormTab === 'batch'} + {#if batchFormError} +
+ {batchFormError} +
+ {/if}
@@ -859,6 +945,7 @@ 0} onchange={() => crud.toggleSelectAll( @@ -868,13 +955,13 @@ class="rounded" /> - Property - Meter Group + Property + Meter Group Reading Meter Cycle - Date - Created - Actions + Date + Created + Actions @@ -893,6 +980,7 @@ crud.toggleSelection(item.id)} class="rounded" @@ -929,8 +1017,12 @@ } as any); }} onSoftDelete={() => - crud.handleSoftDelete(item.id, softDeleteReading, handleMeterGroupChange, () => - confirm('Archive this reading? It can be restored from the archive.') + crud.handleSoftDelete(item.id, softDeleteReading, applyFilters, () => + confirmAsync( + 'Archive reading', + 'Archive this reading? It can be restored from the archive.', + { danger: true } + ) )} isLoading={crud.deletingId === item.id} /> diff --git a/ui/src/routes/(app)/settings/+page.svelte b/ui/src/routes/(app)/settings/+page.svelte index b7d9f35..e026ac0 100644 --- a/ui/src/routes/(app)/settings/+page.svelte +++ b/ui/src/routes/(app)/settings/+page.svelte @@ -1,5 +1,8 @@