Skip to content

feat(usage-hub): charges auto-matching wiring — producer export, collector matcher, once-only surfacing (#1057) - #2182

Closed
dezren39 wants to merge 1 commit into
mainfrom
queue/2990-1057-charges-auto-match
Closed

dezren39 wants to merge 1 commit into
mainfrom
queue/2990-1057-charges-auto-match

Conversation

@dezren39

@dezren39 dezren39 commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Implements #1057 (queue task 2990), resuming from the lane-D scoping checkpoint: the pure matcher core (src/charges-match.ts) was already on main; this PR adds the remaining wiring.

What changed

  • scripts/gather-charges.ts (new) — the Muse-side producer: normalizes a saved Plaid transactions-get response + Gmail alert extractions into the ChargeCandidate[] export at ~/atlas/charges-export/latest.json (file-export convention, gather-quota family 2). Charge evidence only — no balances/debt read or written; pending Plaid rows skipped; the Citi-not-linked gap is untouched.
  • src/charges-collect.ts (new) — collector-side, stateful half: export parsing (fail-soft, malformed rows dropped individually), service refs from the hub's SaaS list, and the persisted "already surfaced" planning for unmatched charges.
  • scripts/collect-fleet-feed.ts — when the export is present and parseable, the hub charges section comes from matchCharges instead of the prose extractor; fresh unmatched SaaS charges go into the feed's new chargesUnmatched section and their keys persist to charges-surfaced-state.json after the feed is written, so each surfaces exactly once. No export → prose path unchanged.
  • src/feed.ts / src/model.ts / src/sources/live.ts — chargesUnmatched feed field; AttentionFlag gains kind unmatched-charge; the live usage view renders each freshly surfaced charge as an attention flag.

Verification

  • bun test test/charges-match.test.ts test/charges-collect.test.ts — 16/16 pass, including a collector fixture replay: one row per charge, Plaid source-of-record on cross-source duplicates, unmatched surfacing exactly once across replays, a new posting next period surfaces again.
  • Full suite: 338 pass / 1 fail — the one failure (actions.test.ts attention badges) fails identically on clean main; pre-existing, unrelated.
  • bunx tsc --noEmit clean.

Remaining for full acceptance: a live producer run (agent with the Plaid/Gmail connectors saves the raw reads, runs gather-charges.ts, next collector run publishes) — the queue task parks on this PR's CI and tracks it.


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 07:56
@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 49 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: 2a618b32-664c-490b-a0fa-91d7c49a7435
Commits

Reviewing files that changed from the base of the PR and between 324c6b1 and ac4ed2e.

Files selected for processing: 7
  • pm-stack/aggregator/scripts/collect-fleet-feed.ts
  • pm-stack/aggregator/scripts/gather-charges.ts
  • pm-stack/aggregator/src/charges-collect.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-29T08:00:07.724678Z ac4ed2e PR opened
ℹ️ 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: ac4ed2ebee

ℹ️ 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 +90 to +91
const parsed = parseChargeCandidatesExport(JSON.stringify(gmailDoc));
candidates.push(...(parsed ?? []).filter((c) => c.source === "gmail"));

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 Tag Gmail alert extractions before validating candidates

When GMAIL_ALERTS_PATH contains the documented {at, merchant, amountUsd, method?} rows, every alert is silently dropped: parseChargeCandidatesExport requires a source field equal to gmail or plaid, but the documented extraction format supplies neither. A valid Gmail-only receipt therefore produces no charge evidence or unmatched alert despite the producer reporting success. Add the Gmail source while normalizing these rows before candidate validation.

Useful? React with 👍 / 👎.

Comment on lines 608 to +610
await writeFile(outPath, JSON.stringify(feed, null, 2));
if (pendingSurfacedKeys) {
await writeFile(chargesSurfacedStatePath, JSON.stringify({ surfacedKeys: pendingSurfacedKeys }, null, 2)).catch(

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 the laptop is unreachable during a collection, these keys are permanently marked as surfaced before the feed reaches its consumer. In scripts/push-fleet-feed.sh, the collector runs before the Python transfer section; if that transfer fails, the next timer run recollects the feed, excludes the already-recorded charges, and overwrites the only feed containing their alerts. Consequently, those unmatched charges never appear on the laptop. Retain pending alerts across collections or acknowledge their keys only after successful delivery.

Useful? React with 👍 / 👎.

Comment on lines +72 to +73
export function unmatchedChargeKey(u: UnmatchedCharge): string {
return `${u.normalized}|${u.amountUsd.toFixed(2)}|${u.at}`;

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 unmatched-charge identity when Plaid evidence arrives

When a Gmail alert is collected before its corresponding Plaid posting, the same purchase surfaces twice if their dates differ. For example, a Gmail Cursor charge dated October 26 is recorded under that exact date; when the matching Plaid charge dated October 28 arrives, matchCharges correctly merges the evidence and prefers the Plaid date, but this key changes and the planner treats it as fresh. Reconcile persisted identities using the matcher's cross-source date window or another stable purchase identity so adding source-of-record evidence does not notify again.

Useful? React with 👍 / 👎.

Comment on lines +98 to +99
await writeFile(tmp, JSON.stringify({ generatedAt: new Date().toISOString(), candidates }, null, 2));
await rename(tmp, outPath);

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 the export when both input sources are unavailable

When both input files are missing, unreadable, or invalid JSON, the producer still exits successfully and overwrites the last-known-good export with candidates: []. The collector accepts that empty array and replaces its prose-derived charges with zero matched rows, so a source-read failure erases existing evidence and disables the otherwise-working prose fallback. Track whether any source was successfully read and leave the existing export untouched when neither was available, distinguishing that failure from a successful read containing no transactions.

Useful? React with 👍 / 👎.

@dezren39

Copy link
Copy Markdown
Member Author

Closing as a duplicate of PR #2149 (same queue task 2990, same #1057 wiring scope). #2149 was opened first (2026-10-29T06:30Z, before this PR's shift claimed the task), its CI is green, and its producer is more complete. Per protocol §8, a salvage diff was done first and posted on the canonical PR: #2149 (comment) — nothing unique to lift, one flagged item (recurring-charge surfacing key) for the canonical holder to lift or explicitly decline before merge. This branch stays readable as the salvage source until #2149 merges.

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