# 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.