Skip to content

fix(expenses): dedupe double-posted receipts, Personal/Tech sides - #1607

Merged
jaywedgeworth22 merged 2 commits into
mainfrom
fix/expenses-dedup-personal-tech
Oct 6, 2026
Merged

jaywedgeworth22 merged 2 commits into
mainfrom
fix/expenses-dedup-personal-tech

Conversation

@jaywedgeworth22

@jaywedgeworth22 jaywedgeworth22 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes double-posted receipts showing up as duplicate expenses on the dashboard, and reorganizes expense categorization around two top-level sides (Personal / Tech).

Functional changes

Expense sync (workers/expenses-site/src/sync.mjs)

  • Added expenseFingerprint and dedupeUpstreamRows to collapse the same receipt when it was posted twice under different idempotency keys (e.g., the Sep 2026 bulk run filing several receipts under both one_time and usage). Matching is based on vendor, amount, calendar day, and label, keeping the most canonical kind.
  • Added loadSuppressedKeys to read the new suppression table and skip those idempotency keys on every sync run.
  • syncExpenses now filters suppressed keys, dedupes the remaining rows before upserting, and reports suppressedCount and dedupedCount alongside upserted and pages.

Schema (workers/expenses-site/schema.sql)

  • New suppressed_expenses table storing idempotency keys that should never render on the dashboard, with a reason and suppressed_at. Used for ledger rows posted in error (double-posted receipts, superseded gross/discount/correction rows) corrected at the display layer.

Categories (workers/expenses-site/src/categories.mjs)

  • Introduced SIDES (Personal, Tech) with sideForCategory and sideLabel. All business categories map to the Tech side; the personal category maps to Personal.
  • Renamed the tech-ai label from "Tech / AI" to "AI" and added a personal category.

Dashboard API (workers/expenses-site/src/index.mjs)

  • readDashboardRows now accepts side filters (personal, tech) in addition to category filters, mirroring sideForCategory in SQL via a CASE expression derived from category (no schema change).
  • The JSON expense output now includes a side field per row.

Rendering (workers/expenses-site/src/render.mjs)

  • Filter bar now leads with All, then the Personal and Tech sides, then the subcategories present (with "Other" last). Rows carry a data-side attribute for client-side filtering.
  • The Personal category is excluded from the subcategory list since it is covered by the side filter.
  • Page title and heading changed from "Business Expenses" to "Expenses".

Tests

  • Added coverage for deduping same-receipt double-posts, keeping genuinely different charges apart, fingerprinting, suppression loading, and the skip/collapse behavior in syncExpenses; updated category and render tests for the new side filters and "AI" label.

Review findings (per automated review, not addressed here)

The review agent reported 3 high-severity concerns in workers/expenses-site/src/sync.mjs: the day-granularity fingerprint may merge distinct same-day charges; the dedupe filters upserts but does not delete the collapsed loser from D1; and the fingerprint concatenates fields without a delimiter, which can cause collisions.

- sync: collapse same-receipt double-posts (vendor/amount/day/label
  fingerprint) and skip suppressed idempotency keys, so one receipt is
  always one net expense row going forward
- schema: suppressed_expenses table for ledger rows posted in error
- categories: Personal and Tech top-level sides; all business stays
  under Tech with the existing subcategories (AI, Cloud/Hosting,
  Domains, Software, Services, Other); Personal empty for now
- dashboard: filter bar leads with All / Personal / Tech; title Expenses
- 26 unit tests green
@kody-ai

This comment has been minimized.

Comment thread workers/expenses-site/src/sync.mjs
Comment thread workers/expenses-site/src/sync.mjs
Comment thread workers/expenses-site/src/sync.mjs
- fingerprint uses full occurredAt timestamp with NUL delimiters
  (no day-truncation, no adjacent-field bleed)
- sync deletes dedupe losers from D1, not just the upsert batch
@jaywedgeworth22
jaywedgeworth22 enabled auto-merge (squash) October 6, 2026 04:58
@kody-ai

kody-ai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ✅

Access your configuration settings here.

​

@jaywedgeworth22
jaywedgeworth22 merged commit 6e2c270 into main Oct 6, 2026
11 checks passed
@jaywedgeworth22
jaywedgeworth22 deleted the fix/expenses-dedup-personal-tech branch October 6, 2026 05:01
@kody-ai

kody-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown

kody code-review Kody Rules medium

Sentence-spacing violation in the PR-body bullet leaves only one space between “...usage). Matching is based on...”, although Rule 5 requires two spaces between sentences in every human-facing string and every paragraph an agent writes, including PR titles and bodies; add the missing space here and throughout the body’s other multi-sentence paragraphs. The fingerprint description is also stale because it names the calendar day while the code keys on the full occurredAt timestamp; update the PR body to match the implementation.

Kody rule violation: Use two spaces between sentences in every human-facing string and agent-written paragraph

Comment on lines +189 to +195
for (const loser of unique.dropped) {
await env.EXPENSES_DB.prepare(
"DELETE FROM expenses WHERE idempotency_key = ?"
)
.bind(loser.idempotencyKey)
.run();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Performance high

The new dedupe cleanup in workers/expenses-site/src/sync.mjs creates one sequential await ...run() D1 round trip per loser in the per-page loop instead of using the env.EXPENSES_DB.batch(...) call for upserts two lines later, making a page with K colliding receipts cost K serialized round trips per sync run. Because losers never enter suppressed_expenses (schema.sql:30-38, which defines the table for this double-post case), every cron run resends the same K DELETE statements even after the rows are gone; add prepared loser DELETEs to the same db.batch() as the upserts and/or persist loser keys in suppressed_expenses so later runs skip them.

const rows = unique.kept.map(mapUpstreamExpense);
const deletes = unique.dropped.map((loser) =>
  env.EXPENSES_DB
    .prepare("DELETE FROM expenses WHERE idempotency_key = ?")
    .bind(loser.idempotencyKey)
);
const statements = [
  ...deletes,
  ...buildUpsertStatements(env.EXPENSES_DB, rows, syncedAt),
];
Prompt for LLM

File workers/expenses-site/src/sync.mjs:

Line 189 to 195:

The new dedupe cleanup in `workers/expenses-site/src/sync.mjs` creates one sequential `await ...run()` D1 round trip per loser in the per-page loop instead of using the `env.EXPENSES_DB.batch(...)` call for upserts two lines later, making a page with K colliding receipts cost K serialized round trips per sync run. Because losers never enter `suppressed_expenses` (`schema.sql:30-38`, which defines the table for this double-post case), every cron run resends the same K DELETE statements even after the rows are gone; add prepared loser DELETEs to the same `db.batch()` as the upserts and/or persist loser keys in `suppressed_expenses` so later runs skip them.

Suggested Code:

      const rows = unique.kept.map(mapUpstreamExpense);
      const deletes = unique.dropped.map((loser) =>
        env.EXPENSES_DB
          .prepare("DELETE FROM expenses WHERE idempotency_key = ?")
          .bind(loser.idempotencyKey)
      );
      const statements = [
        ...deletes,
        ...buildUpsertStatements(env.EXPENSES_DB, rows, syncedAt),
      ];

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

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