From 86f863a84527634c934e484a0093144920daeae3 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:04:31 +0800 Subject: [PATCH 01/37] fix(api): eliminate SSRF in OCR image fetch by accepting base64 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image-fetch.util.ts and the shared ImageUrlSchema previously allowed server-side fetches of client-supplied http/https image URLs, guarded only by a hostname-string blocklist with no DNS resolution — any attacker-registered domain resolving to an internal/metadata IP (or a handful of unblocked IPv6 ranges/0.0.0.0) reached internal infrastructure from the Cloud Functions runtime. Remove the vulnerable branch entirely instead of patching the blocklist: every OCR endpoint (readings, billing-cycles, bills, image-extraction) now only accepts inline data:image/*;base64,... payloads, so the server never issues an outbound request to a client-supplied host. Readings no longer have an image_url field at all — meter photos are used transiently for OCR suggest only, never persisted, which also removes the now-pointless photo-settings feature (per-user savePhotos toggle) and its Firestore collection entirely. Co-Authored-By: Claude Sonnet 5 --- api/functions/src/config/swagger.config.ts | 40 +----------- .../src/constants/collection.constants.ts | 1 - .../billing-cycle/billing-cycle.swagger.ts | 2 +- .../src/features/bills/bills.swagger.ts | 5 +- .../image-extraction.swagger.ts | 6 +- .../photo-settings.controller.ts | 20 ------ .../photo-settings/photo-settings.dto.ts | 14 ----- .../photo-settings/photo-settings.model.ts | 5 -- .../photo-settings.repository.ts | 19 ------ .../photo-settings/photo-settings.route.ts | 16 ----- .../photo-settings/photo-settings.service.ts | 29 --------- .../photo-settings/photo-settings.swagger.ts | 54 ---------------- .../src/features/reading/reading.dto.ts | 4 +- .../src/features/reading/reading.model.ts | 1 - api/functions/src/index.ts | 2 - api/functions/src/lib/image-fetch.util.ts | 63 +++---------------- .../src/utils/image-url.util.test.ts | 40 +++--------- api/functions/src/utils/image-url.util.ts | 58 +++-------------- 18 files changed, 37 insertions(+), 342 deletions(-) delete mode 100644 api/functions/src/features/photo-settings/photo-settings.controller.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.dto.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.model.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.repository.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.route.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.service.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.swagger.ts diff --git a/api/functions/src/config/swagger.config.ts b/api/functions/src/config/swagger.config.ts index 5ac5e20..c7b74c0 100644 --- a/api/functions/src/config/swagger.config.ts +++ b/api/functions/src/config/swagger.config.ts @@ -12,7 +12,6 @@ import {billsPaths} from "../features/bills/bills.swagger"; import {paths as imageExtractionPaths} from "../features/image-extraction/image-extraction.swagger"; import {reportsPaths} from "../features/reports/reports.swagger"; import {llmConfigPaths} from "../features/llm-config/llm-config.swagger"; -import {photoSettingsPaths} from "../features/photo-settings/photo-settings.swagger"; import {chatbotPaths} from "../features/chatbot/chatbot.swagger"; const swaggerSpec = { @@ -471,11 +470,6 @@ const swaggerSpec = { reading_date: { $ref: "#/components/schemas/Timestamp", }, - image_url: { - type: "string", - format: "uri", - description: "Optional photo of the meter (requires Firebase Storage)", - }, meter_version: { type: "integer", minimum: 1, @@ -504,11 +498,6 @@ const swaggerSpec = { reading_date: { $ref: "#/components/schemas/Timestamp", }, - image_url: { - type: "string", - format: "uri", - description: "Optional photo URL (requires Firebase Storage to be configured)", - }, }, required: ["meter_group_id", "property_id", "reading_amount", "reading_date"], }, @@ -530,10 +519,6 @@ const swaggerSpec = { reading_date: { $ref: "#/components/schemas/Timestamp", }, - image_url: { - type: "string", - format: "uri", - }, }, }, PaginatedReadings: { @@ -935,9 +920,8 @@ const swaggerSpec = { properties: { image_url: { type: "string", - format: "uri", - description: "URL of the bill image to process", - example: "https://storage.googleapis.com/bucket/bills/bill.jpg", + description: "Base64 data URL of the bill image to process (data:image/*;base64,...)", + example: "data:image/jpeg;base64,/9j/4AAQSkZJRg...", }, }, }, @@ -1013,25 +997,6 @@ const swaggerSpec = { }, required: ["provider", "model"], }, - PhotoSettingsResponse: { - type: "object", - properties: { - savePhotos: { - type: "boolean", - description: "Whether meter-reading photos are persisted (image_url) on create. Defaults to false. Bill/billing-cycle photos are never persisted regardless.", - }, - }, - required: ["savePhotos"], - }, - UpsertPhotoSettingsRequest: { - type: "object", - properties: { - savePhotos: { - type: "boolean", - }, - }, - required: ["savePhotos"], - }, ChatHistoryMessage: { type: "object", properties: { @@ -1128,7 +1093,6 @@ const swaggerSpec = { ...reportsPaths, ...llmConfigPaths, ...chatbotPaths, - ...photoSettingsPaths, }, }; diff --git a/api/functions/src/constants/collection.constants.ts b/api/functions/src/constants/collection.constants.ts index 854fb22..08fc7c8 100644 --- a/api/functions/src/constants/collection.constants.ts +++ b/api/functions/src/constants/collection.constants.ts @@ -9,5 +9,4 @@ USERS: "users", READING_LOCKS: "reading_locks", LLM_CONFIG: "llm_config", - PHOTO_SETTINGS: "photo_settings", }; diff --git a/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts b/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts index c2e50da..7feac14 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts @@ -202,7 +202,7 @@ export const billingCyclePaths = { properties: { image_url: { type: "string", - description: "Data URL (data:image/...) or public HTTPS URL of the utility bill photo", + description: "Base64 data URL of the utility bill photo (data:image/*;base64,...)", }, }, }, diff --git a/api/functions/src/features/bills/bills.swagger.ts b/api/functions/src/features/bills/bills.swagger.ts index 288e408..31ee306 100644 --- a/api/functions/src/features/bills/bills.swagger.ts +++ b/api/functions/src/features/bills/bills.swagger.ts @@ -44,9 +44,8 @@ export const schemas = { properties: { image_url: { type: "string", - format: "uri", - description: "URL of the bill image to process", - example: "https://storage.googleapis.com/bucket/bills/bill.jpg", + description: "Base64 data URL of the bill image to process (data:image/*;base64,...)", + example: "data:image/jpeg;base64,/9j/4AAQSkZJRg...", }, }, }, diff --git a/api/functions/src/features/image-extraction/image-extraction.swagger.ts b/api/functions/src/features/image-extraction/image-extraction.swagger.ts index 3bf41dc..89f5f2d 100644 --- a/api/functions/src/features/image-extraction/image-extraction.swagger.ts +++ b/api/functions/src/features/image-extraction/image-extraction.swagger.ts @@ -13,8 +13,7 @@ export const paths = { properties: { image_url: { type: "string", - format: "uri", - description: "URL of the meter photo (data URL or HTTPS)", + description: "Base64 data URL of the meter photo (data:image/*;base64,...)", }, }, required: ["image_url"], @@ -61,8 +60,7 @@ export const paths = { properties: { image_url: { type: "string", - format: "uri", - description: "URL of the utility bill photo (data URL or HTTPS)", + description: "Base64 data URL of the utility bill photo (data:image/*;base64,...)", }, }, required: ["image_url"], diff --git a/api/functions/src/features/photo-settings/photo-settings.controller.ts b/api/functions/src/features/photo-settings/photo-settings.controller.ts deleted file mode 100644 index 0f744e6..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type {AuthenticatedRequest} from "../../utils/auth.util"; -import {Response} from "express"; -import {photoSettingsService} from "./photo-settings.service"; -import {UpsertPhotoSettingsDTO} from "./photo-settings.dto"; -import {AppError} from "../../utils/error.util"; - -export const getPhotoSettings = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); - const result = await photoSettingsService.get(userId); - res.status(200).json(result); -}; - -export const upsertPhotoSettings = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); - const data = req.body as UpsertPhotoSettingsDTO; - const result = await photoSettingsService.upsert(userId, data); - res.status(200).json(result); -}; diff --git a/api/functions/src/features/photo-settings/photo-settings.dto.ts b/api/functions/src/features/photo-settings/photo-settings.dto.ts deleted file mode 100644 index e2957a8..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {z} from "zod"; - -export const UpsertPhotoSettingsDTOSchema = z.object({ - savePhotos: z.boolean(), -}); -export type UpsertPhotoSettingsDTO = z.infer; - -export interface PhotoSettingsResponseDTO { - // Whether to persist meter-reading photos (image_url) when creating readings. - // Defaults to false — OCR suggest still works either way, but the photo - // itself is discarded before submission unless explicitly enabled. Bill / - // billing-cycle photos are never persisted regardless of this setting. - savePhotos: boolean; -} diff --git a/api/functions/src/features/photo-settings/photo-settings.model.ts b/api/functions/src/features/photo-settings/photo-settings.model.ts deleted file mode 100644 index 3baa4d7..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {BaseModel} from "../../utils/model.util"; - -export interface PhotoSettings extends BaseModel { - save_photos: boolean; -} diff --git a/api/functions/src/features/photo-settings/photo-settings.repository.ts b/api/functions/src/features/photo-settings/photo-settings.repository.ts deleted file mode 100644 index 2eeb269..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.repository.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {PhotoSettings} from "./photo-settings.model"; -import {COLLECTIONS} from "../../constants/collection.constants"; -import {getDocument, setDocument, updateDocument} from "../../lib/firestore.lib"; -import {WithoutBaseModel} from "../../utils/model.util"; - -// One settings document per user, keyed by userId (same pattern as llm-config). -export const photoSettingsRepository = { - async getByUserId(userId: string): Promise { - return getDocument(COLLECTIONS.PHOTO_SETTINGS, userId); - }, - - async create(userId: string, data: WithoutBaseModel): Promise { - return setDocument(COLLECTIONS.PHOTO_SETTINGS, userId, data); - }, - - async update(userId: string, data: Partial>): Promise { - return updateDocument(COLLECTIONS.PHOTO_SETTINGS, userId, data); - }, -}; diff --git a/api/functions/src/features/photo-settings/photo-settings.route.ts b/api/functions/src/features/photo-settings/photo-settings.route.ts deleted file mode 100644 index 0fdc9ad..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {Router} from "express"; -import {getPhotoSettings, upsertPhotoSettings} from "./photo-settings.controller"; -import {UpsertPhotoSettingsDTOSchema} from "./photo-settings.dto"; -import {validateRequest} from "../../middlewares/validate-request.middleware"; - -const router = Router(); - -router.get("/", getPhotoSettings); - -router.patch( - "/", - validateRequest({body: UpsertPhotoSettingsDTOSchema}), - upsertPhotoSettings -); - -export const photoSettingsRouter = router; diff --git a/api/functions/src/features/photo-settings/photo-settings.service.ts b/api/functions/src/features/photo-settings/photo-settings.service.ts deleted file mode 100644 index 102bbe3..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {photoSettingsRepository} from "./photo-settings.repository"; -import {UpsertPhotoSettingsDTO, PhotoSettingsResponseDTO} from "./photo-settings.dto"; -import {PhotoSettings} from "./photo-settings.model"; - -const DEFAULT_SAVE_PHOTOS = false; - -function toResponseDTO(config: PhotoSettings): PhotoSettingsResponseDTO { - return {savePhotos: config.save_photos}; -} - -export const photoSettingsService = { - // Unlike llm-config, this always resolves — no config yet just means the - // (disabled-by-default) default applies, not a 404. - async get(userId: string): Promise { - const config = await photoSettingsRepository.getByUserId(userId); - return config ? toResponseDTO(config) : {savePhotos: DEFAULT_SAVE_PHOTOS}; - }, - - async upsert(userId: string, data: UpsertPhotoSettingsDTO): Promise { - const existing = await photoSettingsRepository.getByUserId(userId); - const payload = {save_photos: data.savePhotos}; - - const result = existing ? - await photoSettingsRepository.update(userId, payload) : - await photoSettingsRepository.create(userId, payload as Omit); - - return toResponseDTO(result); - }, -}; diff --git a/api/functions/src/features/photo-settings/photo-settings.swagger.ts b/api/functions/src/features/photo-settings/photo-settings.swagger.ts deleted file mode 100644 index cb23874..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.swagger.ts +++ /dev/null @@ -1,54 +0,0 @@ -export const photoSettingsPaths = { - "/photo-settings": { - get: { - tags: ["Photo Settings"], - summary: "Get the current user's photo-saving preference", - description: "Whether meter-reading photos should be persisted (image_url) when creating readings. Defaults to false (disabled) if never configured — OCR suggest still works, the photo is just discarded before submission. Bill / billing-cycle photos are never persisted regardless of this setting.", - security: [{BearerAuth: []}], - responses: { - "200": { - description: "Photo-saving preference", - content: { - "application/json": { - schema: {$ref: "#/components/schemas/PhotoSettingsResponse"}, - }, - }, - }, - "401": { - description: "Unauthorized", - content: {"application/json": {schema: {$ref: "#/components/schemas/ErrorResponse"}}}, - }, - }, - }, - patch: { - tags: ["Photo Settings"], - summary: "Set the current user's photo-saving preference", - security: [{BearerAuth: []}], - requestBody: { - content: { - "application/json": { - schema: {$ref: "#/components/schemas/UpsertPhotoSettingsRequest"}, - }, - }, - }, - responses: { - "200": { - description: "Updated photo-saving preference", - content: { - "application/json": { - schema: {$ref: "#/components/schemas/PhotoSettingsResponse"}, - }, - }, - }, - "400": { - description: "Validation error", - content: {"application/json": {schema: {$ref: "#/components/schemas/ValidationErrorResponse"}}}, - }, - "401": { - description: "Unauthorized", - content: {"application/json": {schema: {$ref: "#/components/schemas/ErrorResponse"}}}, - }, - }, - }, - }, -}; diff --git a/api/functions/src/features/reading/reading.dto.ts b/api/functions/src/features/reading/reading.dto.ts index f58d595..d55c0d7 100644 --- a/api/functions/src/features/reading/reading.dto.ts +++ b/api/functions/src/features/reading/reading.dto.ts @@ -1,5 +1,6 @@ import {z} from "zod"; import {parseTimestamp} from "../../utils/firestore.util"; +import {ImageUrlSchema} from "../../utils/image-url.util"; // Create DTOS export const CreateReadingDTOSchema = z.object({ @@ -16,7 +17,6 @@ export const CreateReadingDTOSchema = z.object({ {message: "reading_date must be a valid date"} ) .transform((val) => parseTimestamp(val)), - image_url: z.url().optional(), }); export type CreateReadingDTO = z.infer; @@ -25,7 +25,7 @@ export type CreateSeedReadingDTO = z.infer; // OCR DTOS export const OcrReadingDTOSchema = z.object({ - image_url: z.url(), + image_url: ImageUrlSchema, }); export type OcrReadingDTO = z.infer; diff --git a/api/functions/src/features/reading/reading.model.ts b/api/functions/src/features/reading/reading.model.ts index b211574..d6e07c8 100644 --- a/api/functions/src/features/reading/reading.model.ts +++ b/api/functions/src/features/reading/reading.model.ts @@ -6,6 +6,5 @@ export interface Reading extends BaseModel { property_id: string; reading_amount: number; reading_date: Timestamp; - image_url?: string; meter_version: number; } diff --git a/api/functions/src/index.ts b/api/functions/src/index.ts index ef14a91..031615c 100644 --- a/api/functions/src/index.ts +++ b/api/functions/src/index.ts @@ -28,7 +28,6 @@ import {imageExtractionRouter} from "./features/image-extraction/image-extractio import {reportsRouter} from "./features/reports/reports.route"; import {llmConfigRouter} from "./features/llm-config/llm-config.route"; import {chatbotRouter} from "./features/chatbot/chatbot.route"; -import {photoSettingsRouter} from "./features/photo-settings/photo-settings.route"; const app = express(); @@ -97,7 +96,6 @@ app.use("/image-extraction", imageExtractionRouter); app.use("/reports", reportsRouter); app.use("/llm-config", llmConfigRouter); app.use("/chatbot", chatbotRouter); -app.use("/photo-settings", photoSettingsRouter); // Error handling app.use(errorHandler); diff --git a/api/functions/src/lib/image-fetch.util.ts b/api/functions/src/lib/image-fetch.util.ts index 69de663..c7bbe46 100644 --- a/api/functions/src/lib/image-fetch.util.ts +++ b/api/functions/src/lib/image-fetch.util.ts @@ -10,68 +10,25 @@ const ALLOWED_IMAGE_MIME_TYPES = new Set([ "image/heif", ]); -function validateImageUrl(imageUrl: string): void { - let parsed: URL; - try { - parsed = new URL(imageUrl); - } catch { - throw new Error("Invalid image URL"); - } - - if (!["http:", "https:"].includes(parsed.protocol)) { - throw new Error("Invalid image URL: only http and https are allowed"); - } - - // Block RFC-1918, loopback, link-local, and GCP metadata service - const blockedPattern = - /^(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|127\.\d+\.\d+\.\d+|169\.254\.\d+\.\d+|::1|localhost|metadata\.google\.internal)/i; - - if (blockedPattern.test(parsed.hostname)) { - throw new Error("Invalid image URL: private or reserved addresses are not allowed"); - } -} - +// Images are only ever provided as inline `data:image/*;base64,...` payloads +// (enforced upstream by ImageUrlSchema) — the server never fetches a +// client-supplied URL, so there is no SSRF surface here to guard against. async function resolveImage(imageUrl: string): Promise<{ buffer: Buffer; mimeType: string }> { - // Handle data URLs (e.g., data:image/jpeg;base64,...) - if (imageUrl.startsWith("data:")) { - const [header, base64Data] = imageUrl.split(","); - if (!base64Data) throw new Error("Invalid data URL format"); - // Extract MIME type from "data:image/png;base64" prefix - const mimeType = header.split(":")[1]?.split(";")[0] ?? "image/jpeg"; - if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) { - throw new Error(`Unsupported image type: ${mimeType}`); - } - const buffer = Buffer.from(base64Data, "base64"); - if (buffer.byteLength > MAX_IMAGE_BYTES) { - throw new Error(`Image exceeds maximum allowed size of ${MAX_IMAGE_BYTES} bytes`); - } - return {buffer, mimeType}; + if (!imageUrl.startsWith("data:")) { + throw new Error("Invalid image URL: only data:image/*;base64,... URLs are allowed"); } - validateImageUrl(imageUrl); - - // Handle regular URLs - const response = await fetch(imageUrl); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`); - } - - const contentLength = response.headers.get("content-length"); - if (contentLength && Number(contentLength) > MAX_IMAGE_BYTES) { - throw new Error(`Image exceeds maximum allowed size of ${MAX_IMAGE_BYTES} bytes`); - } - - const contentType = response.headers.get("content-type") ?? "image/jpeg"; - const mimeType = contentType.split(";")[0].trim(); + const [header, base64Data] = imageUrl.split(","); + if (!base64Data) throw new Error("Invalid data URL format"); + // Extract MIME type from "data:image/png;base64" prefix + const mimeType = header.split(":")[1]?.split(";")[0] ?? "image/jpeg"; if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) { throw new Error(`Unsupported image type: ${mimeType}`); } - - const buffer = Buffer.from(await response.arrayBuffer()); + const buffer = Buffer.from(base64Data, "base64"); if (buffer.byteLength > MAX_IMAGE_BYTES) { throw new Error(`Image exceeds maximum allowed size of ${MAX_IMAGE_BYTES} bytes`); } - return {buffer, mimeType}; } diff --git a/api/functions/src/utils/image-url.util.test.ts b/api/functions/src/utils/image-url.util.test.ts index 00c571e..2752043 100644 --- a/api/functions/src/utils/image-url.util.test.ts +++ b/api/functions/src/utils/image-url.util.test.ts @@ -2,39 +2,21 @@ import { describe, it, expect } from '@jest/globals'; import { isSafeImageUrl, ImageUrlSchema } from './image-url.util'; describe('isSafeImageUrl', () => { - it('allows https URLs to public hosts', () => { - expect(isSafeImageUrl('https://storage.googleapis.com/bucket/photo.jpg')).toBe(true); - }); - it('allows data:image/* URLs', () => { expect(isSafeImageUrl('data:image/png;base64,iVBORw0KGgo=')).toBe(true); + expect(isSafeImageUrl('data:image/jpeg;base64,/9j/4AAQ')).toBe(true); }); - it('rejects http (non-https) URLs', () => { - expect(isSafeImageUrl('http://example.com/photo.jpg')).toBe(false); - }); - - it('rejects the cloud metadata endpoint', () => { + it('rejects https URLs (no server-side fetch is ever performed)', () => { + expect(isSafeImageUrl('https://storage.googleapis.com/bucket/photo.jpg')).toBe(false); expect(isSafeImageUrl('https://169.254.169.254/latest/meta-data/')).toBe(false); }); - it('rejects loopback hosts', () => { - expect(isSafeImageUrl('https://127.0.0.1/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://localhost/photo.jpg')).toBe(false); - }); - - it('rejects private RFC1918 ranges', () => { - expect(isSafeImageUrl('https://10.0.0.5/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://172.16.0.1/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://172.31.255.255/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://192.168.1.1/photo.jpg')).toBe(false); - }); - - it('allows a public host that merely resembles a private one lexically', () => { - expect(isSafeImageUrl('https://172.32.0.1/photo.jpg')).toBe(true); + it('rejects http URLs', () => { + expect(isSafeImageUrl('http://example.com/photo.jpg')).toBe(false); }); - it('rejects malformed URLs', () => { + it('rejects malformed or non-data strings', () => { expect(isSafeImageUrl('not-a-url')).toBe(false); }); @@ -46,17 +28,15 @@ describe('isSafeImageUrl', () => { }); describe('ImageUrlSchema', () => { - it('parses a valid https URL', () => { - expect(ImageUrlSchema.parse('https://example.com/photo.jpg')).toBe( - 'https://example.com/photo.jpg' - ); - }); - it('parses a valid data URL', () => { const dataUrl = 'data:image/jpeg;base64,/9j/4AAQ'; expect(ImageUrlSchema.parse(dataUrl)).toBe(dataUrl); }); + it('throws on an https URL, even to a public host', () => { + expect(() => ImageUrlSchema.parse('https://example.com/photo.jpg')).toThrow(); + }); + it('throws on an SSRF-prone internal URL', () => { expect(() => ImageUrlSchema.parse('https://169.254.169.254/latest/meta-data/')).toThrow(); }); diff --git a/api/functions/src/utils/image-url.util.ts b/api/functions/src/utils/image-url.util.ts index bb55ceb..ead10c5 100644 --- a/api/functions/src/utils/image-url.util.ts +++ b/api/functions/src/utils/image-url.util.ts @@ -1,58 +1,16 @@ import {z} from "zod"; -// Blocks SSRF: only allow `data:image/*` URLs or `https://` URLs to a public host. -// Rejects internal/private/loopback/link-local hosts so a server-side fetch of a -// user-supplied image_url can't be used to probe internal services or the cloud -// metadata endpoint (169.254.169.254). -const PRIVATE_HOSTNAME_PATTERNS = [ - /^localhost$/i, - /^127\./, - /^0\.0\.0\.0$/, - /^10\./, - /^172\.(1[6-9]|2\d|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^::1$/, - /^\[?::1\]?$/, - /^fe80:/i, - /^fc00:/i, - /^fd[0-9a-f]{2}:/i, -]; - -function isPrivateHostname(hostname: string): boolean { - return PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname)); -} +// Eliminates SSRF entirely: the server never fetches a client-supplied URL. +// Only inline `data:image/*;base64,...` payloads are accepted, so there is no +// code path where the server issues an outbound request to an attacker-chosen +// host (previously guarded by a hostname/IP blocklist, which is inherently +// incomplete against DNS-controlled hosts). +const DATA_IMAGE_URL_PATTERN = /^data:image\/[a-zA-Z0-9.+-]+;base64,/; export function isSafeImageUrl(value: string): boolean { - if (value.startsWith("data:image/")) { - return true; - } - - let url: URL; - try { - url = new URL(value); - } catch { - return false; - } - - if (url.protocol !== "https:") { - return false; - } - - return !isPrivateHostname(url.hostname); + return DATA_IMAGE_URL_PATTERN.test(value); } export const ImageUrlSchema = z .string() - .refine((value) => { - try { - // Reuse url() validation for non-data URLs; data URLs skip it. - if (!value.startsWith("data:")) { - z.string().url().parse(value); - } - return true; - } catch { - return false; - } - }, "Invalid image URL") - .refine(isSafeImageUrl, "image_url must be an https:// URL to a public host, or a data:image/* URL"); + .refine(isSafeImageUrl, "image_url must be a data:image/*;base64,... URL"); From fccf06c52bcdfad966250fe41512dc4294daf08f Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:04:47 +0800 Subject: [PATCH 02/37] fix(ui): switch OCR photo uploads to base64, drop photo persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the API's SSRF fix: the backend now only accepts inline data:image/*;base64,... payloads for OCR, so image_url is no longer a fetchable URL anywhere. - readings page: drop the Firebase Storage upload path and the savePhotos-gated persistence — the compressed data URL is now the only value ever held in image_url, used purely to drive OCR suggest. - bills page: was uploading the bill photo to Storage first and then sending that HTTPS URL to POST /bills/ocr — the same SSRF-relevant upload-then-fetch pattern, and a pointless one since the resulting URL was never attached to the created billing cycle anyway. Switched to sending a compressed base64 data URL directly, matching the billings page's existing OCR flow. - Delete the photo-settings API module/types/settings page — nothing is persisted anymore, so there's nothing left to toggle. Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/api/photo-settings.ts | 15 --- ui/src/lib/types/photo-settings.types.ts | 7 -- ui/src/lib/types/reading.types.ts | 4 - ui/src/lib/utils/true-reading.test.ts | 1 - ui/src/routes/(app)/bills/+page.svelte | 6 +- ui/src/routes/(app)/readings/+page.svelte | 76 +++---------- ui/src/routes/(app)/settings/+page.svelte | 14 --- .../routes/(app)/settings/photos/+page.svelte | 100 ------------------ 8 files changed, 14 insertions(+), 209 deletions(-) delete mode 100644 ui/src/lib/api/photo-settings.ts delete mode 100644 ui/src/lib/types/photo-settings.types.ts delete mode 100644 ui/src/routes/(app)/settings/photos/+page.svelte diff --git a/ui/src/lib/api/photo-settings.ts b/ui/src/lib/api/photo-settings.ts deleted file mode 100644 index a47d4aa..0000000 --- a/ui/src/lib/api/photo-settings.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { apiGet, apiPatch } from './client'; -import type { - PhotoSettingsResponse, - UpsertPhotoSettingsRequest -} from '$lib/types/photo-settings.types'; - -export async function getPhotoSettings(): Promise { - return apiGet('/photo-settings'); -} - -export async function upsertPhotoSettings( - data: UpsertPhotoSettingsRequest -): Promise { - return apiPatch('/photo-settings', data); -} diff --git a/ui/src/lib/types/photo-settings.types.ts b/ui/src/lib/types/photo-settings.types.ts deleted file mode 100644 index 19a6a35..0000000 --- a/ui/src/lib/types/photo-settings.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface PhotoSettingsResponse { - savePhotos: boolean; -} - -export interface UpsertPhotoSettingsRequest { - savePhotos: boolean; -} diff --git a/ui/src/lib/types/reading.types.ts b/ui/src/lib/types/reading.types.ts index 7a18a03..9d2dd9e 100644 --- a/ui/src/lib/types/reading.types.ts +++ b/ui/src/lib/types/reading.types.ts @@ -5,7 +5,6 @@ export interface Reading extends BaseModel { property_id: string; reading_amount: number; reading_date: FirestoreTimestamp; - image_url?: string; meter_version: number; } @@ -14,7 +13,6 @@ export interface CreateReadingRequest { property_id: string; reading_amount: number; reading_date: FirestoreTimestamp | string; - image_url?: string; } export interface CreateSeedReadingRequest { @@ -22,12 +20,10 @@ export interface CreateSeedReadingRequest { property_id: string; reading_amount: number; reading_date: FirestoreTimestamp | string; - image_url?: string; } export interface UpdateReadingRequest { meter_group_id?: string; reading_amount?: number; reading_date?: FirestoreTimestamp | string; - image_url?: string; } diff --git a/ui/src/lib/utils/true-reading.test.ts b/ui/src/lib/utils/true-reading.test.ts index 9ca0c1b..54e2dc4 100644 --- a/ui/src/lib/utils/true-reading.test.ts +++ b/ui/src/lib/utils/true-reading.test.ts @@ -56,7 +56,6 @@ describe('true-reading (submeter, post-reset cumulative offset)', () => { reading_amount: 5.5, reading_date: '' as any, meter_version: 2, - image_url: null as any, created_at: '' as any, updated_at: '' as any, is_deleted: false, diff --git a/ui/src/routes/(app)/bills/+page.svelte b/ui/src/routes/(app)/bills/+page.svelte index 8fda8b8..35ad01d 100644 --- a/ui/src/routes/(app)/bills/+page.svelte +++ b/ui/src/routes/(app)/bills/+page.svelte @@ -6,7 +6,7 @@ import { getMeterGroups } from '$lib/api/meter-groups'; import { getBillings } from '$lib/api/billings'; import { createBillingCycle } from '$lib/api/billing-cycles'; - import { uploadToStorage } from '$lib/utils/firebase-storage'; + import { compressImage } from '$lib/utils/image-compression'; import { formatCurrency, formatDate } from '$lib/utils/format'; import { billAmount } from '$lib/utils/money'; import type { OcrBillResponse } from '$lib/types/bill.types'; @@ -62,9 +62,7 @@ isUploadingFile = true; error = ''; try { - const timestamp = Date.now(); - const path = `bills/${timestamp}_${file.name}`; - billImageUrl = await uploadToStorage(file, path); + billImageUrl = await compressImage(file, 800, 0.7); ocrResult = await ocrBill(billImageUrl); reviewForm.billing_start_date = ocrResult.billing_start_date; diff --git a/ui/src/routes/(app)/readings/+page.svelte b/ui/src/routes/(app)/readings/+page.svelte index ca5318a..7eded63 100644 --- a/ui/src/routes/(app)/readings/+page.svelte +++ b/ui/src/routes/(app)/readings/+page.svelte @@ -16,11 +16,14 @@ import type { MeterGroup } from '$lib/types/meter-group.types'; import type { Property } from '$lib/types/property.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate, formatLongDate, formatReading, getReadingUnit } from '$lib/utils/format'; + import { + formatFirestoreDate, + formatLongDate, + formatReading, + getReadingUnit + } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; - import { uploadToStorage } from '$lib/utils/firebase-storage'; import { compressImage } from '$lib/utils/image-compression'; - import { getPhotoSettings } from '$lib/api/photo-settings'; import { trueReading, resolveCurrentVersion, @@ -112,18 +115,8 @@ let isUpdating = $state(false); - // Defaults to false (don't persist photos to Storage) until loaded — matches the - // backend default so there's no window where an unloaded setting reads as "save". - let savePhotos = $state(false); - onMount(async () => { await loadData(); - try { - const settings = await getPhotoSettings(); - savePhotos = settings.savePhotos; - } catch { - // Keep the safe default (don't persist) if the setting fails to load. - } }); async function applyFilters() { @@ -304,9 +297,7 @@ reading_date: { _seconds: Math.floor(new Date(manualReadingForm.reading_date).getTime() / 1000), _nanoseconds: 0 - }, - image_url: - savePhotos && manualReadingForm.image_url ? manualReadingForm.image_url : undefined + } } as any; const isSeed = await shouldSeedReading( @@ -344,7 +335,7 @@ // Compress image to avoid "request entity too large" errors const compressedDataUrl = await compressImage(file, 800, 0.7); - // Show preview and run Suggest immediately — don't wait for Storage + // Photo is only ever used transiently for OCR suggest — never persisted. row.data_url = compressedDataUrl; row.image_url = compressedDataUrl; } catch (err) { @@ -356,19 +347,6 @@ // Auto-suggest a reading value from the photo — no separate Suggest button. await handleSuggestReading(rowIndex); - - // Silently upgrade to a persistent Storage URL in the background — only when the - // user has opted in via Photo Settings. Otherwise the data URL stays in-memory - // only, long enough to have been OCR'd above, and is never persisted. - if (row.data_url && savePhotos) { - uploadToStorage(file, `readings/${Date.now()}_${file.name}`) - .then((url) => { - row.image_url = url; - }) - .catch(() => { - /* keep data URL */ - }); - } } async function handleManualImageUpload(file: File | null) { @@ -395,18 +373,6 @@ } catch (err) { error = err instanceof Error ? err.message : 'Failed to suggest reading'; } - - // Silently upgrade to a persistent Storage URL in the background — only when the - // user has opted in via Photo Settings. - if (savePhotos) { - uploadToStorage(file, `readings/${Date.now()}_${file.name}`) - .then((url) => { - manualReadingForm.image_url = url; - }) - .catch(() => { - /* keep data URL */ - }); - } } async function handleCreateBatch() { @@ -435,10 +401,7 @@ reading_date: { _seconds: Math.floor(dateObj.getTime() / 1000), _nanoseconds: 0 - }, - // Only attach the photo when the user has opted into saving it — - // otherwise it was only ever used in-memory for the Suggest button. - image_url: savePhotos && row.image_url ? row.image_url : undefined + } })); const result = await createReadingsBatch(readingsData); @@ -884,7 +847,7 @@
{#if isLoading} - + {:else if readings.data.length === 0}
@@ -917,7 +880,6 @@ Meter Group Reading True Total - Photo Date Created Actions @@ -949,22 +911,8 @@ {trueReading(item, itemMeterGroup, itemProperty).toLocaleString()} {getReadingUnit(itemMeterGroup?.utility_type || 'electricity')} - - {#if item.image_url} - - - Meter reading - - {:else} - No image - {/if} - - {formatDate(toDate(item.reading_date))} - {formatDate(toDate(item.created_at))} + {formatFirestoreDate(item.reading_date)} + {formatFirestoreDate(item.created_at)} { diff --git a/ui/src/routes/(app)/settings/+page.svelte b/ui/src/routes/(app)/settings/+page.svelte index b0faad9..b7d9f35 100644 --- a/ui/src/routes/(app)/settings/+page.svelte +++ b/ui/src/routes/(app)/settings/+page.svelte @@ -130,20 +130,6 @@ Go to settings →
- - - -

Photo Settings

-

- Control whether meter-reading photos are saved after OCR, on web and mobile -

-
- Go to settings → -
-
diff --git a/ui/src/routes/(app)/settings/photos/+page.svelte b/ui/src/routes/(app)/settings/photos/+page.svelte deleted file mode 100644 index 9a03d84..0000000 --- a/ui/src/routes/(app)/settings/photos/+page.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
-
-

Photo Settings

-

- Control whether meter-reading photos are kept after being used for OCR suggestions. -

-
- -
- {#if error} -
- {error} -
- {/if} - - {#if success} -
- {success} -
- {/if} - - {#if isLoading} -

Loading...

- {:else} -
-
-

Save meter-reading photos

-

- When off (default), a captured photo is only used in-memory to suggest a reading value — - it's discarded before the reading is submitted, never stored. When on, the photo is kept - and attached to the reading. -

-

- Applies to meter-reading photos on web and mobile. Utility-bill photos used for - billing-cycle OCR are never saved either way. -

-
- -
- {/if} -
-
From dc9b38f1214364e392acda044351170c29de6cba Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:04:59 +0800 Subject: [PATCH 03/37] fix(mobile): drop photo persistence for meter-reading OCR Matches the API/UI SSRF fix: readings no longer have an image_url field, so there's nothing to persist. Remove the savePhotos toggle from Settings, the photo-settings API module, the gated image_url attachment in CaptureReadings' submit payload, and the photo thumbnail in ReadingHistory. A captured photo is still used in-memory to drive the OCR suggest call, sent as a base64 data: URI, then discarded. Co-Authored-By: Claude Sonnet 5 --- mobile/src/lib/api/photo-settings.ts | 13 ----- mobile/src/lib/api/readings.ts | 6 --- mobile/src/screens/CaptureReadings.svelte | 63 ++++++----------------- mobile/src/screens/ReadingHistory.svelte | 28 ++-------- mobile/src/screens/Settings.svelte | 61 ---------------------- 5 files changed, 19 insertions(+), 152 deletions(-) delete mode 100644 mobile/src/lib/api/photo-settings.ts diff --git a/mobile/src/lib/api/photo-settings.ts b/mobile/src/lib/api/photo-settings.ts deleted file mode 100644 index 28a0346..0000000 --- a/mobile/src/lib/api/photo-settings.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { apiGet, apiPatch } from './client'; - -export interface PhotoSettings { - savePhotos: boolean; -} - -export async function getPhotoSettings(): Promise { - return apiGet('/photo-settings'); -} - -export async function upsertPhotoSettings(data: PhotoSettings): Promise { - return apiPatch('/photo-settings', data); -} diff --git a/mobile/src/lib/api/readings.ts b/mobile/src/lib/api/readings.ts index 32d52f6..a4c9830 100644 --- a/mobile/src/lib/api/readings.ts +++ b/mobile/src/lib/api/readings.ts @@ -6,7 +6,6 @@ export interface Reading { property_id: string; reading_amount: number; reading_date: string; - image_url?: string; meter_version: number; created_at: string; updated_at: string; @@ -18,7 +17,6 @@ export interface CreateReadingRequest { property_id: string; reading_amount: number; reading_date: string; - image_url?: string; } export interface BatchReadingRequest { @@ -46,10 +44,6 @@ export async function getReading(id: string): Promise { return apiGet(`/readings/${id}`); } -export async function createReading(data: CreateReadingRequest) { - return apiPost('/readings', data); -} - 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.'); diff --git a/mobile/src/screens/CaptureReadings.svelte b/mobile/src/screens/CaptureReadings.svelte index dab1adb..dcb1d39 100644 --- a/mobile/src/screens/CaptureReadings.svelte +++ b/mobile/src/screens/CaptureReadings.svelte @@ -1,11 +1,11 @@ + +
+ +
diff --git a/ui/src/lib/stores/archive-page.svelte.ts b/ui/src/lib/stores/archive-page.svelte.ts new file mode 100644 index 0000000..6c650ff --- /dev/null +++ b/ui/src/lib/stores/archive-page.svelte.ts @@ -0,0 +1,79 @@ +import type { PaginatedResult } from '$lib/types/api.types'; + +interface ArchivePageConfig { + listFn: (params: { limit: number; archived: true }) => Promise>; + restoreFn: (id: string) => Promise; + clearCacheFn: () => Promise<{ message: string }>; + entityLabel: string; + /** Optional side-fetch run alongside the main list (e.g. property/meter-group name lookups). */ + loadExtra?: () => Promise; +} + +export function createArchivePageState(config: ArchivePageConfig) { + let data = $state>({ data: [], nextCursor: null, hasMore: false }); + let isLoading = $state(false); + let error = $state(''); + let restoringId = $state(null); + let isClearing = $state(false); + + async function loadData() { + isLoading = true; + error = ''; + try { + const [result] = await Promise.all([ + config.listFn({ limit: 100, archived: true }), + config.loadExtra?.() ?? Promise.resolve() + ]); + data = result; + } catch (err) { + error = err instanceof Error ? err.message : `Failed to load archived ${config.entityLabel}s`; + } finally { + isLoading = false; + } + } + + async function handleRestore(id: string) { + restoringId = id; + try { + await config.restoreFn(id); + await loadData(); + } catch (err) { + error = err instanceof Error ? err.message : `Failed to restore ${config.entityLabel}`; + } finally { + restoringId = null; + } + } + + async function handleClearCache() { + isClearing = true; + try { + const result = await config.clearCacheFn(); + error = result.message; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to clear cache'; + } finally { + isClearing = false; + } + } + + return { + get data() { + return data; + }, + get isLoading() { + return isLoading; + }, + get error() { + return error; + }, + get restoringId() { + return restoringId; + }, + get isClearing() { + return isClearing; + }, + loadData, + handleRestore, + handleClearCache + }; +} diff --git a/ui/src/routes/(app)/billings/archive/+page.svelte b/ui/src/routes/(app)/billings/archive/+page.svelte index c7900b0..ce7314a 100644 --- a/ui/src/routes/(app)/billings/archive/+page.svelte +++ b/ui/src/routes/(app)/billings/archive/+page.svelte @@ -1,22 +1,17 @@
-
- -
+
diff --git a/ui/src/routes/(app)/meter-groups/archive/+page.svelte b/ui/src/routes/(app)/meter-groups/archive/+page.svelte index 1ca69b6..95ff014 100644 --- a/ui/src/routes/(app)/meter-groups/archive/+page.svelte +++ b/ui/src/routes/(app)/meter-groups/archive/+page.svelte @@ -1,22 +1,17 @@
-
- -
+
diff --git a/ui/src/routes/(app)/properties/archive/+page.svelte b/ui/src/routes/(app)/properties/archive/+page.svelte index b010d12..38dbfe8 100644 --- a/ui/src/routes/(app)/properties/archive/+page.svelte +++ b/ui/src/routes/(app)/properties/archive/+page.svelte @@ -1,22 +1,17 @@
-
- -
+
diff --git a/ui/src/routes/(app)/readings/archive/+page.svelte b/ui/src/routes/(app)/readings/archive/+page.svelte index a836a74..472791d 100644 --- a/ui/src/routes/(app)/readings/archive/+page.svelte +++ b/ui/src/routes/(app)/readings/archive/+page.svelte @@ -2,24 +2,24 @@ import { onMount } from 'svelte'; import { getReadings, restoreReading, clearCache } from '$lib/api/readings'; import { getMeterGroups } from '$lib/api/meter-groups'; - import type { Reading } from '$lib/types/reading.types'; import type { MeterGroup } from '$lib/types/meter-group.types'; - import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { formatFirestoreDate } from '$lib/utils/format'; import ArchivePageTemplate from '$lib/components/shared/ArchivePageTemplate.svelte'; - import { Trash2 } from 'lucide-svelte'; + import ClearCacheButton from '$lib/components/shared/ClearCacheButton.svelte'; + import { createArchivePageState } from '$lib/stores/archive-page.svelte'; - let data = $state>({ - data: [], - nextCursor: null, - hasMore: false - }); let meterGroups = $state>(new Map()); - let isLoading = $state(false); - let error = $state(''); - let restoringId = $state(null); - let isClearing = $state(false); + + const page = createArchivePageState({ + listFn: getReadings, + restoreFn: restoreReading, + clearCacheFn: clearCache, + entityLabel: 'reading', + loadExtra: async () => { + const result = await getMeterGroups({ limit: 100 }); + meterGroups = new Map(result.data.map((mg: MeterGroup) => [mg.id, mg.meter_name])); + } + }); const columns = [ { @@ -35,79 +35,26 @@ { key: 'reading_date', label: 'Reading Date', - format: (v: any) => formatDate(toDate(v)) + format: (v: any) => formatFirestoreDate(v) } ]; onMount(async () => { - await loadData(); + await page.loadData(); }); - - async function loadData() { - isLoading = true; - error = ''; - try { - const [readingsResult, meterGroupsResult] = await Promise.all([ - getReadings({ limit: 100, archived: true }), - getMeterGroups({ limit: 100 }) - ]); - - // Build meter group name map - meterGroups = new Map(meterGroupsResult.data.map((mg: MeterGroup) => [mg.id, mg.meter_name])); - - data = readingsResult; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load archived readings'; - } finally { - isLoading = false; - } - } - - async function handleRestore(id: string) { - restoringId = id; - try { - await restoreReading(id); - await loadData(); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to restore reading'; - } finally { - restoringId = null; - } - } - - async function handleClearCache() { - isClearing = true; - try { - const result = await clearCache(); - error = result.message; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to clear cache'; - } finally { - isClearing = false; - } - }
-
- -
+
diff --git a/ui/src/routes/(app)/tenants/archive/+page.svelte b/ui/src/routes/(app)/tenants/archive/+page.svelte index be342ff..54d95a7 100644 --- a/ui/src/routes/(app)/tenants/archive/+page.svelte +++ b/ui/src/routes/(app)/tenants/archive/+page.svelte @@ -2,24 +2,24 @@ import { onMount } from 'svelte'; import { getTenants, restoreTenant, clearCache } from '$lib/api/tenants'; import { getProperties } from '$lib/api/properties'; - import type { Tenant } from '$lib/types/tenant.types'; import type { Property } from '$lib/types/property.types'; - import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { formatFirestoreDate } from '$lib/utils/format'; import ArchivePageTemplate from '$lib/components/shared/ArchivePageTemplate.svelte'; - import { Trash2 } from 'lucide-svelte'; + import ClearCacheButton from '$lib/components/shared/ClearCacheButton.svelte'; + import { createArchivePageState } from '$lib/stores/archive-page.svelte'; - let data = $state>({ - data: [], - nextCursor: null, - hasMore: false - }); let properties = $state([]); - let isLoading = $state(false); - let error = $state(''); - let restoringId = $state(null); - let isClearing = $state(false); + + const page = createArchivePageState({ + listFn: getTenants, + restoreFn: restoreTenant, + clearCacheFn: clearCache, + entityLabel: 'tenant', + loadExtra: async () => { + const result = await getProperties({ limit: 100 }); + properties = result.data; + } + }); const columns = [ { key: 'tenant_name', label: 'Tenant Name' }, @@ -31,76 +31,26 @@ { key: 'created_at', label: 'Created', - format: (v: any) => formatDate(toDate(v)) + format: (v: any) => formatFirestoreDate(v) } ]; onMount(async () => { - await loadData(); + await page.loadData(); }); - - async function loadData() { - isLoading = true; - error = ''; - try { - const [tenantsResult, propertiesResult] = await Promise.all([ - getTenants({ limit: 100, archived: true }), - getProperties({ limit: 100 }) - ]); - data = tenantsResult; - properties = propertiesResult.data; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load archived tenants'; - } finally { - isLoading = false; - } - } - - async function handleRestore(id: string) { - restoringId = id; - try { - await restoreTenant(id); - await loadData(); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to restore tenant'; - } finally { - restoringId = null; - } - } - - async function handleClearCache() { - isClearing = true; - try { - const result = await clearCache(); - error = result.message; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to clear cache'; - } finally { - isClearing = false; - } - }
-
- -
+
From 2e4df364b239203d0d044ff046ab6d9d04a7c531 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:14:55 +0800 Subject: [PATCH 15/37] refactor(ui): factor out createListStore for meter-group/property/tenant stores meter-groups.svelte.ts, properties.svelte.ts, and tenants.svelte.ts were byte-for-byte identical (fetch-with-staleness caching) aside from the imported fetch function and type. Extracted createListStore() so each file is now a 3-line instantiation. Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/stores/list-store.svelte.ts | 54 ++++++++++++++++++++++++ ui/src/lib/stores/meter-groups.svelte.ts | 47 +-------------------- ui/src/lib/stores/properties.svelte.ts | 47 +-------------------- ui/src/lib/stores/tenants.svelte.ts | 47 +-------------------- 4 files changed, 60 insertions(+), 135 deletions(-) create mode 100644 ui/src/lib/stores/list-store.svelte.ts diff --git a/ui/src/lib/stores/list-store.svelte.ts b/ui/src/lib/stores/list-store.svelte.ts new file mode 100644 index 0000000..bcc66c3 --- /dev/null +++ b/ui/src/lib/stores/list-store.svelte.ts @@ -0,0 +1,54 @@ +interface PaginatedResult { + data: T[]; +} + +const STALE_MS = 5 * 60 * 1000; // 5 minutes + +/** Shared cache-with-staleness pattern behind meterGroupsStore/propertiesStore/tenantsStore. */ +export function createListStore( + fetchFn: (params: { limit: number }) => Promise> +) { + let _data = $state([]); + let _lastFetched = $state(null); + let _isLoading = $state(false); + + return { + get data() { + return _data; + }, + get isLoading() { + return _isLoading; + }, + get isStale() { + return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; + }, + + async fetch(force = false) { + if (!force && !this.isStale && _data.length > 0) { + return _data; + } + + if (_isLoading) { + return _data; + } + + _isLoading = true; + try { + const result = await fetchFn({ limit: 1000 }); + _data = result.data; + _lastFetched = Date.now(); + return _data; + } finally { + _isLoading = false; + } + }, + + invalidate() { + _lastFetched = null; + }, + + async refresh() { + return this.fetch(true); + } + }; +} diff --git a/ui/src/lib/stores/meter-groups.svelte.ts b/ui/src/lib/stores/meter-groups.svelte.ts index 1fddc81..3a93b22 100644 --- a/ui/src/lib/stores/meter-groups.svelte.ts +++ b/ui/src/lib/stores/meter-groups.svelte.ts @@ -1,47 +1,4 @@ import { getMeterGroups } from '$lib/api/meter-groups'; -import type { MeterGroup } from '$lib/types/meter-group.types'; +import { createListStore } from '$lib/stores/list-store.svelte'; -let _data = $state([]); -let _lastFetched = $state(null); -let _isLoading = $state(false); -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -export const meterGroupsStore = { - get data() { - return _data; - }, - get isLoading() { - return _isLoading; - }, - get isStale() { - return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; - }, - - async fetch(force = false) { - if (!force && !this.isStale && _data.length > 0) { - return _data; - } - - if (_isLoading) { - return _data; - } - - _isLoading = true; - try { - const result = await getMeterGroups({ limit: 1000 }); - _data = result.data; - _lastFetched = Date.now(); - return _data; - } finally { - _isLoading = false; - } - }, - - invalidate() { - _lastFetched = null; - }, - - async refresh() { - return this.fetch(true); - } -}; +export const meterGroupsStore = createListStore(getMeterGroups); diff --git a/ui/src/lib/stores/properties.svelte.ts b/ui/src/lib/stores/properties.svelte.ts index 49f6c2a..88a45f4 100644 --- a/ui/src/lib/stores/properties.svelte.ts +++ b/ui/src/lib/stores/properties.svelte.ts @@ -1,47 +1,4 @@ import { getProperties } from '$lib/api/properties'; -import type { Property } from '$lib/types/property.types'; +import { createListStore } from '$lib/stores/list-store.svelte'; -let _data = $state([]); -let _lastFetched = $state(null); -let _isLoading = $state(false); -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -export const propertiesStore = { - get data() { - return _data; - }, - get isLoading() { - return _isLoading; - }, - get isStale() { - return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; - }, - - async fetch(force = false) { - if (!force && !this.isStale && _data.length > 0) { - return _data; - } - - if (_isLoading) { - return _data; - } - - _isLoading = true; - try { - const result = await getProperties({ limit: 1000 }); - _data = result.data; - _lastFetched = Date.now(); - return _data; - } finally { - _isLoading = false; - } - }, - - invalidate() { - _lastFetched = null; - }, - - async refresh() { - return this.fetch(true); - } -}; +export const propertiesStore = createListStore(getProperties); diff --git a/ui/src/lib/stores/tenants.svelte.ts b/ui/src/lib/stores/tenants.svelte.ts index 50e4bc8..d584b0f 100644 --- a/ui/src/lib/stores/tenants.svelte.ts +++ b/ui/src/lib/stores/tenants.svelte.ts @@ -1,47 +1,4 @@ import { getTenants } from '$lib/api/tenants'; -import type { Tenant } from '$lib/types/tenant.types'; +import { createListStore } from '$lib/stores/list-store.svelte'; -let _data = $state([]); -let _lastFetched = $state(null); -let _isLoading = $state(false); -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -export const tenantsStore = { - get data() { - return _data; - }, - get isLoading() { - return _isLoading; - }, - get isStale() { - return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; - }, - - async fetch(force = false) { - if (!force && !this.isStale && _data.length > 0) { - return _data; - } - - if (_isLoading) { - return _data; - } - - _isLoading = true; - try { - const result = await getTenants({ limit: 1000 }); - _data = result.data; - _lastFetched = Date.now(); - return _data; - } finally { - _isLoading = false; - } - }, - - invalidate() { - _lastFetched = null; - }, - - async refresh() { - return this.fetch(true); - } -}; +export const tenantsStore = createListStore(getTenants); From f31d6a140674e781c2c405f123d7a49b25430b71 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:15:02 +0800 Subject: [PATCH 16/37] refactor(ui): dedupe true-reading calculation in billings page billings/+page.svelte reimplemented getCumulativeOffset/getVersionsSource/ trueReading verbatim (including comments) from lib/utils/true-reading.ts instead of importing them. Now imports the shared versions and keeps only the page-specific readingConsumption() on top. Co-Authored-By: Claude Sonnet 5 --- ui/src/routes/(app)/billings/+page.svelte | 46 ++--------------------- 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index cb0a653..ad8ae57 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -17,11 +17,12 @@ import type { Billing, UpdateBillingRequest } from '$lib/types/billing.types'; import type { Reading } from '$lib/types/reading.types'; import type { Property, MeterGroupEntry } from '$lib/types/property.types'; - import type { MeterGroup, MeterGroupVersionEntry } from '$lib/types/meter-group.types'; + import type { MeterGroup } from '$lib/types/meter-group.types'; import type { PaginatedResult } from '$lib/types/api.types'; import { formatDate, formatCurrency, formatReading, getReadingUnit } from '$lib/utils/format'; import { billAmount, sumMoney } from '$lib/utils/money'; import { toDate } from '$lib/utils/timestamp'; + import { trueReading } from '$lib/utils/true-reading'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import TableSkeleton from '$lib/components/shared/TableSkeleton.svelte'; @@ -112,47 +113,8 @@ const round = (value: number, decimals = 2) => Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); - // Version-aware "true reading" — mirrors the API's billing-cycle validator so consumption - // previews stay correct across meter resets (cumulative offset of prior versions + raw amount). - function getCumulativeOffset( - versions: Record | undefined, - version: number - ): number { - if (!versions) return 0; - let offset = 0; - for (let v = 1; v < version; v++) { - const versionData = versions[String(v)]; - if (versionData) offset += versionData.last_reading; - } - return offset; - } - - function getVersionsSource( - meterGroup: MeterGroup | undefined, - property: Property | undefined, - meterGroupId: string - ): Record | undefined { - let versionsSource = meterGroup?.versions; - if (property) { - const entry = Object.values(property.meter_groups).find( - (e): e is MeterGroupEntry => typeof e === 'object' && e?.meter_group_id === meterGroupId - ); - if (entry && !entry.is_main_meter) { - versionsSource = entry.versions; - } - } - return versionsSource; - } - - function trueReading( - reading: Reading, - meterGroup: MeterGroup | undefined, - property: Property | undefined - ): number { - const versionsSource = getVersionsSource(meterGroup, property, reading.meter_group_id); - return getCumulativeOffset(versionsSource, reading.meter_version ?? 1) + reading.reading_amount; - } - + // Version-aware "true reading" (imported from $lib/utils/true-reading) mirrors the + // API's billing-cycle validator so consumption previews stay correct across meter resets. function readingConsumption( currReading: Reading, prevReading: Reading | undefined, From 04c0a0edfc9cc9f21f5b3a21913bb81c3892a314 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:15:09 +0800 Subject: [PATCH 17/37] refactor(ui): add formatFirestoreDate helper and adopt across pages formatDate(toDate(x)) was composed ad hoc at call sites across dashboard, meter-groups, properties, and tenants. Added formatFirestoreDate() to format.ts and swapped in the affected call sites. Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/utils/format.ts | 6 ++++++ ui/src/routes/(app)/dashboard/+page.svelte | 6 +++--- ui/src/routes/(app)/meter-groups/+page.svelte | 5 ++--- ui/src/routes/(app)/properties/+page.svelte | 12 ++++++------ ui/src/routes/(app)/tenants/+page.svelte | 4 ++-- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/ui/src/lib/utils/format.ts b/ui/src/lib/utils/format.ts index f483708..8c70493 100644 --- a/ui/src/lib/utils/format.ts +++ b/ui/src/lib/utils/format.ts @@ -1,4 +1,6 @@ import { format, parse } from 'date-fns'; +import type { FirestoreTimestamp } from '$lib/types/api.types'; +import { toDate } from '$lib/utils/timestamp'; export function formatCurrency(amount: number): string { return new Intl.NumberFormat('en-PH', { @@ -25,6 +27,10 @@ export function formatDate(date: Date): string { return format(date, 'MMM d, yyyy'); } +export function formatFirestoreDate(timestamp: FirestoreTimestamp | string): string { + return formatDate(toDate(timestamp)); +} + export function formatLongDate(date: Date): string { return format(date, 'MMMM d, yyyy'); } diff --git a/ui/src/routes/(app)/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte index 19a101b..6f6fea4 100644 --- a/ui/src/routes/(app)/dashboard/+page.svelte +++ b/ui/src/routes/(app)/dashboard/+page.svelte @@ -5,7 +5,7 @@ import { getBillings } from '$lib/api/billings'; import { getBillingCycles } from '$lib/api/billing-cycles'; import { getMeterGroups } from '$lib/api/meter-groups'; - import { formatCurrency, formatDate, getReadingUnit } from '$lib/utils/format'; + import { formatCurrency, formatFirestoreDate, getReadingUnit } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import { getCyclePaidAmount, getCycleOutstandingAmount } from '$lib/utils/billing-cycle.util'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; @@ -186,8 +186,8 @@ {@const meterGroup = meterGroups.find((m) => m.id === cycle.meter_group_id)} - {formatDate(toDate(cycle.billing_start_date))} – {formatDate( - toDate(cycle.billing_end_date) + {formatFirestoreDate(cycle.billing_start_date)} – {formatFirestoreDate( + cycle.billing_end_date )} diff --git a/ui/src/routes/(app)/meter-groups/+page.svelte b/ui/src/routes/(app)/meter-groups/+page.svelte index a13615e..f64fad8 100644 --- a/ui/src/routes/(app)/meter-groups/+page.svelte +++ b/ui/src/routes/(app)/meter-groups/+page.svelte @@ -13,8 +13,7 @@ UpdateMeterGroupRequest } from '$lib/types/meter-group.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { formatFirestoreDate } from '$lib/utils/format'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import EditModal from '$lib/components/shared/EditModal.svelte'; @@ -229,7 +228,7 @@ {item.utility_type} - {formatDate(toDate(item.created_at))} + {formatFirestoreDate(item.created_at)}
- {formatDate(toDate(property.created_at))} + {formatFirestoreDate(property.created_at)}
@@ -784,7 +784,7 @@ > - {formatDate(toDate(tenant.tenant_start_date))} + {formatFirestoreDate(tenant.tenant_start_date)} {/each} @@ -851,7 +851,7 @@ )} - {formatDate(toDate(reading.reading_date))} + {formatFirestoreDate(reading.reading_date)} {/each} @@ -940,9 +940,9 @@ {prevReading && currReading - ? formatDate(toDate(prevReading.reading_date)) + + ? formatFirestoreDate(prevReading.reading_date) + ' - ' + - formatDate(toDate(currReading.reading_date)) + formatFirestoreDate(currReading.reading_date) : 'N/A'} diff --git a/ui/src/routes/(app)/tenants/+page.svelte b/ui/src/routes/(app)/tenants/+page.svelte index 50a341b..91d3328 100644 --- a/ui/src/routes/(app)/tenants/+page.svelte +++ b/ui/src/routes/(app)/tenants/+page.svelte @@ -6,7 +6,7 @@ import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '$lib/types/tenant.types'; import type { Property } from '$lib/types/property.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; + import { formatFirestoreDate } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import TableSkeleton from '$lib/components/shared/TableSkeleton.svelte'; @@ -270,7 +270,7 @@ {properties.find((p) => p.id === item.property_id)?.room_name || 'Unknown'} - {formatDate(toDate(item.tenant_start_date))} + {formatFirestoreDate(item.tenant_start_date)} {#if item.tenant_end_date} From 9166edda08453e284baff1002071e6db03d84cf5 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:16:37 +0800 Subject: [PATCH 18/37] fix(mobile): enforce Android cleartext-traffic config automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android/ is git-ignored and regenerated locally via `npx cap add android`, so the network_security_config.xml + AndroidManifest.xml wiring documented in BUILD.md was a manual, unenforced step — nothing checked it existed before packaging, so it was easy to lose silently after a fresh checkout, a new machine, or deleting android/ to fix a sync issue. Add scripts/apply-network-security-config.mjs, wired into both `npm run cap:add:android` and `npm run cap:sync`, so the config is created and wired into the manifest automatically every time those commands run instead of relying on a human to remember BUILD.md. Idempotent — verified against both a fresh (config/attribute missing) and already-configured android/ tree. Co-Authored-By: Claude Sonnet 5 --- mobile/BUILD.md | 41 +++++---- mobile/package.json | 5 +- .../scripts/apply-network-security-config.mjs | 87 +++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 mobile/scripts/apply-network-security-config.mjs diff --git a/mobile/BUILD.md b/mobile/BUILD.md index c27e913..ad79be3 100644 --- a/mobile/BUILD.md +++ b/mobile/BUILD.md @@ -23,24 +23,29 @@ npx cap open android # Open Android Studio - Enable USB debugging on physical device - Connect via `adb devices` -## Security: Network Security Config (required after a fresh `npx cap add android`) - -The `android/` directory is git-ignored and regenerated locally, so this step must be repeated -whenever the Android project is recreated from scratch: - -1. Create `android/app/src/main/res/xml/network_security_config.xml`: - ```xml - - - - - - - - - ``` -2. Add `android:networkSecurityConfig="@xml/network_security_config"` to the `` tag - in `android/app/src/main/AndroidManifest.xml`. +## Security: Network Security Config (enforced automatically) + +The `android/` directory is git-ignored and regenerated locally via `npx cap add android`, so this +config previously had to be manually recreated every time — an easy step to forget on a fresh +checkout, a new machine, or after deleting `android/` to fix a sync issue. It's now applied +automatically by `scripts/apply-network-security-config.mjs`, wired into both +`npm run cap:add:android` and `npm run cap:sync` (see `package.json`). Run +`npm run verify:network-security-config` any time to check/re-apply it manually. + +The script creates `android/app/src/main/res/xml/network_security_config.xml`: +```xml + + + + + + + + +``` +and adds `android:networkSecurityConfig="@xml/network_security_config"` to the `` tag +in `android/app/src/main/AndroidManifest.xml` if it's missing. It's idempotent — safe to run +repeatedly, and a no-op once both are already in place. This explicitly denies cleartext (`http://`) traffic app-wide, rather than relying on the Android API-level default. Pairs with the build-time HTTPS guard in `src/lib/api/client.ts`. diff --git a/mobile/package.json b/mobile/package.json index 780c479..6ac38b1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -6,9 +6,10 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "cap:add:android": "npx cap add android", + "cap:add:android": "npx cap add android && node scripts/apply-network-security-config.mjs", "cap:open:android": "npx cap open android", - "cap:sync": "npx cap sync" + "cap:sync": "npx cap sync && node scripts/apply-network-security-config.mjs", + "verify:network-security-config": "node scripts/apply-network-security-config.mjs" }, "devDependencies": { "@capacitor/cli": "^6.1.0", diff --git a/mobile/scripts/apply-network-security-config.mjs b/mobile/scripts/apply-network-security-config.mjs new file mode 100644 index 0000000..2bc0432 --- /dev/null +++ b/mobile/scripts/apply-network-security-config.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Enforces the Android cleartext-traffic guard documented in BUILD.md. `android/` +// is git-ignored and regenerated locally via `npx cap add android`, so this step +// was previously a manual, unenforced instruction — easy to forget on a fresh +// checkout, a new machine, or after deleting `android/` to fix a sync issue. Wired +// into `cap:add:android` and `cap:sync` (see package.json) so it runs every time +// those commands do, instead of relying on a human to remember BUILD.md. +// +// Safe to run repeatedly: no-ops if the config already exists and is wired up. +// No-ops (with a warning, not a failure) if `android/` hasn't been generated yet, +// since `cap:add:android` runs this only after `cap add android` succeeds, but a +// bare `npm install` or first clone shouldn't fail over a platform that hasn't +// been added. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const mobileRoot = join(__dirname, ".."); +const androidRoot = join(mobileRoot, "android"); +const xmlDir = join(androidRoot, "app", "src", "main", "res", "xml"); +const configPath = join(xmlDir, "network_security_config.xml"); +const manifestPath = join(androidRoot, "app", "src", "main", "AndroidManifest.xml"); + +const NETWORK_SECURITY_CONFIG = ` + + + + + + + +`; + +function main() { + if (!existsSync(androidRoot)) { + console.warn( + "[network-security-config] android/ not found yet — skipping " + + "(run `npm run cap:add:android` first)." + ); + return; + } + + let changed = false; + + if (!existsSync(configPath)) { + mkdirSync(xmlDir, { recursive: true }); + writeFileSync(configPath, NETWORK_SECURITY_CONFIG, "utf8"); + console.log(`[network-security-config] created ${configPath}`); + changed = true; + } + + if (!existsSync(manifestPath)) { + throw new Error( + `[network-security-config] android/ exists but AndroidManifest.xml is missing at ${manifestPath} — android/ may be partially generated; try removing it and re-running \`npm run cap:add:android\`.` + ); + } + + const manifest = readFileSync(manifestPath, "utf8"); + const applicationTagMatch = manifest.match(/]*>/s); + + if (!applicationTagMatch) { + throw new Error( + `[network-security-config] could not find an tag in ${manifestPath} — apply android:networkSecurityConfig="@xml/network_security_config" manually.` + ); + } + + const applicationTag = applicationTagMatch[0]; + if (!applicationTag.includes("android:networkSecurityConfig")) { + const patchedTag = applicationTag.replace( + />$/, + '\n android:networkSecurityConfig="@xml/network_security_config">' + ); + writeFileSync(manifestPath, manifest.replace(applicationTag, patchedTag), "utf8"); + console.log(`[network-security-config] wired networkSecurityConfig into ${manifestPath}`); + changed = true; + } + + console.log( + changed + ? "[network-security-config] cleartext traffic protection applied." + : "[network-security-config] already up to date." + ); +} + +main(); From 26417e0e5977152794f10d150fe080129651ecbf Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Mon, 13 Jul 2026 08:50:51 +0800 Subject: [PATCH 19/37] lint: fix lint errors --- .../src/features/chatbot/chatbot.tools.ts | 38 +- ui/src/routes/(app)/billings/+page.svelte | 509 +++++++++--------- ui/src/routes/(app)/dashboard/+page.svelte | 12 +- 3 files changed, 278 insertions(+), 281 deletions(-) diff --git a/api/functions/src/features/chatbot/chatbot.tools.ts b/api/functions/src/features/chatbot/chatbot.tools.ts index 64d2efa..140a7fd 100644 --- a/api/functions/src/features/chatbot/chatbot.tools.ts +++ b/api/functions/src/features/chatbot/chatbot.tools.ts @@ -439,25 +439,25 @@ export async function getBillingReports(args: GetBillingReportsArgs) { const userId = "chatbot"; // unused by reportsService — every report is scoped by query filters, not caller switch (args.reportType) { - case "summary": - return reportsService.getSummary(userId, query); - case "consumption": { - const report = await reportsService.getConsumption(userId, query); - return { - by_month: report.by_month, - by_property: report.by_property.map((p) => ({ - propertyName: p.room_name, - electricity: p.electricity, - water: p.water, - })), - }; - } - case "billing_trends": - return reportsService.getBillingTrends(userId, query); - case "collection_status": - return reportsService.getCollectionStatus(userId, query); - default: - throw new AppError(400, `Unknown reportType: ${args.reportType}`); + case "summary": + return reportsService.getSummary(userId, query); + case "consumption": { + const report = await reportsService.getConsumption(userId, query); + return { + by_month: report.by_month, + by_property: report.by_property.map((p) => ({ + propertyName: p.room_name, + electricity: p.electricity, + water: p.water, + })), + }; + } + case "billing_trends": + return reportsService.getBillingTrends(userId, query); + case "collection_status": + return reportsService.getCollectionStatus(userId, query); + default: + throw new AppError(400, `Unknown reportType: ${args.reportType}`); } } diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index ad8ae57..78f77f5 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -790,14 +790,15 @@ return true; }) .sort( - (a, b) => - toDate(b.billing_start_date).getTime() - toDate(a.billing_start_date).getTime() + (a, b) => toDate(b.billing_start_date).getTime() - toDate(a.billing_start_date).getTime() ); }); const cyclesPageSize = 20; let currentPage = $state(1); - const totalPages = $derived.by(() => Math.max(1, Math.ceil(filteredCycles.length / cyclesPageSize))); + const totalPages = $derived.by(() => + Math.max(1, Math.ceil(filteredCycles.length / cyclesPageSize)) + ); const pagedCycles = $derived.by(() => { const start = (currentPage - 1) * cyclesPageSize; return filteredCycles.slice(start, start + cyclesPageSize); @@ -1356,9 +1357,7 @@
- + - {getCycleMeterGroupName(cycle)} + {getCycleMeterGroupName(cycle)} {cycleUtilityType}{cycleUtilityType}
@@ -1581,266 +1581,257 @@
-
-
-
Consumption
-
- {cycle.billing_consumption.toLocaleString()} - {getReadingUnit(getCycleUtilityType(cycle))} -
+
+
+
Consumption
+
+ {cycle.billing_consumption.toLocaleString()} + {getReadingUnit(getCycleUtilityType(cycle))}
-
-
Rate
-
- ₱{cycle.billing_rate.toFixed(2)}/{getReadingUnit( - getCycleUtilityType(cycle) - )} -
+
+
+
Rate
+
+ ₱{cycle.billing_rate.toFixed(2)}/{getReadingUnit( + getCycleUtilityType(cycle) + )}
-
-
Total Amount
-
- {formatCurrency( - billAmount(cycle.billing_consumption, cycle.billing_rate) - )} -
+
+
+
Total Amount
+
+ {formatCurrency( + billAmount(cycle.billing_consumption, cycle.billing_rate) + )}
-
-
Currently Paid
-
- {formatCurrency(getCyclePaidAmount(cycle))} -
+
+
+
Currently Paid
+
+ {formatCurrency(getCyclePaidAmount(cycle))}
- -
-
- - - - -
+
+
+
+ + + + +
+
- - {#if expandedCycleId === cycle.id} -
- {#if cycleBillings?.length === 0} -
- -
- {:else if cycleBillings} -
- - - - - - - - - - - - - - {#each cycleBillings || [] as billing (billing.id)} - {@const billingProperty = properties.find( - (p) => p.id === billing.property_id - )} - {@const cycleMeterGroup = cycle.meter_group_id - ? meterGroupMap.get(cycle.meter_group_id) - : undefined} - {@const meterEntry = cycle.meter_group_id - ? cycleMeterGroup?.utility_type === 'water' - ? billingProperty?.meter_groups.water - : billingProperty?.meter_groups.electricity - : null} - {@const isMainMeter = - typeof meterEntry === 'string' - ? false - : (meterEntry?.is_main_meter ?? false)} - {@const previousReading = readingMap.get(billing.previous_reading_id)} - {@const currentReading = readingMap.get(billing.current_reading_id)} - {@const previousMeterGroup = previousReading - ? meterGroupMap.get(previousReading.meter_group_id) - : undefined} - {@const currentMeterGroup = currentReading - ? meterGroupMap.get(currentReading.meter_group_id) - : undefined} - - - - - - + + {/each} + +
PropertyPrevious ReadingCurrent Reading - Consumption - AmountStatusActions
-
- {billingProperty?.room_name ?? 'Unknown Property'} -
-
- {#if previousReading} - {formatReading( - trueReading( - previousReading, - previousMeterGroup, - billingProperty - ), - previousMeterGroup?.utility_type || 'electricity' - )} - {:else} - N/A - {/if} - - {#if currentReading} - {formatReading( - trueReading(currentReading, currentMeterGroup, billingProperty), - currentMeterGroup?.utility_type || 'electricity' - )} - {:else} - N/A - {/if} - - {#if currentReading} - {(cycle.billing_ids[billing.id] ?? 0).toLocaleString()} - {getReadingUnit(currentMeterGroup?.utility_type || 'electricity')} - {:else} - N/A - {/if} - - {formatCurrency( - (cycle.billing_ids[billing.id] ?? 0) * cycle.billing_rate + + {#if expandedCycleId === cycle.id} +
+ {#if cycleBillings?.length === 0} +
+ +
+ {:else if cycleBillings} +
+ + + + + + + + + + + + + + {#each cycleBillings || [] as billing (billing.id)} + {@const billingProperty = properties.find( + (p) => p.id === billing.property_id + )} + {@const cycleMeterGroup = cycle.meter_group_id + ? meterGroupMap.get(cycle.meter_group_id) + : undefined} + {@const meterEntry = cycle.meter_group_id + ? cycleMeterGroup?.utility_type === 'water' + ? billingProperty?.meter_groups.water + : billingProperty?.meter_groups.electricity + : null} + {@const isMainMeter = + typeof meterEntry === 'string' + ? false + : (meterEntry?.is_main_meter ?? false)} + {@const previousReading = readingMap.get(billing.previous_reading_id)} + {@const currentReading = readingMap.get(billing.current_reading_id)} + {@const previousMeterGroup = previousReading + ? meterGroupMap.get(previousReading.meter_group_id) + : undefined} + {@const currentMeterGroup = currentReading + ? meterGroupMap.get(currentReading.meter_group_id) + : undefined} + + + - - + + + + + - - {/each} - -
PropertyPrevious ReadingCurrent Reading + Consumption + AmountStatusActions
+
+ {billingProperty?.room_name ?? 'Unknown Property'} +
+
+ {#if previousReading} + {formatReading( + trueReading(previousReading, previousMeterGroup, billingProperty), + previousMeterGroup?.utility_type || 'electricity' )} - - - -
- {#if billing.payment_status === 'pending'} - - {/if} - + {:else} + N/A + {/if} +
+ {#if currentReading} + {formatReading( + trueReading(currentReading, currentMeterGroup, billingProperty), + currentMeterGroup?.utility_type || 'electricity' + )} + {:else} + N/A + {/if} + + {#if currentReading} + {(cycle.billing_ids[billing.id] ?? 0).toLocaleString()} + {getReadingUnit(currentMeterGroup?.utility_type || 'electricity')} + {:else} + N/A + {/if} + + {formatCurrency( + (cycle.billing_ids[billing.id] ?? 0) * cycle.billing_rate + )} + + + +
+ {#if billing.payment_status === 'pending'} -
-
-
- {:else} - - {/if} -
- {/if} - - {/each} + {/if} + + + +
+
+ {:else} + + {/if} +
+ {/if} +
+ {/each} {#if totalPages > 1}
diff --git a/ui/src/routes/(app)/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte index 6f6fea4..3ba63cd 100644 --- a/ui/src/routes/(app)/dashboard/+page.svelte +++ b/ui/src/routes/(app)/dashboard/+page.svelte @@ -25,8 +25,12 @@ let recentCyclesRangeMonths = $state<3 | 6 | 12>(3); const recentCycles = $derived.by(() => { - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - recentCyclesRangeMonths); + const now = new Date(); + const cutoff = new Date( + now.getFullYear(), + now.getMonth() - recentCyclesRangeMonths, + now.getDate() + ); return allCycles .filter((cycle) => toDate(cycle.billing_start_date) >= cutoff) .sort( @@ -57,7 +61,9 @@ // page would silently under-count older cycles/billings, so page through with cursors // (mirrors the same fix applied to the Billings page). async function fetchAllPages( - fetchPage: (cursor?: string) => Promise<{ data: T[]; hasMore: boolean; nextCursor: string | null }> + fetchPage: ( + cursor?: string + ) => Promise<{ data: T[]; hasMore: boolean; nextCursor: string | null }> ): Promise { const all: T[] = []; let cursor: string | undefined; From be8cca7f46d3189bc91b716d2d2085f9aa28f4fd Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 05:18:07 +0800 Subject: [PATCH 20/37] fix(ui): show raw meter readings instead of cumulative true total Tenants read raw meter dial values, so a cumulative all-time total never matches what they see and photograph. Consumption math still uses the version-aware cumulative offset internally; only the displayed numbers on the readings table, batch form, and expanded billing cycle rows now show the raw reading plus a meter-version/ reset indicator instead. Co-Authored-By: Claude Sonnet 5 --- ui/src/routes/(app)/billings/+page.svelte | 16 +++++++-- ui/src/routes/(app)/readings/+page.svelte | 44 ++++++++++++----------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index 78f77f5..46066c3 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -1735,6 +1735,11 @@ {@const currentMeterGroup = currentReading ? meterGroupMap.get(currentReading.meter_group_id) : undefined} + {@const meterWasReset = + previousReading && + currentReading && + (previousReading.meter_version ?? 1) !== + (currentReading.meter_version ?? 1)}
@@ -1744,7 +1749,7 @@ {#if previousReading} {formatReading( - trueReading(previousReading, previousMeterGroup, billingProperty), + previousReading.reading_amount, previousMeterGroup?.utility_type || 'electricity' )} {:else} @@ -1754,9 +1759,16 @@ {#if currentReading} {formatReading( - trueReading(currentReading, currentMeterGroup, billingProperty), + currentReading.reading_amount, currentMeterGroup?.utility_type || 'electricity' )} + {#if meterWasReset} + meter reset + {/if} {:else} N/A {/if} diff --git a/ui/src/routes/(app)/readings/+page.svelte b/ui/src/routes/(app)/readings/+page.svelte index 7eded63..3878ffb 100644 --- a/ui/src/routes/(app)/readings/+page.svelte +++ b/ui/src/routes/(app)/readings/+page.svelte @@ -16,20 +16,10 @@ import type { MeterGroup } from '$lib/types/meter-group.types'; import type { Property } from '$lib/types/property.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { - formatFirestoreDate, - formatLongDate, - formatReading, - getReadingUnit - } from '$lib/utils/format'; + import { formatFirestoreDate, formatLongDate, formatReading } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import { compressImage } from '$lib/utils/image-compression'; - import { - trueReading, - resolveCurrentVersion, - getVersionsSource, - getCumulativeOffset - } from '$lib/utils/true-reading'; + import { resolveCurrentVersion, getVersionsSource } from '$lib/utils/true-reading'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import TableSkeleton from '$lib/components/shared/TableSkeleton.svelte'; import EditModal from '$lib/components/shared/EditModal.svelte'; @@ -630,11 +620,13 @@ row.property, selectedMeterGroup )} - {@const offset = getCumulativeOffset(versionsSource, version)} - {@const unit = getReadingUnit(selectedMg?.utility_type || 'electricity')} + {@const resetInfo = + version > 1 ? versionsSource?.[String(version - 1)] : undefined}

- True total: {(offset + row.reading_amount).toLocaleString()} - {unit} + Meter v{version} + {#if resetInfo} + (reset {formatFirestoreDate(resetInfo.reset_at)} from {resetInfo.last_reading.toLocaleString()}) + {/if}

{/if} @@ -879,7 +871,7 @@ Property Meter Group Reading - True Total + Meter Cycle Date Created Actions @@ -889,6 +881,14 @@ {#each readings.data as item (item.id)} {@const itemProperty = propertyMap.get(item.property_id)} {@const itemMeterGroup = meterGroupMap.get(item.meter_group_id)} + {@const itemMeterVersion = item.meter_version ?? 1} + {@const itemVersionsSource = getVersionsSource( + itemMeterGroup, + itemProperty, + item.meter_group_id + )} + {@const itemResetInfo = + itemMeterVersion > 1 ? itemVersionsSource?.[String(itemMeterVersion - 1)] : undefined} {formatReading(item.reading_amount, itemMeterGroup?.utility_type || 'electricity')} - - {trueReading(item, itemMeterGroup, itemProperty).toLocaleString()} - {getReadingUnit(itemMeterGroup?.utility_type || 'electricity')} + + v{itemMeterVersion} + {#if itemResetInfo} + reset {formatFirestoreDate(itemResetInfo.reset_at)}, prior meter ended at {itemResetInfo.last_reading.toLocaleString()} + {/if} {formatFirestoreDate(item.reading_date)} {formatFirestoreDate(item.created_at)} From 4b8b4e292add6af0906675c5fa277f9318156d72 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 07:00:01 +0800 Subject: [PATCH 21/37] feat(billing): denormalize meter group & billing period, add scoped filters + update consistency Denormalize meter_group_id and billing_period_date onto Billing (copied from current_reading, mirroring what BillingCycle already does), and add scoped query filters so the UI can ask for less instead of scanning whole collections: - Billing gains meter_group_id / billing_period_date, set at every write path via a shared deriveBillingDenormalizedFields() helper (create, batch, createFromReadings, update, updateBatch). - GET /billings gains meterGroupId / startDate / endDate filters; GET /billing-cycles gains a meterGroupId equality filter. - update()/updateBatch() re-derive the denormalized fields when current_reading_id changes, so the PATCH correction escape hatch can no longer leave them stale. - billing.validator: validateUpdate now resolves the stored billing by id and validates whenever either reading id changes (previously only when property + both readings were all present together, so a lone current_reading_id skipped the same-meter-group / rollback / main-meter checks). - billing-cycle.validator: cross-check each billing's reading meter_group_id against the cycle's declared meter_group_id, run on create and whenever a PATCH changes billing_ids or meter_group_id (resolving the missing side from the stored cycle). - UI api/types wired through (pages don't call the new params yet). Verified: tsc --noEmit clean, lint 0 errors, npm test 349/349. Co-Authored-By: Claude Opus 4.8 --- .../billing-cycle/billing-cycle.controller.ts | 1 + .../billing-cycle/billing-cycle.dto.ts | 8 + .../billing-cycle/billing-cycle.service.ts | 11 +- .../billing-cycle/billing-cycle.swagger.ts | 9 ++ .../billing-cycle.validator.test.ts | 78 ++++++++++ .../billing-cycle/billing-cycle.validator.ts | 44 +++++- .../features/billing/billing.controller.ts | 3 + .../src/features/billing/billing.dto.ts | 17 +++ .../src/features/billing/billing.model.ts | 5 +- .../src/features/billing/billing.service.ts | 141 ++++++++++++++++-- .../src/features/billing/billing.swagger.ts | 25 ++++ .../src/features/billing/billing.test.ts | 6 + .../billing/billing.validator.test.ts | 49 ++++++ .../src/features/billing/billing.validator.ts | 70 +++++---- ui/src/lib/api/billing-cycles.ts | 2 + ui/src/lib/api/billings.ts | 6 + ui/src/lib/types/billing.types.ts | 4 +- 17 files changed, 431 insertions(+), 48 deletions(-) diff --git a/api/functions/src/features/billing-cycle/billing-cycle.controller.ts b/api/functions/src/features/billing-cycle/billing-cycle.controller.ts index b5fad5e..e5e82cb 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.controller.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.controller.ts @@ -56,6 +56,7 @@ export const getBillingCycles = async ( const userId = req.user!.userId; const result = await billingCycleService.search(userId, { + meterGroupId: query.meterGroupId, billingStartDate: query.billingStartDate, billingEndDate: query.billingEndDate, sortBy: query.sortBy, diff --git a/api/functions/src/features/billing-cycle/billing-cycle.dto.ts b/api/functions/src/features/billing-cycle/billing-cycle.dto.ts index ab287dd..da1444c 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.dto.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.dto.ts @@ -80,6 +80,7 @@ export type BillingCycleByIdParamsDTO = z.infer; 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 98c7962..26bb4ee 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.service.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.service.ts @@ -112,6 +112,7 @@ async function injectMainMeterBilling( } type BillingCycleSearchOptions = { + meterGroupId?: string; billingStartDate?: string; billingEndDate?: string; sortBy?: string; @@ -197,7 +198,9 @@ export const billingCycleService = { // For archived queries, we need custom date filtering if (options.archived) { - const filters: Record = {}; + const filters: Record = { + ...(options.meterGroupId ? {meter_group_id: options.meterGroupId} : {}), + }; if (options.billingStartDate) { filters.billing_start_date = {gte: new Date(options.billingStartDate)}; } @@ -221,7 +224,9 @@ export const billingCycleService = { orderDirection: options.sortOrder ?? "desc", cursor: options.cursor, archived: false, - filters: {}, + filters: { + ...(options.meterGroupId ? {meter_group_id: options.meterGroupId} : {}), + }, }); // Post-filter for date ranges (can't query dates directly in Firestore query) @@ -255,7 +260,7 @@ export const billingCycleService = { id: string, data: Partial ): Promise { - await validator.validateUpdate(data); + await validator.validateUpdate(id, data); const cachedRepo = new CachedRepository(billingCycleRepository, userId, "billing-cycles", CACHE_TTL); return cachedRepo.update(id, data); }, diff --git a/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts b/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts index 7feac14..9a663a2 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts @@ -77,6 +77,15 @@ export const billingCyclePaths = { "Retrieve paginated list of billing cycles. Can filter by start and end dates. Note: cursor and date filters are mutually exclusive.", security: [{BearerAuth: []}], parameters: [ + { + name: "meterGroupId", + in: "query", + description: "Filter by meter group ID", + schema: { + type: "string", + minLength: 1, + }, + }, { name: "billingStartDate", in: "query", diff --git a/api/functions/src/features/billing-cycle/billing-cycle.validator.test.ts b/api/functions/src/features/billing-cycle/billing-cycle.validator.test.ts index 8460f22..b14607c 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.validator.test.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.validator.test.ts @@ -171,6 +171,7 @@ describe('BillingCycleValidator', () => { it('accepts the correct true-reading delta across the reset (5.5)', async () => { await expect( validator.validateSubmeterConsumption({ + meter_group_id: 'mg-1', billing_ids: { 'b-1': 5.5 }, } as any) ).resolves.toBeUndefined(); @@ -179,6 +180,7 @@ describe('BillingCycleValidator', () => { it('rejects an overstated consumption that used the full true reading (639.5) instead of the delta', async () => { await expect( validator.validateSubmeterConsumption({ + meter_group_id: 'mg-1', billing_ids: { 'b-1': 639.5 }, } as any) ).rejects.toMatchObject({ @@ -187,4 +189,80 @@ describe('BillingCycleValidator', () => { }); }); }); + + describe('meter_group_id cross-check against billings\' readings', () => { + const prevReading = { + id: 'r-1', + meter_group_id: 'mg-1', + property_id: 'prop-1', + reading_amount: 100, + reading_date: startTs, + meter_version: 1, + }; + const currReading = { + id: 'r-2', + meter_group_id: 'mg-1', + property_id: 'prop-1', + reading_amount: 200, + reading_date: endTs, + meter_version: 1, + }; + const property = { id: 'prop-1', meter_groups: {} }; + const meterGroup = { id: 'mg-1', current_version: 1, versions: {} }; + + beforeEach(() => { + jest.mocked(readingRepository.getByIds).mockResolvedValue([prevReading, currReading] as any); + jest.mocked(propertyRepository.getByIds).mockResolvedValue([property] as any); + jest.mocked(meterGroupRepository.getByIds).mockResolvedValue([meterGroup] as any); + }); + + it('rejects create when the cycle meter_group_id does not match its billings\' readings', async () => { + await expect( + validator.validateCreate({ + meter_group_id: 'mg-OTHER', + billing_ids: { 'b-1': 100 }, + billing_rate: 5, + billing_consumption: 100, + billing_start_date: startTs, + billing_end_date: endTs, + } as any) + ).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining('meter group'), + }); + }); + + it('accepts create when the cycle meter_group_id matches its billings\' readings', async () => { + await expect( + validator.validateCreate({ + meter_group_id: 'mg-1', + billing_ids: { 'b-1': 100 }, + billing_rate: 5, + billing_consumption: 100, + billing_start_date: startTs, + billing_end_date: endTs, + } as any) + ).resolves.toBeUndefined(); + }); + + it('rejects a PATCH changing only meter_group_id to one that does not match the stored billings', async () => { + // Stored cycle is for mg-1 with b-1; PATCH switches only meter_group_id to + // mg-OTHER, which no longer matches b-1's reading (mg-1). + jest.mocked(billingCycleRepository.getById).mockResolvedValue( + { id: 'bc-1', meter_group_id: 'mg-1', billing_ids: { 'b-1': 100 } } as any + ); + await expect( + validator.validateUpdate('bc-1', { meter_group_id: 'mg-OTHER' }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('accepts a PATCH changing only meter_group_id to one that matches the stored billings', async () => { + jest.mocked(billingCycleRepository.getById).mockResolvedValue( + { id: 'bc-1', meter_group_id: 'mg-OTHER', billing_ids: { 'b-1': 100 } } as any + ); + await expect( + validator.validateUpdate('bc-1', { meter_group_id: 'mg-1' }) + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/api/functions/src/features/billing-cycle/billing-cycle.validator.ts b/api/functions/src/features/billing-cycle/billing-cycle.validator.ts index 3179bc6..955d3d1 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.validator.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.validator.ts @@ -27,7 +27,8 @@ export class BillingCycleValidator { } private async validateBillingConsumptionAmounts( - billingIds: Record + billingIds: Record, + expectedMeterGroupId: string ): Promise { const PER_BILLING_TOLERANCE = 0.05; const billingIdList = Object.keys(billingIds); @@ -76,6 +77,19 @@ export class BillingCycleValidator { if (!prevReading || !currReading) continue; + // Cross-check the cycle's declared meter_group_id against the meter group + // of the reading each billing actually summarizes — otherwise a cycle can + // claim a meter_group_id that doesn't match its billings' readings, and + // the denormalized field silently lies (nothing else validates it). + if (currReading.meter_group_id !== expectedMeterGroupId) { + throw new AppError( + 400, + `Billing ${billingId}: its reading belongs to meter group ` + + `${currReading.meter_group_id}, but the billing cycle is for meter group ` + + `${expectedMeterGroupId}` + ); + } + const meterGroup = meterGroupMap.get(currReading.meter_group_id); const property = propertyMap.get(billing.property_id); @@ -181,7 +195,7 @@ export class BillingCycleValidator { } await this.validateBillingIdsExist(billingIds); - await this.validateBillingConsumptionAmounts(data.billing_ids); + await this.validateBillingConsumptionAmounts(data.billing_ids, data.meter_group_id); } async validateCreate(data: CreateBillingCycleDTO): Promise { @@ -192,7 +206,7 @@ export class BillingCycleValidator { } await this.validateBillingIdsExist(billingIds); - await this.validateBillingConsumptionAmounts(data.billing_ids); + await this.validateBillingConsumptionAmounts(data.billing_ids, data.meter_group_id); await this.ensureBillingCycleNotDuplicate(data.meter_group_id, data.billing_start_date); this.validateBillingDates( @@ -239,6 +253,7 @@ export class BillingCycleValidator { } async validateUpdate( + id: string, data: Partial ): Promise { if (data.billing_ids !== undefined) { @@ -249,7 +264,24 @@ export class BillingCycleValidator { } await this.validateBillingIdsExist(billingIds); - await this.validateBillingConsumptionAmounts(data.billing_ids); + } + + // Re-run the meter-group cross-check whenever billing_ids OR meter_group_id + // changes — a PATCH touching only meter_group_id (no billing_ids in the + // payload) would otherwise bypass validation entirely. Resolve whichever + // side isn't in this PATCH from the stored cycle. + if (data.billing_ids !== undefined || data.meter_group_id !== undefined) { + let effectiveBillingIds = data.billing_ids; + let effectiveMeterGroupId = data.meter_group_id; + if (!effectiveBillingIds || !effectiveMeterGroupId) { + const existing = await billingCycleRepository.getById(id); + if (!existing) { + throw new AppError(404, "Billing cycle not found"); + } + effectiveBillingIds = effectiveBillingIds ?? existing.billing_ids; + effectiveMeterGroupId = effectiveMeterGroupId ?? existing.meter_group_id; + } + await this.validateBillingConsumptionAmounts(effectiveBillingIds, effectiveMeterGroupId); } if (data.billing_start_date && data.billing_end_date) { @@ -279,8 +311,8 @@ export class BillingCycleValidator { async validateUpdateBatch( updates: {id: string; data: Partial}[] ): Promise { - for (const {data} of updates) { - await this.validateUpdate(data); + for (const {id, data} of updates) { + await this.validateUpdate(id, data); } } } diff --git a/api/functions/src/features/billing/billing.controller.ts b/api/functions/src/features/billing/billing.controller.ts index d4de2eb..4b27517 100644 --- a/api/functions/src/features/billing/billing.controller.ts +++ b/api/functions/src/features/billing/billing.controller.ts @@ -54,6 +54,9 @@ export const getBillings = async ( const result = await billingService.search(userId, { propertyId: query.propertyId, + meterGroupId: query.meterGroupId, + startDate: query.startDate, + endDate: query.endDate, sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, diff --git a/api/functions/src/features/billing/billing.dto.ts b/api/functions/src/features/billing/billing.dto.ts index ca5e050..02a4eda 100644 --- a/api/functions/src/features/billing/billing.dto.ts +++ b/api/functions/src/features/billing/billing.dto.ts @@ -37,6 +37,9 @@ export type BillingByIdParamsDTO = z.infer; export const GetBillingsQueryDTOSchema = z .object({ propertyId: z.string().trim().min(1).optional(), + meterGroupId: z.string().trim().min(1).optional(), + startDate: z.union([z.string().datetime(), z.string().date()]).optional(), + endDate: z.union([z.string().datetime(), z.string().date()]).optional(), sortBy: z.enum(["created_at", "payment_status"]).optional(), sortOrder: z.enum(["asc", "desc"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(20), @@ -53,5 +56,19 @@ export const GetBillingsQueryDTOSchema = z path: ["cursor"], }); } + if (value.meterGroupId && value.cursor) { + context.addIssue({ + code: "custom", + message: "cursor cannot be combined with meterGroupId", + path: ["cursor"], + }); + } + if ((value.startDate || value.endDate) && value.cursor) { + context.addIssue({ + code: "custom", + message: "cursor cannot be combined with startDate or endDate", + path: ["cursor"], + }); + } }); export type GetBillingsQueryDTO = z.infer; diff --git a/api/functions/src/features/billing/billing.model.ts b/api/functions/src/features/billing/billing.model.ts index 160c3de..1ef00c0 100644 --- a/api/functions/src/features/billing/billing.model.ts +++ b/api/functions/src/features/billing/billing.model.ts @@ -1,9 +1,12 @@ -import {BaseModel} from "../../utils/model.util"; +import {Timestamp} from "firebase-admin/firestore"; +import {BaseModel} from "../../utils/model.util"; export interface Billing extends BaseModel { property_id: string; previous_reading_id: string; current_reading_id: string; + meter_group_id: string; + billing_period_date: Timestamp; payment_status: "pending" | "paid"; paid_at?: string; } diff --git a/api/functions/src/features/billing/billing.service.ts b/api/functions/src/features/billing/billing.service.ts index 154848b..a4ef991 100644 --- a/api/functions/src/features/billing/billing.service.ts +++ b/api/functions/src/features/billing/billing.service.ts @@ -1,4 +1,4 @@ -import {FieldValue, Transaction, DocumentData} from "firebase-admin/firestore"; +import {FieldValue, Transaction, DocumentData, Timestamp} from "firebase-admin/firestore"; import {firestore} from "../../config/firebase.config"; import {billingRepository} from "./billing.repository"; import {Billing} from "./billing.model"; @@ -11,12 +11,32 @@ import {snapshotToModel} from "../../utils/firestore.util"; import {validateMeterRollback} from "../reading/reading.util"; import {cacheSet} from "../../utils/cache.util"; import {CachedRepository} from "../../lib/cached-repository.lib"; +import {readingRepository} from "../reading/reading.repository"; const validator = new BillingValidator(); const CACHE_TTL = 10 * 60; // 10 minutes +/** + * Derives the denormalized fields Billing copies off its current reading + * (meter_group_id + billing_period_date). Single source of the copy logic so + * create/createBatch/createFromReadings and update() can't drift apart. + * Accepts any reading-shaped value (DocumentData from a txn snapshot or a + * resolved Reading model) — both carry meter_group_id/reading_date. + */ +function deriveBillingDenormalizedFields( + currReading: { meter_group_id: string; reading_date: Timestamp } +): { meter_group_id: string; billing_period_date: Timestamp } { + return { + meter_group_id: currReading.meter_group_id, + billing_period_date: currReading.reading_date, + }; +} + type BillingSearchOptions = { propertyId?: string; + meterGroupId?: string; + startDate?: string; + endDate?: string; sortBy?: string; sortOrder?: "asc" | "desc"; limit: number; @@ -71,6 +91,7 @@ export const billingService = { newBillingId = newRef.id; txn.set(newRef, { ...data, + ...deriveBillingDenormalizedFields(currReading as any), payment_status: "pending" as const, created_at: FieldValue.serverTimestamp(), is_deleted: false, @@ -87,28 +108,95 @@ export const billingService = { async createBatch(userId: string, data: CreateBillingDTO[]): Promise { await validator.validateBatch(data); + + // Batch is capped at 10 items (CreateBillingBatchDTOSchema), so this is a small, + // one-time lookup — resolves meter_group_id/reading_date for the denormalized fields + // below, mirroring what create()/createFromReadings() already get for free from their + // transaction reads. + const currentReadingIds = Array.from(new Set(data.map((item) => item.current_reading_id))); + const currentReadings = await readingRepository.getByIds(currentReadingIds); + const readingById = new Map( + currentReadings.filter((r): r is NonNullable => r !== null).map((r) => [r.id, r]) + ); + const cachedRepo = new CachedRepository(billingRepository, userId, "billings", CACHE_TTL); const created = await cachedRepo.createBatch( - data.map((item) => ({ - ...item, - payment_status: "pending" as const, - })) + data.map((item) => { + const currReading = readingById.get(item.current_reading_id)!; + return { + ...item, + ...deriveBillingDenormalizedFields(currReading), + payment_status: "pending" as const, + }; + }) ); return created; }, async search(userId: string, options: BillingSearchOptions): Promise> { const cachedRepo = new CachedRepository(billingRepository, userId, "billings", CACHE_TTL); - return cachedRepo.search({ + + // Archived queries go direct to Firestore, same as billing-cycle.service.ts — + // range filters can be pushed down to the query since there's no list cache involved. + if (options.archived) { + const filters: Record = { + ...(options.propertyId ? {property_id: options.propertyId} : {}), + ...(options.meterGroupId ? {meter_group_id: options.meterGroupId} : {}), + }; + if (options.startDate) { + filters.billing_period_date = {...filters.billing_period_date, gte: new Date(options.startDate)}; + } + if (options.endDate) { + filters.billing_period_date = {...filters.billing_period_date, lte: new Date(options.endDate)}; + } + return billingRepository.search({ + limit: options.limit, + orderBy: (options.sortBy ?? "created_at") as any, + orderDirection: options.sortOrder ?? "desc", + cursor: options.cursor, + archived: true, + filters, + }); + } + + // Active items: load the cached list, apply equality filters via CachedRepository, + // then post-filter date range in memory — CachedRepository.applyFilters rejects range + // filters outright, so date filtering can't be pushed into the same call. Mirrors the + // same two-step pattern billing-cycle.service.ts uses for its date filters. + const result = await cachedRepo.search({ limit: options.limit, orderBy: (options.sortBy ?? "created_at") as any, orderDirection: options.sortOrder ?? "desc", cursor: options.cursor, - archived: options.archived, + archived: false, filters: { ...(options.propertyId ? {property_id: options.propertyId} : {}), + ...(options.meterGroupId ? {meter_group_id: options.meterGroupId} : {}), }, }); + + if (options.startDate || options.endDate) { + if (options.startDate) { + const startDate = new Date(options.startDate); + result.data = result.data.filter((b) => { + const periodDate = b.billing_period_date instanceof Date ? + b.billing_period_date : + new Date(b.billing_period_date as any); + return periodDate >= startDate; + }); + } + if (options.endDate) { + const endDate = new Date(options.endDate); + result.data = result.data.filter((b) => { + const periodDate = b.billing_period_date instanceof Date ? + b.billing_period_date : + new Date(b.billing_period_date as any); + return periodDate <= endDate; + }); + } + } + + return result; }, async getById(userId: string, id: string): Promise { @@ -117,21 +205,53 @@ export const billingService = { }, async update(userId: string, id: string, data: Partial & { payment_status?: "pending" | "paid"; paid_at?: string }): Promise { - await validator.validateUpdate(data); + await validator.validateUpdate(id, data); - const updateData = {...data}; + const updateData: Record = {...data}; if (data.payment_status === "paid" && !data.paid_at) { updateData.paid_at = new Date().toISOString(); } + // Keep the denormalized fields in sync when the current reading changes via + // the PATCH correction escape hatch — otherwise meter_group_id / + // billing_period_date would silently point at the old reading. + if (data.current_reading_id) { + const currReading = await readingRepository.getById(data.current_reading_id); + if (!currReading) { + throw new AppError(404, "Current reading not found"); + } + Object.assign(updateData, deriveBillingDenormalizedFields(currReading)); + } + const cachedRepo = new CachedRepository(billingRepository, userId, "billings", CACHE_TTL); return cachedRepo.update(id, updateData); }, async updateBatch(userId: string, updates: {id: string, data: Partial}[]): Promise { await validator.validateUpdateBatch(updates); + + // Re-derive denormalized fields for any item whose current reading changes, + // mirroring update()'s single-item sync. + const changedReadingIds = Array.from( + new Set(updates.map((u) => u.data.current_reading_id).filter((x): x is string => !!x)) + ); + const readingById = new Map>>(); + if (changedReadingIds.length > 0) { + const readings = await readingRepository.getByIds(changedReadingIds); + readings.forEach((r) => r && readingById.set(r.id, r)); + } + const enriched = updates.map((u) => { + if (u.data.current_reading_id) { + const currReading = readingById.get(u.data.current_reading_id); + if (currReading) { + return {id: u.id, data: {...u.data, ...deriveBillingDenormalizedFields(currReading)}}; + } + } + return u; + }); + const cachedRepo = new CachedRepository(billingRepository, userId, "billings", CACHE_TTL); - return cachedRepo.updateBatch(updates); + return cachedRepo.updateBatch(enriched); }, async delete(userId: string, id: string): Promise { @@ -198,6 +318,7 @@ export const billingService = { property_id: propertyId, previous_reading_id: prevReadingId, current_reading_id: currReadingId, + ...deriveBillingDenormalizedFields(currReading as any), payment_status: "pending" as const, created_at: FieldValue.serverTimestamp(), is_deleted: false, diff --git a/api/functions/src/features/billing/billing.swagger.ts b/api/functions/src/features/billing/billing.swagger.ts index bfd2490..80762b7 100644 --- a/api/functions/src/features/billing/billing.swagger.ts +++ b/api/functions/src/features/billing/billing.swagger.ts @@ -86,6 +86,31 @@ export const billingPaths = { minLength: 1, }, }, + { + name: "meterGroupId", + in: "query", + description: "Filter by meter group ID", + schema: { + type: "string", + minLength: 1, + }, + }, + { + name: "startDate", + in: "query", + description: "Filter by billing_period_date >= startDate (ISO 8601)", + schema: { + type: "string", + }, + }, + { + name: "endDate", + in: "query", + description: "Filter by billing_period_date <= endDate (ISO 8601)", + schema: { + type: "string", + }, + }, { name: "limit", in: "query", diff --git a/api/functions/src/features/billing/billing.test.ts b/api/functions/src/features/billing/billing.test.ts index 1e7afc5..6b2a625 100644 --- a/api/functions/src/features/billing/billing.test.ts +++ b/api/functions/src/features/billing/billing.test.ts @@ -1,5 +1,6 @@ jest.mock('./billing.repository'); jest.mock('./billing.validator'); +jest.mock('../reading/reading.repository'); // Mock Firestore transaction used in billingService.create jest.mock('../../config/firebase.config', () => { @@ -31,6 +32,7 @@ jest.mock('../../config/firebase.config', () => { import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { billingService } from './billing.service'; import { billingRepository } from './billing.repository'; +import { readingRepository } from '../reading/reading.repository'; import { BillingValidator } from './billing.validator'; import { CreateBillingDTOSchema, UpdateBillingDTOSchema, BillingByIdParamsDTOSchema, CreateBillingBatchDTOSchema, UpdateBillingBatchDTOSchema } from './billing.dto'; import { AppError } from '../../utils/error.util'; @@ -80,6 +82,10 @@ describe('billingService', () => { const mocks = [mockBilling({ id: 'billing-1' }), mockBilling({ id: 'billing-2' })]; jest.mocked(BillingValidator.prototype.validateBatch).mockResolvedValue(undefined); jest.mocked(billingRepository.createBatch).mockResolvedValue(mocks); + jest.mocked(readingRepository.getByIds).mockResolvedValue([ + { id: 'reading-2', meter_group_id: 'mg-1', reading_date: now } as any, + { id: 'reading-3', meter_group_id: 'mg-1', reading_date: now } as any, + ]); const input = [ { property_id: 'prop-1', previous_reading_id: 'reading-1', current_reading_id: 'reading-2' }, diff --git a/api/functions/src/features/billing/billing.validator.test.ts b/api/functions/src/features/billing/billing.validator.test.ts index 39a6d6d..2e5e73e 100644 --- a/api/functions/src/features/billing/billing.validator.test.ts +++ b/api/functions/src/features/billing/billing.validator.test.ts @@ -115,4 +115,53 @@ describe('BillingValidator', () => { })).rejects.toMatchObject({ statusCode: 404 }); }); }); + + describe('validateUpdate — correction escape hatch', () => { + const storedBilling = { + id: 'billing-1', + property_id: 'prop-1', + previous_reading_id: 'r-prev', + current_reading_id: 'r-old', + }; + + it('skips cross-entity validation when no reading id changes', async () => { + await expect(validator.validateUpdate('billing-1', {})).resolves.toBeUndefined(); + }); + + it('rejects a lone current_reading_id PATCH pointing at a different meter group', async () => { + jest.mocked(billingRepository.getById).mockResolvedValue(storedBilling as any); + jest.mocked(propertyRepository.getById).mockResolvedValue(mockProperty() as any); + jest.mocked(readingRepository.getById).mockImplementation(async (id: string) => { + if (id === 'r-prev') return mockReading('r-prev', 'mg-1', 100) as any; + if (id === 'r-new') return mockReading('r-new', 'mg-2', 200) as any; + return null; + }); + + await expect( + validator.validateUpdate('billing-1', { current_reading_id: 'r-new' }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('resolves a lone current_reading_id PATCH when same meter group and greater', async () => { + jest.mocked(billingRepository.getById).mockResolvedValue(storedBilling as any); + jest.mocked(propertyRepository.getById).mockResolvedValue(mockProperty() as any); + jest.mocked(readingRepository.getById).mockImplementation(async (id: string) => { + if (id === 'r-prev') return mockReading('r-prev', 'mg-1', 100) as any; + if (id === 'r-new') return mockReading('r-new', 'mg-1', 300) as any; + return null; + }); + + await expect( + validator.validateUpdate('billing-1', { current_reading_id: 'r-new' }) + ).resolves.toBeUndefined(); + }); + + it('rejects when the billing to update no longer exists', async () => { + jest.mocked(billingRepository.getById).mockResolvedValue(null); + + await expect( + validator.validateUpdate('billing-1', { current_reading_id: 'r-new' }) + ).rejects.toMatchObject({ statusCode: 404 }); + }); + }); }); diff --git a/api/functions/src/features/billing/billing.validator.ts b/api/functions/src/features/billing/billing.validator.ts index 001ed0d..3c701b4 100644 --- a/api/functions/src/features/billing/billing.validator.ts +++ b/api/functions/src/features/billing/billing.validator.ts @@ -158,42 +158,58 @@ export class BillingValidator { } async validateUpdate( + id: string, data: Partial ): Promise { - if ( - data.property_id || - data.previous_reading_id || - data.current_reading_id - ) { - const propertyId = data.property_id; - const previousReadingId = data.previous_reading_id; - const currentReadingId = data.current_reading_id; - - if (propertyId && previousReadingId && currentReadingId) { - const [property, previousReading, currentReading] = await Promise.all([ - propertyRepository.getById(propertyId), - readingRepository.getById(previousReadingId), - readingRepository.getById(currentReadingId), - ]); - - if (!property) { - throw new AppError(404, "Property not found"); - } - if (!previousReading || !currentReading) { - throw new AppError(404, "Reading not found"); - } - - await this.validateReadingsBelongToProperty(property, previousReading, currentReading); - this.validatePropertyIsNotMainMeter(property, currentReading.meter_group_id); + // Only a change to one of the readings needs cross-entity re-validation. + const changesReadingPair = + data.previous_reading_id !== undefined || data.current_reading_id !== undefined; + + if (!changesReadingPair) { + return; + } + + // The correction escape hatch: a PATCH may send only current_reading_id (or + // only previous_reading_id). Resolve the full triple, falling back to the + // stored billing for whatever isn't in this PATCH, so a lone reading change + // still passes the same-meter-group / rollback / main-meter checks instead + // of silently skipping them. + let propertyId = data.property_id; + let previousReadingId = data.previous_reading_id; + let currentReadingId = data.current_reading_id; + + if (!propertyId || !previousReadingId || !currentReadingId) { + const existing = await billingRepository.getById(id); + if (!existing) { + throw new AppError(404, "Billing not found"); } + propertyId = propertyId ?? existing.property_id; + previousReadingId = previousReadingId ?? existing.previous_reading_id; + currentReadingId = currentReadingId ?? existing.current_reading_id; + } + + const [property, previousReading, currentReading] = await Promise.all([ + propertyRepository.getById(propertyId), + readingRepository.getById(previousReadingId), + readingRepository.getById(currentReadingId), + ]); + + if (!property) { + throw new AppError(404, "Property not found"); + } + if (!previousReading || !currentReading) { + throw new AppError(404, "Reading not found"); } + + await this.validateReadingsBelongToProperty(property, previousReading, currentReading); + this.validatePropertyIsNotMainMeter(property, currentReading.meter_group_id); } async validateUpdateBatch( updates: {id: string; data: Partial}[] ): Promise { - for (const {data} of updates) { - await this.validateUpdate(data); + for (const {id, data} of updates) { + await this.validateUpdate(id, data); } } } diff --git a/ui/src/lib/api/billing-cycles.ts b/ui/src/lib/api/billing-cycles.ts index 5ca20bd..12a0046 100644 --- a/ui/src/lib/api/billing-cycles.ts +++ b/ui/src/lib/api/billing-cycles.ts @@ -7,6 +7,7 @@ import type { import type { PaginatedResult, BatchCreateResult } from '$lib/types/api.types'; export async function getBillingCycles(params?: { + meterGroupId?: string; billingStartDate?: string; billingEndDate?: string; limit?: number; @@ -14,6 +15,7 @@ export async function getBillingCycles(params?: { archived?: boolean; }): Promise> { const query = new URLSearchParams(); + if (params?.meterGroupId) query.set('meterGroupId', params.meterGroupId); if (params?.billingStartDate) query.set('billingStartDate', params.billingStartDate); if (params?.billingEndDate) query.set('billingEndDate', params.billingEndDate); if (params?.limit) query.set('limit', params.limit.toString()); diff --git a/ui/src/lib/api/billings.ts b/ui/src/lib/api/billings.ts index 01a79e5..094eed6 100644 --- a/ui/src/lib/api/billings.ts +++ b/ui/src/lib/api/billings.ts @@ -4,12 +4,18 @@ import type { PaginatedResult } from '$lib/types/api.types'; export async function getBillings(params?: { propertyId?: string; + meterGroupId?: string; + startDate?: string; + endDate?: string; limit?: number; cursor?: string; archived?: boolean; }): Promise> { const query = new URLSearchParams(); if (params?.propertyId) query.set('propertyId', params.propertyId); + if (params?.meterGroupId) query.set('meterGroupId', params.meterGroupId); + if (params?.startDate) query.set('startDate', params.startDate); + if (params?.endDate) query.set('endDate', params.endDate); if (params?.limit) query.set('limit', params.limit.toString()); if (params?.cursor) query.set('cursor', params.cursor); if (params?.archived) query.set('archived', 'true'); diff --git a/ui/src/lib/types/billing.types.ts b/ui/src/lib/types/billing.types.ts index b776d03..17558ef 100644 --- a/ui/src/lib/types/billing.types.ts +++ b/ui/src/lib/types/billing.types.ts @@ -1,9 +1,11 @@ -import type { BaseModel } from './api.types'; +import type { BaseModel, FirestoreTimestamp } from './api.types'; export interface Billing extends BaseModel { property_id: string; previous_reading_id: string; current_reading_id: string; + meter_group_id: string; + billing_period_date: FirestoreTimestamp; payment_status: 'pending' | 'paid'; paid_at?: string; } From b626625d01dddb18f3a799eb01d2260f9097b7c6 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 07:00:08 +0800 Subject: [PATCH 22/37] chore(billing): add one-time meter-group/period-date backfill script Idempotent Admin SDK script that denormalizes meter_group_id / billing_period_date onto existing Billing docs, resolved from each billing's current_reading_id. --dry-run (default) writes a report and performs no writes; --apply writes in Firestore batches. Already-backfilled docs are skipped, so re-runs are safe. Excludes scripts/** from the eslint "src" scope and adds a backfill:billing-meter-group npm script. Applied against staging: 365/365 billings resolved, 0 failures (verified idempotent on re-run). Co-Authored-By: Claude Opus 4.8 --- api/functions/.eslintrc.js | 1 + api/functions/package.json | 3 +- .../scripts/backfill-billing-meter-group.ts | 129 ++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 api/functions/scripts/backfill-billing-meter-group.ts diff --git a/api/functions/.eslintrc.js b/api/functions/.eslintrc.js index 4c34c83..c8505ce 100644 --- a/api/functions/.eslintrc.js +++ b/api/functions/.eslintrc.js @@ -27,6 +27,7 @@ "jest.config.ts", "jest.setup.ts", "seed.js", + "scripts/**/*", // One-off admin scripts, outside the main tsconfig "src" scope. ], plugins: [ "@typescript-eslint", diff --git a/api/functions/package.json b/api/functions/package.json index 2388eed..567b344 100644 --- a/api/functions/package.json +++ b/api/functions/package.json @@ -15,7 +15,8 @@ "deploy": "firebase deploy --only functions", "deploy:staging": "cross-env APP_ENV=staging firebase deploy --project staging --only functions", "deploy:prod": "cross-env APP_ENV=production firebase deploy --project production --only functions", - "logs": "firebase functions:log" + "logs": "firebase functions:log", + "backfill:billing-meter-group": "ts-node scripts/backfill-billing-meter-group.ts" }, "engines": { "node": "24" diff --git a/api/functions/scripts/backfill-billing-meter-group.ts b/api/functions/scripts/backfill-billing-meter-group.ts new file mode 100644 index 0000000..bd5b97a --- /dev/null +++ b/api/functions/scripts/backfill-billing-meter-group.ts @@ -0,0 +1,129 @@ +/** + * One-time backfill: denormalizes meter_group_id + billing_period_date onto existing + * Billing documents, resolved from each billing's current_reading_id. Idempotent — already + * backfilled billings are skipped, so it's safe to re-run. + * + * Usage (from api/functions/): + * APP_ENV=staging GOOGLE_APPLICATION_CREDENTIALS=... npx ts-node scripts/backfill-billing-meter-group.ts --dry-run + * APP_ENV=staging GOOGLE_APPLICATION_CREDENTIALS=... npx ts-node scripts/backfill-billing-meter-group.ts --apply + * + * --dry-run (default if neither flag is passed) writes a report to + * scripts/backfill-billing-meter-group.report.json and performs no writes. + * --apply performs the writes, in batches of 500 (Firestore batch limit). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import {firestore} from "../src/config/firebase.config"; +import {COLLECTIONS} from "../src/constants/collection.constants"; + +const BATCH_LIMIT = 500; +const REPORT_PATH = path.join(__dirname, "backfill-billing-meter-group.report.json"); + +interface ReportEntry { + billingId: string; + currentReadingId: string; + status: "resolved" | "already-backfilled" | "reading-not-found"; + meterGroupId?: string; + billingPeriodDate?: string; +} + +async function main() { + const apply = process.argv.includes("--apply"); + const mode = apply ? "apply" : "dry-run"; + console.log(`Running backfill in ${mode} mode...`); + + const billingsSnap = await firestore.collection(COLLECTIONS.BILLINGS).get(); + console.log(`Found ${billingsSnap.size} billing documents.`); + + const report: ReportEntry[] = []; + const toWrite: { ref: FirebaseFirestore.DocumentReference; meterGroupId: string; billingPeriodDate: FirebaseFirestore.Timestamp }[] = []; + + // Firestore getAll() accepts many refs in one round trip; chunk to stay well under + // request-size limits. + const readingIds = Array.from( + new Set(billingsSnap.docs.map((d) => d.data().current_reading_id as string).filter(Boolean)) + ); + const readingById = new Map(); + const CHUNK = 300; + for (let i = 0; i < readingIds.length; i += CHUNK) { + const chunk = readingIds.slice(i, i + CHUNK); + const refs = chunk.map((id) => firestore.collection(COLLECTIONS.READINGS).doc(id)); + const snaps = await firestore.getAll(...refs); + snaps.forEach((snap, idx) => { + if (snap.exists) readingById.set(chunk[idx], snap.data()!); + }); + } + + for (const doc of billingsSnap.docs) { + const data = doc.data(); + const currentReadingId = data.current_reading_id as string; + + if (data.meter_group_id && data.billing_period_date) { + report.push({ + billingId: doc.id, + currentReadingId, + status: "already-backfilled", + meterGroupId: data.meter_group_id, + }); + continue; + } + + const reading = readingById.get(currentReadingId); + if (!reading) { + report.push({billingId: doc.id, currentReadingId, status: "reading-not-found"}); + continue; + } + + report.push({ + billingId: doc.id, + currentReadingId, + status: "resolved", + meterGroupId: reading.meter_group_id, + billingPeriodDate: reading.reading_date?.toDate?.().toISOString(), + }); + toWrite.push({ + ref: doc.ref, + meterGroupId: reading.meter_group_id, + billingPeriodDate: reading.reading_date, + }); + } + + const resolved = report.filter((r) => r.status === "resolved").length; + const alreadyDone = report.filter((r) => r.status === "already-backfilled").length; + const failed = report.filter((r) => r.status === "reading-not-found").length; + console.log(`Resolved: ${resolved}, already backfilled: ${alreadyDone}, failed: ${failed}`); + + fs.writeFileSync(REPORT_PATH, JSON.stringify({mode, resolved, alreadyDone, failed, entries: report}, null, 2)); + console.log(`Report written to ${REPORT_PATH}`); + + if (!apply) { + console.log("Dry-run complete. No writes performed. Re-run with --apply to write changes."); + return; + } + + if (failed > 0) { + console.warn(`WARNING: ${failed} billing(s) could not be resolved (see report) — these will remain un-backfilled.`); + } + + for (let i = 0; i < toWrite.length; i += BATCH_LIMIT) { + const chunk = toWrite.slice(i, i + BATCH_LIMIT); + const batch = firestore.batch(); + for (const item of chunk) { + batch.update(item.ref, { + meter_group_id: item.meterGroupId, + billing_period_date: item.billingPeriodDate, + }); + } + await batch.commit(); + console.log(`Wrote batch ${i / BATCH_LIMIT + 1} (${chunk.length} documents).`); + } + + console.log(`Apply complete. ${toWrite.length} billing document(s) updated.`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); From 4fab1dae23da5e911cbc39a3735ded16adaf1192 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 07:00:31 +0800 Subject: [PATCH 23/37] docs: correct meter-version migration status (main meters stay on MeterGroup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "move version tracking to Property for BOTH submeters and main meters" plan was only partially carried out: submeters moved, but main meters deliberately stayed on MeterGroup.current_version/.versions (propertyService rejects main-meter resets and routes them to meterGroupService; resolveVersionsSource reads MeterGroup for main meters). No backfill script was ever written and none is pending. Runtime is self-consistent; the risk was documentation debt — a future dev trusting the @deprecated tag or "migration pending" framing could delete the still-live main-meter fields and break every main-meter consumption calculation. Corrects: - meter-group.model.ts — JSDoc now says deprecated for submeters only; main-meter fields are live and required, do not delete - root CLAUDE.md — meter-groups feature bullet (The decisions/ log files are gitignored and updated locally only.) Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- .../src/features/meter-group/meter-group.model.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e4d5a2..3e8ca50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,7 +229,7 @@ Each page/component is organized by: ### API Features (Complete + Audited May 2026) -- ✅ Meter Groups (CRUD, batch; dynamic sorting; `POST /:id/reset` and its `current_version`/`versions` fields are **@deprecated** — version tracking now lives per-property on `Property.meter_groups[entry]`, see `decisions/`) +- ✅ Meter Groups (CRUD, batch; dynamic sorting; **submeter** version tracking lives per-property on `Property.meter_groups[entry]`, but `POST /:id/reset` and `MeterGroup.current_version`/`versions` are **still the live source of truth for main meters** — the "migrate main meters too" backfill was never done and is not pending, so do NOT treat those fields as fully deprecated or delete them, see `decisions/20260608_meter-group-version-tracking-moved-to-property.md` Amendment 2026-07-19) - ✅ Properties (CRUD, batch; dynamic sorting; optimized duplicate detection) - ✅ Tenants (CRUD, batch; dynamic sorting) - ✅ Readings (CRUD, batch; auto-billing on single create; anomaly guard; meter rollback prevention; utility extraction) diff --git a/api/functions/src/features/meter-group/meter-group.model.ts b/api/functions/src/features/meter-group/meter-group.model.ts index 8851af0..48b201c 100644 --- a/api/functions/src/features/meter-group/meter-group.model.ts +++ b/api/functions/src/features/meter-group/meter-group.model.ts @@ -11,15 +11,18 @@ export interface MeterGroup extends BaseModel { meter_name: string; utility_type: UtilityType; /** - * @deprecated Version tracking has moved to `Property.meter_groups[entry].current_version`. - * Submeter entries already track their own versions independently; main-meter entries are - * pending backfill (see `migrations/backfill-property-meter-versions.ts`). Do not write new - * data here — read paths should resolve from the property entry once that backfill lands. + * Deprecated FOR SUBMETERS ONLY — submeter version tracking moved to + * `Property.meter_groups[entry].current_version`. For **main meters this is still the live, + * required source of truth** for `Reading.meter_version`: `propertyService.recordMeterGroupReset` + * rejects main-meter resets and routes them here, and `resolveVersionsSource`/`resolveMeterVersion` + * read this field for main meters. The planned "migrate main meters too" backfill was never done + * and is not pending — do NOT delete this field. See + * `decisions/20260608_meter-group-version-tracking-moved-to-property.md` (Amendment 2026-07-19). */ current_version: number; /** - * @deprecated Version history has moved to `Property.meter_groups[entry].versions`. - * See `current_version` deprecation note for migration status. + * Deprecated FOR SUBMETERS ONLY — see `current_version`. Still the live source of version history + * for **main meters**; do not delete. */ versions: Record; } From 2c828d60ba8566b81c095319ec1efe42bd0093f7 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 07:01:52 +0800 Subject: [PATCH 24/37] chore: gitignore generated backfill report artifact Co-Authored-By: Claude Opus 4.8 --- api/functions/.gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/functions/.gitignore b/api/functions/.gitignore index 2e00864..d47a097 100644 --- a/api/functions/.gitignore +++ b/api/functions/.gitignore @@ -13,4 +13,7 @@ secrets/ .env logs/ -src/migrations/ \ No newline at end of file +src/migrations/ + +# Generated report from the one-off backfill script (runtime artifact) +scripts/*.report.json \ No newline at end of file From 3137780bee0f6c3cc59909e8aaef81be4af73232 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 10:33:11 +0800 Subject: [PATCH 25/37] fix(api): correct date-range filters on billing & billing-cycle list endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-list in-memory date post-filters compared `new Date(billing_start_date)` / `new Date(billing_period_date)`, but at that layer the value is a Firestore Timestamp (or a Redis-round-tripped {_seconds,_nanoseconds}), not an ISO string — so `new Date()` produced `Invalid Date` and every comparison was false, making `GET /billing-cycles?billingStartDate=`/`billingEndDate=` and `GET /billings?startDate=`/`endDate=` return 0 rows for ANY input. Fixed by converting with the existing `parseTimestamp(x).toDate()` helper. This was a pre-existing bug in billing-cycle.service and the identical pattern Landing 1 copied into billing.service; the Landing 2 dashboard depends on the cycle filter. Verified live against staging: billingStartDate 2020 -> 98, 2025-07-19 -> 33, 2099 -> 0; billings startDate 2020 -> 100, meterGroupId+startDate -> 99. Suite 349/349. Co-Authored-By: Claude Opus 4.8 --- .../billing-cycle/billing-cycle.service.ts | 16 +++++++-------- .../src/features/billing/billing.service.ts | 20 +++++++------------ 2 files changed, 14 insertions(+), 22 deletions(-) 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 26bb4ee..fa9e9ef 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.service.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.service.ts @@ -11,7 +11,7 @@ import {findPreviousMonthReading, findCurrentMonthReading} from "../reading/read 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"; const validator = new BillingCycleValidator(); @@ -233,17 +233,15 @@ export const billingCycleService = { if (options.billingStartDate || options.billingEndDate) { if (options.billingStartDate) { const startDate = new Date(options.billingStartDate); - result.data = result.data.filter((bc) => { - const bcDate = bc.billing_start_date instanceof Date ? bc.billing_start_date : new Date(bc.billing_start_date as any); - return bcDate >= startDate; - }); + result.data = result.data.filter( + (bc) => parseTimestamp(bc.billing_start_date).toDate() >= startDate + ); } if (options.billingEndDate) { const endDate = new Date(options.billingEndDate); - result.data = result.data.filter((bc) => { - const bcDate = bc.billing_end_date instanceof Date ? bc.billing_end_date : new Date(bc.billing_end_date as any); - return bcDate <= endDate; - }); + result.data = result.data.filter( + (bc) => parseTimestamp(bc.billing_end_date).toDate() <= endDate + ); } } diff --git a/api/functions/src/features/billing/billing.service.ts b/api/functions/src/features/billing/billing.service.ts index a4ef991..39f3fe9 100644 --- a/api/functions/src/features/billing/billing.service.ts +++ b/api/functions/src/features/billing/billing.service.ts @@ -7,7 +7,7 @@ import {PaginatedResult} from "../../utils/pagination.util"; import {BillingValidator} from "./billing.validator"; import {AppError} from "../../utils/error.util"; import {COLLECTIONS} from "../../constants/collection.constants"; -import {snapshotToModel} from "../../utils/firestore.util"; +import {snapshotToModel, parseTimestamp} from "../../utils/firestore.util"; import {validateMeterRollback} from "../reading/reading.util"; import {cacheSet} from "../../utils/cache.util"; import {CachedRepository} from "../../lib/cached-repository.lib"; @@ -178,21 +178,15 @@ export const billingService = { if (options.startDate || options.endDate) { if (options.startDate) { const startDate = new Date(options.startDate); - result.data = result.data.filter((b) => { - const periodDate = b.billing_period_date instanceof Date ? - b.billing_period_date : - new Date(b.billing_period_date as any); - return periodDate >= startDate; - }); + result.data = result.data.filter( + (b) => parseTimestamp(b.billing_period_date).toDate() >= startDate + ); } if (options.endDate) { const endDate = new Date(options.endDate); - result.data = result.data.filter((b) => { - const periodDate = b.billing_period_date instanceof Date ? - b.billing_period_date : - new Date(b.billing_period_date as any); - return periodDate <= endDate; - }); + result.data = result.data.filter( + (b) => parseTimestamp(b.billing_period_date).toDate() <= endDate + ); } } From 9daa5721398262f91096c8ebe9492f21248982df Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 10:33:31 +0800 Subject: [PATCH 26/37] feat(ui): scope billings page & dashboard fetches (Landing 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop pulling whole collections into memory on every visit, using the denormalized fields + scoped filters from Landing 1. - New ui/src/lib/stores/entity-lookup-cache.ts: persistent ID->entity caches (getBillingsByIds / getReadingsByIds) resolving only the IDs visible cycles reference, via the API's per-ID getById cache. - Billings page: removed the full `readings` load. Discovery/gap-fill/overrides, edit modal, and straggler now fetch readings scoped by meterGroupId/propertyId; expanded/printed cycles resolve reading amounts via getReadingsByIds. autoCalculateCycleDates now queries getBillingCycles({ meterGroupId }) — one precise read, fixing the latent bug where it silently reset dates to the current month once cycle volume exceeded one page. (allBillings stays a full load: the payment-status filter spans all cycles and has no scoped server query — see decisions/20260719_landing2-deviations-and-reports-finding.md.) - Dashboard: cycles fetched date-bounded to a 12-month window; their billings resolved via getBillingsByIds. Kept the existing raw-consumption stat math rather than switching to getSummaryReport (which uses version-aware consumption and would change the displayed totals) — behavior-preserving. - createListStore: page at limit 100 with cursors instead of one limit:1000 request; .data stays the complete list so consumers are unaffected. Verified: svelte-check 0/0, eslint 0 errors, prettier-clean on touched files; every scoped query smoke-tested live against staging. Co-Authored-By: Claude Opus 4.8 --- ui/src/lib/stores/entity-lookup-cache.ts | 74 +++++++ ui/src/lib/stores/list-store.svelte.ts | 18 +- ui/src/routes/(app)/billings/+page.svelte | 215 ++++++++++++++------- ui/src/routes/(app)/dashboard/+page.svelte | 46 +++-- 4 files changed, 259 insertions(+), 94 deletions(-) create mode 100644 ui/src/lib/stores/entity-lookup-cache.ts diff --git a/ui/src/lib/stores/entity-lookup-cache.ts b/ui/src/lib/stores/entity-lookup-cache.ts new file mode 100644 index 0000000..74b5484 --- /dev/null +++ b/ui/src/lib/stores/entity-lookup-cache.ts @@ -0,0 +1,74 @@ +import { getBillingById } from '$lib/api/billings'; +import { getReadingById } from '$lib/api/readings'; +import type { Billing } from '$lib/types/billing.types'; +import type { Reading } from '$lib/types/reading.types'; + +/** + * Plain (non-Svelte-state) in-memory ID → entity caches for billings and readings. + * + * The denormalized meter_group_id/billing_period_date fields (Landing 1) let the pages + * fetch scoped *lists* directly, so this only handles ID-based lookups: resolving the + * billing/reading IDs a set of visible billing cycles references, without pulling whole + * collections. Per-ID reads hit the API's CachedRepository.getById cache, which is far + * less prone to invalidation churn than the per-collection list cache (a single mark-paid + * invalidates the whole list, but only one ID entry). + * + * Only uncached IDs are fetched; results are merged into a persistent module-level cache. + * Call invalidateEntityLookupCache() after mutations, alongside the existing reload paths. + */ + +const billingCache = new Map(); +const readingCache = new Map(); + +async function resolveByIds( + ids: string[], + cache: Map, + fetchById: (id: string) => Promise +): Promise> { + const uniqueIds = Array.from(new Set(ids.filter((id) => id && id.trim()))); + const missing = uniqueIds.filter((id) => !cache.has(id)); + + if (missing.length > 0) { + const fetched = await Promise.all( + missing.map(async (id) => { + try { + return await fetchById(id); + } catch { + // A missing/soft-deleted entity resolves to undefined rather than aborting + // the whole batch — callers already treat absent IDs as "unknown". + return undefined; + } + }) + ); + for (const entity of fetched) { + if (entity) cache.set(entity.id, entity); + } + } + + // Return only the requested subset, in a fresh map the caller owns. + const result = new Map(); + for (const id of uniqueIds) { + const entity = cache.get(id); + if (entity) result.set(id, entity); + } + return result; +} + +export async function getBillingsByIds(ids: string[]): Promise> { + return resolveByIds(ids, billingCache, getBillingById); +} + +export async function getReadingsByIds(ids: string[]): Promise> { + return resolveByIds(ids, readingCache, getReadingById); +} + +/** Update a single cached billing in place (e.g. after a local mark-as-paid) without a refetch. */ +export function setCachedBilling(billing: Billing): void { + billingCache.set(billing.id, billing); +} + +/** Clear both caches. Call after mutations that may change billing/reading data. */ +export function invalidateEntityLookupCache(): void { + billingCache.clear(); + readingCache.clear(); +} diff --git a/ui/src/lib/stores/list-store.svelte.ts b/ui/src/lib/stores/list-store.svelte.ts index bcc66c3..209e736 100644 --- a/ui/src/lib/stores/list-store.svelte.ts +++ b/ui/src/lib/stores/list-store.svelte.ts @@ -1,12 +1,15 @@ interface PaginatedResult { data: T[]; + hasMore?: boolean; + nextCursor?: string | null; } const STALE_MS = 5 * 60 * 1000; // 5 minutes +const PAGE_SIZE = 100; /** Shared cache-with-staleness pattern behind meterGroupsStore/propertiesStore/tenantsStore. */ export function createListStore( - fetchFn: (params: { limit: number }) => Promise> + fetchFn: (params: { limit: number; cursor?: string }) => Promise> ) { let _data = $state([]); let _lastFetched = $state(null); @@ -34,8 +37,17 @@ export function createListStore( _isLoading = true; try { - const result = await fetchFn({ limit: 1000 }); - _data = result.data; + // Page through with cursors at a uniform limit instead of one oversized request, + // matching the pattern used everywhere else. Consumers still get the full list in + // _data — these collections are small, so assembling all pages is cheap. + const all: T[] = []; + let cursor: string | undefined; + do { + const result = await fetchFn({ limit: PAGE_SIZE, cursor }); + all.push(...result.data); + cursor = result.hasMore ? (result.nextCursor ?? undefined) : undefined; + } while (cursor); + _data = all; _lastFetched = Date.now(); return _data; } finally { diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index 46066c3..74e5569 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -11,6 +11,7 @@ } from '$lib/api/billing-cycles'; import { getBillings, createBilling, updateBilling, softDeleteBilling } from '$lib/api/billings'; import { getReadings } from '$lib/api/readings'; + import { getReadingsByIds, invalidateEntityLookupCache } from '$lib/stores/entity-lookup-cache'; import { getProperties } from '$lib/api/properties'; import { getMeterGroups } from '$lib/api/meter-groups'; import type { BillingCycle, UpdateBillingCycleRequest } from '$lib/types/billing-cycle.types'; @@ -35,8 +36,20 @@ let cycles = $state([]); let billings: SvelteMap = new SvelteMap(); + // allBillings stays a full load: the payment-status filter (getCyclePaymentStatus) filters + // ALL cycles by whether every billing under them is paid, which needs every billing's status + // — there's no scoped server query for that. Readings, by contrast, are fetched scoped below. let allBillings = $state([]); - let readings = $state([]); + // Readings are no longer bulk-loaded. Scoped, purpose-specific reading sets replace the old + // full `readings` array: + // - cycleFormReadings: the selected meter group's readings (discovery / gap-fill / overrides) + // - editReadings: the edited billing's property's readings (edit modal selects) + // - stragglerReadings: the straggler property's readings on the cycle's meter group + // - readingMap: prev/current readings for the billings of expanded / printed cycles + let cycleFormReadings = $state([]); + let editReadings = $state([]); + let stragglerReadings = $state([]); + let readingMap: SvelteMap = new SvelteMap(); let properties = $state([]); let meterGroups = $state([]); let isLoading = $state(false); @@ -127,9 +140,9 @@ } const billingCycleReadings = $derived.by(() => { - if (!readings || !cycleFormMeterGroup || !cycleFormEndDate) return []; + if (!cycleFormReadings.length || !cycleFormMeterGroup || !cycleFormEndDate) return []; const endDate = new Date(cycleFormEndDate); - return readings.filter((reading) => { + return cycleFormReadings.filter((reading) => { if (reading.meter_group_id !== cycleFormMeterGroup) return false; const readingDate = toDate(reading.reading_date); return ( @@ -139,18 +152,25 @@ }); }); - // Filter readings for edit modal based on selected property's meter groups - const editModalReadings = $derived.by(() => { - if (!readings || !editData.property_id) return readings || []; - const selectedProp = properties.find((p) => p.id === editData.property_id); - if (!selectedProp) return readings; - - const meterGroupIds = Object.values(selectedProp.meter_groups || {}) - .filter((e): e is any => e !== undefined && e !== null) - .map((e) => e.meter_group_id); - return readings.filter( - (r) => meterGroupIds.includes(r.meter_group_id) && r.property_id === editData.property_id - ); + // Edit-modal reading options — fetched scoped to the edited billing's property (a property's + // readings are all for its own meter groups, so propertyId scoping is sufficient). + $effect(() => { + const propertyId = editData.property_id; + if (!propertyId) { + editReadings = []; + return; + } + let cancelled = false; + getReadings({ propertyId, limit: 100 }) + .then((res) => { + if (!cancelled) editReadings = res.data; + }) + .catch(() => { + if (!cancelled) editReadings = []; + }); + return () => { + cancelled = true; + }; }); // Get utility type for cycle form meter group @@ -195,20 +215,21 @@ isLoading = true; error = ''; try { - const [cyclesData, billingsData, readingsData, propertiesResult, meterGroupsResult] = - await Promise.all([ - fetchAllPages((cursor) => getBillingCycles({ limit: 100, cursor })), - fetchAllPages((cursor) => getBillings({ limit: 100, cursor })), - fetchAllPages((cursor) => getReadings({ limit: 100, cursor })), - getProperties({ limit: 100 }), - getMeterGroups({ limit: 100 }) - ]); + // Readings are no longer bulk-loaded here — resolved scoped/on-demand instead. + // Clear the ID lookup cache so a reload after a mutation can't serve stale readings. + invalidateEntityLookupCache(); + const [cyclesData, billingsData, propertiesResult, meterGroupsResult] = await Promise.all([ + fetchAllPages((cursor) => getBillingCycles({ limit: 100, cursor })), + fetchAllPages((cursor) => getBillings({ limit: 100, cursor })), + getProperties({ limit: 100 }), + getMeterGroups({ limit: 100 }) + ]); cycles = cyclesData; allBillings = billingsData; - readings = readingsData; properties = propertiesResult.data; meterGroups = meterGroupsResult.data; billings.clear(); + readingMap.clear(); currentPage = 1; } catch (err) { error = err instanceof Error ? err.message : 'Failed to load data'; @@ -233,6 +254,18 @@ if (cycle) { const cycleBillings = allBillings.filter((b) => b.id in cycle.billing_ids); billings.set(cycleId, cycleBillings); + await resolveReadingsFor(cycleBillings); + } + } + + // Resolve (and cache) the previous/current readings for a set of billings into readingMap, + // so the expanded billing table and receipt printing can show reading amounts without the + // old full readings load. + async function resolveReadingsFor(billingList: Billing[]) { + const ids = billingList.flatMap((b) => [b.previous_reading_id, b.current_reading_id]); + const resolved = await getReadingsByIds(ids); + for (const [id, reading] of resolved) { + readingMap.set(id, reading); } } @@ -294,7 +327,7 @@ const endDate = new Date(cycleFormEndDate); const base = allBillings .filter((billing) => { - const currReading = readingMap.get(billing.current_reading_id); + const currReading = cycleFormReadingMap.get(billing.current_reading_id); if (!currReading) return false; if (currReading.meter_group_id !== cycleFormMeterGroup) return false; const readingDate = toDate(currReading.reading_date); @@ -304,8 +337,8 @@ ); }) .map((billing) => { - const currReading = readingMap.get(billing.current_reading_id)!; - const prevReading = readingMap.get(billing.previous_reading_id); + const currReading = cycleFormReadingMap.get(billing.current_reading_id)!; + const prevReading = cycleFormReadingMap.get(billing.previous_reading_id); const property = properties.find((p) => p.id === billing.property_id); // Version-aware true-reading diff — matches the API's billing-cycle validator so the @@ -326,8 +359,8 @@ return base.map((entry) => { const override = cycleFormOverrideSelections.get(entry.propertyId); if (!override) return entry; - const prevReading = readingMap.get(override.prev_reading_id); - const currReading = readingMap.get(override.curr_reading_id); + const prevReading = cycleFormReadingMap.get(override.prev_reading_id); + const currReading = cycleFormReadingMap.get(override.curr_reading_id); if (!prevReading || !currReading) return entry; const property = properties.find((p) => p.id === entry.propertyId); const consumption = readingConsumption(currReading, prevReading, property); @@ -357,7 +390,7 @@ .map((property) => ({ propertyId: property.id, propertyName: property.room_name, - availableReadings: readings.filter( + availableReadings: cycleFormReadings.filter( (r) => r.meter_group_id === cycleFormMeterGroup && r.property_id === property.id && @@ -369,8 +402,24 @@ .filter((g) => g.availableReadings.length >= 2); } - function runDiscovery() { + // Discovery needs the selected meter group's readings — fetch them scoped (one meter group, + // small) before computing, replacing the old full readings load. + async function runDiscovery() { error = ''; + if (!cycleFormMeterGroup || !cycleFormEndDate) { + cycleFormDiscoveredBillings = []; + cycleFormGapProperties = []; + return; + } + try { + cycleFormReadings = await fetchAllPages((cursor) => + getReadings({ meterGroupId: cycleFormMeterGroup, limit: 100, cursor }) + ); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load readings for this meter group'; + cycleFormReadings = []; + return; + } cycleFormDiscoveredBillings = discoverBillings(); cycleFormGapProperties = discoverGapProperties(); } @@ -381,6 +430,7 @@ cycleFormEndDate = ''; cycleFormDueDate = ''; cycleFormRate = 0; + cycleFormReadings = []; cycleFormDiscoveredBillings = []; cycleFormGapProperties = []; cycleFormTotalConsumption = 0; @@ -393,24 +443,24 @@ billOcrRawAmount = null; } - function autoCalculateCycleDates() { + async function autoCalculateCycleDates() { if (!cycleFormMeterGroup) return; - // Find the last billing cycle for this meter group - const meterGroupCycles = cycles - .filter((c) => { - const cycleBillingIds = Object.keys(c.billing_ids); - const cycleBillings = allBillings.filter((b) => cycleBillingIds.includes(b.id)); - if (cycleBillings.length === 0) return false; - const firstBilling = cycleBillings[0]; - const firstReading = readingMap.get(firstBilling.current_reading_id); - return firstReading?.meter_group_id === cycleFormMeterGroup; - }) - .sort( - (a, b) => - new Date(toDate(b.billing_end_date)).getTime() - - new Date(toDate(a.billing_end_date)).getTime() - ); + // Find the last billing cycle for this meter group via a scoped query — the cycle's own + // denormalized meter_group_id makes this a precise lookup, correct regardless of how many + // total cycles exist (the old "scan the loaded cycles" approach silently reset dates to + // the current month once cycle volume exceeded a page). + let meterGroupCycles: BillingCycle[] = []; + try { + const res = await getBillingCycles({ meterGroupId: cycleFormMeterGroup, limit: 100 }); + meterGroupCycles = res.data + .slice() + .sort( + (a, b) => toDate(b.billing_end_date).getTime() - toDate(a.billing_end_date).getTime() + ); + } catch { + // Leave meterGroupCycles empty → falls back to the current-month default below. + } if (meterGroupCycles.length === 0) { // No previous cycle, use current month @@ -507,8 +557,8 @@ // Gap-fill billings don't exist yet — create them first, then fold their // consumption into the same billing_ids map the cycle expects. for (const gap of gapsToFill) { - const prevReading = readings.find((r) => r.id === gap.selectedPrevReadingId); - const currReading = readings.find((r) => r.id === gap.selectedCurrReadingId); + const prevReading = cycleFormReadings.find((r) => r.id === gap.selectedPrevReadingId); + const currReading = cycleFormReadings.find((r) => r.id === gap.selectedCurrReadingId); if (!prevReading || !currReading) continue; const newBilling = await createBilling({ @@ -642,20 +692,40 @@ ); }); + // Fetch the straggler property's readings on this cycle's meter group, scoped, when the + // selected property changes — replaces filtering the old full readings array. + $effect(() => { + const propertyId = stragglerPropertyId; + const meterGroupId = stragglerCycle ? getCycleMeterGroupId(stragglerCycle) : null; + if (!propertyId || !meterGroupId) { + stragglerReadings = []; + return; + } + let cancelled = false; + getReadings({ propertyId, meterGroupId, limit: 100 }) + .then((res) => { + if (!cancelled) stragglerReadings = res.data; + }) + .catch(() => { + if (!cancelled) stragglerReadings = []; + }); + return () => { + cancelled = true; + }; + }); + // Readings for the selected straggler property on this cycle's meter group, oldest first. const stragglerAvailableReadings = $derived.by(() => { if (!stragglerCycle || !stragglerPropertyId) return []; - const meterGroupId = getCycleMeterGroupId(stragglerCycle); - if (!meterGroupId) return []; - return readings - .filter((r) => r.property_id === stragglerPropertyId && r.meter_group_id === meterGroupId) + return stragglerReadings + .slice() .sort((a, b) => toDate(a.reading_date).getTime() - toDate(b.reading_date).getTime()); }); const stragglerConsumptionPreview = $derived.by(() => { - const curr = readings.find((r) => r.id === stragglerCurrReadingId); + const curr = stragglerReadings.find((r) => r.id === stragglerCurrReadingId); if (!curr) return 0; - const prev = readings.find((r) => r.id === stragglerPrevReadingId); + const prev = stragglerReadings.find((r) => r.id === stragglerPrevReadingId); const property = properties.find((p) => p.id === stragglerPropertyId); return readingConsumption(curr, prev, property); }); @@ -672,8 +742,8 @@ } isAddingStraggler = true; try { - const currReading = readings.find((r) => r.id === stragglerCurrReadingId); - const prevReading = readings.find((r) => r.id === stragglerPrevReadingId); + const currReading = stragglerReadings.find((r) => r.id === stragglerCurrReadingId); + const prevReading = stragglerReadings.find((r) => r.id === stragglerPrevReadingId); const property = properties.find((p) => p.id === stragglerPropertyId); if (!currReading) throw new Error('Selected current reading not found'); @@ -717,7 +787,9 @@ } const billingMap = $derived.by(() => new Map(allBillings.map((b) => [b.id, b]))); - const readingMap = $derived.by(() => new Map(readings.map((r) => [r.id, r]))); + // Lookup over the meter-group-scoped reading set used by cycle discovery/gap-fill. + // (readingMap above is the on-demand cache for expanded/printed cycles.) + const cycleFormReadingMap = $derived.by(() => new Map(cycleFormReadings.map((r) => [r.id, r]))); const meterGroupMap = $derived.by(() => new Map(meterGroups.map((m) => [m.id, m]))); function getCycleUtilityType(cycle: BillingCycle): string { @@ -831,7 +903,7 @@ const cycleBillings = billings.get(firstCycle.id) || []; if (cycleBillings.length > 0) { const firstBilling = cycleBillings[0]; - const firstReading = readings.find((r) => r.id === firstBilling.current_reading_id); + const firstReading = readingMap.get(firstBilling.current_reading_id); if (firstReading) { const meterGroup = meterGroups.find((m) => m.id === firstReading.meter_group_id); utilityType = meterGroup?.utility_type || 'electricity'; @@ -851,8 +923,8 @@ const cycleBillings = billings.get(cycle.id) || []; for (const billing of cycleBillings) { const property = properties.find((p) => p.id === billing.property_id); - const currReading = readings.find((r) => r.id === billing.current_reading_id); - const prevReading = readings.find((r) => r.id === billing.previous_reading_id); + const currReading = readingMap.get(billing.current_reading_id); + const prevReading = readingMap.get(billing.previous_reading_id); const consumption = cycle.billing_ids[billing.id] ?? 0; const billRate = cycle.billing_rate; @@ -1041,9 +1113,9 @@ + {error} +
+ {/if} + + {#if success} +
+ {success} +
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ + +

Minimum 8 characters

+
+ +
+ + +
+ +
- -
- - -
- -
- - -

Minimum 8 characters

-
- -
- - -
- - - + {isLoading ? 'Creating user...' : 'Create User'} + + {/if} From a1c3610ddd1f1c5fd3a2876a461accb301999b9d Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 16:26:26 +0800 Subject: [PATCH 36/37] chore(api): update api packages --- api/functions/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/functions/package-lock.json b/api/functions/package-lock.json index 6a1911c..38acddb 100644 --- a/api/functions/package-lock.json +++ b/api/functions/package-lock.json @@ -5890,9 +5890,9 @@ } }, "node_modules/firebase-functions": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz", - "integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.3.0.tgz", + "integrity": "sha512-S3JhjESOWMq13iDodwgUPWNQ3gLAu7oTLDwF7hTtisyjVanEeQzYezgW+JTfd8I0kCJQzjGPC/wHQ19IL7CgaA==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.5", @@ -5910,7 +5910,7 @@ "peerDependencies": { "@apollo/server": "^5.2.0", "@as-integrations/express4": "^1.1.2", - "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0 || ^14.0.0", "graphql": "^16.12.0" }, "peerDependenciesMeta": { @@ -11891,9 +11891,9 @@ "optional": true }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", From bc6e9b91ab446f814f69cd134d5205fcd86499ad Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 19 Jul 2026 16:26:45 +0800 Subject: [PATCH 37/37] chore(ui): update ui packages --- ui/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 1997312..96a8064 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1932,9 +1932,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.69.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz", - "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", "dev": true, "license": "MIT", "dependencies": {