Add/stripe connect transactions (#1139)

* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login

Add chatgpt.com/connector/oauth/* (per-instance) and the legacy
chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth
redirect allowlist so ChatGPT MCP connectors can register and authorize.

Fix the login page dropping the ?next= destination: an OAuth-initiated
visit that required login previously ended on the dashboard and the
connection flow silently died. Login now resumes to the sanitized next
path (hard navigation, since the consent page is route-handler HTML),
carries it through the MFA step-up as returnTo, and /mfa/verify
hard-navigates for /api/ destinations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): dedup incoming feed rows against booked hand-entered twins

Users who bookkeep via MCP/chat first and connect their bank afterwards got
the same movement twice: the synced row's external_id lives in a different
namespace, the free-form manual title never text-bridges the bank's raw
string, and the cross-channel mirror deliberately excluded manual/mcp rows.

Extend the mirror with a booked-hand-entered track: an incoming feed row is
skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-
symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be
booked (staged rows never consume an import), currencies must not contradict
(bucket key is date+ore only), the cash-account guard applies to the count
exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming
count so an already-stored row cannot inflate it. Consumption stamps the
batch cash_account_id onto an account-unbound hand row, so one hand row can
never consume feed rows on other accounts in later syncs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit)

Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style:
strike lines inside a posted verifikat with replacements in the same
voucher, and correct description/entry_date without an andringsverifikat.
Envelope: posted entries, open unlocked periods, company lock date,
same-period date moves, structural/FX/doc-linked lines excluded, and a
reconciliation guard preserving per-account net on bank/reskontra sides of
externally linked entries. Every rattelse writes an immutable who/when row
(journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and
struck originals render struck-through in the verifikat; list rows and the
detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the
swedish-accounting-compliance skill are amended to state the two-track
rule. Staging carries the DDL; prod gets it on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB

- Manual journal entry: saldo column now shows before -> after computed
  from the typed debit/credit amounts (direction feedback while booking)
- Resultatrapport: a narrowed date range now compares against the same
  window shifted one year back (#862), merged across fiscal periods for
  brutet rakenskapsar; P&L rows report window activity instead of
  rolled-forward YTD closing
- Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab,
  settings > assistant), sidebar entry unaffected; collapsed sessions keep
  their reopen handle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(stripe): sync balance transactions as a bank feed on 1686

Import the connected Stripe balance into the transactions inbox, opt-in
per connection (transaction_sync_enabled on stripe_connections):

- Balance transactions map to feed rows with the two-row gross+fee split
  and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on
  created, bound to a provisioned "Stripe-saldo" cash account on 1686 so
  booking settles against the clearing account by construction.
- Double-booking protection: settled payment-link charges import
  pre-linked to their settlement entry; payout rows import pre-linked to
  the payout entry; processPayoutPaidEvent claims the payout's fee rows
  at booking time (linkPayoutFeedRows, idempotent from both directions).
- Cursor last_balance_txn_synced_at with 24h overlap; first run
  backfills 90 days floored at the day after the company lock date.
- Nightly cron /api/extensions/stripe/transactions/cron (03:30),
  transaction-sync toggle route, "Synka nu" covers both feeds, settings
  panel toggle with last-synced/backfill note, sv+en strings.
- Migration 20260723200000 (applied to staging).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): offer match-to-voucher on unbooked history rows

Unbooked transactions with is_business already set (e.g. left behind when
a voucher was removed without a full uncategorize) land in the history
list instead of the inbox, where the match-against-existing-voucher
action did not exist, leaving them with no path back to voucher
matching. Add the same menu item to the history list for unbooked rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transactions): enhance ownership checks and error handling in journal entry routes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-24 01:16:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent be9d630347
commit d840257c0c
55 changed files with 23207 additions and 125 deletions
+40
View File
@@ -1022,6 +1022,46 @@ export const CorrectJournalEntrySchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Inline rättelse of a posted verifikat (BFL 5 kap 5 § / 9 §)
// ============================================================
// The correct_entry_metadata / correct_entry_lines_inline RPCs enforce the
// full envelope (posted status, open period, company lock date, balance,
// who/when logging); these schemas only shape the payload.
/** POST /api/bookkeeping/journal-entries/[id]/correct-metadata */
export const CorrectEntryMetadataSchema = z
.object({
description: z.string().trim().min(1, 'Beskrivningen kan inte vara tom').max(500).optional(),
entry_date: isoDate.optional(),
})
.refine((body) => body.description !== undefined || body.entry_date !== undefined, {
message: 'Minst ett fält måste anges',
})
/**
* Replacement line for an inline strike. Deliberately narrower than
* CreateJournalEntryLineSchema: inline additions are SEK-only and carry no
* tax_code or currency conversion (those corrections use the storno flow).
*/
export const InlineRattelseLineSchema = z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().max(500).optional(),
dimensions: DimensionsBagSchema.optional(),
})
/** POST /api/bookkeeping/journal-entries/[id]/strike-lines */
export const StrikeLinesSchema = z
.object({
strike_line_ids: z.array(uuid).max(200).default([]),
lines: z.array(InlineRattelseLineSchema).max(100).default([]),
})
.refine((body) => body.strike_line_ids.length > 0 || body.lines.length > 0, {
message: 'Rättelsen måste stryka eller lägga till minst en rad',
})
// ============================================================
// Dimension registry schemas (kostnadsställe/projekt)
// ============================================================
@@ -6,6 +6,11 @@ describe('isBuiltInRedirectUri', () => {
it.each([
['https://claude.ai/api/oauth/callback', true],
['https://claude.com/api/oauth/callback', true],
['https://chatgpt.com/connector/oauth/abc123', true],
['https://chatgpt.com/connector_platform_oauth_redirect', true],
['https://chatgpt.com/connector_platform_oauth_redirect/extra', false],
['https://chatgpt.com/other/path', false],
['https://chatgpt.com.evil.com/connector/oauth/x', false],
['http://localhost:3000/cb', true],
['http://localhost/cb', true],
['http://127.0.0.1:8080/cb', true],
+9 -2
View File
@@ -3,12 +3,19 @@ import { createServiceClientNoCookies } from './api-keys'
/**
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
* Claude's connector keeps working without seeded rows, and so local
* development never depends on having a registration.
* Claude's and ChatGPT's connectors keep working without seeded rows, and so
* local development never depends on having a registration.
*
* ChatGPT uses a per-connector-instance callback path
* (https://chatgpt.com/connector/oauth/{callback_id}) plus the legacy fixed
* callback for already-published apps; both are documented at
* developers.openai.com/apps-sdk/build/auth.
*/
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = [
/^https:\/\/claude\.ai\/api\//,
/^https:\/\/claude\.com\/api\//,
/^https:\/\/chatgpt\.com\/connector\/oauth\//,
/^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/,
/^http:\/\/localhost(:\d+)?(\/|$)/,
/^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/,
]
+190 -2
View File
@@ -4,7 +4,7 @@ vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateResultatrapport } from '../resultatrapport'
import { generateResultatrapport, shiftDateOneYearBack } from '../resultatrapport'
import { generateTrialBalance } from '../trial-balance'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow } from '@/types'
@@ -16,7 +16,7 @@ beforeEach(() => {
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
const row: TrialBalanceRow = {
account_number: '3001',
account_name: 'Test',
account_class: 3,
@@ -28,6 +28,14 @@ function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
closing_credit: 0,
...overrides,
}
// Full-period P&L reality: no opening balance, so window activity equals
// closing. Tests specify closing_*; mirror into period_* unless the test
// sets period activity explicitly.
if (row.period_debit === 0 && row.period_credit === 0) {
row.period_debit = row.closing_debit
row.period_credit = row.closing_credit
}
return row
}
function tb(rows: TrialBalanceRow[]) {
@@ -279,4 +287,184 @@ describe('generateResultatrapport', () => {
generateResultatrapport(q.supabase as any, 'company-1', 'missing')
).rejects.toThrow('Fiscal period not found')
})
it('compares a date range against the same window shifted one year back', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
error: null,
})
// Fiscal periods covering the shifted window 2025-01-01..2025-03-31.
q.enqueue({
data: [{ id: 'period-0', period_start: '2025-01-01', period_end: '2025-12-31' }],
error: null,
})
mockTrialBalance
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 90000 })])
)
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 60000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-01-01',
toDate: '2026-03-31',
})
expect(report.groups[0].rows[0].current_period).toBe(90000)
expect(report.groups[0].rows[0].prior_period).toBe(60000)
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-03-31' })
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
fromDate: '2025-01-01',
toDate: '2025-03-31',
})
})
it('merges the shifted window across two fiscal periods (brutet räkenskapsår)', async () => {
const q = createQueuedMockSupabase()
// Current period: brutet räkenskapsår Jul 2025 - Jun 2026; window is the
// calendar Q1 2026, so the shifted window Jan-Mar 2025 spans FY 24/25
// only. Use a window that straddles instead: Jun-Jul 2026 shifted to
// Jun-Jul 2025, split across FY 24/25 (ends Jun 30) and FY 25/26.
q.enqueue({
data: { period_start: '2025-07-01', period_end: '2026-06-30', previous_period_id: 'period-0' },
error: null,
})
q.enqueue({
data: [
{ id: 'period-old', period_start: '2024-07-01', period_end: '2025-06-30' },
{ id: 'period-1', period_start: '2025-07-01', period_end: '2026-06-30' },
],
error: null,
})
mockTrialBalance
// Current window
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 50000 })])
)
// Prior part 1: 2025-06-01..2025-06-30 in period-old
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 10000 })])
)
// Prior part 2: 2025-07-01..2025-07-31 in period-1 (current period is a
// legitimate source when the shifted window reaches into it)
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 15000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-06-01',
toDate: '2026-07-31',
})
expect(report.groups[0].rows[0].prior_period).toBe(25000)
expect(report.prior_period).toEqual({ start: '2025-06-01', end: '2025-07-31' })
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-old', {
fromDate: '2025-06-01',
toDate: '2025-06-30',
})
expect(mockTrialBalance).toHaveBeenNthCalledWith(3, expect.anything(), 'company-1', 'period-1', {
fromDate: '2025-07-01',
toDate: '2025-07-31',
})
})
it('drops the comparison when the shifted window would overlap the current one', async () => {
const q = createQueuedMockSupabase()
// 18-month fiscal period with a 14-month window: shifting back one year
// overlaps the window itself, so no comparison is possible.
q.enqueue({
data: { period_start: '2025-01-01', period_end: '2026-06-30', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2025-01-01',
toDate: '2026-02-28',
})
expect(report.prior_period).toBeNull()
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
})
it('still drops the comparison for dimension-filtered ranges', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-01-01',
toDate: '2026-03-31',
dimensions: { '6': 'P001' },
})
expect(report.prior_period).toBeNull()
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
})
it('reports window activity, not rolled-forward YTD closing', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
q.enqueue({ data: [], error: null }) // no fiscal period covers the shifted window
// A June window: trial balance rolls Jan-May (60 000) into opening, so
// closing shows 100 000 YTD while the window's own activity is 40 000.
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({
account_number: '3001',
account_class: 3,
opening_credit: 60000,
period_credit: 40000,
closing_credit: 100000,
}),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-06-01',
toDate: '2026-06-30',
})
expect(report.groups[0].rows[0].current_period).toBe(40000)
expect(report.net_result_current).toBe(40000)
})
})
describe('shiftDateOneYearBack', () => {
it('shifts a plain date one year back', () => {
expect(shiftDateOneYearBack('2026-03-15')).toBe('2025-03-15')
expect(shiftDateOneYearBack('2026-01-01')).toBe('2025-01-01')
expect(shiftDateOneYearBack('2026-12-31')).toBe('2025-12-31')
})
it('clamps leap day to the last day of February', () => {
expect(shiftDateOneYearBack('2028-02-29')).toBe('2027-02-28')
})
it('keeps Feb 29 when the target year is also a leap year divisible correctly', () => {
// 2001 -> 2000 is a leap year (divisible by 400)
expect(shiftDateOneYearBack('2001-02-28')).toBe('2000-02-28')
})
})
+4
View File
@@ -845,6 +845,10 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'account_dimension_rules', file: 'account_dimension_rules.json' },
// Compliance records
{ name: 'voucher_gap_explanations', file: 'voucher_gap_explanations.json', orderBy: 'created_at' },
// Inline rättelse trail (BFL 5 kap 5 § / 9 §): holds the struck original
// lines and the old description/date, i.e. the preserved side of every
// in-verifikat rättelse — räkenskapsinformation, not an operation log.
{ name: 'journal_entry_rattelse_log', file: 'journal_entry_rattelse_log.json', orderBy: 'created_at' },
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
+73 -9
View File
@@ -60,17 +60,17 @@ export async function generateResultatrapport(
})
const currentRows = filterPnl(currentTb.rows)
// Prior-period comparison stays full-year. A narrower current window
// compared against a full prior year would be misleading; until we ship a
// proper "same window, prior year" comparison the cleanest move is to
// drop the prior column entirely when the user narrows the range.
// Same rule for a dimension filter: project codes are time-limited under
// K2/K3 (registry start/end dates), so "this code last year" may be a
// different project entirely: drop the column rather than compare
// Prior-period comparison. Full period: the previous fiscal period.
// Narrowed range: the same window shifted one year back (#862), so
// "Hittills i år" compares against the same dates last year.
// Dimension filter: no comparison at all; project codes are time-limited
// under K2/K3 (registry start/end dates), so "this code last year" may be
// a different project entirely: drop the column rather than compare
// unrelated activity (#862 review).
let priorRows: TrialBalanceRow[] = []
let priorPeriodInfo: { start: string; end: string } | null = null
const isFullPeriod = !options?.fromDate && !options?.toDate && !options?.dimensions
const hasRange = Boolean(options?.fromDate || options?.toDate)
const isFullPeriod = !hasRange && !options?.dimensions
if (isFullPeriod) {
// Prefer the explicit continuity chain; fall back to the period that ends
// immediately before this one. The fallback keeps the comparison working
@@ -102,6 +102,51 @@ export async function generateResultatrapport(
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
}
}
} else if (hasRange && !options?.dimensions) {
// Same window, prior year. Shift the window back one year (leap-day
// clamped) and read activity from whichever fiscal period(s) cover the
// shifted dates: brutet räkenskapsår can split a calendar window across
// two periods, so merge per account. The current period itself is a
// valid source (a long first fiscal year can contain both windows).
const shiftedFrom = shiftDateOneYearBack(effectiveFromDate)
const shiftedTo = shiftDateOneYearBack(effectiveToDate)
// A window longer than a year would overlap itself when shifted back;
// that comparison is meaningless, so leave the column empty.
if (shiftedTo < effectiveFromDate) {
const { data: candidatePeriods } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.lte('period_start', shiftedTo)
.gte('period_end', shiftedFrom)
.order('period_start', { ascending: true })
const merged = new Map<string, TrialBalanceRow>()
let coveredAny = false
for (const p of candidatePeriods ?? []) {
const from = shiftedFrom > p.period_start ? shiftedFrom : p.period_start
const to = shiftedTo < p.period_end ? shiftedTo : p.period_end
if (from > to) continue
const tbPart = await generateTrialBalance(supabase, companyId, p.id, {
fromDate: from,
toDate: to,
})
coveredAny = true
for (const row of filterPnl(tbPart.rows)) {
const existing = merged.get(row.account_number)
if (!existing) {
merged.set(row.account_number, { ...row })
} else {
existing.period_debit = round2(existing.period_debit + row.period_debit)
existing.period_credit = round2(existing.period_credit + row.period_credit)
}
}
}
if (coveredAny) {
priorRows = [...merged.values()]
priorPeriodInfo = { start: shiftedFrom, end: shiftedTo }
}
}
}
const priorByAccount = new Map<string, TrialBalanceRow>()
@@ -135,9 +180,28 @@ function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
* (class 4-7) have debit. We render every line as `credit - debit` so that
* revenue is positive, expenses are negative, and a positive net result
* means profit. This matches how Fortnox and Visma present a Resultatrapport.
*
* Window activity (`period_*`), not `closing_*`: when fromDate > period_start
* the trial balance rolls pre-window activity into the opening columns, so
* `closing_*` on a P&L account would silently report year-to-date amounts in
* a month/quarter window. In the full-period case P&L accounts carry no
* opening balance, so period_* equals closing_* and nothing changes there.
*/
function signedAmount(row: TrialBalanceRow): number {
return row.closing_credit - row.closing_debit
return row.period_credit - row.period_debit
}
/**
* `2026-03-15` -> `2025-03-15`; leap day clamps to the target month's last
* day (`2028-02-29` -> `2027-02-28`). String math on the ISO parts: no Date
* object, no timezone edge.
*/
export function shiftDateOneYearBack(isoDate: string): string {
const [y, m, d] = isoDate.split('-').map(Number)
const year = y - 1
const daysInMonth = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
const day = Math.min(d, daysInMonth[m - 1])
return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
function sumNet(rows: TrialBalanceRow[]): number {
+290
View File
@@ -517,6 +517,296 @@ describe('ingestTransactions', () => {
expect(result.duplicates).toBe(0)
})
// -----------------------------------------------------------------------
// 2c-hand. Booked-hand-entered mirror: the user bookkeeps by chat/MCP FIRST
// (free-form Swedish title, booked), then connects the bank; the feed
// delivers the bank's copy of the same movement with a raw provider
// string that shares no text. Same (date, öre), count-symmetric bucket
// → deduped against the BOOKED hand-entered row.
// -----------------------------------------------------------------------
it('dedupes an incoming feed row against a booked hand-entered (mcp) twin whose title does not bridge', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-06-24',
amount: -520,
description: 'PLAN_ORDER_CHECKOUT-invoice-11111 Account fee', // raw provider text
external_id: 'eb_SE00_2026-06-24_-52000_0',
import_source: 'enable_banking',
})
// Booked map: the hand-entered MCP row the user already booked.
enqueue({
data: [{
date: '2026-06-24', amount: -520,
original_description: 'Wise Business engångsavgift kontoöppning',
description: 'Wise Business engångsavgift kontoöppning',
import_source: 'mcp', bank_connection_id: null,
cash_account_id: null, currency: 'SEK',
}],
error: null,
})
enqueue({ data: [], error: null }) // unbooked map: none
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup: different namespace, no match
// No insert: deduped by the booked-hand-entered mirror.
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
it('never consumes an UNBOOKED hand-entered row (staged intent is not ledger evidence)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-06-24',
amount: -520,
description: 'PLAN_ORDER_CHECKOUT-invoice-11111 Account fee',
external_id: 'eb_SE00_2026-06-24_-52000_0',
import_source: 'enable_banking',
})
const inserted = makeTransaction({ id: 'tx-kept', external_id: raw.external_id, amount: -520 })
enqueue({ data: [], error: null }) // booked map: none
// Unbooked map: even if an mcp row leaked in here, it must not be a mirror
// candidate (in production the query excludes manual/mcp at the DB level).
enqueue({
data: [{
date: '2026-06-24', amount: -520,
original_description: 'Wise Business engångsavgift kontoöppning',
description: 'Wise Business engångsavgift kontoöppning',
import_source: 'mcp', bank_connection_id: null,
cash_account_id: null, currency: 'SEK',
}],
error: null,
})
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert: NOT deduped
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
})
it('does not hand-entered-dedupe an asymmetric bucket (two incoming vs one booked mcp row)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const rows = [
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-1 Payment', external_id: 'eb_a', import_source: 'enable_banking' }),
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-2 Payment', external_id: 'eb_b', import_source: 'enable_banking' }),
]
// Booked map: ONE hand-entered row → incoming 2 vs stored 1 = asymmetric.
enqueue({
data: [{
date: '2026-06-24', amount: -520,
original_description: 'Betalning till leverantör',
description: 'Betalning till leverantör',
import_source: 'mcp', bank_connection_id: null,
cash_account_id: null, currency: 'SEK',
}],
error: null,
})
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: makeTransaction({ id: 'tx-a', amount: -520 }), error: null })
enqueue({ data: makeTransaction({ id: 'tx-b', amount: -520 }), error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows)
expect(result.imported).toBe(2)
expect(result.duplicates).toBe(0)
})
it('does not hand-entered-dedupe across currencies (SEK manual row vs USD feed row)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Numerically equal amounts land in the same (date, öre) bucket, but the
// currencies differ → the mirror must stay off.
const raw = makeRaw({
date: '2026-07-13',
amount: 2500,
currency: 'USD',
description: 'TRANSFER-9 Facilitation',
external_id: 'eb_US00_2026-07-13_250000_0',
import_source: 'enable_banking',
})
const inserted = makeTransaction({ id: 'tx-usd', external_id: raw.external_id, amount: 2500 })
enqueue({
data: [{
date: '2026-07-13', amount: 2500,
original_description: 'Kundinbetalning bankgiro',
description: 'Kundinbetalning bankgiro',
import_source: 'mcp', bank_connection_id: null,
cash_account_id: null, currency: 'SEK',
}],
error: null,
}) // booked map: SEK hand-entered row, same date+öre
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert: kept
mockFetchExchangeRate.mockResolvedValue(null)
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
expect(mockGetBestInvoiceMatch).toHaveBeenCalled() // income path still runs
})
it('respects the cash-account guard on the booked-hand-entered mirror path', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-06-24',
amount: -520,
description: 'TRANSFER-5 Payment',
external_id: 'eb_acctB_2026-06-24_-52000_0',
import_source: 'enable_banking',
})
const inserted = makeTransaction({ id: 'tx-acctB', amount: -520 })
// Booked hand-entered row explicitly bound to a DIFFERENT cash account.
enqueue({
data: [{
date: '2026-06-24', amount: -520,
original_description: 'Egen insättning',
description: 'Egen insättning',
import_source: 'mcp', bank_connection_id: null,
cash_account_id: 'acct-A', currency: 'SEK',
}],
error: null,
})
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: { id: 'acct-B' }, error: null }) // cash_accounts → batch on account B
enqueue({ data: inserted, error: null }) // insert: kept
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
settlementAccount: '1931',
})
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
})
it('stamps the batch cash account onto a consumed null-account hand row (one-consume-ever adoption)', async () => {
const { supabase, enqueue, updates } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-06-24',
amount: -520,
description: 'TRANSFER-5 Payment', // does not bridge the hand row's title
external_id: 'eb_acctA_2026-06-24_-52000_0',
import_source: 'enable_banking',
})
// Booked MANUAL row, account-unbound (cash_account_id null).
enqueue({
data: [{
id: 'tx-hand-1',
date: '2026-06-24', amount: -520,
original_description: 'Egen insättning',
description: 'Egen insättning',
import_source: 'manual', bank_connection_id: null,
cash_account_id: null, currency: 'SEK',
}],
error: null,
})
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: { id: 'acct-A' }, error: null }) // cash_accounts → batch on account A
enqueue({ data: null, error: null }) // adoption stamp update
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
settlementAccount: '1930',
})
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
// The hand row is now bound to account A, so it can never consume a feed
// row on another account in a later sync.
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
expect(txUpdates).toContainEqual({ cash_account_id: 'acct-A' })
})
it('does not let a Layer-1 duplicate inflate the hand-mirror symmetry count', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// R0 is already stored (Layer-1 kills it); R1 is genuinely new. TWO booked
// hand rows share the bucket. Coarse counting would say 2 incoming == 2
// stored and consume a hand row for R1; the honest unmatched count is
// 1 vs 2 → asymmetric → R1 must insert.
const rows = [
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-1 Pay', external_id: 'eb_stored_0', import_source: 'enable_banking' }),
makeRaw({ date: '2026-06-24', amount: -520, description: 'TRANSFER-2 Pay', external_id: 'eb_new_1', import_source: 'enable_banking' }),
]
const inserted = makeTransaction({ id: 'tx-new', amount: -520 })
enqueue({
data: [
{ id: 'h1', date: '2026-06-24', amount: -520, original_description: 'Hyra lokal', description: 'Hyra lokal', import_source: 'mcp', bank_connection_id: null, cash_account_id: null, currency: 'SEK' },
{ id: 'h2', date: '2026-06-24', amount: -520, original_description: 'Egen insättning', description: 'Egen insättning', import_source: 'mcp', bank_connection_id: null, cash_account_id: null, currency: 'SEK' },
],
error: null,
}) // booked map: two hand rows
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [{ external_id: 'eb_stored_0' }], error: null }) // external_id dedup: R0 already stored
enqueue({ data: inserted, error: null }) // insert R1: kept
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows)
expect(result.duplicates).toBe(1) // R0 via Layer-1 only
expect(result.imported).toBe(1) // R1 inserted, no hand row consumed
})
it('excludes account-incompatible hand rows from the symmetry count (no mirror flip)', async () => {
const { supabase, enqueue, updates } = createQueueMockSupabase()
// Batch settles on account B. Stored: M1 (account-unbound) and M2 (bound to
// account A). If M2 were counted, 2 incoming == 2 stored would switch the
// mirror on and the non-bridging R1 would wrongly consume M1. With the
// guard applied to the COUNT, symmetry is 2 vs 1 → mirror off → R1 inserts
// and R2 dedups via its text bridge against M1.
const rows = [
makeRaw({ date: '2026-06-24', amount: -10000, description: 'TRANSFER-7 Pay', external_id: 'eb_r1', import_source: 'enable_banking' }),
makeRaw({ date: '2026-06-24', amount: -10000, description: 'Hyra avtal 12 betalning juni', external_id: 'eb_r2', import_source: 'enable_banking' }),
]
const inserted = makeTransaction({ id: 'tx-r1', amount: -10000 })
enqueue({
data: [
{ id: 'm1', date: '2026-06-24', amount: -10000, original_description: 'Hyra avtal 12', description: 'Hyra avtal 12', import_source: 'mcp', bank_connection_id: null, cash_account_id: null, currency: 'SEK' },
{ id: 'm2', date: '2026-06-24', amount: -10000, original_description: 'Hyra avtal 12', description: 'Hyra avtal 12', import_source: 'mcp', bank_connection_id: null, cash_account_id: 'acct-A', currency: 'SEK' },
],
error: null,
}) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: { id: 'acct-B' }, error: null }) // cash_accounts → account B
enqueue({ data: inserted, error: null }) // insert R1
enqueue({ data: null, error: null }) // adoption stamp for M1 (text-bridged by R2)
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows, {
settlementAccount: '1931',
})
expect(result.imported).toBe(1) // R1 kept (genuinely new)
expect(result.duplicates).toBe(1) // R2 text-bridged M1
// The text-bridge consumption also binds M1 to account B.
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
expect(txUpdates).toContainEqual({ cash_account_id: 'acct-B' })
})
// -----------------------------------------------------------------------
// 2c-shadow. Same-feed scope-drift (Hole A): SHADOW MODE. Enable Banking
// returns the same account under a drifted IBAN, so the IBAN-embedded
+172 -19
View File
@@ -15,6 +15,13 @@ import type { Transaction, RawTransaction, IngestResult, IngestOptions, Supplier
// Re-export types for backward compatibility
export type { RawTransaction, IngestResult } from '@/types'
/**
* Sentinel for a (date, öre) bucket whose incoming rows carry more than one
* currency: the booked-hand-entered mirror's per-bucket currency gate cannot be
* evaluated there, so the mirror is disabled for that bucket.
*/
const MIXED_CURRENCIES = Symbol('mixed-currencies')
/**
* One existing row in a content-dedup bucket: its normalized/lowercased
* description, the cash account it settled on (null for legacy rows that
@@ -24,10 +31,19 @@ export type { RawTransaction, IngestResult } from '@/types'
* `consumeBridgingTwin`); `cashAccountId` is the cross-account guard.
*/
type BucketEntry = {
/** Row id of the stored transaction; used to persist hand-mirror adoption. */
id: string | null
desc: string
cashAccountId: string | null
source: string | null
isImportFeed: boolean
/**
* ISO currency of the stored row ('SEK', 'USD', ...), null for rows without
* one. Guards the booked-hand-entered mirror: the content bucket keys on
* (date, öre) only, so without this a manual 2 500 SEK row could consume an
* incoming 2 500 USD feed row.
*/
currency: string | null
/**
* The stored row's `external_id`. Used ONLY by the shadow-mode same-feed
* scope-drift instrumentation (see ingestTransactions): a stored row is a
@@ -48,7 +64,15 @@ type BucketEntry = {
type DescBucket = Map<string, BucketEntry[]>
interface ExistingTransactionMaps {
/** Booked transactions (any source): consumed by any incoming raw transaction. */
/**
* Booked transactions (any source): consumed by any incoming raw transaction.
* Hand-entered rows (import_source manual/mcp/null, no bank connection) in
* THIS map are also cross-channel-mirror candidates: a booked hand-entered
* row is the ledger asserting the movement already exists, so an incoming
* feed row for the same (date, öre, currency, account) in a count-symmetric
* bucket is the bank's copy of it, not new money (the MCP-then-bank-sync
* case: user bookkeeps by chat first, connects the bank later).
*/
booked: DescBucket
/**
* Unbooked rows from ANY external import feed (Enable Banking PSD2 sync,
@@ -57,8 +81,9 @@ interface ExistingTransactionMaps {
* account pulled once via PSD2 and once via a CSV/CAMT file upload (in either
* order), plus PSD2 reconnect duplicates whose external_id regenerated.
* Hand-entered rows (import_source manual/mcp/null) are deliberately
* excluded: only real feeds mirror one another, and a manual row must never
* be silently consumed by an import.
* excluded HERE: an UNBOOKED hand-entered row is just a staged intent (e.g.
* an MCP draft awaiting approval), not ledger evidence, so it must never
* consume an incoming import.
*/
unbookedImported: DescBucket
}
@@ -66,20 +91,24 @@ interface ExistingTransactionMaps {
/** Push a row into its (date, öre) bucket, normalizing the description. */
function addToBucket(
bucket: DescBucket,
id: string | null,
date: string,
amount: number | string,
description: string,
cashAccountId: string | null,
source: string | null,
isImportFeed: boolean,
currency: string | null,
externalId: string | null,
): void {
const key = contentBucketKey(date, amount)
const entry: BucketEntry = {
id,
desc: description.toLowerCase().trim(),
cashAccountId,
source,
isImportFeed,
currency,
externalId,
}
const entries = bucket.get(key)
@@ -103,7 +132,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, external_id')
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -117,12 +146,14 @@ async function buildExistingTransactionMaps(
// original_description column.
addToBucket(
booked,
tx.id ?? null,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
tx.import_source ?? null,
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
tx.currency ?? null,
tx.external_id ?? null,
)
}
@@ -139,7 +170,7 @@ async function buildExistingTransactionMaps(
// manual / mcp are hand-entered and intentionally excluded.
const { data: unbookedRows } = await supabase
.from('transactions')
.select('date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, external_id')
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.not('import_source', 'is', null)
@@ -154,12 +185,14 @@ async function buildExistingTransactionMaps(
// user title edit cannot reopen the duplicate-import window.
addToBucket(
unbookedImported,
tx.id ?? null,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
tx.import_source ?? null,
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
tx.currency ?? null,
tx.external_id ?? null,
)
}
@@ -249,14 +282,30 @@ export async function ingestTransactions(
// (date, öre, account) without a description match (see consumeBridgingTwin).
// An asymmetric bucket keeps the description requirement, so a genuinely-new
// row is never collapsed into a different one.
//
// The same count-symmetry signal also runs against BOOKED hand-entered rows
// (import_source manual/mcp/null): a user who bookkeeps by chat/MCP first and
// connects the bank afterwards has already put the movement in the ledger,
// and the feed's copy of it must not re-appear as a duplicate. This mirror is
// gated harder than feed-vs-feed: the stored row must be BOOKED (staged/
// unbooked hand-entered rows never consume an import), its currency must not
// contradict the incoming row's, its cash account must be compatible, and the
// bucket counts must match exactly. Tracked in a SEPARATE count map (built
// further down, once the batch settlement account and the stored external_id
// set are known) so the feed-vs-feed mirror semantics are untouched: a
// hand-entered twin never breaks feed count symmetry.
const batchSource = rawTransactions[0]?.import_source ?? null
const batchIsImportFeed = isImportedTransaction({ import_source: batchSource })
const incomingByBucket = new Map<string, number>()
const crossSourceStoredByBucket = new Map<string, number>()
const incomingCurrencyByBucket = new Map<string, string | null | typeof MIXED_CURRENCIES>()
if (batchIsImportFeed) {
for (const raw of rawTransactions) {
const k = contentBucketKey(raw.date, raw.amount)
incomingByBucket.set(k, (incomingByBucket.get(k) ?? 0) + 1)
const cur = raw.currency ?? null
if (!incomingCurrencyByBucket.has(k)) incomingCurrencyByBucket.set(k, cur)
else if (incomingCurrencyByBucket.get(k) !== cur) incomingCurrencyByBucket.set(k, MIXED_CURRENCIES)
}
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
for (const [k, entries] of bucket) {
@@ -391,15 +440,50 @@ export async function ingestTransactions(
// bucket is left alone. Counts are pre-loop snapshots; the gate is evaluated
// per incoming row inside the loop.
const incomingIdSet = new Set(externalIds)
// Incoming rows Layer-1 will NOT reconcile (their external_id is not already
// stored): the honest per-bucket count of rows that will actually reach the
// content-dedup layer. Shared by the hand-entered mirror (enforcing) and the
// scope-drift shadow (measure-only): the coarse incomingByBucket would let a
// Layer-1 duplicate inflate the symmetry check.
const unmatchedIncomingByBucket = new Map<string, number>()
const driftCandidateStoredByBucket = new Map<string, number>()
if (batchIsImportFeed && scopeDriftShadow) {
if (batchIsImportFeed) {
for (const raw of rawTransactions) {
if (!existingExternalIds.has(raw.external_id)) {
const k = contentBucketKey(raw.date, raw.amount)
unmatchedIncomingByBucket.set(k, (unmatchedIncomingByBucket.get(k) ?? 0) + 1)
}
}
}
// ── Booked-hand-entered mirror candidates ────────────────────────────────
// Built HERE, after the batch settlement account (cashAccountId) and the
// stored external_id set are known, so the counts apply the SAME guards the
// consume step applies (account compatibility + currency). Counting entries
// the consume step would reject makes "symmetry" lie: an unconsumable twin
// could switch the mirror on and collapse a genuinely-new row. Hand-entered
// candidates exist ONLY in the booked map: the unbooked map's query excludes
// manual/mcp/null sources at the DB level.
const bookedHandEnteredByBucket = new Map<string, number>()
if (batchIsImportFeed) {
for (const [k, entries] of existingMaps.booked) {
const bucketCurrency = incomingCurrencyByBucket.get(k)
// No incoming rows in this bucket, or the incoming rows disagree on
// currency: the currency gate cannot be evaluated per-bucket, so the
// hand-entered mirror stays off there (conservative: row inserts).
if (bucketCurrency === undefined || bucketCurrency === MIXED_CURRENCIES) continue
for (const entry of entries) {
if (entry.isImportFeed) continue
const accountCompatible =
cashAccountId === null || entry.cashAccountId === null || entry.cashAccountId === cashAccountId
if (!accountCompatible) continue
if (entry.currency !== null && bucketCurrency !== null && entry.currency !== bucketCurrency) continue
bookedHandEnteredByBucket.set(k, (bookedHandEnteredByBucket.get(k) ?? 0) + 1)
}
}
}
const driftCandidateStoredByBucket = new Map<string, number>()
if (batchIsImportFeed && scopeDriftShadow) {
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
for (const [k, entries] of bucket) {
for (const entry of entries) {
@@ -460,13 +544,17 @@ export async function ingestTransactions(
// 1b/1c. Content-dedup bridge: skip if an existing booked row (any source)
// OR an unbooked import-feed row shares this (date, öre) bucket and EITHER
// (a) a *bridging* description (prefix-containment, see descriptionsBridge),
// OR (b) the bucket is a cross-channel mirror (crossSourceMirror below).
// OR (b) the bucket is a cross-channel mirror (crossSourceMirror below),
// OR (c) the bucket is a booked-hand-entered mirror (handEnteredMirror).
// (a) catches re-imports the external_id check misses: old-format ids
// re-synced after the id scheme changed, and PSD2 description enrichment
// between syncs ("TIC" → "TIC BG … via internet"). (b) catches the same
// bank account imported via two channels whose descriptions don't bridge at
// all (Nordea CSV payee "TELENOR"/"Nordea" vs PSD2 OCR/message), which (a)
// alone cannot. Booked first, then unbooked.
// alone cannot. (c) catches the feed delivering a movement the user already
// booked by hand (MCP/manual) under a free-form title that shares no text
// with the bank's ("Egen insättning …" vs "TRANSFER-123 Topped up").
// Booked first, then unbooked.
//
// Consumed with COUNTING semantics: each match splices one stored entry out
// of its bucket, so N stored twins dedup exactly N incoming and two
@@ -489,17 +577,34 @@ export async function ingestTransactions(
// requirement dropped; an asymmetric bucket keeps it, so when the channels
// disagree on how many transactions a bucket holds we keep a visible
// (deletable) duplicate rather than risk collapsing a genuinely-new row.
//
// handEnteredMirror: the analogous signal against BOOKED hand-entered rows
// (manual/mcp/null source). A booked hand-entered row means the user
// already put this movement in the ledger before the feed delivered the
// bank's copy (bookkeep-by-chat first, connect the bank later). Symmetry
// compares the bucket's Layer-1-UNMATCHED incoming count (a row Layer-1
// reconciles never reaches this layer, so it must not inflate the count)
// against the guarded hand-entered candidate count, plus a per-entry
// currency gate in consumeBridgingTwin (the bucket key is only date+öre,
// and hand-entered rows often lack a cash_account_id for the account guard
// to bite on).
const bucketKey = contentBucketKey(raw.date, raw.amount)
const rawCurrency = raw.currency ?? null
const crossSourceMirror =
batchIsImportFeed &&
(crossSourceStoredByBucket.get(bucketKey) ?? 0) > 0 &&
incomingByBucket.get(bucketKey) === crossSourceStoredByBucket.get(bucketKey)
const consumeBridgingTwin = (bucket: DescBucket): boolean => {
const handEnteredMirror =
batchIsImportFeed &&
(bookedHandEnteredByBucket.get(bucketKey) ?? 0) > 0 &&
unmatchedIncomingByBucket.get(bucketKey) === bookedHandEnteredByBucket.get(bucketKey)
const consumeBridgingTwin = (bucket: DescBucket): BucketEntry | null => {
const entries = bucket.get(bucketKey)
if (!entries || entries.length === 0) return false
if (!entries || entries.length === 0) return null
let bestIdx = -1
let bestLen = -1
let crossIdx = -1
let handIdx = -1
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const sameAccount =
@@ -515,17 +620,65 @@ export async function ingestTransactions(
if (crossIdx === -1 && crossSourceMirror && entry.isImportFeed && entry.source !== batchSource) {
crossIdx = i
}
// Booked-hand-entered fallback: hand-entered entries only exist in the
// booked map (the unbooked query excludes manual/mcp at the DB level),
// so !isImportFeed here already implies booked. Currency must not
// contradict: null on either side is compatible (legacy rows).
if (
handIdx === -1 &&
handEnteredMirror &&
!entry.isImportFeed &&
(entry.currency === null || rawCurrency === null || entry.currency === rawCurrency)
) {
handIdx = i
}
}
const idx = bestIdx !== -1 ? bestIdx : crossIdx
if (idx === -1) return false
entries.splice(idx, 1)
return true
const idx = bestIdx !== -1 ? bestIdx : crossIdx !== -1 ? crossIdx : handIdx
if (idx === -1) return null
const [consumed] = entries.splice(idx, 1)
return consumed
}
if (
consumeBridgingTwin(existingMaps.booked) ||
consumeBridgingTwin(existingMaps.unbookedImported)
) {
const consumedTwin =
consumeBridgingTwin(existingMaps.booked) ?? consumeBridgingTwin(existingMaps.unbookedImported)
if (consumedTwin) {
result.duplicates++
// Persist the hand-mirror adoption: bind the account-unbound hand row to
// the account this feed batch settled on. Consumption is otherwise
// in-memory only, so without this ONE null-account hand row could
// consume one genuine feed row per sync call on EVERY account whose
// bucket happens to be date+öre+currency symmetric: permanent silent
// suppression across accounts. After the stamp, the account guard
// excludes this row from any other account's mirror. The `.is()` filter
// makes the write race-safe (never overwrites a concurrent binding),
// and a failure is non-critical: dedup already happened, the stamp only
// narrows future consumption.
if (
!consumedTwin.isImportFeed &&
consumedTwin.id !== null &&
consumedTwin.cashAccountId === null &&
cashAccountId !== null
) {
try {
const { error: stampError } = await supabase
.from('transactions')
.update({ cash_account_id: cashAccountId })
.eq('id', consumedTwin.id)
.is('cash_account_id', null)
if (stampError) {
log.warn('hand-mirror adoption stamp failed; row stays unbound', {
transactionId: consumedTwin.id,
cashAccountId,
error: stampError.message,
})
}
} catch (stampError) {
log.warn('hand-mirror adoption stamp failed; row stays unbound', {
transactionId: consumedTwin.id,
cashAccountId,
error: stampError instanceof Error ? stampError.message : String(stampError),
})
}
}
continue
}