Skip to content

Auto-release resolved OCR purchases; settle already-paid receipts in Moco - #22

Merged
tompluess merged 10 commits into
mainfrom
create-payments-for-paid-expenses
Aug 20, 2026
Merged

tompluess merged 10 commits into
mainfrom
create-payments-for-paid-expenses

Conversation

@tompluess

@tompluess tompluess commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Auto-release: new PurchaseReviewGate drops the Review pending tag when OCR resolved everything (company matched + trusted category + confidence ≥ 90% + not a credit note), so those purchases flow straight to bexio-expense-sync instead of waiting for a human. Held ones behave exactly as today.
  • Payments: already-paid receipts (card / TWINT / POS) get a Moco purchase payment registered right after the create via the new MocoPurchaseClient.create_payment, plus a 💳 comment explaining the settled balance.
  • Comments: the 🤖 OCR comment now leads with the verdict (auto-released vs. held, and why) and marks the three resolved-against-Moco fields with ✓/✗.
  • Both operator scripts gained a REVIEW preview 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 4000 fallback 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_pay returns 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 from CONFIDENCE_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 an exact/substring match (never token-overlap, where a single shared token counts), 4000 default yes. Card receipts can never present as default because MocoCategoryResolver short-circuits at already_paid first, so card-receipt strictness falls out of the existing chain: the resolver needed no change, and the gate needs no payment-method branch.

total on the payment comes from the create response's server-computed gross_total rather than the OCR figure (the open balance is measured against it; a rounding cent would leave a residual), and date is the purchase date rather than today (so a late import lands in the right period).

Operator-facing strings in comments are German throughout — CategoryDecision.source maps through CATEGORY_SOURCE_LABELS and its internal English reason is replaced by _category_comment_text, so log vocabulary never leaks into a Moco comment. The resolver's own reason/source stay untouched for logs and the batch table.

What the reviewer sees in Moco

Auto-released:

✅ Automatisch freigegeben — keine Prüfung nötig
Firma und Konto wurden eindeutig erkannt (Konfidenz 94%). …
🤖 OCR-Extraktion (Konfidenz: 94%)
  • ✓ Lieferant: Digitec Galaxus AG
  • ✓ Konto: 6510 (Lieferant)
  • ✓ Kommission: Haldenweg12 → Sanierung Haldenweg
  • Betrag: CHF 249.00

Held:

⚠️ Bitte prüfen und freigeben
Zurückgehalten: keine Firma zugeordnet, kein Aufwandkonto, Konfidenz 72% (< 90%)
Nach der Prüfung das Tag "Review pending" entfernen — …
🤖 OCR-Extraktion (Konfidenz: 72%)
  • ✗ Lieferant: Nozomi Luzern GmbH (keine Firma in Moco zugeordnet)
  • ✗ Konto: kein Aufwandkonto hinterlegt — bitte manuell wählen
  • ✗ Kommission: —
  • Betrag: CHF 145.00 — 💳 bereits bezahlt (Karte / Terminal)

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 --apply deliberately inlines the pipeline rather than calling service.process(), delegating only the post-create steps. _register_payment was added to the service without being mirrored there, so --apply created 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 (payment 3394736, open balance now 0.00). Fixed in 3961f91, which also documents the maintenance hazard on _process_draft so 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).
  • Live validation of create_payment against real Moco data (the 4642707 backfill).
  • Read the preview column before enabling this. vercel env pull .env.local then .venv/bin/python scripts/batch_ocr_drafts.py --max 20 — dry-run, no writes. Check the REVIEW column and the auto-release: N summary: that's the historical hit rate, i.e. exactly which past invoices would have skipped review.
  • If the transfer-bill AUTO rate looks high, raise AUTO_RELEASE_CONFIDENCE in api/purchase_review_gate.py before merging — it's one constant, nothing else keys off it.
  • Single-draft detail: .venv/bin/python scripts/test_ocr_create_purchase.py <draft-id> — dry-run shows the review: AUTO/HOLD line and the exact tags that would be POSTed.
  • --apply on one already-paid draft, then confirm in Moco: no open balance, a payment row, and both the 💳 and 🤖 comments reading correctly.
  • vercel --prod from 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

tompluess and others added 7 commits August 20, 2026 16:35
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
@vercel

vercel Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moco-skyr-functions Ready Ready Preview Aug 20, 2026 4:24pm
vercel-functions Ready Ready Preview Aug 20, 2026 4:24pm

`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
@tompluess
tompluess merged commit c84105d into main Aug 20, 2026
4 checks passed
@tompluess
tompluess deleted the create-payments-for-paid-expenses branch August 20, 2026 16:27

This branch was successfully deployed

2 active deployments
Preview – vercel-functions — 3f9eec35 Deployed Aug 20, 2026 by vercel[bot]
Preview – moco-skyr-functions — 3f9eec35 Deployed Aug 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant