From c87ad1810d5bc6ff211a1b1f89f655f60fa33e55 Mon Sep 17 00:00:00 2001 From: Chasan Moustafa Date: Mon, 12 Oct 2026 10:20:42 -0700 Subject: [PATCH 1/2] fix: offer warned draft saves for receipt discrepancies --- src/App.tsx | 39 ++++++++- src/styles.css | 9 ++ tests/receipt-review.test.tsx | 157 ++++++++++++++++++++++++++++++++-- 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7437435..91893b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -579,6 +579,26 @@ function SaveScreen({ draft, imageUrl, onBack, onSave, saving, editing }: { }) { const calculations = calculateReceipt(draft); const currencyNeedsReview = needsCurrencyReview(draft); + const [showSaveWarning, setShowSaveWarning] = useState(false); + const warningDialog = useRef(null); + const saveButton = useRef(null); + useEffect(() => { + if (!showSaveWarning) return; + const dialog = warningDialog.current; + const trigger = saveButton.current; + if (!dialog) return; + dialog.showModal(); + return () => { + if (dialog.open) dialog.close(); + if (trigger?.isConnected) trigger.focus(); + }; + }, [showSaveWarning]); + + function requestSave() { + if (calculations.isBalanced && !currencyNeedsReview) onSave("confirmed"); + else setShowSaveWarning(true); + } + return (
Letzte Kontrolle
@@ -590,14 +610,29 @@ function SaveScreen({ draft, imageUrl, onBack, onSave, saving, editing }: {
Gesamtbetrag{formatMoney(draft.totalMinor, draft.currency)}
- {!calculations.isBalanced &&

Noch nicht ausgeglichenDu kannst diesen Einkauf als Entwurf speichern und später ergänzen. Bestätigen ist erst bei übereinstimmenden Beträgen möglich.

} + {!calculations.isBalanced &&

Beträge prüfenDu kannst den Beleg trotzdem speichern. Bei einer Abweichung bleibt er als Entwurf gekennzeichnet; deine Werte werden nicht angepasst.

} {currencyNeedsReview &&

Währung prüfenBitte wähle oder bestätige die Währung in der Prüfansicht. Als Entwurf kannst du den Bon bereits speichern.

}
SQLite-Datensatz Originalbild Portable JSON-Exporte
{(editing || !calculations.isBalanced || currencyNeedsReview) && } - +
+ {showSaveWarning && { event.preventDefault(); setShowSaveWarning(false); }} onClose={() => setShowSaveWarning(false)}> +

Beleg mit Prüfbedarf speichern?

+

Der Beleg wird als Entwurf gespeichert. Deine eingegebenen Preise und die gedruckten Werte bleiben erhalten. Du kannst ihn später weiter prüfen.

+ {!calculations.isBalanced &&
+
Gedruckter Gesamtbetrag
{formatMoney(draft.totalMinor, draft.currency)}
+
Aus deinen Angaben berechnet
{formatMoney(calculations.calculatedTotalMinor, draft.currency)}
+
{calculations.differenceMinor === null ? "Status" : "Differenz"}
{calculations.differenceMinor === null ? "Angaben unvollständig" : formatMoney(calculations.differenceMinor, draft.currency)}
+
} + {currencyNeedsReview &&

Die Währung muss noch ausgewählt oder bestätigt werden.

} + {editing &&

Die Änderungen werden im bestehenden Beleg gespeichert. Sein Status wird auf „Entwurf“ gesetzt.

} +
+ + +
+
}
); } diff --git a/src/styles.css b/src/styles.css index 9345825..74c2dac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -262,6 +262,15 @@ h2, h3, p { margin-top: 0; } .save-facts span { display: flex; align-items: center; gap: 5px; } .save-facts svg { color: var(--green); } .save-buttons { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; } +.save-warning-dialog { width: min(520px, calc(100% - 32px)); max-height: calc(100dvh - 32px); padding: 24px; border: 1px solid var(--line); border-radius: 24px; color: var(--ink); background: var(--paper); box-shadow: var(--shadow); text-align: left; overflow-wrap: anywhere; } +.save-warning-dialog::backdrop { background: rgba(4, 10, 13, .7); } +.save-warning-dialog h2 { margin: 0 0 16px; font-size: 23px; } +.save-warning-dialog p { color: var(--muted); line-height: 1.6; } +.save-warning-totals { padding: 16px; background: var(--warning-bg); border-radius: 16px; } +.save-warning-totals > div { display: flex; justify-content: space-between; gap: 16px; margin: 10px 0; } +.save-warning-totals dd { margin: 0; text-align: right; font-weight: 750; white-space: nowrap; flex-shrink: 0; } +.save-warning-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 12px; margin-top: 24px; } +.save-warning-actions .button { flex: 1 1 180px; } .history-page { max-width: 820px; padding-bottom: 130px; } .history-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; } diff --git a/tests/receipt-review.test.tsx b/tests/receipt-review.test.tsx index 8bca32a..ceae7eb 100644 --- a/tests/receipt-review.test.tsx +++ b/tests/receipt-review.test.tsx @@ -1,12 +1,33 @@ // @vitest-environment jsdom import { StrictMode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import App from "../src/App.js"; import * as api from "../src/api.js"; import { calculateReceipt, createEmptyDraft, createEmptyItem, createUuid, type ReceiptDraft } from "../shared/receipt.js"; import type { StoredReceipt } from "../src/api.js"; +import { receiptValidationState } from "../shared/receipt-validation.js"; + +// jsdom has no native dialog lifecycle. Simulate only opening/closing and the +// initial focus; real modal focus trapping, Escape and touch need browser checks. +const dialogDescriptors = ["showModal", "close"].map((name) => [name, Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, name)] as const); +beforeAll(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { + this.open = true; + this.querySelector("[autofocus]")?.focus(); + } }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { + this.open = false; + this.dispatchEvent(new Event("close")); + } }); +}); +afterAll(() => { + for (const [name, descriptor] of dialogDescriptors) { + if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor); + else Reflect.deleteProperty(HTMLDialogElement.prototype, name); + } +}); // Only persistence/platform boundaries are mocked. App navigation, parent state, // review controls, money inputs, and the save-screen guard are real React UI. @@ -24,8 +45,8 @@ function exampleDraft(): ReceiptDraft { return { ...createEmptyDraft("CAD"), merchantName: "Testmarkt", items: [item], subtotalMinor: 129, receiptDiscountMinor: 0, taxTotalMinor: 0, totalMinor: 129, fieldSources: { totalMinor: "extracted" } }; } -function stored(draft: ReceiptDraft, id = createUuid(), revision = 1): StoredReceipt { - return { id, revision, deletedAt: null, status: "confirmed", validationState: "balanced", scannedAt: "2026-10-08T12:00:00Z", confirmedAt: "2026-10-08T12:00:00Z", draft, attachment: null, calculations: calculateReceipt(draft) }; +function stored(draft: ReceiptDraft, id = createUuid(), revision = 1, status: StoredReceipt["status"] = "confirmed"): StoredReceipt { + return { id, revision, deletedAt: null, status, validationState: receiptValidationState(draft), scannedAt: "2026-10-08T12:00:00Z", confirmedAt: status === "confirmed" ? "2026-10-08T12:00:00Z" : null, draft, attachment: null, calculations: calculateReceipt(draft) }; } beforeEach(() => { @@ -35,8 +56,8 @@ beforeEach(() => { vi.spyOn(window, "scrollTo").mockImplementation(() => undefined); vi.mocked(api.loadConfig).mockResolvedValue({ extractionMode: "manual", provider: "manual", model: null, disclosure: "" }); vi.mocked(api.loadHistory).mockResolvedValue({ receipts: [] }); - vi.mocked(api.saveReceipt).mockImplementation(async (input) => ({ id: "new-receipt", receipt: stored(input.draft, "new-receipt") })); - vi.mocked(api.updateReceipt).mockImplementation(async (id, input) => ({ id, receipt: stored(input.draft, id, input.expectedRevision + 1) })); + vi.mocked(api.saveReceipt).mockImplementation(async (input) => ({ id: "new-receipt", receipt: stored(input.draft, "new-receipt", 1, input.status) })); + vi.mocked(api.updateReceipt).mockImplementation(async (id, input) => ({ id, receipt: stored(input.draft, id, input.expectedRevision + 1, input.status) })); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); @@ -222,15 +243,137 @@ describe("rendered receipt review", () => { expect(money("Gedruckter Gesamtbetrag").value).toBe("1,29"); expect(screen.getByText("Prüfung erforderlich")).toBeTruthy(); await user.click(screen.getByRole("button", { name: "Weiter" })); - expect(screen.getByRole("button", { name: "Einkauf speichern" }).hasAttribute("disabled")).toBe(true); + expect(screen.getByRole("button", { name: "Einkauf speichern" }).hasAttribute("disabled")).toBe(false); await user.click(screen.getByRole("button", { name: "Zurück zur Prüfung" })); await replace(user, money("Gedruckter Gesamtbetrag"), "1,32"); await user.click(screen.getByRole("button", { name: "Weiter" })); await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + expect(screen.queryByRole("dialog")).toBeNull(); await waitFor(() => expect(api.saveReceipt).toHaveBeenCalledOnce()); expect(vi.mocked(api.saveReceipt).mock.calls[0][0]).toMatchObject({ status: "confirmed", extractionId, draft: { totalMinor: 132, items: [{ rawName: "COFFEE RAW", lineTotalMinor: 132, verified: false }] } }); }); + it("warns before saving different item totals and lets cancellation retain the correction", async () => { + const original = exampleDraft(); + const snapshot = structuredClone(original); + const { user } = await openImportedReview(original); + await replace(user, money("Positionssumme"), "1,32"); + await user.click(screen.getByRole("button", { name: "Weiter" })); + await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + const dialog = screen.getByRole("dialog", { name: "Beleg mit Prüfbedarf speichern?" }); + expect(within(dialog).getByText(/als Entwurf gespeichert/)).toBeTruthy(); + expect(within(dialog).getByText(/1,29/)).toBeTruthy(); + expect(within(dialog).getByText(/1,32/)).toBeTruthy(); + expect(api.saveReceipt).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole("button", { name: "Zurück zur Prüfung" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(money("Positionssumme").value).toBe("1,32"); + expect(money("Gedruckter Gesamtbetrag").value).toBe("1,29"); + expect(api.saveReceipt).not.toHaveBeenCalled(); + expect(original).toEqual(snapshot); + }); + + it("saves and reopens a corrected unbalanced import as a draft without rewriting printed evidence", async () => { + const original = exampleDraft(); + const { user, extractionId } = await openImportedReview(original); + await replace(user, money("Positionssumme"), "1,32"); + await user.click(screen.getByRole("button", { name: "Weiter" })); + await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Trotzdem als Entwurf speichern" })); + await waitFor(() => expect(api.saveReceipt).toHaveBeenCalledOnce()); + const input = vi.mocked(api.saveReceipt).mock.calls[0][0]; + expect(input).toMatchObject({ status: "draft", extractionId, draft: { totalMinor: 129, subtotalMinor: 129, fieldSources: { totalMinor: "extracted" }, items: [{ id: original.items[0].id, rawName: "COFFEE RAW", quantity: "1.000001", lineTotalMinor: 132, source: "user_entered", verified: false }] } }); + expect(screen.getByText("Entwurf wurde gespeichert.")).toBeTruthy(); + const receipt = stored(input.draft, "new-receipt", 1, "draft"); + vi.mocked(api.loadReceipt).mockResolvedValue({ receipt }); + vi.mocked(api.loadHistory).mockResolvedValue({ receipts: [{ id: receipt.id, merchantName: "Testmarkt", purchasedDate: null, purchasedTime: null, scannedAt: receipt.scannedAt, totalMinor: 129, currency: "CAD", positionCount: 1, status: "draft", validationState: "discrepancy" }] }); + await user.click(screen.getByRole("button", { name: /Zum Verlauf/ })); + await user.click(await screen.findByRole("button", { name: /Testmarkt/ })); + expect(screen.getByText("Entwurf")).toBeTruthy(); + expect(screen.getByText("Prüfung erforderlich")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Beleg bearbeiten" })); + expect(money("Positionssumme").value).toBe("1,32"); + expect(money("Gedruckter Gesamtbetrag").value).toBe("1,29"); + expect(original.items[0].lineTotalMinor).toBe(129); + }); + + it("updates an existing receipt as a draft after acknowledging the warning without creating another receipt", async () => { + const original = stored(exampleDraft()); + const snapshot = structuredClone(original); + const user = await openDetail(original); + await user.click(screen.getByRole("button", { name: "Beleg bearbeiten" })); + await replace(user, money("Positionssumme"), "1,32"); + await user.click(screen.getByRole("button", { name: "Weiter" })); + await user.click(screen.getByRole("button", { name: "Änderungen speichern" })); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText(/bestehenden Beleg/)).toBeTruthy(); + expect(api.updateReceipt).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole("button", { name: "Trotzdem als Entwurf speichern" })); + await waitFor(() => expect(api.updateReceipt).toHaveBeenCalledOnce()); + expect(vi.mocked(api.updateReceipt).mock.calls[0]).toMatchObject([original.id, { expectedRevision: 1, status: "draft", draft: { totalMinor: 129, items: [{ id: original.draft.items[0].id, lineTotalMinor: 132 }] } }]); + expect(api.saveReceipt).not.toHaveBeenCalled(); + expect(original).toEqual(snapshot); + expect(screen.getByText("Entwurf")).toBeTruthy(); + expect(screen.getByText("Prüfung erforderlich")).toBeTruthy(); + }); + + it("dismisses the warning without saving and restores focus to the save button", async () => { + const { user } = await openImportedReview({ ...exampleDraft(), totalMinor: 130 }); + await user.click(screen.getByRole("button", { name: "Weiter" })); + const trigger = screen.getByRole("button", { name: "Einkauf speichern" }); + await user.click(trigger); + const dialog = screen.getByRole("dialog"); + expect(document.activeElement).toBe(within(dialog).getByRole("button", { name: "Zurück zur Prüfung" })); + fireEvent(dialog, new Event("cancel", { cancelable: true })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(api.saveReceipt).not.toHaveBeenCalled(); + await user.click(trigger); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("offers a draft warning for an unresolved currency instead of silently confirming it", async () => { + const draft = exampleDraft(); + draft.fieldSources.currency = "uncertain"; + const { user } = await openImportedReview(draft); + await user.click(screen.getByRole("button", { name: "Weiter" })); + await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText(/Währung muss noch/)).toBeTruthy(); + expect(api.saveReceipt).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole("button", { name: "Trotzdem als Entwurf speichern" })); + await waitFor(() => expect(api.saveReceipt).toHaveBeenCalledOnce()); + expect(vi.mocked(api.saveReceipt).mock.calls[0][0]).toMatchObject({ status: "draft", draft: { currency: "CAD", fieldSources: { currency: "uncertain" } } }); + }); + + it("retains missing printed totals when explicitly saving an incomplete draft", async () => { + const { user } = await openImportedReview({ ...exampleDraft(), totalMinor: null }); + await user.click(screen.getByRole("button", { name: "Weiter" })); + await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText("Angaben unvollständig")).toBeTruthy(); + await user.click(within(dialog).getByRole("button", { name: "Trotzdem als Entwurf speichern" })); + await waitFor(() => expect(api.saveReceipt).toHaveBeenCalledOnce()); + expect(vi.mocked(api.saveReceipt).mock.calls[0][0]).toMatchObject({ status: "draft", draft: { totalMinor: null } }); + }); + + it("retains corrected data and the mutation identity after a failed warned draft save", async () => { + const { user } = await openImportedReview(); + await replace(user, money("Positionssumme"), "1,32"); + vi.mocked(api.saveReceipt).mockRejectedValueOnce(new Error("Speicher nicht verfügbar")); + await user.click(screen.getByRole("button", { name: "Weiter" })); + await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Trotzdem als Entwurf speichern" })); + await screen.findByText("Speicher nicht verfügbar"); + const firstInput = vi.mocked(api.saveReceipt).mock.calls[0][0]; + expect(firstInput.status).toBe("draft"); + await user.click(screen.getByRole("button", { name: "Einkauf speichern" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Trotzdem als Entwurf speichern" })); + await waitFor(() => expect(api.saveReceipt).toHaveBeenCalledTimes(2)); + expect(vi.mocked(api.saveReceipt).mock.calls[1][0]).toEqual(firstInput); + expect(firstInput.draft).toMatchObject({ totalMinor: 129, items: [{ lineTotalMinor: 132 }] }); + }); + it.each(["abc", "-", "9007199254740992"])("rejects invalid final input %s without falling back to a previous price", async (text) => { const { user } = await openImportedReview(); const input = money("Positionssumme"); From 7faa2484616f272989344901b9553d091e033486 Mon Sep 17 00:00:00 2001 From: Chasan Moustafa Date: Mon, 12 Oct 2026 10:20:50 -0700 Subject: [PATCH 2/2] docs: explain save warnings and verification boundaries --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 2 ++ docs/TESTING.md | 10 ++++++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a36ac45..87c0954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- The main save button remains available for receipts with arithmetic discrepancies or unresolved currency. It opens an explicit warning and can save the entered values as a draft without rewriting printed totals or original extraction evidence; balanced reviewed receipts still save as confirmed. Tracked in #24; browser and Samsung acceptance remain separate. - Stored receipt data remains accessible when an Android image read fails or a web/native image cannot be displayed. A German placeholder distinguishes unavailable images from image-less receipts; attachment references and extraction evidence are retained. Web full exports now reject missing or checksum-mismatched referenced images; Android backup validation remains strict. Tracked in #32; physical-device acceptance is pending. - Money fields preserve partial input while typing and format only when leaving the field, allowing corrections such as 1,29 to 1,32 without automatic completion. Invalid amounts are shown explicitly and cannot silently retain an earlier valid value. - README Android backup guidance now distinguishes implemented export/restore, pending physical-device acceptance, and unsupported webserver-to-Android backup migration. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 722e55b..e58ecbd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,6 +80,8 @@ Suggested currencies remain uncertain until explicitly selected or confirmed in ## Receipt and item CRUD +The primary save action does not require equality with an AI-extracted price. When the corrected receipt is arithmetically balanced and its currency is reviewed, it saves as confirmed. Otherwise the save screen opens a native HTML dialog showing the printed total, calculated total and discrepancy where applicable. The user can return to review or explicitly save the same values as a draft. Updating an existing confirmed receipt this way changes its status to draft; source extraction, printed totals, receipt identity and audit behavior remain governed by the existing persistence adapters. Invalid money syntax still requires correction in the review form. This is a UI change with no schema or backup-format change. + Open a saved receipt and choose **Beleg bearbeiten**, or use an item's **Bearbeiten** link. The existing review form can add, change, or remove items and edit receipt metadata, payment details, notes, and totals. Cancel leaves the stored receipt unchanged. Saving edits updates the same receipt ID; scan time, original image, and original extraction output are retained. Confirmed updates use the same arithmetic and currency checks as confirmed creates; incomplete edits can be stored as drafts. Both persistence adapters apply schema migration `002_receipt_crud.sql`, adding a revision, a soft-delete timestamp, and a mutation ledger. Mutations include an expected revision and a unique client mutation ID: stale edits are rejected and successful retries do not apply twice. Web updates atomically replace normalized child rows and append old/new draft snapshots to the existing audit trail. Android updates the complete draft and an audit snapshot inside a serialized SQLite transaction. diff --git a/docs/TESTING.md b/docs/TESTING.md index 8f41cd9..29ee953 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -70,3 +70,13 @@ Initial implementation verification: `pnpm check` passed TypeScript checks and * Publication follow-up: the user reported positive development feedback and requested a merge and a durable-signed APK on October 11. Publication proceeds through a focused PR with CI/native compilation gates, followed by an explicitly dispatched signed build of the merged source. Build success is not installation, update-retention, or device acceptance. Issue #32 remains open for its physical Samsung checks; source publication alone must not close it. Physical Samsung acceptance remains pending. On a disposable test installation, retain a saved receipt but remove only its private original image, then verify detail/review values, edit and reopen, trash/restore, app/phone restart, and a clearly failed full export with no success claim. Repeat with corrupted/undecodable bytes while retaining the original checksum, and a denied read where feasible. A display-only failure with intact, checksum-matching bytes does not itself make the backup incomplete. Confirm healthy and deliberately image-less receipts still work. Never damage the user's only real receipt/image copy for this test. No physical device was available for this implementation; no new APK or native compilation run is implied by the local checks. + +## Save warning and manual price corrections — issue #24 + +The reported disabled primary save action is caused by receipt arithmetic or unresolved currency, rather than equality with the original AI price. The primary button now remains available and requests explicit draft-save acknowledgement when the receipt cannot be confirmed. The dialog preserves entered amounts and source evidence, and offers a return to review. The direct draft action remains available. + +Rendered regressions cover warning/cancellation with retained corrections, saving/reopening an unbalanced imported receipt, updating the same receipt as a draft without a duplicate, dialog cancellation/focus restoration, unresolved currency, missing printed totals, and failed-save retry with unchanged mutation identity. Existing balanced-correction coverage proves that a manual price differing from the import can still save as confirmed when its totals agree. Persistence/domain tests continue to enforce balanced confirmed saves and allow incomplete/discrepant drafts. + +jsdom does not implement the native HTML dialog lifecycle. These tests simulate opening, closing and initial focus, not browser-native focus trapping, Escape handling or touch behavior. Real browser and physical Samsung acceptance must separately check the modal at mobile sizes, keyboard/Back behavior, cancel/back-to-review, draft save/reopen, and balanced confirmed saves. No APK distribution or device acceptance is implied by local source changes or automated checks. + +Verification on October 12, 2026: `pnpm check` passed 150 tests in 15 files and `pnpm build` passed. A targeted Opera smoke check used the production web build at a loopback-only server and an isolated `.local/issue24-smoke` SQLite database. At a 360×800 viewport, the native dialog matched `:modal`, focused the return-to-review action initially, showed 1.29 CAD printed versus 1.32 CAD calculated, and had no horizontal overflow. Keyboard navigation and Escape dismissal restored the save action. Returning to review retained both values; explicit draft save, browser reload/reopen, and a subsequent warned update retained the same receipt ID and values. The screenshot `.local/issue24-save-warning.jpg` is ignored local test output. These checks do not exercise Android's virtual keyboard/Back, storage plugins, or device acceptance. No installable APK was requested for this work.