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>
110 lines
5.4 KiB
Markdown
110 lines
5.4 KiB
Markdown
# 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.
|