Files
accounted/tests/pg/supplier-invoice-overdue-cron.pg.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

205 lines
7.1 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* pg-real coverage for update_overdue_supplier_invoices() and its fix in
* 20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql.
*
* Regression: supplier invoices (and credit notes) with remaining_amount = 0
* were being flipped to 'overdue' by the daily cron: surfacing in the UI as
* "Förfallen" with "kvar att betala 0 kr". Credit notes are the systematic
* case: they are created status='registered', remaining_amount=0,
* due_date=today, so the cron caught them the next day.
*
* Locks in:
* - The function still marks a genuinely-unpaid, past-due invoice overdue.
* - It NEVER marks a credit note overdue.
* - It NEVER marks a fully-paid (remaining ~= 0) invoice overdue.
* - Not-yet-due invoices are untouched.
* - The one-off backfill corrects rows already mis-flagged.
*
* Tests write through the superuser pool (RLS bypassed); the function is
* SECURITY DEFINER. Dates are pinned far in the past/future so the result is
* independent of the wall-clock date the suite runs on.
*/
const PAST = '2000-01-01'
const FUTURE = '2999-01-01'
const MIGRATION_SQL = readFileSync(
join(
process.cwd(),
'supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql',
),
'utf8',
)
async function insertSupplier(userId: string, companyId: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
[id, userId, companyId],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
status: string
dueDate: string
total: number
remaining: number
paidAmount?: number
isCreditNote?: boolean
paidAt?: string | null
}): Promise<string> {
const id = randomUUID()
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 100_000)
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency,
subtotal, vat_amount, total, paid_amount, remaining_amount, paid_at,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $7, $8, 'SEK',
$9, 0, $9, $10, $11, $12, 'standard_25', false, $13)`,
[
id,
params.userId,
params.companyId,
params.supplierId,
arrivalNumber,
`LF-${arrivalNumber}`,
params.dueDate,
params.status,
params.total,
params.paidAmount ?? 0,
params.remaining,
params.paidAt ?? null,
params.isCreditNote ?? false,
],
)
return id
}
async function statusOf(id: string): Promise<string> {
const { rows } = await getPool().query(
'SELECT status FROM public.supplier_invoices WHERE id = $1',
[id],
)
return rows[0].status
}
describe('update_overdue_supplier_invoices()', () => {
it('marks a genuinely-unpaid, past-due invoice overdue', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'approved', dueDate: PAST, total: 1000, remaining: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('overdue')
})
it('never marks a credit note overdue (remaining 0, status registered)', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
// Mirrors how the credit routes create a credit note: registered, fully
// settled (remaining 0), due today (here: long past).
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'registered', dueDate: PAST, total: 1000, remaining: 0,
isCreditNote: true,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('registered')
})
it('never marks a fully-paid (remaining ~0) invoice overdue', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'approved', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('approved')
})
it('leaves not-yet-due invoices untouched', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'approved', dueDate: FUTURE, total: 1000, remaining: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('approved')
})
})
describe('overdue backfill (migration 20260607120000)', () => {
it('reverts a credit note wrongly stuck on overdue back to registered', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, isCreditNote: true,
})
// Idempotent: re-running the migration only touches status='overdue' rows.
await getPool().query(MIGRATION_SQL)
expect(await statusOf(id)).toBe('registered')
})
it('marks a fully-paid invoice stuck on overdue as paid (and stamps paid_at)', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000,
paidAt: null,
})
await getPool().query(MIGRATION_SQL)
const { rows } = await getPool().query(
'SELECT status, paid_at FROM public.supplier_invoices WHERE id = $1',
[id],
)
expect(rows[0].status).toBe('paid')
expect(rows[0].paid_at).not.toBeNull()
})
it('leaves a genuinely-overdue unpaid invoice on overdue', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: PAST, total: 1000, remaining: 1000,
})
await getPool().query(MIGRATION_SQL)
expect(await statusOf(id)).toBe('overdue')
})
})