Files
accounted/tests/pg/transactions-transaction-method.pg.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

312 lines
11 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { TransactionMethod } from '@/types'
import { seedCompany, insertTransaction } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* pg-real coverage for transactions.transaction_method
* (20260808090000_transactions_transaction_method.sql + the paired backfill
* 20260808090100_..._backfill.sql).
*
* Locks in:
* - The closed-vocabulary CHECK constraint.
* - The backfill's trailing-phrase classification (real production strings),
* its precedence (word-boundary: "Löneinsättning" is salary, never
* deposit), the MCC and Stripe-prefix fallbacks, and the NULL result for
* unclassifiable rows.
* - FEED-ROW SCOPE: user-created rows (import_source NULL/manual/mcp) are
* never classified and never rewritten ("Egen insättning" stays intact).
* - Title stripping: unedited titles lose the trailing channel phrase but
* are never emptied; user-edited titles are untouched even when the
* original carries a phrase (classification still fires off the original);
* the adjective guard keeps "Egen insättning" whole even on feed rows.
* - Idempotence: a second run changes nothing.
*/
// Run the real backfill migration SQL so the test exercises exactly what
// ships, not a re-implementation.
const BACKFILL_SQL = readFileSync(
join(
process.cwd(),
'supabase/migrations/20260808090100_transactions_transaction_method_backfill.sql',
),
'utf8',
)
async function runBackfill(): Promise<void> {
await getPool().query(BACKFILL_SQL)
}
async function insertFeedRow(params: {
companyId: string
userId: string
description: string
originalDescription?: string | null
titleEditedAt?: string | null
/** Defaults to a bank feed; pass null/'manual'/'mcp' for user-created rows. */
importSource?: string | null
mccCode?: number | null
}): Promise<string> {
const id = await insertTransaction({
companyId: params.companyId,
userId: params.userId,
description: params.description,
})
await getPool().query(
`UPDATE public.transactions
SET original_description = $2,
title_edited_at = $3::timestamptz,
import_source = $4,
mcc_code = $5
WHERE id = $1`,
[
id,
params.originalDescription === undefined ? params.description : params.originalDescription,
params.titleEditedAt ?? null,
params.importSource === undefined ? 'enable_banking' : params.importSource,
params.mccCode ?? null,
],
)
return id
}
async function getRow(
txId: string,
): Promise<{ description: string; transaction_method: TransactionMethod | null }> {
const { rows } = await getPool().query(
`SELECT description, transaction_method FROM public.transactions WHERE id = $1`,
[txId],
)
return rows[0]
}
describe('transactions.transaction_method: CHECK constraint', () => {
it('accepts vocabulary values and NULL, rejects anything else', async () => {
const { userId, companyId } = await seedCompany()
const tx = await insertTransaction({ companyId, userId })
await getPool().query(
`UPDATE public.transactions SET transaction_method = 'card' WHERE id = $1`,
[tx],
)
await getPool().query(
`UPDATE public.transactions SET transaction_method = NULL WHERE id = $1`,
[tx],
)
await expect(
getPool().query(
`UPDATE public.transactions SET transaction_method = 'bankid' WHERE id = $1`,
[tx],
),
).rejects.toMatchObject({ code: '23514' })
})
})
describe('transactions.transaction_method: backfill classification + title strip', () => {
it('classifies the real production strings and strips the trailing phrase', async () => {
const { userId, companyId } = await seedCompany()
const cases: Array<{
description: string
method: TransactionMethod
stripped: string
}> = [
{
description: 'Vercel Jul Överföring via internet',
method: 'transfer',
stripped: 'Vercel Jul',
},
{
description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO Kortköp/uttag',
method: 'card',
stripped: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
},
{
description: 'Inbetalning skat BG 0000050501055 Bg-bet. via internet',
method: 'bankgiro',
stripped: 'Inbetalning skat BG 0000050501055',
},
{ description: '1260624917587 Europabetalning', method: 'international', stripped: '1260624917587' },
{ description: '1260624917587 Pris betalning', method: 'fee', stripped: '1260624917587' },
{ description: 'SWED2607270AUEOU Insättning', method: 'deposit', stripped: 'SWED2607270AUEOU' },
{ description: 'Lön Juli Emil Överföring via internet', method: 'transfer', stripped: 'Lön Juli Emil' },
{ description: 'ACME AB Löneinsättning', method: 'salary', stripped: 'ACME AB' },
{ description: 'Swish till Erik Andersson', method: 'swish', stripped: 'Swish till Erik Andersson' },
]
const ids = []
for (const c of cases) {
ids.push(await insertFeedRow({ companyId, userId, description: c.description }))
}
await runBackfill()
for (let i = 0; i < cases.length; i++) {
const row = await getRow(ids[i])
expect(row.transaction_method).toBe(cases[i].method)
expect(row.description).toBe(cases[i].stripped)
}
})
it('never touches user-created rows (import_source NULL, manual, mcp)', async () => {
const { userId, companyId } = await seedCompany()
const cases = [null, 'manual', 'mcp']
const ids: string[] = []
for (const src of cases) {
// Titles that WOULD classify+strip if they came from a bank feed.
ids.push(
await insertFeedRow({ companyId, userId, description: 'Egen insättning', importSource: src }),
)
}
await runBackfill()
for (const id of ids) {
const row = await getRow(id)
expect(row.transaction_method).toBeNull()
expect(row.description).toBe('Egen insättning')
}
})
it('adjective guard: feed rows classify but keep "Egen insättning"-style titles', async () => {
const { userId, companyId } = await seedCompany()
const deposit = await insertFeedRow({ companyId, userId, description: 'Egen insättning' })
const withdrawal = await insertFeedRow({ companyId, userId, description: 'Eget uttag' })
const transfer = await insertFeedRow({ companyId, userId, description: 'Intern överföring' })
await runBackfill()
expect(await getRow(deposit)).toEqual({
description: 'Egen insättning',
transaction_method: 'deposit',
})
expect(await getRow(withdrawal)).toEqual({
description: 'Eget uttag',
transaction_method: 'withdrawal',
})
expect(await getRow(transfer)).toEqual({
description: 'Intern överföring',
transaction_method: 'transfer',
})
})
it('never empties a title that IS the phrase', async () => {
const { userId, companyId } = await seedCompany()
const tx = await insertFeedRow({ companyId, userId, description: 'Insättning' })
await runBackfill()
const row = await getRow(tx)
expect(row.transaction_method).toBe('deposit')
expect(row.description).toBe('Insättning')
})
it('classifies an edited row from the original but leaves the edited title alone', async () => {
const { userId, companyId } = await seedCompany()
const tx = await insertFeedRow({
companyId,
userId,
description: 'Serverhyra juli Överföring via internet',
originalDescription: 'Hetzner GmbH Överföring via internet',
titleEditedAt: '2026-07-01T10:00:00Z',
})
await runBackfill()
const row = await getRow(tx)
expect(row.transaction_method).toBe('transfer')
// User-edited titles are never rewritten, even when they end in a phrase.
expect(row.description).toBe('Serverhyra juli Överföring via internet')
})
it('falls back to the legacy description when original_description is NULL', async () => {
const { userId, companyId } = await seedCompany()
const tx = await insertFeedRow({
companyId,
userId,
description: 'TELIA AB Autogiro',
originalDescription: null,
})
await runBackfill()
const row = await getRow(tx)
expect(row.transaction_method).toBe('autogiro')
expect(row.description).toBe('TELIA AB')
// The strip must preserve the only copy of the full bank string: a legacy
// NULL original_description is filled from the pre-strip description in
// the same statement, never lost.
const { rows } = await getPool().query(
`SELECT original_description FROM public.transactions WHERE id = $1`,
[tx],
)
expect(rows[0].original_description).toBe('TELIA AB Autogiro')
})
it('uses MCC presence as the card-rail fallback (6011 = withdrawal)', async () => {
const { userId, companyId } = await seedCompany()
const card = await insertFeedRow({
companyId,
userId,
description: 'COOP KONSUM STOCKHOLM',
mccCode: 5411,
})
const atm = await insertFeedRow({
companyId,
userId,
description: 'BANKOMAT VASAGATAN',
mccCode: 6011,
})
await runBackfill()
expect((await getRow(card)).transaction_method).toBe('card')
expect((await getRow(atm)).transaction_method).toBe('withdrawal')
// No phrase → the title is untouched.
expect((await getRow(card)).description).toBe('COOP KONSUM STOCKHOLM')
})
it('classifies Stripe feed rows from their deterministic prefixes', async () => {
const { userId, companyId } = await seedCompany()
const mk = (description: string) =>
insertFeedRow({ companyId, userId, description, importSource: 'stripe' })
const fee = await mk('Stripe-avgift (Stripe-betalning Carl)')
const usage = await mk('Stripe: Billing - Usage Fee (2026-07-26)')
const charge = await mk('Stripe-betalning Fredrik Schöön')
const payout = await mk('Stripe-utbetalning po_1')
await runBackfill()
expect((await getRow(fee)).transaction_method).toBe('fee')
expect((await getRow(usage)).transaction_method).toBe('fee')
expect((await getRow(charge)).transaction_method).toBe('card')
expect((await getRow(payout)).transaction_method).toBe('transfer')
})
it('leaves unclassifiable rows NULL and untouched, and is idempotent', async () => {
const { userId, companyId } = await seedCompany()
const plain = await insertFeedRow({ companyId, userId, description: 'Okänd transaktion' })
const phrased = await insertFeedRow({
companyId,
userId,
description: 'Vercel Jul Överföring via internet',
})
// Pre-classified rows must not be reclassified.
const preset = await insertFeedRow({ companyId, userId, description: 'X Kortköp' })
await getPool().query(
`UPDATE public.transactions SET transaction_method = 'swish' WHERE id = $1`,
[preset],
)
await runBackfill()
const first = [await getRow(plain), await getRow(phrased), await getRow(preset)]
await runBackfill()
const second = [await getRow(plain), await getRow(phrased), await getRow(preset)]
expect(first[0]).toEqual({ description: 'Okänd transaktion', transaction_method: null })
expect(first[1]).toEqual({ description: 'Vercel Jul', transaction_method: 'transfer' })
expect(first[2].transaction_method).toBe('swish')
expect(second).toEqual(first)
})
})