Repository navigation
Auto-release resolved OCR purchases; settle already-paid receipts in Moco - #22
Merged
Merged
Conversation
Plan only, no implementation. Card/POS receipts OCR'd into Moco purchases currently land as status=pending with payments: [], so the operator has to settle each one by hand. Moco exposes POST /purchases/payments (date, total, purchase_id) for exactly this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
Two corrections after checking the Moco docs and the Bexio sync's reads:
- A purchase's `status` is pending(Inbox)/archived(Archive), not a payment
status. A payment doesn't change it; Moco derives the open balance from
sum(payments) vs gross_total. The first draft claimed otherwise.
- DELETE /purchases/{id} is refused once any payment is registered. Doing
this at create time would make every OCR'd card receipt undeletable
during the review window, and leave the payment stale if the operator
corrected the amount.
So the trigger moves to the Purchase:update that fires when 'Review pending'
comes off, on a new /api/moco-purchase-payment endpoint, gated on
payment_method=credit_card + payments==[] + no review tag + gross_total>0.
The empty payments array doubles as the idempotency guard. Scope is any card
purchase, not just OCR-created ones.
Verified unaffected: BexioExpenseSyncService reads neither `payments` nor
`status`, so the tag-removal -> Bexio flow is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
Drops the separate /api/moco-purchase-payment endpoint from the previous revision. Both changes now live inside SupplierInvoiceOcrService: - Payment registered directly after POST /purchases for already-paid card receipts, using the create response's gross_total and date. - `Review pending` applied only when the result wasn't fully resolved (company + category + confidence + not a credit note). The already-paid category rule (explicit Aufwandkonto only, never the 4000 default) becomes load-bearing: it stops card receipts from auto-releasing on a guessed booking, and gives the operator a per-supplier lever. Accepted costs documented: a purchase with a payment can't be deleted, and an auto-released bank-transfer bill reaches Bexio's book + outgoing-payment step unreviewed (mitigated by the confidence condition, D2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
- Auto-release policy moves into a pure PurchaseReviewGate collaborator (api/purchase_review_gate.py) so the service and both operator scripts share one implementation of the rule instead of a drifting copy. - AUTO_RELEASE_CONFIDENCE = 0.90 as its own constant, separate from the 0.85 CONFIDENCE_THRESHOLD that drives the Telegram emoji split. Tuning alert noise must not retune a gate that can let money move. - Auto-released purchases get an "Auto" tag, so Moco's UI can filter what bypassed human review. - The batch-script REVIEW preview column ships in the same PR; it's the rollout gate, not a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
- source="supplier": always trusted (matched by name off the receipt). - source="project": only on an exact/substring project match. The loosest tier, token-overlap, treats any single shared token as a match — loose enough that SmartmeProjectMatcher exists to avoid it. The Aufwandkonto is equally deliberate either way; it's the match selecting the project that's weaker, and ProjectMatch.tier already reports which fired. - source="default" (4000): trusted, and unreachable for card receipts since the resolver short-circuits at already_paid first. Recorded consequence: for bank-transfer bills the fallback nearly always resolves, so the category condition rarely bites there. The gate therefore needs no payment-method branch and MocoCategoryResolver needs no change; PurchaseReviewGate.evaluate() gains a project_match arg to read .tier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
No behaviour change — both alternatives were considered and declined, so write down why before they get re-proposed: - Dropping the confidence condition for already-paid receipts (they cannot move money: no IBAN means _try_book_and_pay returns early and the Bexio bill stays DRAFT). Rejected in favour of one uniform rule with no payment-method branch in the gate. - Registering the payment only for auto-released receipts. Rejected per D5; the settle chore should disappear for every card receipt, not just the trusted ones. Also pins down the Bexio DRAFT behaviour with a file:line reference, since it is the load-bearing fact behind the card-receipt risk assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
Implements specs/SPEC_purchase_payment_already_paid.md. **Auto-release.** New `PurchaseReviewGate` (pure, no I/O) decides whether a newly OCR'd purchase gets `Review pending` or `Auto`. It releases only when a supplier company matched, the category is trusted, confidence >= 0.90, and it isn't a credit note. A category is trusted by its `CategoryDecision.source`, not merely by being set: supplier always; project only on an exact/substring `ProjectMatch.tier`, never the token-overlap tier where a single shared token counts; the 4000 default yes — unreachable for card receipts anyway, since `MocoCategoryResolver` short-circuits at `already_paid` first. That is why the gate needs no payment-method branch and the resolver needed no change. `AUTO_RELEASE_CONFIDENCE` (0.90) is deliberately separate from the existing `CONFIDENCE_THRESHOLD` (0.85): the latter only picks a Telegram emoji, and tuning alert noise must not retune a gate that lets purchases reach Bexio unreviewed. `ReviewDecision.reasons` names the failing conditions so a held purchase is self-explanatory in the log, the alert, and the scripts. **Payments.** New `MocoPurchaseClient.create_payment` posts to the top-level `/purchases/payments` collection. Already-paid receipts (card / TWINT / POS) are settled right after the create so they stop showing an open balance on money that already left the account. `total` comes from the create response's server-computed `gross_total`, not the OCR figure — the open balance is measured against it and a rounding cent would leave a residual; `date` is the purchase date, not today, so a late import lands in the right period. Best-effort like the project assign. Runs regardless of the review decision, so a held receipt is settled too — at the documented cost that a purchase carrying a payment can no longer be deleted. Both operator scripts call the same gate for a REVIEW preview column, so the preview cannot drift from the real rule. Run batch_ocr_drafts.py over recent drafts and read that column before enabling this in production. Tests: 562 passing (34 new). The one pre-existing assertion that changed is the endpoint happy path, which now legitimately auto-releases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`batch_ocr_drafts.py --apply` deliberately inlines the pipeline instead of calling `service.process()` (per-step operator logging, avoids a double-OCR cost), delegating only the post-create steps to the service's own methods. `_register_payment` was added to `process()` without being mirrored there, so `--apply` created already-paid purchases and left them showing an open balance — exactly the chore the feature exists to remove. Confirmed against live data: purchase 4642707 (E260187, credit_card, CHF 145, tags ["OCR","Review pending"]) has payments: []. A full scan of all 499 purchases found it to be the only affected record. Also documents the maintenance hazard on `_process_draft`, since the inlining means every future post-create step has to be added in two places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
A settled balance with no explanation is confusing on a purchase nobody transferred money for. Post a 💳 comment alongside the payment saying the receipt showed it as already paid (card / TWINT / POS) and that it was booked directly, matching the existing 📧 / 🤖 comment style and Moco's allowed HTML subset. Posted AFTER the payment and swallowed on failure: the payment is the authoritative side effect, so a failed comment must not flip `payment_registered` back to False. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
The comment opened with a field list and buried "bitte prüfen" at the bottom — wrong order now that some purchases skip review entirely, and on an auto-released one that trailing banner would have been actively wrong. - Verdict first: ✅ auto-released, or⚠️ held with the gate's reasons spelled out plus the "Review pending"-Tag removal as the explicit next action. - The three fields resolved against Moco (Lieferant / Konto / Kommission) get ✓/✗ markers mirroring the batch script's table, and the ✗ rows say WHY ("keine Firma in Moco zugeordnet", "mehrdeutig (3 Projekte)"). They render even when empty — for a resolution outcome, "nothing matched" is precisely what the reviewer needs to see. Plain OCR readings stay unmarked; a ✓ there would imply a match that was never attempted. - Operator-facing strings are German throughout. `CategoryDecision.source` maps through CATEGORY_SOURCE_LABELS (supplier → Lieferant) and its internal English `reason` is replaced by `_category_comment_text` ("skipped: already paid" → "kein Aufwandkonto hinterlegt — bitte manuell wählen"). The resolver's own reason/source stay untouched for logs and the batch table. The review/company/project/category args are optional, so standalone callers still render — and keep the original trailing banner, since without a ReviewDecision there is no verdict to lead with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PurchaseReviewGatedrops theReview pendingtag when OCR resolved everything (company matched + trusted category + confidence ≥ 90% + not a credit note), so those purchases flow straight tobexio-expense-syncinstead of waiting for a human. Held ones behave exactly as today.MocoPurchaseClient.create_payment, plus a 💳 comment explaining the settled balance.REVIEWpreview column driven by the same gate object, so the preview can't drift from the real rule.Spec:
specs/SPEC_purchase_payment_already_paid.md— 6 commits of decision history, including alternatives considered and rejected.Two things worth a reviewer's attention
1. This lets purchases reach Bexio unreviewed. For a bank-transfer bill that means Bexio books the bill and creates an outgoing payment. The mitigation is the four gate conditions — but note the category condition rarely bites for transfer bills, because the
4000fallback nearly always resolves, so their effective gate is company + confidence ≥ 0.90. Recorded as D1 in the spec, not an oversight. Card receipts cannot move money: no IBAN means_try_book_and_payreturns early and their Bexio bill stays DRAFT.2. A purchase with a payment can no longer be deleted (
DELETE /purchases/{id}is refused). Payment registration is deliberately independent of the review decision, so a held receipt is settled too — if you correct its total during review the payment goes stale and shows a residual. Accepted as D5; the alternative left the manual settle chore in place for exactly the receipts you already open by hand.Design notes
AUTO_RELEASE_CONFIDENCE(0.90) is a separate constant fromCONFIDENCE_THRESHOLD(0.85). The latter only picks a Telegram emoji; tuning alert noise must not silently retune a gate that can release money-moving bills.A category is trusted by its
CategoryDecision.source, not merely by being set — supplier always, project only on anexact/substringmatch (nevertoken-overlap, where a single shared token counts),4000default yes. Card receipts can never present asdefaultbecauseMocoCategoryResolvershort-circuits atalready_paidfirst, so card-receipt strictness falls out of the existing chain: the resolver needed no change, and the gate needs no payment-method branch.totalon the payment comes from the create response's server-computedgross_totalrather than the OCR figure (the open balance is measured against it; a rounding cent would leave a residual), anddateis the purchase date rather than today (so a late import lands in the right period).Operator-facing strings in comments are German throughout —
CategoryDecision.sourcemaps throughCATEGORY_SOURCE_LABELSand its internal Englishreasonis replaced by_category_comment_text, so log vocabulary never leaks into a Moco comment. The resolver's ownreason/sourcestay untouched for logs and the batch table.What the reviewer sees in Moco
Auto-released:
Held:
Only the three resolved fields carry ✓/✗; plain OCR readings stay unmarked, since a ✓ there would imply a match that was never attempted.
A bug found and fixed mid-review
batch_ocr_drafts.py --applydeliberately inlines the pipeline rather than callingservice.process(), delegating only the post-create steps._register_paymentwas added to the service without being mirrored there, so--applycreated already-paid purchases and left them unsettled — exactly the chore this PR removes. Caught on a live run (purchase 4642707); a full scan of all 499 purchases confirmed it was the only affected record, and it has been backfilled (payment3394736, open balance now 0.00). Fixed in 3961f91, which also documents the maintenance hazard on_process_draftso the next post-create step doesn't repeat it.Test plan
.venv/bin/pytest -v— 572 passing, 44 new. Three pre-existing assertions changed: the endpoint happy path (now legitimately auto-releases) and two OCR-comment assertions (field order + the moved banner).create_paymentagainst real Moco data (the 4642707 backfill).vercel env pull .env.localthen.venv/bin/python scripts/batch_ocr_drafts.py --max 20— dry-run, no writes. Check theREVIEWcolumn and theauto-release: Nsummary: that's the historical hit rate, i.e. exactly which past invoices would have skipped review.AUTOrate looks high, raiseAUTO_RELEASE_CONFIDENCEinapi/purchase_review_gate.pybefore merging — it's one constant, nothing else keys off it..venv/bin/python scripts/test_ocr_create_purchase.py <draft-id>— dry-run shows thereview: AUTO/HOLDline and the exact tags that would be POSTed.--applyon one already-paid draft, then confirm in Moco: no open balance, a payment row, and both the 💳 and 🤖 comments reading correctly.vercel --prodfrom this branch, forward a real supplier invoice into the Moco inbox, and check the Telegram message — auto-released ones read "Automatisch freigegeben" instead of "bitte prüfen".No new env vars, no new webhooks, no Moco configuration needed.
Rollback: revert the four implementation commits (
1678f6f,3961f91,869bf6b,3f9eec3). Nothing is persisted outside Moco and existing purchases are untouched — the gate only affects newly created ones.🤖 Generated with Claude Code
https://claude.ai/code/session_015tJ3WMgEt94JuRkGSBeMo9