From cb023577bd96ebd020ab138dc23d8358091e119e Mon Sep 17 00:00:00 2001 From: Ricardo Canales Date: Fri, 19 Jun 2026 18:30:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(rules):=20richer=20conditions=20=E2=80=94?= =?UTF-8?q?=20amount=20tolerance=20+=20date-part=20fields=20(P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the condition expressiveness that lets rules absorb recurrence without a shipped detector (rules-as-substrate P1, roadmap §4a): - Numeric `approx` (value ± tolerance) and `between` (min/max) operators. - Date-part fields derived from the tz-naive `date` column (no timezone math): day_of_month, month, day_of_week, day_of_year — all numeric. - `day_of_month approx` is cyclic (1 ↔ the month's last day are 1 apart) and clamps a target past a short month to its last day, so "the 31st" matches Feb. Boundary-tested across Feb / 30- / 31-day / leap months. Threaded end-to-end (mirroring the metadata-condition pattern from #1874/ #1876): service validation + compile/eval, sync resolver + the four retroactive/preview context queries, engine date seeding, MCP tool schemas, the admin rule-form builder (field optgroup + ≈/between inputs), and docs/rule-dsl.md + docs/mcp-tools-reference.md. openapi.yaml needs no change (conditions are additionalProperties; no new routes — drift green). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU --- docs/mcp-tools-reference.md | 2 +- docs/rule-dsl.md | 68 +++++++ internal/mcp/tools.go | 2 +- internal/service/rules.go | 155 +++++++++++++- .../rules_dateparts_integration_test.go | 127 ++++++++++++ internal/service/rules_dateparts_test.go | 189 ++++++++++++++++++ internal/service/types.go | 15 ++ internal/sync/engine.go | 3 + internal/sync/rule_resolver.go | 108 +++++++++- internal/sync/rule_resolver_dateparts_test.go | 107 ++++++++++ .../sync/rules_dateparts_integration_test.go | 98 +++++++++ .../components/pages/rule_form.templ | 17 +- static/js/admin/components/rule_form.js | 51 ++++- 13 files changed, 915 insertions(+), 27 deletions(-) create mode 100644 internal/service/rules_dateparts_integration_test.go create mode 100644 internal/service/rules_dateparts_test.go create mode 100644 internal/sync/rule_resolver_dateparts_test.go create mode 100644 internal/sync/rules_dateparts_integration_test.go diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index 6e9461e5..a85d9a74 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -307,7 +307,7 @@ Create a rule that fires during sync. Actions compose (`set_category` + `add_tag | Parameter | Type | Description | |-----------|------|-------------| | `name` | string | Human-readable rule name | -| `conditions` | object | Condition tree. Omit or `{}` for match-all. Supports `and` / `or` / `not` nesting up to depth 10. Leaf fields include `metadata.` to read a key from the free-form metadata blob (ops: `eq`/`neq`/`contains`/`not_contains`/`matches`/`in`/`gt`/`gte`/`lt`/`lte`/`exists`/`not_exists`; an absent key matches only `not_exists`). | +| `conditions` | object | Condition tree. Omit or `{}` for match-all. Supports `and` / `or` / `not` nesting up to depth 10. Numeric fields (incl. `amount`) add `approx` (`value` + sibling `tolerance`) and `between` (sibling `min`/`max`). Date-part fields derived from the tz-naive `date` — `day_of_month`, `month`, `day_of_week` (`0`=Sun), `day_of_year` — are numeric; `day_of_month approx` is cyclic + clamped (31 matches Feb's last day). Encode annual cadence as `month` + `day_of_month` (leap-robust), not `day_of_year`. Leaf fields also include `metadata.` to read a key from the free-form metadata blob (ops: `eq`/`neq`/`contains`/`not_contains`/`matches`/`in`/`gt`/`gte`/`lt`/`lte`/`exists`/`not_exists`; an absent key matches only `not_exists`). Full grammar: `docs/rule-dsl.md`. | | `actions` | array | Typed actions: `set_category`, `add_tag`, `remove_tag`, `add_comment`, `set_metadata` (`metadata_key` + `metadata_value`, any JSON), `remove_metadata` (`metadata_key`), `assign_series`. Either this or `category_slug` is required. | | `category_slug` | string | Shorthand for `actions=[{type:set_category,category_slug:...}]` | | `trigger` | string | `on_create` (default) / `on_change` / `always`. `on_update` accepted as legacy alias. | diff --git a/docs/rule-dsl.md b/docs/rule-dsl.md index 9576d592..c6943b5d 100644 --- a/docs/rule-dsl.md +++ b/docs/rule-dsl.md @@ -75,6 +75,10 @@ Combinators nest. Max depth: **10**. Empty / zero-value condition (`{}`) means * | `tags` | tags | List of current transaction tag slugs (special, see below) | | `series` | string | `short_id` of the recurring series the transaction belongs to (empty when unassigned) | | `in_series` | bool | Whether the transaction is linked to any recurring series | +| `day_of_month` | numeric | Day component of the posting `date`, `1`–`31`. Supports the cyclic/clamped `approx` (see "Date-part conditions"). | +| `month` | numeric | Month of the posting `date`, `1`–`12` | +| `day_of_week` | numeric | Weekday of the posting `date`, `0`=Sunday … `6`=Saturday | +| `day_of_year` | numeric | Ordinal day of the posting `date`, `1`–`366` | | `metadata.` | metadata | One key from the transaction's free-form metadata blob — e.g. `metadata.tax_deductible` reads `metadata["tax_deductible"]`. See "Metadata conditions" below. | > **Raw vs assigned category.** `provider_category_primary` / `provider_category_detailed` are the provider's classification — they don't change when Breadbox, a rule, or the user reassigns. Use `category` when you want to react to the *current* category, including mid-pass rule updates (see "Rule chaining" below). @@ -90,6 +94,7 @@ The classes: - **raw-immutable** — verbatim provider data. Breadbox never rewrites it; it changes only if the provider re-reports the transaction. A condition on it means the same thing on the create pass, on every re-sync, and on retroactive apply. **This is the substrate. Author here.** - **stable-surrogate** — a Breadbox-assigned `id` (UUID) that is stable for the life of the entity. Not provider data, but it doesn't drift when a user renames the entity, so it's a safe primary match. **Also safe.** - **mutable-display** — a human-facing label or a value a prior rule / the user / an agent can change. It silently *breaks* when someone renames the thing (`account_name`, `user_name`), or its truth *depends on pipeline order* (`category`, `tags`, `series`, `in_series` — set by earlier-stage rules in the same pass). **Avoid as a primary match condition.** +- **stable-derived** — a value *computed* deterministically from a raw-immutable column, carrying no independent state. The date-parts (`day_of_month`, `month`, `day_of_week`, `day_of_year`) are pure functions of the immutable, tz-naive `date`, so a condition on them is as durable as one on `amount`. **Safe to author on** (this is the backbone of recurrence rules). | Field | Type | Class | Why | | ------------------------------ | -------- | -------------------- | ----------------------------------------------------------------------------------------- | @@ -108,6 +113,10 @@ The classes: | `tags` | tags | **mutable-display** | Current tag slugs; mutate mid-pass as earlier `add_tag`/`remove_tag` rules fire | | `series` | string | **mutable-display** | Current series `short_id`; set by `assign_series` (a rule write), empty until then | | `in_series` | bool | **mutable-display** | Whether a series link exists; same pipeline/timing caveat as `series` | +| `day_of_month` | numeric | **stable-derived** | Day-of-month of the immutable tz-naive `date`; pure function, no independent state | +| `month` | numeric | **stable-derived** | Month of the immutable `date` | +| `day_of_week` | numeric | **stable-derived** | Weekday of the immutable `date` (`0`=Sun) | +| `day_of_year` | numeric | **stable-derived** | Ordinal day of the immutable `date` | | `metadata.` | metadata | **mutable-display** | Free-form blob a user / agent / rule writes; no stability guarantee | > **The contract.** *Author conditions on **raw-immutable** + **stable-surrogate** fields.* These are the immutable provider substrate (and stable surrogates of it) the whole model rests on — a rule built on them resolves identically on the create pass, on every future re-sync, and on retroactive apply over history. @@ -122,6 +131,8 @@ The classes: | | `matches` | RE2 regex; case-sensitive by default (`(?i)` for insensitive) | | | `in` | Non-empty array; case-insensitive membership test | | **numeric** | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` | `float64` comparison | +| | `approx` | `value ± tolerance`: matches when `abs(actual - value) ≤ tolerance`. Requires a sibling `tolerance ≥ 0`. For `day_of_month` the comparison is **cyclic + clamped** (see "Date-part conditions"). | +| | `between` | `min ≤ actual ≤ max` (inclusive). Requires sibling `min` and `max` (with `min ≤ max`); `value` is ignored. | | **bool** | `eq`, `neq` | `true` / `false` | | **tags** | `contains`, `not_contains` | `value` is a single tag slug; case-insensitive | | | `in` | `value` is an array of slugs; matches if any slug is present | @@ -178,6 +189,63 @@ The key (everything after `metadata.`) must be a valid metadata key — non-empt Metadata conditions evaluate identically at sync time, in `preview_rule`, and during retroactive apply. They also chain: a `set_metadata` action by an earlier-stage rule is visible to a later-stage rule's `metadata.` condition within the same pass (see "Rule chaining" above and the `set_metadata` action below). +### Amount tolerance (`approx` / `between`) + +Two numeric operators express "near a value" without a pile of `gte`/`lte` pairs — the building block for recurring-charge rules: + +```json +{ "field": "amount", "op": "approx", "value": 15.49, "tolerance": 0.50 } +{ "field": "amount", "op": "between", "min": 9.99, "max": 19.99 } +``` + +- `approx` matches when `abs(amount - value) ≤ tolerance`. The `tolerance` sibling is **required and must be ≥ 0**. +- `between` matches when `min ≤ amount ≤ max` (both ends inclusive). Both `min` and `max` are **required**, with `min ≤ max`; the `value` field is ignored. +- Both work on every numeric field — `amount` and the date-parts below. + +### Date-part conditions + +Four **numeric** fields are derived from the transaction's posting `date` — the **tz-naive, provider-localized** date column. There is deliberately **no timezone math**: the date is taken exactly as the provider reported it, so a charge "on the 14th" is the 14th in the provider's locale regardless of where the server runs. + +| Field | Range | Notes | +| -------------- | -------------- | -------------------------------------- | +| `day_of_month` | `1`–`31` | Cyclic + clamped under `approx` (below) | +| `month` | `1`–`12` | January = 1 | +| `day_of_week` | `0`–`6` | `0` = Sunday … `6` = Saturday | +| `day_of_year` | `1`–`366` | Ordinal day; `366` only on leap years | + +They support the full numeric operator set: `eq` / `neq` / `gt` / `gte` / `lt` / `lte`, plus `between` and `approx`. A transaction with no date never matches a date-part condition. + +**`day_of_month` `approx` is cyclic and clamped** — this is what makes "around the 1st" and "the 31st" behave sanely: + +- **Cyclic:** the distance wraps around the month, so the 1st and the month's last day are **1 day apart**. `day_of_month approx 1 ± 2` therefore matches the 30th/31st of the previous-style cycle as well as the 1st–3rd. +- **Clamped:** a target past the month's actual length collapses to the **last day** of that month. So `day_of_month approx 31 ± 0` matches **Feb 28** (or **Feb 29** in a leap year), April 30, etc. — "bill me on the 31st" still fires in short months. + +Both the clamp boundary and the wrap distance use the transaction's **own** month length (28/29/30/31), so the same rule behaves correctly across every month and across leap years. + +```json +{ "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 3 } +``` + +> **Annual cadence — use `month` + `day_of_month`, not `day_of_year`.** `day_of_year` drifts by one after February in leap years (day 60 is Mar 1 in a common year but Feb 29 in a leap year), so an annual rule keyed on `day_of_year` silently misses every fourth year. Express a yearly charge as a conjunction instead, which is leap-robust: +> +> ```json +> { "and": [ +> { "field": "month", "op": "eq", "value": 4 }, +> { "field": "day_of_month", "op": "approx", "value": 15, "tolerance": 2 } +> ] } +> ``` + +Date-part conditions evaluate identically at sync time, in `preview_rule`, and during retroactive apply (all three read the same tz-naive `date`). + +A full recurring-charge rule combines amount tolerance with a date-part — the detector-free way to capture a subscription: + +```json +{ "and": [ + { "field": "amount", "op": "approx", "value": 15.49, "tolerance": 0.50 }, + { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 3 } +] } +``` + ## Actions Actions describe what a matching rule does to the transaction. An action array must have at least one element. diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 59cdf470..7d9f933a 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -315,7 +315,7 @@ func (s *MCPServer) handleTransactionSummary(_ context.Context, _ *mcpsdk.CallTo type createTransactionRuleInput struct { Name string `json:"name" jsonschema:"required,Name for this rule (human-readable description)"` - Conditions map[string]any `json:"conditions,omitempty" jsonschema:"JSON condition tree. Omit or pass {} to match every transaction. Leaf: {\"field\":\"...\",\"op\":\"...\",\"value\":...}. Combinators: {\"and\":[...]}, {\"or\":[...]}, {\"not\":{...}} (nest freely, max depth 10). Fields: provider_name provider_merchant_name amount provider_category_primary provider_category_detailed category(assigned slug, live-updated by earlier-stage rules) pending provider account_id account_name user_id user_name tags series in_series, plus metadata. to read a key from the free-form metadata blob (e.g. metadata.tax_deductible). Ops: string/category=eq|neq|contains|not_contains|matches(RE2)|in; numeric=eq|neq|gt|gte|lt|lte; bool=eq|neq; tags=contains|not_contains|in; metadata.=eq|neq|contains|not_contains|matches|in|gt|gte|lt|lte|exists|not_exists (an absent key matches only not_exists; eq/neq comparison type follows the value's type). Nested example: {\"or\":[{\"and\":[{\"field\":\"provider_merchant_name\",\"op\":\"contains\",\"value\":\"starbucks\"},{\"field\":\"amount\",\"op\":\"gte\",\"value\":5}]},{\"field\":\"metadata.reimbursable\",\"op\":\"eq\",\"value\":true}]}. Full spec: docs/rule-dsl.md."` + Conditions map[string]any `json:"conditions,omitempty" jsonschema:"JSON condition tree. Omit or pass {} to match every transaction. Leaf: {\"field\":\"...\",\"op\":\"...\",\"value\":...}. Combinators: {\"and\":[...]}, {\"or\":[...]}, {\"not\":{...}} (nest freely, max depth 10). Fields: provider_name provider_merchant_name amount provider_category_primary provider_category_detailed category(assigned slug, live-updated by earlier-stage rules) pending provider account_id account_name user_id user_name tags series in_series day_of_month month day_of_week(0=Sun..6=Sat) day_of_year (the four date-parts are numeric, derived from the tz-naive date), plus metadata. to read a key from the free-form metadata blob (e.g. metadata.tax_deductible). Ops: string/category=eq|neq|contains|not_contains|matches(RE2)|in; numeric=eq|neq|gt|gte|lt|lte|approx|between; bool=eq|neq; tags=contains|not_contains|in; metadata.=eq|neq|contains|not_contains|matches|in|gt|gte|lt|lte|exists|not_exists (an absent key matches only not_exists; eq/neq comparison type follows the value's type). approx needs a sibling tolerance: {\"field\":\"amount\",\"op\":\"approx\",\"value\":15.49,\"tolerance\":0.5}; between needs sibling min+max: {\"field\":\"day_of_month\",\"op\":\"between\",\"min\":1,\"max\":5}. day_of_month approx is cyclic (1 and the month's last day are 1 apart) and clamps a target past a short month to its last day; encode annual cadence as month + day_of_month (leap-robust): {\"and\":[{\"field\":\"month\",\"op\":\"eq\",\"value\":4},{\"field\":\"day_of_month\",\"op\":\"approx\",\"value\":15,\"tolerance\":2}]}. Nested example: {\"or\":[{\"and\":[{\"field\":\"provider_merchant_name\",\"op\":\"contains\",\"value\":\"starbucks\"},{\"field\":\"amount\",\"op\":\"gte\",\"value\":5}]},{\"field\":\"metadata.reimbursable\",\"op\":\"eq\",\"value\":true}]}. Full spec: docs/rule-dsl.md."` Actions []map[string]any `json:"actions,omitempty" jsonschema:"Array of typed actions. {\"type\":\"set_category\",\"category_slug\":\"...\"} | {\"type\":\"add_tag\",\"tag_slug\":\"...\"} | {\"type\":\"remove_tag\",\"tag_slug\":\"...\"} | {\"type\":\"add_comment\",\"content\":\"...\"} | {\"type\":\"set_metadata\",\"metadata_key\":\"...\",\"metadata_value\":} | {\"type\":\"remove_metadata\",\"metadata_key\":\"...\"} | {\"type\":\"assign_series\",\"series_short_id\":\"...\"} or {\"type\":\"assign_series\",\"merchant_key\":\"...\",\"create_if_missing\":true}. Actions compose: a rule can set a category AND add a tag AND write metadata in the same match. set_metadata upserts one key (value may be any JSON type); remove_metadata deletes one key; both repeat freely. add_comment fires only at sync time (not on retroactive apply). remove_tag/remove_metadata net-diff against add_tag/set_metadata within the same sync pass. If omitted, use category_slug instead."` CategorySlug string `json:"category_slug,omitempty" jsonschema:"Shorthand for actions: [{\"type\":\"set_category\",\"category_slug\":\"\"}]. Either actions or category_slug is required."` Trigger string `json:"trigger,omitempty" jsonschema:"When the rule fires during sync: 'on_create' (default — first-synced transactions) | 'on_change' (existing transactions that changed on re-sync) | 'always' (both). 'on_update' is accepted as a legacy alias for 'on_change'. Retroactive apply ignores trigger."` diff --git a/internal/service/rules.go b/internal/service/rules.go index 7dbcba15..b246e614 100644 --- a/internal/service/rules.go +++ b/internal/service/rules.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "math" "regexp" "strconv" "strings" @@ -43,6 +44,20 @@ var validConditionFields = map[string]string{ "tags": "tags", "series": "string", // recurring-series short_id the txn belongs to "in_series": "bool", // whether the txn belongs to any recurring series + + // Date-part fields — numeric values derived from the tz-naive `date` + // column (no timezone math; provider-localized). day_of_week uses Go's + // time.Weekday numbering (0=Sunday … 6=Saturday). + "day_of_month": "numeric", // 1..31 (clamped/cyclic under approx, see evalDayOfMonth) + "month": "numeric", // 1..12 + "day_of_week": "numeric", // 0=Sun .. 6=Sat + "day_of_year": "numeric", // 1..366 +} + +// datePartFields is the set of derived date-part condition fields. They are +// numeric but computed from TransactionContext.Date rather than a stored scalar. +var datePartFields = map[string]bool{ + "day_of_month": true, "month": true, "day_of_week": true, "day_of_year": true, } // stringOps are operators valid for string fields. @@ -51,8 +66,13 @@ var stringOps = map[string]bool{ } // numericOps are operators valid for numeric fields. +// +// - approx: amount ≈ value ± tolerance (requires Tolerance ≥ 0). For +// day_of_month, the comparison is cyclic + clamped (see evalDayOfMonth). +// - between: min ≤ amount ≤ max (requires Min and Max, Min ≤ Max). var numericOps = map[string]bool{ "eq": true, "neq": true, "gt": true, "gte": true, "lt": true, "lte": true, + "approx": true, "between": true, } // boolOps are operators valid for boolean fields. @@ -175,8 +195,28 @@ func validateConditionDepth(c Condition, depth int) error { if !numericOps[c.Op] { return fmt.Errorf("%w: operator %q not valid for numeric field %q", ErrInvalidParameter, c.Op, c.Field) } - if _, ok := toFloat64(c.Value); !ok { - return fmt.Errorf("%w: numeric value required for field %q", ErrInvalidParameter, c.Field) + switch c.Op { + case "between": + if c.Min == nil || c.Max == nil { + return fmt.Errorf("%w: 'between' operator requires numeric 'min' and 'max' for field %q", ErrInvalidParameter, c.Field) + } + if *c.Min > *c.Max { + return fmt.Errorf("%w: 'between' requires min ≤ max for field %q", ErrInvalidParameter, c.Field) + } + case "approx": + if _, ok := toFloat64(c.Value); !ok { + return fmt.Errorf("%w: numeric value required for field %q", ErrInvalidParameter, c.Field) + } + if c.Tolerance == nil { + return fmt.Errorf("%w: 'approx' operator requires a numeric 'tolerance' for field %q", ErrInvalidParameter, c.Field) + } + if *c.Tolerance < 0 { + return fmt.Errorf("%w: 'tolerance' must be ≥ 0 for field %q", ErrInvalidParameter, c.Field) + } + default: + if _, ok := toFloat64(c.Value); !ok { + return fmt.Errorf("%w: numeric value required for field %q", ErrInvalidParameter, c.Field) + } } case "bool": if !boolOps[c.Op] { @@ -270,6 +310,13 @@ type CompiledCondition struct { Value interface{} Regex *regexp.Regexp + // Tolerance / Min / Max carry the extra numeric-operator parameters + // (approx tolerance, between bounds) so evalNumeric / evalDayOfMonth can + // read them without re-parsing the source Condition. + Tolerance *float64 + Min *float64 + Max *float64 + // Pre-lowercased expected value for case-insensitive string comparisons. // Set at compile time so evalString avoids repeated ToLower on the expected side. lowerValue string @@ -291,9 +338,12 @@ func CompileCondition(c Condition) (*CompiledCondition, error) { return nil, nil } cc := &CompiledCondition{ - Field: c.Field, - Op: c.Op, - Value: c.Value, + Field: c.Field, + Op: c.Op, + Value: c.Value, + Tolerance: c.Tolerance, + Min: c.Min, + Max: c.Max, } fieldType := validConditionFields[c.Field] @@ -429,6 +479,26 @@ func evaluateLeaf(c *CompiledCondition, tctx TransactionContext) bool { return evalString(c, tctx.SeriesShortID) case "in_series": return evalBool(c, tctx.InSeries) + case "day_of_month": + if tctx.Date.IsZero() { + return false + } + return evalDayOfMonth(c, tctx.Date) + case "month": + if tctx.Date.IsZero() { + return false + } + return evalNumeric(c, float64(int(tctx.Date.Month()))) + case "day_of_week": + if tctx.Date.IsZero() { + return false + } + return evalNumeric(c, float64(int(tctx.Date.Weekday()))) + case "day_of_year": + if tctx.Date.IsZero() { + return false + } + return evalNumeric(c, float64(tctx.Date.YearDay())) } if key, ok := metadataKeyFromField(c.Field); ok { return evalMetadata(c, key, tctx.Metadata) @@ -436,6 +506,54 @@ func evaluateLeaf(c *CompiledCondition, tctx TransactionContext) bool { return false } +// evalDayOfMonth evaluates a day_of_month leaf. For the approx operator it uses +// cyclic + clamped matching against the transaction's actual month length: the +// target is clamped to [1, daysInMonth] (so "the 31st" matches Feb's last day) +// and the distance wraps around the month (so the 1st and the last day are 1 +// apart). Every other operator falls back to the plain numeric comparison on +// the literal day-of-month (1..31). +func evalDayOfMonth(c *CompiledCondition, date time.Time) bool { + day := date.Day() + if c.Op != "approx" { + return evalNumeric(c, float64(day)) + } + target, ok := toFloat64(c.Value) + if !ok || c.Tolerance == nil { + return false + } + monthLen := daysInMonth(date.Year(), date.Month()) + return dayOfMonthApproxMatch(day, int(math.Round(target)), *c.Tolerance, monthLen) +} + +// daysInMonth returns the number of days in the given year/month (handles leap +// February via the day-0-of-next-month trick). +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +// dayOfMonthApproxMatch reports whether actualDay is within tolerance of +// targetDay on a cyclic month of monthLen days. targetDay is first clamped into +// [1, monthLen]; the distance is the smaller of the direct and wrap-around gaps. +func dayOfMonthApproxMatch(actualDay, targetDay int, tolerance float64, monthLen int) bool { + if monthLen <= 0 { + return false + } + if targetDay > monthLen { + targetDay = monthLen + } + if targetDay < 1 { + targetDay = 1 + } + diff := actualDay - targetDay + if diff < 0 { + diff = -diff + } + if wrap := monthLen - diff; wrap < diff { + diff = wrap + } + return float64(diff) <= tolerance +} + // evalMetadata evaluates a metadata. leaf against the transaction's // metadata blob. Presence operators (exists / not_exists) test key presence; // every other operator requires the key to be present (an absent key matches @@ -626,6 +744,12 @@ func evalString(c *CompiledCondition, actual string) bool { } func evalNumeric(c *CompiledCondition, actual float64) bool { + if c.Op == "between" { + if c.Min == nil || c.Max == nil { + return false + } + return actual >= *c.Min && actual <= *c.Max + } expected, ok := toFloat64(c.Value) if !ok { return false @@ -643,6 +767,11 @@ func evalNumeric(c *CompiledCondition, actual float64) bool { return actual < expected case "lte": return actual <= expected + case "approx": + if c.Tolerance == nil { + return false + } + return math.Abs(actual-expected) <= *c.Tolerance } return false } @@ -1464,7 +1593,7 @@ const transactionContextQuery = `SELECT t.id, t.provider_name, COALESCE(t.provid COALESCE(t.provider_category_primary, ''), COALESCE(t.provider_category_detailed, ''), t.pending, bc.provider, t.account_id::text, COALESCE(u.id::text, ''), COALESCE(u.name, ''), COALESCE(array_agg(DISTINCT tag.slug) FILTER (WHERE tag.slug IS NOT NULL), ARRAY[]::text[]), - COALESCE(rs.short_id, ''), (t.series_id IS NOT NULL), t.metadata + COALESCE(rs.short_id, ''), (t.series_id IS NOT NULL), t.metadata, t.date FROM transactions t JOIN accounts a ON t.account_id = a.id JOIN bank_connections bc ON a.connection_id = bc.id @@ -1476,18 +1605,19 @@ const transactionContextQuery = `SELECT t.id, t.provider_name, COALESCE(t.provid AND (a.is_dependent_linked = FALSE OR NOT EXISTS (SELECT 1 FROM transaction_matches tm WHERE tm.dependent_transaction_id = t.id))` // transactionContextGroupBy is the GROUP BY clause matching transactionContextQuery. -const transactionContextGroupBy = ` GROUP BY t.id, t.provider_name, t.provider_merchant_name, t.amount, t.provider_category_primary, t.provider_category_detailed, t.pending, bc.provider, t.account_id, u.id, u.name, rs.short_id, t.series_id, t.metadata` +const transactionContextGroupBy = ` GROUP BY t.id, t.provider_name, t.provider_merchant_name, t.amount, t.provider_category_primary, t.provider_category_detailed, t.pending, bc.provider, t.account_id, u.id, u.name, rs.short_id, t.series_id, t.metadata, t.date` // transactionContextColumns is the number of columns selected by // transactionContextQuery (and bound by scanTransactionContextRow). Callers size // their scan-dest slice with this so adding a column is a single-line change. -const transactionContextColumns = 15 +const transactionContextColumns = 16 // transactionContextRow holds a scanned transaction row for rule evaluation. type transactionContextRow struct { id pgtype.UUID tctx TransactionContext - metadataRaw []byte // raw JSONB; unmarshalled into tctx.Metadata by finalize + metadataRaw []byte // raw JSONB; unmarshalled into tctx.Metadata by finalize + dateRaw pgtype.Date // tz-naive posting date; copied into tctx.Date by finalize } func scanTransactionContextRow(dest []any) *transactionContextRow { @@ -1507,6 +1637,7 @@ func scanTransactionContextRow(dest []any) *transactionContextRow { dest[12] = &r.tctx.SeriesShortID dest[13] = &r.tctx.InSeries dest[14] = &r.metadataRaw + dest[15] = &r.dateRaw return r } @@ -1514,6 +1645,9 @@ func scanTransactionContextRow(dest []any) *transactionContextRow { // rows.Scan. A malformed blob leaves Metadata nil (metadata conditions simply // won't match) rather than failing the whole apply pass. func (r *transactionContextRow) finalize() { + if r.dateRaw.Valid { + r.tctx.Date = r.dateRaw.Time + } if len(r.metadataRaw) == 0 { return } @@ -2457,6 +2591,9 @@ func (s *Service) previewRuleInternal(ctx context.Context, excludeRuleID *pgtype tctx.Metadata = meta } } + if date.Valid { + tctx.Date = date.Time + } lastID = id result.TotalScanned++ diff --git a/internal/service/rules_dateparts_integration_test.go b/internal/service/rules_dateparts_integration_test.go new file mode 100644 index 00000000..239443b0 --- /dev/null +++ b/internal/service/rules_dateparts_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration && !lite + +package service_test + +import ( + "context" + "testing" + + "breadbox/internal/pgconv" + "breadbox/internal/service" + "breadbox/internal/testutil" +) + +func fp(f float64) *float64 { return &f } + +// TestApplyRuleRetroactively_RecurrenceCondition is the canonical detector-free +// subscription rule: amount ≈ X ± Y AND day_of_month ≈ D ± N → add_tag. It must +// match only the in-window charge during retroactive apply. +func TestApplyRuleRetroactively_RecurrenceCondition(t *testing.T) { + svc, queries, pool := newService(t) + ctx := context.Background() + + user := testutil.MustCreateUser(t, queries, "User") + conn := testutil.MustCreateConnection(t, queries, user.ID, "item_1") + acct := testutil.MustCreateAccount(t, queries, conn.ID, "ext_1", "Checking") + testutil.MustCreateTag(t, queries, "subscription", "Subscription") + + // In-window: $15.49 on the 16th (within 14 ± 3 for a 31-day month). + match := testutil.MustCreateTransaction(t, queries, acct.ID, "m_match", "Netflix", 1549, "2026-03-16") + // Out-of-window day: $15.49 on the 25th. + offDay := testutil.MustCreateTransaction(t, queries, acct.ID, "m_offday", "Netflix", 1549, "2026-03-25") + // Out-of-window amount: $99.00 on the 14th. + offAmt := testutil.MustCreateTransaction(t, queries, acct.ID, "m_offamt", "Spotify", 9900, "2026-03-14") + + rule, err := svc.CreateTransactionRule(ctx, service.CreateTransactionRuleParams{ + Name: "Netflix subscription", + Actions: []service.RuleAction{{Type: "add_tag", TagSlug: "subscription"}}, + Conditions: service.Condition{And: []service.Condition{ + {Field: "amount", Op: "approx", Value: 15.49, Tolerance: fp(0.5)}, + {Field: "day_of_month", Op: "approx", Value: 14, Tolerance: fp(3)}, + }}, + Priority: 10, + Actor: service.Actor{Type: "user", Name: "Test"}, + }) + if err != nil { + t.Fatalf("CreateTransactionRule: %v", err) + } + + matched, err := svc.ApplyRuleRetroactively(ctx, rule.ID) + if err != nil { + t.Fatalf("ApplyRuleRetroactively: %v", err) + } + if matched != 1 { + t.Fatalf("expected exactly 1 matched txn, got %d", matched) + } + + tagged := func(id string) bool { + var n int + if err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM transaction_tags tt + JOIN tags g ON g.id = tt.tag_id + WHERE tt.transaction_id = $1 AND g.slug = 'subscription'`, id).Scan(&n); err != nil { + t.Fatalf("count tags for %s: %v", id, err) + } + return n == 1 + } + + if !tagged(pgconv.FormatUUID(match.ID)) { + t.Errorf("in-window charge should be tagged 'subscription'") + } + if tagged(pgconv.FormatUUID(offDay.ID)) { + t.Errorf("out-of-window day should NOT be tagged") + } + if tagged(pgconv.FormatUUID(offAmt.ID)) { + t.Errorf("out-of-window amount should NOT be tagged") + } +} + +// TestApplyRuleRetroactively_AmountBetweenAndMonth covers the between operator +// and the month date-part through the retroactive path. +func TestApplyRuleRetroactively_AmountBetweenAndMonth(t *testing.T) { + svc, queries, pool := newService(t) + ctx := context.Background() + + user := testutil.MustCreateUser(t, queries, "User") + conn := testutil.MustCreateConnection(t, queries, user.ID, "item_1") + acct := testutil.MustCreateAccount(t, queries, conn.ID, "ext_1", "Checking") + testutil.MustCreateTag(t, queries, "annual", "Annual") + + // April charge in [$90,$110] → match. December charge same amount → month miss. + apr := testutil.MustCreateTransaction(t, queries, acct.ID, "a_apr", "Insurance", 10000, "2026-04-12") + dec := testutil.MustCreateTransaction(t, queries, acct.ID, "a_dec", "Insurance", 10000, "2026-12-12") + + rule, err := svc.CreateTransactionRule(ctx, service.CreateTransactionRuleParams{ + Name: "Annual insurance", + Actions: []service.RuleAction{{Type: "add_tag", TagSlug: "annual"}}, + Conditions: service.Condition{And: []service.Condition{ + {Field: "amount", Op: "between", Min: fp(90), Max: fp(110)}, + {Field: "month", Op: "eq", Value: 4}, + {Field: "day_of_month", Op: "approx", Value: 12, Tolerance: fp(2)}, + }}, + Priority: 10, + Actor: service.Actor{Type: "user", Name: "Test"}, + }) + if err != nil { + t.Fatalf("CreateTransactionRule: %v", err) + } + if _, err := svc.ApplyRuleRetroactively(ctx, rule.ID); err != nil { + t.Fatalf("ApplyRuleRetroactively: %v", err) + } + + tagged := func(id string) bool { + var n int + if err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM transaction_tags tt JOIN tags g ON g.id = tt.tag_id + WHERE tt.transaction_id = $1 AND g.slug = 'annual'`, id).Scan(&n); err != nil { + t.Fatalf("count tags: %v", err) + } + return n == 1 + } + if !tagged(pgconv.FormatUUID(apr.ID)) { + t.Errorf("April charge in range should be tagged") + } + if tagged(pgconv.FormatUUID(dec.ID)) { + t.Errorf("December charge should NOT match the month=4 condition") + } +} diff --git a/internal/service/rules_dateparts_test.go b/internal/service/rules_dateparts_test.go new file mode 100644 index 00000000..310a2660 --- /dev/null +++ b/internal/service/rules_dateparts_test.go @@ -0,0 +1,189 @@ +//go:build !lite + +package service + +import ( + "testing" + "time" +) + +func fptr(f float64) *float64 { return &f } + +// --- Numeric tolerance operators: approx / between -------------------------- + +func TestValidateCondition_NumericToleranceOps(t *testing.T) { + cases := []struct { + name string + cond Condition + wantErr bool + }{ + {"approx ok", Condition{Field: "amount", Op: "approx", Value: 15.49, Tolerance: fptr(0.5)}, false}, + {"approx zero tolerance ok", Condition{Field: "amount", Op: "approx", Value: 10, Tolerance: fptr(0)}, false}, + {"approx missing tolerance", Condition{Field: "amount", Op: "approx", Value: 15.49}, true}, + {"approx negative tolerance", Condition{Field: "amount", Op: "approx", Value: 15.49, Tolerance: fptr(-1)}, true}, + {"approx non-numeric value", Condition{Field: "amount", Op: "approx", Value: "x", Tolerance: fptr(1)}, true}, + {"between ok", Condition{Field: "amount", Op: "between", Min: fptr(1), Max: fptr(5)}, false}, + {"between equal bounds ok", Condition{Field: "amount", Op: "between", Min: fptr(3), Max: fptr(3)}, false}, + {"between missing max", Condition{Field: "amount", Op: "between", Min: fptr(1)}, true}, + {"between missing min", Condition{Field: "amount", Op: "between", Max: fptr(5)}, true}, + {"between min>max", Condition{Field: "amount", Op: "between", Min: fptr(5), Max: fptr(1)}, true}, + {"approx on date-part ok", Condition{Field: "day_of_month", Op: "approx", Value: 14, Tolerance: fptr(3)}, false}, + {"between on date-part ok", Condition{Field: "day_of_month", Op: "between", Min: fptr(1), Max: fptr(5)}, false}, + {"date-part string op rejected", Condition{Field: "month", Op: "contains", Value: "2"}, true}, + {"date-part eq numeric ok", Condition{Field: "month", Op: "eq", Value: 4}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateCondition(tc.cond); (err != nil) != tc.wantErr { + t.Errorf("ValidateCondition(%+v) err=%v wantErr=%v", tc.cond, err, tc.wantErr) + } + }) + } +} + +func TestEvaluateCondition_AmountApproxBetween(t *testing.T) { + tctx := TransactionContext{Amount: 15.49} + cases := []struct { + cond Condition + want bool + }{ + {Condition{Field: "amount", Op: "approx", Value: 15.49, Tolerance: fptr(0.5)}, true}, + {Condition{Field: "amount", Op: "approx", Value: 15.00, Tolerance: fptr(0.5)}, true}, + {Condition{Field: "amount", Op: "approx", Value: 14.00, Tolerance: fptr(0.5)}, false}, + {Condition{Field: "amount", Op: "approx", Value: 16.0, Tolerance: fptr(0)}, false}, + {Condition{Field: "amount", Op: "between", Min: fptr(15), Max: fptr(16)}, true}, + {Condition{Field: "amount", Op: "between", Min: fptr(15.49), Max: fptr(15.49)}, true}, + {Condition{Field: "amount", Op: "between", Min: fptr(16), Max: fptr(20)}, false}, + } + for _, tc := range cases { + cc := mustCompileSvc(t, tc.cond) + if got := EvaluateCondition(cc, tctx); got != tc.want { + t.Errorf("amount %s %v: got %v want %v", tc.cond.Op, tc.cond.Value, got, tc.want) + } + } +} + +// --- Date-part fields -------------------------------------------------------- + +func TestEvaluateCondition_DateParts(t *testing.T) { + // 2026-04-15 is a Wednesday; day-of-year 105 (2026 is not a leap year). + d := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC) + tctx := TransactionContext{Date: d} + cases := []struct { + name string + cond Condition + want bool + }{ + {"day_of_month eq", Condition{Field: "day_of_month", Op: "eq", Value: 15}, true}, + {"day_of_month neq", Condition{Field: "day_of_month", Op: "eq", Value: 16}, false}, + {"month eq", Condition{Field: "month", Op: "eq", Value: 4}, true}, + {"month between", Condition{Field: "month", Op: "between", Min: fptr(3), Max: fptr(6)}, true}, + {"day_of_week eq", Condition{Field: "day_of_week", Op: "eq", Value: int(d.Weekday())}, true}, + {"day_of_week wrong", Condition{Field: "day_of_week", Op: "eq", Value: (int(d.Weekday()) + 1) % 7}, false}, + {"day_of_year eq", Condition{Field: "day_of_year", Op: "eq", Value: d.YearDay()}, true}, + {"day_of_month approx in window", Condition{Field: "day_of_month", Op: "approx", Value: 14, Tolerance: fptr(3)}, true}, + {"day_of_month approx out of window", Condition{Field: "day_of_month", Op: "approx", Value: 1, Tolerance: fptr(3)}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cc := mustCompileSvc(t, tc.cond) + if got := EvaluateCondition(cc, tctx); got != tc.want { + t.Errorf("%s: got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestEvaluateCondition_DatePartsZeroDate(t *testing.T) { + tctx := TransactionContext{} // zero Date + for _, f := range []string{"day_of_month", "month", "day_of_week", "day_of_year"} { + cc := mustCompileSvc(t, Condition{Field: f, Op: "eq", Value: 1}) + if EvaluateCondition(cc, tctx) { + t.Errorf("%s on zero date should not match", f) + } + } +} + +// TestDayOfMonthApproxCyclicClamp exercises the cyclic + clamped boundary +// behavior directly: short months, 30/31-day months, leap February, and the +// 1↔last-day wrap. +func TestDayOfMonthApproxCyclicClamp(t *testing.T) { + cases := []struct { + name string + date time.Time + target float64 + tolerance float64 + want bool + }{ + // Clamp: "the 31st" lands on the last day of short months. + {"feb28 target31 tol0 clamps to 28", time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), 31, 0, true}, + {"feb27 target31 tol0 clamps to 28 (miss)", time.Date(2026, time.February, 27, 0, 0, 0, 0, time.UTC), 31, 0, false}, + {"leap feb29 target31 tol0 clamps to 29", time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC), 31, 0, true}, + {"leap feb28 target31 tol1 within clamp", time.Date(2024, time.February, 28, 0, 0, 0, 0, time.UTC), 31, 1, true}, + {"apr30 target31 tol0 clamps to 30", time.Date(2026, time.April, 30, 0, 0, 0, 0, time.UTC), 31, 0, true}, + {"jan31 target31 tol0 exact", time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), 31, 0, true}, + // Cyclic wrap: 1st and the month's last day are 1 apart. + {"jan31 target1 tol1 wraps", time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), 1, 1, true}, + {"jan31 target1 tol0 no wrap match", time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), 1, 0, false}, + {"feb1 target28(clamped) tol2 wraps", time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC), 28, 2, true}, + {"feb1 target31(clamp28) tol2 wraps", time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC), 31, 2, true}, + // Non-adjacent stays a miss (distance is the smaller of direct/wrap). + {"jan15 target1 tol2 far", time.Date(2026, time.January, 15, 0, 0, 0, 0, time.UTC), 1, 2, false}, + {"jan2 target1 tol1 direct", time.Date(2026, time.January, 2, 0, 0, 0, 0, time.UTC), 1, 1, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cc := mustCompileSvc(t, Condition{Field: "day_of_month", Op: "approx", Value: tc.target, Tolerance: fptr(tc.tolerance)}) + if got := EvaluateCondition(cc, TransactionContext{Date: tc.date}); got != tc.want { + t.Errorf("%s: got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +// TestDayOfMonthApproxMatch_Unit covers the pure helper independent of date +// construction, including degenerate inputs. +func TestDayOfMonthApproxMatch_Unit(t *testing.T) { + cases := []struct { + actual, target int + tol float64 + monthLen int + want bool + }{ + {28, 31, 0, 28, true}, // clamp target to month length + {1, 31, 1, 28, true}, // clamp + wrap (Feb: 28 and 1 are 1 apart) + {15, 1, 2, 31, false}, // far + {1, 1, 0, 31, true}, // exact + {31, 1, 1, 31, true}, // wrap on 31-day month + {5, 5, 0, 0, false}, // degenerate month length + } + for _, tc := range cases { + got := dayOfMonthApproxMatch(tc.actual, tc.target, tc.tol, tc.monthLen) + if got != tc.want { + t.Errorf("dayOfMonthApproxMatch(%d,%d,%v,%d)=%v want %v", tc.actual, tc.target, tc.tol, tc.monthLen, got, tc.want) + } + } +} + +// TestComposedRecurrenceCondition mirrors the canonical subscription rule: +// amount ≈ X ± Y AND day_of_month ≈ D ± N. +func TestComposedRecurrenceCondition(t *testing.T) { + cond := Condition{And: []Condition{ + {Field: "amount", Op: "approx", Value: 15.49, Tolerance: fptr(0.5)}, + {Field: "day_of_month", Op: "approx", Value: 14, Tolerance: fptr(3)}, + }} + cc := mustCompileSvc(t, cond) + + match := TransactionContext{Amount: 15.30, Date: time.Date(2026, time.March, 16, 0, 0, 0, 0, time.UTC)} + if !EvaluateCondition(cc, match) { + t.Errorf("expected recurrence condition to match the in-window charge") + } + wrongAmount := TransactionContext{Amount: 99.0, Date: time.Date(2026, time.March, 16, 0, 0, 0, 0, time.UTC)} + if EvaluateCondition(cc, wrongAmount) { + t.Errorf("did not expect a match for the wrong amount") + } + wrongDay := TransactionContext{Amount: 15.49, Date: time.Date(2026, time.March, 25, 0, 0, 0, 0, time.UTC)} + if EvaluateCondition(cc, wrongDay) { + t.Errorf("did not expect a match for the out-of-window day") + } +} diff --git a/internal/service/types.go b/internal/service/types.go index 03a7a02f..ab7260da 100644 --- a/internal/service/types.go +++ b/internal/service/types.go @@ -545,6 +545,15 @@ type Condition struct { Op string `json:"op,omitempty"` Value interface{} `json:"value,omitempty"` + // Tolerance is the ± window for the numeric `approx` operator + // (e.g. amount ≈ value ± tolerance). Required (and must be ≥ 0) when + // Op == "approx"; ignored otherwise. + Tolerance *float64 `json:"tolerance,omitempty"` + // Min / Max bound the numeric `between` operator (inclusive on both ends). + // Both required when Op == "between"; ignored otherwise. + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + And []Condition `json:"and,omitempty"` Or []Condition `json:"or,omitempty"` Not *Condition `json:"not,omitempty"` @@ -580,6 +589,12 @@ type TransactionContext struct { // values. Updated mid-resolver as earlier-stage set_metadata / remove_metadata // actions apply, so later-stage rules observe the running blob. Metadata map[string]any + // Date is the transaction's tz-naive posting date (the provider-localized + // `date` column, no time component). The derived date-part condition fields + // (day_of_month / month / day_of_week / day_of_year) are computed from it. + // Zero (IsZero) when the row carries no date — date-part conditions then + // evaluate to false. + Date time.Time } type TransactionRuleResponse struct { diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 6bd5a7a3..2c477e1b 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -691,6 +691,9 @@ func (e *Engine) applyRulesToTransaction(ctx context.Context, tx pgx.Tx, txn *pr AccountName: accountName, UserID: pgconv.FormatUUID(userID), UserName: userName, + // txn.Date is tz-naive (provider-localized); no timezone math. Powers + // the day_of_month / month / day_of_week / day_of_year condition fields. + Date: txn.Date, } if txn.MerchantName != nil { tctx.MerchantName = *txn.MerchantName diff --git a/internal/sync/rule_resolver.go b/internal/sync/rule_resolver.go index 7f0268b1..915f8b23 100644 --- a/internal/sync/rule_resolver.go +++ b/internal/sync/rule_resolver.go @@ -7,9 +7,11 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "regexp" "strconv" "strings" + "time" "breadbox/internal/pgconv" "breadbox/internal/sliceutil" @@ -23,9 +25,16 @@ type Condition struct { Field string `json:"field,omitempty"` Op string `json:"op,omitempty"` Value interface{} `json:"value,omitempty"` - And []Condition `json:"and,omitempty"` - Or []Condition `json:"or,omitempty"` - Not *Condition `json:"not,omitempty"` + // Tolerance is the ± window for the numeric `approx` operator; Min/Max + // bound the `between` operator. Mirrors service.Condition so a rule's JSONB + // round-trips identically between the write-time validator and this + // sync-time evaluator. + Tolerance *float64 `json:"tolerance,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + And []Condition `json:"and,omitempty"` + Or []Condition `json:"or,omitempty"` + Not *Condition `json:"not,omitempty"` } // TransactionContext holds the fields available for rule evaluation during sync. @@ -65,6 +74,11 @@ type TransactionContext struct { // mid-resolver as earlier-stage set_metadata / remove_metadata actions // apply, so later-stage rules observe the running blob. Metadata map[string]any + // Date is the transaction's tz-naive posting date (the `date` column, no + // time component). The derived date-part condition fields (day_of_month / + // month / day_of_week / day_of_year) are computed from it; a zero Date makes + // those conditions evaluate to false. + Date time.Time } // RuleActionSource tracks which rule contributed which action for audit. @@ -176,9 +190,13 @@ type compiledCondition struct { op string value interface{} regex *regexp.Regexp // pre-compiled for "matches" operator - and []*compiledCondition - or []*compiledCondition - not *compiledCondition + // tolerance / min / max carry the approx / between numeric-operator params. + tolerance *float64 + min *float64 + max *float64 + and []*compiledCondition + or []*compiledCondition + not *compiledCondition } // ruleRow holds the raw data from the transaction_rules table query. @@ -437,6 +455,9 @@ func compileCondition(c *Condition) (*compiledCondition, error) { cc.field = c.Field cc.op = c.Op cc.value = c.Value + cc.tolerance = c.Tolerance + cc.min = c.Min + cc.max = c.Max // Pre-compile regex for "matches" operator. if c.Op == "matches" { @@ -930,6 +951,26 @@ func evaluateLeaf(c *compiledCondition, tctx TransactionContext) bool { return evaluateString(c, tctx.SeriesShortID) case "in_series": return evaluateBool(c, tctx.InSeries) + case "day_of_month": + if tctx.Date.IsZero() { + return false + } + return evaluateDayOfMonth(c, tctx.Date) + case "month": + if tctx.Date.IsZero() { + return false + } + return evaluateNumeric(c, float64(int(tctx.Date.Month()))) + case "day_of_week": + if tctx.Date.IsZero() { + return false + } + return evaluateNumeric(c, float64(int(tctx.Date.Weekday()))) + case "day_of_year": + if tctx.Date.IsZero() { + return false + } + return evaluateNumeric(c, float64(tctx.Date.YearDay())) default: if key, ok := metadataKeyFromField(c.field); ok { return evaluateMetadata(c, key, tctx.Metadata) @@ -938,6 +979,50 @@ func evaluateLeaf(c *compiledCondition, tctx TransactionContext) bool { } } +// evaluateDayOfMonth mirrors service.evalDayOfMonth: approx uses cyclic + +// clamped matching against the transaction's actual month length; every other +// operator falls back to a plain numeric comparison on the literal day (1..31). +func evaluateDayOfMonth(c *compiledCondition, date time.Time) bool { + day := date.Day() + if c.op != "approx" { + return evaluateNumeric(c, float64(day)) + } + if c.tolerance == nil { + return false + } + target := toFloat64(c.value) + monthLen := daysInMonth(date.Year(), date.Month()) + return dayOfMonthApproxMatch(day, int(math.Round(target)), *c.tolerance, monthLen) +} + +// daysInMonth returns the number of days in the given year/month. +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +// dayOfMonthApproxMatch reports whether actualDay is within tolerance of +// targetDay on a cyclic month of monthLen days. targetDay clamps into +// [1, monthLen]; distance is the smaller of the direct and wrap-around gaps. +func dayOfMonthApproxMatch(actualDay, targetDay int, tolerance float64, monthLen int) bool { + if monthLen <= 0 { + return false + } + if targetDay > monthLen { + targetDay = monthLen + } + if targetDay < 1 { + targetDay = 1 + } + diff := actualDay - targetDay + if diff < 0 { + diff = -diff + } + if wrap := monthLen - diff; wrap < diff { + diff = wrap + } + return float64(diff) <= tolerance +} + // metadataFieldPrefix marks a condition leaf that reads a key from the // transaction's free-form metadata blob. Mirrors service.metadataFieldPrefix. const metadataFieldPrefix = "metadata." @@ -1109,6 +1194,12 @@ func evaluateString(c *compiledCondition, fieldVal string) bool { // evaluateNumeric applies numeric operators. func evaluateNumeric(c *compiledCondition, fieldVal float64) bool { + if c.op == "between" { + if c.min == nil || c.max == nil { + return false + } + return fieldVal >= *c.min && fieldVal <= *c.max + } v := toFloat64(c.value) switch c.op { @@ -1124,6 +1215,11 @@ func evaluateNumeric(c *compiledCondition, fieldVal float64) bool { return fieldVal < v case "lte": return fieldVal <= v + case "approx": + if c.tolerance == nil { + return false + } + return math.Abs(fieldVal-v) <= *c.tolerance default: return false } diff --git a/internal/sync/rule_resolver_dateparts_test.go b/internal/sync/rule_resolver_dateparts_test.go new file mode 100644 index 00000000..770638cd --- /dev/null +++ b/internal/sync/rule_resolver_dateparts_test.go @@ -0,0 +1,107 @@ +//go:build !lite + +package sync + +import ( + "testing" + "time" +) + +func fptr(f float64) *float64 { return &f } + +func TestEvaluate_AmountToleranceOps(t *testing.T) { + tctx := TransactionContext{Amount: 15.49} + cases := []struct { + cond *Condition + want bool + }{ + {&Condition{Field: "amount", Op: "approx", Value: 15.49, Tolerance: fptr(0.5)}, true}, + {&Condition{Field: "amount", Op: "approx", Value: 14.00, Tolerance: fptr(0.5)}, false}, + {&Condition{Field: "amount", Op: "approx", Value: 15.49}, false}, // nil tolerance → no match + {&Condition{Field: "amount", Op: "between", Min: fptr(15), Max: fptr(16)}, true}, + {&Condition{Field: "amount", Op: "between", Min: fptr(16), Max: fptr(20)}, false}, + {&Condition{Field: "amount", Op: "between", Max: fptr(16)}, false}, // nil min → no match + } + for _, tc := range cases { + cc := mustCompile(t, tc.cond) + if got := evaluateCondition(cc, tctx); got != tc.want { + t.Errorf("amount %s: got %v want %v", tc.cond.Op, got, tc.want) + } + } +} + +func TestEvaluate_DateParts(t *testing.T) { + d := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC) + tctx := TransactionContext{Date: d} + cases := []struct { + name string + cond *Condition + want bool + }{ + {"day_of_month eq", &Condition{Field: "day_of_month", Op: "eq", Value: 15}, true}, + {"month eq", &Condition{Field: "month", Op: "eq", Value: 4}, true}, + {"day_of_week eq", &Condition{Field: "day_of_week", Op: "eq", Value: int(d.Weekday())}, true}, + {"day_of_year eq", &Condition{Field: "day_of_year", Op: "eq", Value: d.YearDay()}, true}, + {"day_of_month approx", &Condition{Field: "day_of_month", Op: "approx", Value: 14, Tolerance: fptr(3)}, true}, + {"month between", &Condition{Field: "month", Op: "between", Min: fptr(3), Max: fptr(6)}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cc := mustCompile(t, tc.cond) + if got := evaluateCondition(cc, tctx); got != tc.want { + t.Errorf("%s: got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestEvaluate_DatePartsZeroDate(t *testing.T) { + tctx := TransactionContext{} + for _, f := range []string{"day_of_month", "month", "day_of_week", "day_of_year"} { + cc := mustCompile(t, &Condition{Field: f, Op: "eq", Value: 1}) + if evaluateCondition(cc, tctx) { + t.Errorf("%s on zero date should not match", f) + } + } +} + +func TestEvaluate_DayOfMonthCyclicClamp(t *testing.T) { + cases := []struct { + name string + date time.Time + target float64 + tolerance float64 + want bool + }{ + {"feb28 target31 clamps", time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), 31, 0, true}, + {"leap feb29 target31 clamps", time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC), 31, 0, true}, + {"apr30 target31 clamps", time.Date(2026, time.April, 30, 0, 0, 0, 0, time.UTC), 31, 0, true}, + {"jan31 target1 wraps", time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), 1, 1, true}, + {"jan15 target1 far", time.Date(2026, time.January, 15, 0, 0, 0, 0, time.UTC), 1, 2, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cc := mustCompile(t, &Condition{Field: "day_of_month", Op: "approx", Value: tc.target, Tolerance: fptr(tc.tolerance)}) + if got := evaluateCondition(cc, TransactionContext{Date: tc.date}); got != tc.want { + t.Errorf("%s: got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +// TestEvaluate_RecurrenceComposition is the subscription-style rule the whole +// feature exists to express: amount ≈ X ± Y AND day_of_month ≈ D ± N. +func TestEvaluate_RecurrenceComposition(t *testing.T) { + cc := mustCompile(t, &Condition{And: []Condition{ + {Field: "amount", Op: "approx", Value: 15.49, Tolerance: fptr(0.5)}, + {Field: "day_of_month", Op: "approx", Value: 14, Tolerance: fptr(3)}, + }}) + hit := TransactionContext{Amount: 15.49, Date: time.Date(2026, time.May, 13, 0, 0, 0, 0, time.UTC)} + if !evaluateCondition(cc, hit) { + t.Errorf("expected recurrence rule to match") + } + miss := TransactionContext{Amount: 15.49, Date: time.Date(2026, time.May, 25, 0, 0, 0, 0, time.UTC)} + if evaluateCondition(cc, miss) { + t.Errorf("did not expect match for out-of-window day") + } +} diff --git a/internal/sync/rules_dateparts_integration_test.go b/internal/sync/rules_dateparts_integration_test.go new file mode 100644 index 00000000..710aaf7b --- /dev/null +++ b/internal/sync/rules_dateparts_integration_test.go @@ -0,0 +1,98 @@ +//go:build integration && !lite + +package sync_test + +import ( + "context" + "testing" + "time" + + "breadbox/internal/db" + "breadbox/internal/provider" + "breadbox/internal/testutil" + + "github.com/shopspring/decimal" +) + +// TestSync_RecurrenceCondition_AddsTag drives the canonical detector-free +// subscription rule through a real sync: amount ≈ X ± Y AND day_of_month ≈ D ± N +// → add_tag. Only the in-window charge should be tagged. +func TestSync_RecurrenceCondition_AddsTag(t *testing.T) { + pool, queries := testutil.ServicePool(t) + ctx := context.Background() + + seedCategories(t, queries) + tag := testutil.MustCreateTag(t, queries, "subscription", "Subscription") + + // amount ≈ 15.49 ± 0.50 AND day_of_month ≈ 14 ± 3. + testutil.MustCreateTransactionRule( + t, queries, "Netflix subscription", + []byte(`{"and":[ + {"field":"amount","op":"approx","value":15.49,"tolerance":0.5}, + {"field":"day_of_month","op":"approx","value":14,"tolerance":3} + ]}`), + []byte(`[{"type":"add_tag","tag_slug":"subscription"}]`), + "on_create", + ) + + user := testutil.MustCreateUser(t, queries, "Alice") + conn := testutil.MustCreateConnection(t, queries, user.ID, "item_1") + testutil.MustCreateAccount(t, queries, conn.ID, "ext_acct_1", "Checking") + + mock := &mockProvider{ + syncResult: provider.SyncResult{ + Added: []provider.Transaction{ + { // in window: $15.49 on the 16th + ExternalID: "txn_match", + AccountExternalID: "ext_acct_1", + Amount: decimal.NewFromFloat(15.49), + Date: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + Name: "Netflix", + ISOCurrencyCode: "USD", + }, + { // out-of-window day: $15.49 on the 25th + ExternalID: "txn_offday", + AccountExternalID: "ext_acct_1", + Amount: decimal.NewFromFloat(15.49), + Date: time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC), + Name: "Netflix", + ISOCurrencyCode: "USD", + }, + { // out-of-window amount: $99.00 on the 14th + ExternalID: "txn_offamt", + AccountExternalID: "ext_acct_1", + Amount: decimal.NewFromFloat(99.00), + Date: time.Date(2026, 3, 14, 0, 0, 0, 0, time.UTC), + Name: "Spotify", + ISOCurrencyCode: "USD", + }, + }, + Cursor: "cursor_1", + }, + } + engine := newEngine(t, pool, queries, map[string]provider.Provider{"plaid": mock}) + if err := engine.Sync(ctx, conn.ID, db.SyncTriggerManual); err != nil { + t.Fatalf("Sync() error: %v", err) + } + + tagged := func(extID string) bool { + var n int + if err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM transaction_tags tt + JOIN transactions t ON tt.transaction_id = t.id + WHERE t.provider_transaction_id = $1 AND tt.tag_id = $2`, extID, tag.ID).Scan(&n); err != nil { + t.Fatalf("count tags for %s: %v", extID, err) + } + return n == 1 + } + + if !tagged("txn_match") { + t.Errorf("in-window charge should be tagged during sync") + } + if tagged("txn_offday") { + t.Errorf("out-of-window day should NOT be tagged") + } + if tagged("txn_offamt") { + t.Errorf("out-of-window amount should NOT be tagged") + } +} diff --git a/internal/templates/components/pages/rule_form.templ b/internal/templates/components/pages/rule_form.templ index 6913c361..d4357dfd 100644 --- a/internal/templates/components/pages/rule_form.templ +++ b/internal/templates/components/pages/rule_form.templ @@ -193,6 +193,12 @@ templ RuleForm(p RuleFormProps) { + + + + + + @@ -224,6 +230,8 @@ templ RuleForm(p RuleFormProps) { + + @@ -247,7 +255,14 @@ templ RuleForm(p RuleFormProps) { - + + + ± + + + + +