Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 37 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDialogElement>(null);
const saveButton = useRef<HTMLButtonElement>(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 (
<section className="page narrow save-page">
<div className="eyebrow">Letzte Kontrolle</div>
Expand All @@ -590,14 +610,29 @@ function SaveScreen({ draft, imageUrl, onBack, onSave, saving, editing }: {
<div className="final-total"><span>Gesamtbetrag</span><strong>{formatMoney(draft.totalMinor, draft.currency)}</strong></div>
<StatusPill calculations={calculations} uncertaintyCount={draft.uncertaintyFields.length} currencyNeedsReview={currencyNeedsReview} compact />
</div>
{!calculations.isBalanced && <div className="save-warning"><AlertIcon /><p><strong>Noch nicht ausgeglichen</strong><span>Du kannst diesen Einkauf als Entwurf speichern und später ergänzen. Bestätigen ist erst bei übereinstimmenden Beträgen möglich.</span></p></div>}
{!calculations.isBalanced && <div className="save-warning"><AlertIcon /><p><strong>Beträge prüfen</strong><span>Du kannst den Beleg trotzdem speichern. Bei einer Abweichung bleibt er als Entwurf gekennzeichnet; deine Werte werden nicht angepasst.</span></p></div>}
{currencyNeedsReview && <div className="save-warning"><AlertIcon /><p><strong>Währung prüfen</strong><span>Bitte wähle oder bestätige die Währung in der Prüfansicht. Als Entwurf kannst du den Bon bereits speichern.</span></p></div>}
<div className="save-facts"><span><DatabaseIcon /> SQLite-Datensatz</span><span><ImageIcon /> Originalbild</span><span><DownloadIcon /> Portable JSON-Exporte</span></div>
<div className="save-buttons">
<button className="button ghost" onClick={onBack} disabled={saving}>Zurück zur Prüfung</button>
{(editing || !calculations.isBalanced || currencyNeedsReview) && <button className="button secondary" onClick={() => onSave("draft")} disabled={saving}>{saving ? "Speichert …" : "Als Entwurf speichern"}</button>}
<button className="button primary large" onClick={() => onSave("confirmed")} disabled={saving || !calculations.isBalanced || currencyNeedsReview}>{saving ? "Speichert …" : editing ? "Änderungen speichern" : "Einkauf speichern"} {!saving && <CheckIcon />}</button>
<button ref={saveButton} className="button primary large" onClick={requestSave} disabled={saving}>{saving ? "Speichert …" : editing ? "Änderungen speichern" : "Einkauf speichern"} {!saving && <CheckIcon />}</button>
</div>
{showSaveWarning && <dialog ref={warningDialog} className="save-warning-dialog" aria-labelledby="save-warning-title" aria-describedby="save-warning-description" onCancel={(event) => { event.preventDefault(); setShowSaveWarning(false); }} onClose={() => setShowSaveWarning(false)}>
<h2 id="save-warning-title">Beleg mit Prüfbedarf speichern?</h2>
<p id="save-warning-description">Der Beleg wird als Entwurf gespeichert. Deine eingegebenen Preise und die gedruckten Werte bleiben erhalten. Du kannst ihn später weiter prüfen.</p>
{!calculations.isBalanced && <dl className="save-warning-totals">
<div><dt>Gedruckter Gesamtbetrag</dt><dd>{formatMoney(draft.totalMinor, draft.currency)}</dd></div>
<div><dt>Aus deinen Angaben berechnet</dt><dd>{formatMoney(calculations.calculatedTotalMinor, draft.currency)}</dd></div>
<div><dt>{calculations.differenceMinor === null ? "Status" : "Differenz"}</dt><dd>{calculations.differenceMinor === null ? "Angaben unvollständig" : formatMoney(calculations.differenceMinor, draft.currency)}</dd></div>
</dl>}
{currencyNeedsReview && <p>Die Währung muss noch ausgewählt oder bestätigt werden.</p>}
{editing && <p>Die Änderungen werden im bestehenden Beleg gespeichert. Sein Status wird auf „Entwurf“ gesetzt.</p>}
<div className="save-warning-actions">
<button autoFocus className="button secondary" disabled={saving} onClick={() => { setShowSaveWarning(false); onBack(); }}>Zurück zur Prüfung</button>
<button className="button primary" disabled={saving} onClick={() => { setShowSaveWarning(false); onSave("draft"); }}>Trotzdem als Entwurf speichern</button>
</div>
</dialog>}
</section>
);
}
Expand Down
9 changes: 9 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Loading