Fix/m sprint fixes (#613)
* fix(dashboard): exclude ignored and already-triaged transactions from stale count The "Gamla transaktioner" widget counted transactions that had been ignored or already marked as is_business=true but not yet booked, so users saw a nag for a row they had already dealt with — and the /transactions inbox correctly hid it. Align the count with the inbox criterion (is_business IS NULL, is_ignored = false) so the widget clears when the row leaves the inbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): read entity_type from settings response wrapper The transactions page read entityRes.entity_type directly, but /api/settings returns { data: { entity_type, ... } }. The expression was always undefined, so setEntityType never fired and entityType stayed at its initial 'enskild_firma'. The template picker's entity_type filter then dropped every aktiebolag-tagged user template for AB customers — only entity_type='all' templates made it through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stale templates bank sync journal entry from transaction * fixed pr comments * fixed pr comment --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -177,6 +177,83 @@ describe('ingestTransactions', () => {
|
||||
expect(result.transaction_ids).toEqual([])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2b. CSV row dedupes against uncategorized enable_banking row when
|
||||
// date+amount+description prefix match (Lunar CSV vs Lunar PSD2 case).
|
||||
// -----------------------------------------------------------------------
|
||||
it('dedupes CSV row against unbooked enable_banking row with matching description', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-15',
|
||||
amount: -250.0,
|
||||
description: 'ICA Maxi Solna',
|
||||
external_id: 'lunar_csvhash123',
|
||||
import_source: 'csv_lunar',
|
||||
})
|
||||
|
||||
// Booked transaction map query — none
|
||||
enqueue({ data: [], error: null })
|
||||
// Unbooked bank-synced transaction map query — one PSD2 row with matching content
|
||||
enqueue({
|
||||
data: [{ date: '2024-06-15', amount: -250.0, description: 'ICA Maxi Solna' }],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Batch external_id dedup query — external_id differs, so no match
|
||||
enqueue({ data: [], error: null })
|
||||
// No insert expected — row should be deduplicated at content layer
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
expect(result.transaction_ids).toEqual([])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2c. No false positive: same date+amount but different description does
|
||||
// NOT trigger content dedup — guards against the historical concern
|
||||
// about unrelated transfers colliding on (date, amount) alone.
|
||||
// -----------------------------------------------------------------------
|
||||
it('does not dedupe when date+amount match but description differs', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-15',
|
||||
amount: -250.0,
|
||||
description: 'Coop Stockholm',
|
||||
external_id: 'lunar_csvhash456',
|
||||
import_source: 'csv_lunar',
|
||||
})
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-no-collision',
|
||||
external_id: raw.external_id,
|
||||
amount: -250.0,
|
||||
})
|
||||
|
||||
// Booked transaction map query — none
|
||||
enqueue({ data: [], error: null })
|
||||
// Unbooked bank-synced transaction map query — a PSD2 row with same date/amount but DIFFERENT description
|
||||
enqueue({
|
||||
data: [{ date: '2024-06-15', amount: -250.0, description: 'ICA Maxi Solna' }],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Batch external_id dedup query — no match
|
||||
enqueue({ data: [], error: null })
|
||||
// Insert succeeds — the new row is not a duplicate
|
||||
enqueue({ data: inserted, error: 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(result.transaction_ids).toEqual(['tx-no-collision'])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Counts errors when insert fails
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -657,7 +734,7 @@ describe('ingestTransactions', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Content-based dedup: cross-source duplicate detection
|
||||
// -----------------------------------------------------------------------
|
||||
it('skips transactions that match already-booked ones by date+amount', async () => {
|
||||
it('skips transactions that match already-booked ones by date+amount+description', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
external_id: 'psd2_conn123_tx456',
|
||||
@@ -665,9 +742,9 @@ describe('ingestTransactions', () => {
|
||||
amount: -250,
|
||||
})
|
||||
|
||||
// Booked transaction map returns a booked tx with same date+amount
|
||||
// Booked transaction map returns a booked tx with same date+amount+description
|
||||
enqueue({
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
data: [{ date: '2024-06-15', amount: -250, description: raw.description }],
|
||||
error: null,
|
||||
})
|
||||
// Unbooked bank-synced transaction map query
|
||||
@@ -694,7 +771,7 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map: same date but different amount
|
||||
enqueue({
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
data: [{ date: '2024-06-15', amount: -250, description: raw.description }],
|
||||
error: null,
|
||||
})
|
||||
// Unbooked bank-synced transaction map query
|
||||
@@ -724,12 +801,12 @@ describe('ingestTransactions', () => {
|
||||
|
||||
const inserted = makeTransaction({ id: 'tx-new', amount: -100 })
|
||||
|
||||
// Booked map: 2 existing booked transactions with same date+amount
|
||||
// Booked map: 2 existing booked transactions with same date+amount+description
|
||||
// So 2 of the 3 incoming should be skipped, 1 should be imported
|
||||
enqueue({
|
||||
data: [
|
||||
{ date: '2024-06-15', amount: -100 },
|
||||
{ date: '2024-06-15', amount: -100 },
|
||||
{ date: '2024-06-15', amount: -100, description: raw1.description },
|
||||
{ date: '2024-06-15', amount: -100, description: raw1.description },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
+44
-27
@@ -16,15 +16,27 @@ interface ExistingTransactionMaps {
|
||||
/** Booked transactions (any source) — consumed by any incoming raw transaction. */
|
||||
booked: Map<string, number>
|
||||
/**
|
||||
* Unbooked enable_banking transactions — only consumed when the incoming raw
|
||||
* transaction is also from enable_banking. This catches reconnect duplicates
|
||||
* (external_id changed but the same tx already exists from a prior sync)
|
||||
* without producing false positives for unrelated CSV imports that happen to
|
||||
* share a date/amount with a pending bank-synced row.
|
||||
* Unbooked enable_banking transactions — consumed by any incoming raw
|
||||
* transaction regardless of source. Catches two cases: PSD2 reconnect
|
||||
* duplicates (external_id regenerated, same tx already pending) AND
|
||||
* CSV imports overlapping an active PSD2 sync (same Lunar/etc tx arriving
|
||||
* twice, once via PSD2 and once via file upload).
|
||||
*/
|
||||
unbookedEnableBanking: Map<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable content-dedup key. Includes a normalized description prefix so the
|
||||
* two-tuple (date, amount) doesn't false-positive across unrelated transfers
|
||||
* that happen to share a date and amount. Lunar's CSV "Text" column and
|
||||
* PSD2's `description || counterparty_name` (see enable-banking/lib/sync.ts)
|
||||
* agree well enough in practice for the same underlying transaction.
|
||||
*/
|
||||
function contentDedupKey(date: string, amount: number | string, description: string | null | undefined): string {
|
||||
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
|
||||
return `${date}|${amount}|${descPrefix}`
|
||||
}
|
||||
|
||||
async function buildExistingTransactionMaps(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -41,7 +53,7 @@ async function buildExistingTransactionMaps(
|
||||
try {
|
||||
const { data: bookedRows } = await supabase
|
||||
.from('transactions')
|
||||
.select('date, amount')
|
||||
.select('date, amount, description')
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.gte('date', dateFrom)
|
||||
@@ -49,7 +61,7 @@ async function buildExistingTransactionMaps(
|
||||
|
||||
if (bookedRows) {
|
||||
for (const tx of bookedRows) {
|
||||
const key = `${tx.date}|${tx.amount}`
|
||||
const key = contentDedupKey(tx.date, tx.amount, tx.description)
|
||||
booked.set(key, (booked.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +72,7 @@ async function buildExistingTransactionMaps(
|
||||
try {
|
||||
const { data: unbookedBank } = await supabase
|
||||
.from('transactions')
|
||||
.select('date, amount')
|
||||
.select('date, amount, description')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('import_source', 'enable_banking')
|
||||
@@ -69,7 +81,7 @@ async function buildExistingTransactionMaps(
|
||||
|
||||
if (unbookedBank) {
|
||||
for (const tx of unbookedBank) {
|
||||
const key = `${tx.date}|${tx.amount}`
|
||||
const key = contentDedupKey(tx.date, tx.amount, tx.description)
|
||||
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
@@ -85,8 +97,12 @@ async function buildExistingTransactionMaps(
|
||||
*
|
||||
* Handles:
|
||||
* 1. Deduplication via external_id
|
||||
* 1b. Content-based dedup via date+amount against already-booked transactions
|
||||
* (catches cross-source duplicates, e.g. CSV import then PSD2 sync)
|
||||
* 1b. Content-based dedup (date+amount+description prefix) against already-booked
|
||||
* transactions — catches cross-source duplicates, e.g. PSD2 row gets booked
|
||||
* before the user later re-imports the same period via CSV.
|
||||
* 1c. Content-based dedup against unbooked enable_banking rows — catches PSD2
|
||||
* reconnect duplicates AND CSV imports overlapping an active PSD2 sync (the
|
||||
* description-prefix component makes this safe to apply across sources).
|
||||
* 2. Insert into transactions table
|
||||
* 3. OCR/reference-based invoice matching (highest confidence)
|
||||
* 4. Amount+customer fallback invoice matching
|
||||
@@ -112,10 +128,11 @@ export async function ingestTransactions(
|
||||
transaction_ids: [],
|
||||
}
|
||||
|
||||
// Pre-fetch existing transactions for content-based dedup (date+amount).
|
||||
// Booked rows (any source) catch cross-source duplicates; unbooked
|
||||
// enable_banking rows catch reconnect duplicates but are only consumed
|
||||
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
|
||||
// Pre-fetch existing transactions for content-based dedup
|
||||
// (date+amount+description prefix). Booked rows catch cross-source
|
||||
// duplicates after they've been booked; unbooked enable_banking rows
|
||||
// catch the more common case where a PSD2 row is still pending in the
|
||||
// inbox when the user re-imports the same period via CSV.
|
||||
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
|
||||
|
||||
// When rawInsertOnly is set (viewer imports), skip pre-fetching supplier
|
||||
@@ -206,8 +223,8 @@ export async function ingestTransactions(
|
||||
}
|
||||
|
||||
// 1b. Content-based dedup: skip if an already-booked transaction
|
||||
// exists with the same date and amount (cross-source duplicate).
|
||||
const contentKey = `${raw.date}|${raw.amount}`
|
||||
// exists with the same date, amount, and description prefix.
|
||||
const contentKey = contentDedupKey(raw.date, raw.amount, raw.description)
|
||||
const bookedCount = existingMaps.booked.get(contentKey) || 0
|
||||
if (bookedCount > 0) {
|
||||
existingMaps.booked.set(contentKey, bookedCount - 1)
|
||||
@@ -215,16 +232,16 @@ export async function ingestTransactions(
|
||||
continue
|
||||
}
|
||||
|
||||
// 1c. Reconnect dedup: only enable_banking rows consume slots from the
|
||||
// unbooked-enable_banking map, so a CSV row with the same date/amount as
|
||||
// a pending bank-synced row is not incorrectly dropped as a duplicate.
|
||||
if (raw.import_source === 'enable_banking') {
|
||||
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
|
||||
if (unbookedEbCount > 0) {
|
||||
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
|
||||
result.duplicates++
|
||||
continue
|
||||
}
|
||||
// 1c. Overlap dedup: skip if an unbooked enable_banking row already
|
||||
// exists with the same (date, amount, description prefix). Applies to
|
||||
// any incoming source — PSD2 reconnects, CSV imports over an active
|
||||
// PSD2 sync, etc. Description prefix prevents unrelated transfers from
|
||||
// colliding on (date, amount) alone.
|
||||
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
|
||||
if (unbookedEbCount > 0) {
|
||||
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
|
||||
result.duplicates++
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Insert new transaction (with SEK conversion for foreign currencies)
|
||||
|
||||
Reference in New Issue
Block a user