Files
fuj-management/docs/plans/2026-10-08-1237-infer-other-purpose.md
T
kacerrandClaude Opus 5.5 bbad19d822 feat(infer): messages starting with "other:" are not monthly fees
When the bank message starts with "other:" (case-insensitive),
inference does not look for a month, neither in the text nor from the
payment date. The trimmed message is written to Purpose, so reconcile
books the payment as an "other" transaction for the person instead of
a monthly fee. Messages containing "other:" elsewhere are unchanged.

- matching.IsOtherPurpose / is_other_purpose shared by inference and
  reconcile
- reconcile applies the rule to rows not yet processed by inference
- no person found: Purpose is still written, row shows as unmatched
- implemented in Go and legacy Python; four new parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00

5.4 KiB

Inference: messages starting with "other:" are not monthly fees

Context

Some payments are not monthly fees (tournament fees, jerseys, …). Reconcile already supports them: a row whose Purpose starts with other: is attached to the person's "other transactions" and is not counted against any month. Today that only happens when someone types the purpose by hand, because inference always tries to find a month — from the message, or failing that from the payment date.

New rule: when the bank message for the recipient starts with other:, inference must not look for a month. It copies the message into Purpose, so the payment is linked to the person but not counted as a monthly fee. Messages that merely contain other: somewhere else are handled the old way.

Implemented in both Go and legacy Python (this is inference behaviour, so the dual implementation rule applies).

Behaviour

  • Trigger: message, after trimming whitespace, starts with other: (case-insensitive, same test reconcile already uses for Purpose).
  • Purpose written to the sheet: the trimmed message, unchanged (e.g. other: turnaj Praha). No month parsing, no date fallback.
  • Person: inferred exactly as today (sender + message + VS, then sender alone), including the [?] review marker.
  • Inferred Amount: the full amount, as today.
  • No person found: Purpose is still written, Person stays empty. The row then shows up under "unmatched" for a manual fix instead of being guessed again on every run.
  • Rows not yet processed by inference (Person and Purpose both empty) are inferred on the fly by reconcile; that path follows the same rule, so such a payment is never briefly counted as a monthly fee.
  • Existing rows are untouched: inference only fills rows where manual fix, Person and Purpose are all empty. To reprocess an old row, clear its Person and Purpose cells.

Implementation

Branch feat/infer-other-purpose off main; MR via tea.

The output shape of the inference function does not change: for an "other" message the months list holds the single purpose string. That is the same convention reconcile already uses internally (matchedMonths = [purpose]), and it means the sheet-writing code needs no change in either language.

  1. Shared predicate

    • Go: matching.IsOtherPurpose(s string) bool in go/internal/domain/matching/infer.go (strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")).
    • Python: is_other_purpose(s) in legacy/scripts/match_payments.py.
    • Replace the two existing inline checks in reconcile with it (go/internal/domain/reconcile/reconcile.go, reconcile() in match_payments.py).
  2. Inference

    • Go matching.InferTransactionDetails and Python infer_transaction_details: after member matching, if the message is an other-purpose, return months = [trimmed message] and skip ParseMonthReferences and the date fallback. Update the doc comments (months may hold one other:… purpose).
    • banksync.InferPayments (go/internal/services/banksync/infer.go) and infer_payments.py: no logic change — they already join months into Purpose and write the row when either members or months are non-empty.
  3. Reconcile fallback (rows with empty Person/Purpose)

    • In the inference-fallback branch of both reconcile implementations: if the inferred months are a single other-purpose, set the row's purpose to it and mark it as other, so it goes to the member's other-transactions ledger instead of the month allocation.
  4. Tests and fixtures

    • Seeds in legacy/scripts/_fixture_seeds.py, captured with the existing capture/scrub scripts for these two functions only:
      • infer_transaction_details: other_prefix (message other: tournament, member in sender → that purpose, no month), other_prefix_uppercase (Other: …), other_not_at_start (leden other: x → old behaviour, month parsed).
      • reconcile: 11_other_message_fallback (empty Person/Purpose, message starts with other: → lands in other_transactions, month stays unpaid).
    • Go unit tests: IsOtherPurpose table; cases in matching/infer_test.go; a case in banksync/infer_test.go asserting the written row (Person, Purpose = "other: …", amount) and one with no matching member; a fallback case in reconcile/reconcile_test.go.
    • Python: cases in legacy/tests/test_match_payments.py for infer_transaction_details and the reconcile fallback.
    • Existing fixtures must stay byte-identical apart from the new files.
  5. Docs

    • CLAUDE.md: add the rule to the payments-sheet / inference description.
    • CHANGELOG.md entry; copy this plan to docs/plans/2026-10-08-HHMM-infer-other-purpose.md.

No change to the JSON API, schemas, templates or the sheet layout.

Verification

  1. make go-test-all (unit + parity, including the new fixtures) and make test.
  2. git status go/tests/fixtures shows only the four new fixture files.
  3. Dry run against the live sheet in both implementations (fuj infer --dry-run, Python infer_payments.py --dry-run): confirm it runs cleanly and proposes nothing unexpected. The live sheet probably has no un-inferred row starting with other:, so the new rule itself is proven by the tests rather than by live data.
  4. make parity between the two servers (or the two-container run used earlier) still reports 3/3 routes matching.