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>
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.
-
Shared predicate
- Go:
matching.IsOtherPurpose(s string) boolingo/internal/domain/matching/infer.go(strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")). - Python:
is_other_purpose(s)inlegacy/scripts/match_payments.py. - Replace the two existing inline checks in reconcile with it
(
go/internal/domain/reconcile/reconcile.go,reconcile()inmatch_payments.py).
- Go:
-
Inference
- Go
matching.InferTransactionDetailsand Pythoninfer_transaction_details: after member matching, if the message is an other-purpose, returnmonths = [trimmed message]and skipParseMonthReferencesand the date fallback. Update the doc comments (months may hold oneother:…purpose). banksync.InferPayments(go/internal/services/banksync/infer.go) andinfer_payments.py: no logic change — they already join months into Purpose and write the row when either members or months are non-empty.
- Go
-
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.
-
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(messageother: 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 withother:→ lands inother_transactions, month stays unpaid).
- Go unit tests:
IsOtherPurposetable; cases inmatching/infer_test.go; a case inbanksync/infer_test.goasserting the written row (Person,Purpose = "other: …", amount) and one with no matching member; a fallback case inreconcile/reconcile_test.go. - Python: cases in
legacy/tests/test_match_payments.pyforinfer_transaction_detailsand the reconcile fallback. - Existing fixtures must stay byte-identical apart from the new files.
- Seeds in
-
Docs
CLAUDE.md: add the rule to the payments-sheet / inference description.CHANGELOG.mdentry; copy this plan todocs/plans/2026-10-08-HHMM-infer-other-purpose.md.
No change to the JSON API, schemas, templates or the sheet layout.
Verification
make go-test-all(unit + parity, including the new fixtures) andmake test.git status go/tests/fixturesshows only the four new fixture files.- Dry run against the live sheet in both implementations
(
fuj infer --dry-run, Pythoninfer_payments.py --dry-run): confirm it runs cleanly and proposes nothing unexpected. The live sheet probably has no un-inferred row starting withother:, so the new rule itself is proven by the tests rather than by live data. make paritybetween the two servers (or the two-container run used earlier) still reports 3/3 routes matching.