Skip to content

feat(aggregator): wire charges auto-matcher into collector + Plaid/Gmail producer (#1057) - #2149

Merged
dezren39 merged 1 commit into
mainfrom
queue/2990-1057-charges-collector
Oct 29, 2026
Merged

dezren39 merged 1 commit into
mainfrom
queue/2990-1057-charges-collector

Conversation

@dezren39

@dezren39 dezren39 commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

What

Wires the merged charges auto-matcher core (#2113) into the daily collector flow, completing #1057:

  • Producer (scripts/produce-charge-candidates.ts, new): Muse-side, runs where the connectors live. Reads the period's Plaid transactions-get (purchase-like only — pending rows and Transfer/Payment/Interest/Bank Fees money-movement categories excluded) plus Gmail receipt/card-alert mail (subject parse, bounded body reads for amount-less receipts, unparseable mail counted not guessed). Writes charges-candidates.json — charge evidence only (date, merchant, amount, card brand+mask); no balances/debt/account data.
  • Collector (scripts/collect-fleet-feed.ts): reads the candidates file fail-soft, runs matchCharges against the hub's SaaS service names, merges auto rows over the hand-fed registry/quota-view charges (auto wins on a duplicate: same amount + date within the dedupe window + squashed merchant containment), and surfaces unmatched SaaS/AI charges in the feed's new chargesAttention exactly once — charges-surfaced-state.json persists surfaced keys.
  • Glue (src/charges-collect.ts, new): candidate-file validation, row merge, surfaced-once filtering — pure, unit-tested.
  • Live source (src/sources/live.ts): maps feed.chargesAttention to unmatched-charge attention flags on the hub's Needs-attention strip. AttentionFlag kind union extended in model.ts; FleetFeed.chargesAttention added in feed.ts.

Tests

  • New test/charges-collect.test.ts (9 tests): candidate validation, merge dedupe vs hand-fed rows, surfaced-once across reruns, and the packet's period-replay acceptance (one row per charge with source labels, no cross-source duplicates; rerun adds no rows and re-surfaces nothing).
  • Laptop run: 340 pass / 1 fail — the one failure (actions.test.ts attention action controls) fails identically on clean main; pre-existing, unrelated. tsc --noEmit clean.

Refs #1057 (core in #2113). The queue task completes after the live producer+collector run proves the strip populates.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@dezren39
dezren39 requested a review from a team as a code owner October 29, 2026 06:30
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 40 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: afd8d306-81d5-4fd2-8e7a-a3ac01b0e40c
Commits

Reviewing files that changed from the base of the PR and between b2b885b and 7d064e5.

Files selected for processing: 8
  • pm-stack/aggregator/scripts/collect-fleet-feed.ts
  • pm-stack/aggregator/scripts/produce-charge-candidates.ts
  • pm-stack/aggregator/src/charges-collect.ts
  • pm-stack/aggregator/src/charges-match.ts
  • pm-stack/aggregator/src/feed.ts
  • pm-stack/aggregator/src/model.ts
  • pm-stack/aggregator/src/sources/live.ts
  • pm-stack/aggregator/test/charges-collect.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T06:51:56.204575Z 7d064e5 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf15a77e56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

try {
const candidates = parseChargeCandidates(JSON.parse(await readFile(chargeCandidatesPath, "utf8")));
if (candidates.length) {
const auto = matchCharges(candidates, (saas ?? []).map((s) => ({ name: s.name })));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include AI quota providers in the charge-matching catalog

For existing Anthropic or OpenAI subscriptions, this catalog always classifies their charges as unrecognized: extractSaasServices() returns only the eight infrastructure services, while Anthropic, OpenAI, and the other AI providers are represented in extractQuotaPools() instead. Consequently, an ANTHROPIC transaction produces an unmatched-charge attention flag even though the hub already tracks that provider. Include the hub's AI quota providers and their service aliases in the matching catalog rather than using only saas.

Useful? React with 👍 / 👎.

skipped++;
continue;
}
candidates.push({ source: "gmail", at, merchant, amountUsd: Number(amount.replaceAll(",", "")) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude declined purchase alerts from charge candidates

For a card-alert subject such as Your $20.00 purchase at OpenAI was declined, the Gmail query includes the message, the amount parser extracts $20, and merchantFromSubject() extracts OpenAI before was. This unconditional insertion therefore records a payment that never occurred, which the matcher subsequently displays as a real charge and may flag as unrecognized SaaS spending. Reject declined or failed purchase notices before creating a candidate.

Useful? React with 👍 / 👎.

Comment on lines +437 to +440
await writeFile(
chargesSurfacedStatePath,
JSON.stringify({ version: 1, surfaced: keys, updatedAt: now.toISOString() }, null, 2),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist surfaced keys only after successful feed delivery

If feed publication or the laptop transfer fails, these charges are permanently suppressed without ever reaching the attention strip. In the inspected scripts/push-fleet-feed.sh, collection runs at line 62 before the transfer at lines 185–195; this state write therefore happens before either local feed publication or successful delivery. On the next five-minute collection, the persisted keys remove those flags and the replacement feed omits them. Keep the notifications pending until successful delivery, or otherwise preserve them across failed publication attempts.

Useful? React with 👍 / 👎.

Comment on lines +75 to +76
if (handFed.amountUsd !== auto.amountUsd) return false;
if (daysBetween(handFed.at, auto.at) > DEDUPE_WINDOW_DAYS) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve charges with conflicting card masks during merging

When a hand-fed incident and an auto row have the same merchant and amount within three days but identify different cards, this predicate still treats them as the same purchase and deletes the hand-fed incident. For example, an auto $20 METAPAY charge on Visa •7535 removes a separate $20 Meta PAYG spill on Visa •9545. The existing extractCharges() incident deduplication explicitly includes the card in its identity, so this merge loses evidence that was previously preserved. Reject duplicate matches when both rows carry known, conflicting card masks.

Useful? React with 👍 / 👎.

});
const text = (JSON.parse(body) as { body_text?: string }).body_text ?? "";
amount =
/(?:amount paid|amount charged|total charged|total)[:\s]*\$([\d,]+\.\d{2})/i.exec(text)?.[1] ?? AMOUNT_RE.exec(text)?.[1];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match the paid total instead of a subtotal

For an amount-less receipt subject whose body contains Subtotal $20.00, followed by tax and Total $21.60, the unbounded total alternative matches the suffix of Subtotal and records a $20 charge rather than the $21.60 payment. This was reproduced through the producer; even a later explicit Amount paid $21.60 does not help because exec() selects the first textual match. Since the matcher requires equal amounts to deduplicate sources, the corresponding $21.60 Plaid transaction also remains as a separate charge. Require a standalone total label and prefer explicit paid or charged totals.

Useful? React with 👍 / 👎.

@dezren39
dezren39 force-pushed the queue/2990-1057-charges-collector branch 2 times, most recently from 4e78f7e to 1adfa43 Compare October 29, 2026 06:45
…ail producer (#1057)

Collector: read the producer candidates file, run matchCharges, merge auto rows over hand-fed charges (auto wins on duplicate), surface unmatched SaaS/AI charges in feed chargesAttention exactly once via persisted surfaced-state. Live source maps chargesAttention to unmatched-charge attention flags. Producer: Plaid transactions-get (purchase-like only) + Gmail receipt/alert parse, charge evidence only.
@dezren39
dezren39 force-pushed the queue/2990-1057-charges-collector branch from 1adfa43 to 7d064e5 Compare October 29, 2026 06:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1adfa438f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +207 to +210
amount ??=
/(?:amount paid|amount charged|total charged|transaction amount|total)[:\s]*\$([\d,]+\.\d{2})/i.exec(
text,
)?.[1] ?? AMOUNT_RE.exec(text)?.[1];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude outstanding invoices from observed charges

For an email titled Your OpenAI invoice whose body says Amount due $20.00\nDue Nov 1, the subject filter accepts the message, the merchant parser returns OpenAI, and this fallback extracts $20 without any evidence of payment. The producer consequently records an outstanding invoice as an actual charge; if payment occurs more than three days later, the matcher also retains the eventual Plaid transaction as a second charge. Reject outstanding invoices or require payment evidence before turning their amounts into charge candidates.

Useful? React with 👍 / 👎.

// first-time unmatched SaaS/AI charges (the collector's
// surfaced-state guarantees exactly-once), so each becomes
// an attention flag here and never repeats on later feeds.
const chargeFlags: AttentionFlag[] = (feed.chargesAttention ?? []).map((u) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume auto-matched charges independently of the SaaS catalog

When the sandbox's quota-view file is unreadable, collect-fleet-feed.ts still runs the auto-matcher and publishes charges and chargesAttention, but omits saas. In the inspected liveSource().usage() implementation, these new fields are consumed only inside if (feed?.saas && feed.saas.length); otherwise, the direct-read fallback reconstructs hand-fed charges or returns fixtures, ignoring the valid auto-matched evidence. Consume the charge fields independently of the SaaS-catalog gate so a quota-view read failure does not hide every automatically collected charge.

Useful? React with 👍 / 👎.

@dezren39

Copy link
Copy Markdown
Member Author

Salvage note (protocol §8) — from the holder of duplicate PR #2182, which is being closed in favour of this PR.

I diffed #2182 against this PR file by file. Verdict: nothing unique to lift, with one item flagged for you to verify or explicitly decline:

How the duplicate happened: the Lane A shift that opened #2182 claimed task 2990 after it was parked, but its revalidation gh pr list result was trapped in a backgrounded tool call that only delivered after the shift ended — the PR search evidence never reached the worker before it implemented. Recorded on #1057 as well.

@dezren39
dezren39 merged commit 478797c into main Oct 29, 2026
4 checks passed
@dezren39
dezren39 deleted the queue/2990-1057-charges-collector branch October 29, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant