Bug/customer cron job (#516)
* fix(reminder-processor): filter out credited invoices in overdue reminders * feat: implement linking of transactions to journal entries - Added POST endpoint for linking a bank transaction to an existing journal entry without creating new bookkeeping. - Implemented validation for required fields and error handling for various scenarios (e.g., missing journal_entry_id, transaction already linked, journal entry not found). - Created tests for the new endpoint to cover various cases including successful linking, error responses, and invoice handling. - Introduced duplicate payment detection logic to prevent double-booking of bank receipts. - Added a new component for correction affordance in the UI to facilitate user corrections on journal entries. * feat(invoice-matching): enhance force matching with expected journal entry validation
This commit is contained in:
+29
-2
@@ -360,8 +360,35 @@ export const BookInboxItemDirectlySchema = z.object({
|
||||
transaction_id: uuid.optional(),
|
||||
})
|
||||
|
||||
export const MatchInvoiceSchema = z.object({
|
||||
invoice_id: uuid,
|
||||
export const MatchInvoiceSchema = z
|
||||
.object({
|
||||
invoice_id: uuid,
|
||||
// Bypass the soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE).
|
||||
// Set after the user reviews the candidate verifikation and confirms it
|
||||
// is not this payment. v1 callers must use a fresh Idempotency-Key on
|
||||
// the retry — the original is body-hash bound.
|
||||
force: z.boolean().optional(),
|
||||
// Required whenever force=true. Echoes the journal_entry_id of the
|
||||
// candidate the user reviewed in the duplicate-payment-check pre-flight.
|
||||
// The server re-detects the candidate and refuses force=true unless the
|
||||
// re-detected id matches this value. That binds the override to a
|
||||
// specific, user-seen duplicate so an automation can't sweep through
|
||||
// force=true to bypass the guard without ever consulting the candidate.
|
||||
expected_journal_entry_id: uuid.optional(),
|
||||
})
|
||||
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
|
||||
message: 'expected_journal_entry_id is required when force=true',
|
||||
path: ['expected_journal_entry_id'],
|
||||
})
|
||||
|
||||
export const LinkTransactionJournalEntrySchema = z.object({
|
||||
journal_entry_id: uuid,
|
||||
// Optional invoice to settle alongside the link. When provided, the
|
||||
// server inserts an invoice_payments row pointing at the existing JE
|
||||
// and flips the invoice status with the same optimistic-lock pattern
|
||||
// as the match-invoice route. Omit to only link the bank transaction
|
||||
// (e.g. when the JE doesn't relate to a customer invoice).
|
||||
invoice_id: uuid.optional(),
|
||||
})
|
||||
|
||||
export const CreateTransactionFromDocumentSchema = z.object({
|
||||
|
||||
@@ -45,6 +45,13 @@ export interface RouteContext {
|
||||
* resolved, so handlers can treat this as guaranteed non-null. Routes that
|
||||
* need to opt out of the guarantee (e.g. onboarding) shouldn't use
|
||||
* withRouteContext.
|
||||
*
|
||||
* Membership invariant: `getActiveCompanyId` only returns a company the
|
||||
* authenticated user is a current member of (it validates
|
||||
* `company_members` and excludes archived companies). The handler may
|
||||
* therefore treat `companyId` as "a company the caller is authorized to
|
||||
* read", and routes that mutate state additionally enforce a non-viewer
|
||||
* role via `requireWrite: true`. ASVS V8.2.1 / SOC 2 CC6.3.
|
||||
*/
|
||||
companyId: string
|
||||
}
|
||||
|
||||
@@ -356,6 +356,60 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Matchningen registrerades men verifikationen kunde inte skapas.',
|
||||
message_en: 'Match recorded but the journal entry could not be created.',
|
||||
},
|
||||
MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Fakturan har redan en betalningsverifikation. Koppla istället bankhändelsen till befintlig verifikation, eller rätta tidigare bokföring först.',
|
||||
message_en:
|
||||
'Invoice already has a payment journal entry. Link the bank transaction to the existing voucher instead, or correct the prior bookkeeping first.',
|
||||
},
|
||||
MATCH_INVOICE_POSSIBLE_DUPLICATE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Det finns redan en bokförd verifikation på samma belopp och datum. Har du redan bokfört denna betalning? Koppla bankhändelsen till befintlig verifikation, eller skapa ny verifikation ändå om de inte hör ihop.',
|
||||
message_en:
|
||||
'A posted journal entry already books the same amount on a nearby date. The user may have already booked this payment manually — link to the existing voucher or pass force=true to create a new one anyway.',
|
||||
},
|
||||
MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Verifikationen som dubblettkontrollen visade matchar inte längre. Stäng dialogen och försök igen så att rätt verifikation visas.',
|
||||
message_en:
|
||||
'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.',
|
||||
},
|
||||
}
|
||||
|
||||
const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
|
||||
LINK_TX_JE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Verifikationen kunde inte hittas.',
|
||||
message_en: 'Journal entry not found.',
|
||||
},
|
||||
LINK_TX_JE_NOT_POSTED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Endast bokförda verifikationer kan kopplas till en banktransaktion.',
|
||||
message_en: 'Only posted journal entries can be linked to a transaction.',
|
||||
},
|
||||
LINK_TX_TX_ALREADY_LINKED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen är redan kopplad till en verifikation.',
|
||||
message_en: 'Transaction is already linked to a journal entry.',
|
||||
},
|
||||
LINK_TX_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Fakturan kunde inte hittas.',
|
||||
message_en: 'Invoice not found.',
|
||||
},
|
||||
LINK_TX_INVOICE_NOT_OPEN: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte kopplas.',
|
||||
message_en: 'Invoice is not in an unpaid state.',
|
||||
},
|
||||
LINK_TX_INVOICE_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Fakturan ändrades samtidigt. Försök igen.',
|
||||
message_en: 'Invoice status changed concurrently. Retry the request.',
|
||||
},
|
||||
}
|
||||
|
||||
const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
@@ -1523,6 +1577,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...BOOKKEEPING,
|
||||
...TRANSACTIONS,
|
||||
...MATCH_INVOICE,
|
||||
...LINK_TX_JE,
|
||||
...MATCH_SI,
|
||||
...INVOICE,
|
||||
...SUPPLIER_INVOICE,
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
describe('detectDuplicatePaymentVoucher', () => {
|
||||
beforeEach(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
function makeLineRow(opts: {
|
||||
je_id: string
|
||||
account: string
|
||||
debit: number
|
||||
date: string
|
||||
voucher_label?: string
|
||||
source_type?: string | null
|
||||
description?: string | null
|
||||
}) {
|
||||
const [series, ...numParts] = (opts.voucher_label ?? 'A1').split('')
|
||||
const num = parseInt(numParts.join(''), 10) || 1
|
||||
return {
|
||||
account_number: opts.account,
|
||||
debit_amount: opts.debit,
|
||||
journal_entry: {
|
||||
id: opts.je_id,
|
||||
entry_date: opts.date,
|
||||
description: opts.description ?? `Voucher ${opts.je_id}`,
|
||||
voucher_series: series,
|
||||
voucher_number: num,
|
||||
status: 'posted',
|
||||
source_type: opts.source_type ?? 'manual',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('returns null when transaction amount is 0', async () => {
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 0,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when transaction date is invalid', async () => {
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: 'not-a-date',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no lines are found', async () => {
|
||||
enqueue({ data: [], error: null })
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-1',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
voucher_label: 'A12',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// invoice_payments link check (no links)
|
||||
enqueue({ data: [], error: null })
|
||||
// transactions link check (no links)
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.journal_entry_id).toBe('je-1')
|
||||
expect(result!.bank_account_number).toBe('1930')
|
||||
expect(result!.reason).toBe('exact_amount_same_date')
|
||||
expect(result!.amount).toBe(1000)
|
||||
})
|
||||
|
||||
it('returns within_window reason when JE date is close but not equal', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-2',
|
||||
account: '1930',
|
||||
debit: 500,
|
||||
date: '2026-05-12',
|
||||
voucher_label: 'A5',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 500,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.reason).toBe('exact_amount_within_window')
|
||||
})
|
||||
|
||||
it('excludes JEs that are already linked via invoice_payments', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-3',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// invoice_payments has a row linking this JE
|
||||
enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes JEs already linked from another transaction', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-4',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
// another transaction already links this JE
|
||||
enqueue({ data: [{ id: 'tx-other', journal_entry_id: 'je-4' }], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes storno entries', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-storno',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
source_type: 'storno',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes correction entries', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-corr',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
source_type: 'correction',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('picks the same-date candidate over a within-window candidate', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-far',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-12',
|
||||
voucher_label: 'A1',
|
||||
}),
|
||||
makeLineRow({
|
||||
je_id: 'je-same',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
voucher_label: 'A2',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.journal_entry_id).toBe('je-same')
|
||||
expect(result!.reason).toBe('exact_amount_same_date')
|
||||
})
|
||||
|
||||
it('matches absolute value for negative transaction amounts (expense)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-x',
|
||||
account: '1930',
|
||||
debit: 250,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: -250,
|
||||
})
|
||||
|
||||
// Note: while the match-invoice route only handles income, the
|
||||
// detector itself is amount-direction agnostic — it just finds JEs
|
||||
// that book the same magnitude on the bank side. Callers gate by
|
||||
// direction.
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.amount).toBe(250)
|
||||
})
|
||||
|
||||
it('skips lines whose amount differs by more than 0.01', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-off',
|
||||
account: '1930',
|
||||
debit: 1001,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores the caller transaction even if it carries a journal_entry_id link', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-caller',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
// The caller transaction itself links the JE (defensive — shouldn't happen
|
||||
// in normal flow because we call this before the link, but a retry could).
|
||||
enqueue({ data: [{ id: 'tx-caller', journal_entry_id: 'je-caller' }], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-caller',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.journal_entry_id).toBe('je-caller')
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
getBestInvoiceMatch,
|
||||
} from '../invoice-matching'
|
||||
import type { Transaction, Invoice, Customer } from '@/types'
|
||||
import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
makeTransaction,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
createMockSupabase,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// amountsMatchExact
|
||||
@@ -353,3 +359,82 @@ describe('getBestInvoiceMatch', () => {
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// findMatchingInvoices — paid-voucher status-leak guard
|
||||
// ============================================================
|
||||
//
|
||||
// Defensive filter added because manual verifikationer (booked outside the
|
||||
// match-invoice flow) leave the invoice in 'sent' status even though a
|
||||
// payment voucher exists via invoice_payments. Matching such an invoice
|
||||
// would double-book the bank receipt. Tests verify that:
|
||||
// - sent/overdue invoices with an invoice_payments.journal_entry_id are
|
||||
// excluded from the candidate list
|
||||
// - partially_paid invoices remain candidates regardless (they may take
|
||||
// more payments legitimately)
|
||||
// - invoices without payment rows still pass through unchanged
|
||||
|
||||
describe('findMatchingInvoices — status-leak guard', () => {
|
||||
it('excludes a sent invoice that already has a payment voucher', async () => {
|
||||
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
|
||||
const inv = {
|
||||
...makeInvoice({
|
||||
id: 'inv-leaked',
|
||||
total: 1000,
|
||||
status: 'sent',
|
||||
remaining_amount: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
customer: makeCustomer({ name: 'Acme AB' }),
|
||||
}
|
||||
enqueue({ data: [inv], error: null })
|
||||
enqueue({ data: [{ invoice_id: 'inv-leaked' }], error: null })
|
||||
|
||||
const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null })
|
||||
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a partially_paid invoice as a candidate even with a prior payment voucher', async () => {
|
||||
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
|
||||
const inv = {
|
||||
...makeInvoice({
|
||||
id: 'inv-partial',
|
||||
total: 1000,
|
||||
status: 'partially_paid',
|
||||
remaining_amount: 400,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
customer: makeCustomer({ name: 'Acme AB' }),
|
||||
}
|
||||
enqueue({ data: [inv], error: null })
|
||||
// The status-leak guard only queries when there are sent/overdue rows;
|
||||
// partially_paid invoices skip the second query, so no enqueue needed.
|
||||
|
||||
const tx = makeTransaction({ amount: 400, description: 'Acme partial', reference: null })
|
||||
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].invoice.id).toBe('inv-partial')
|
||||
})
|
||||
|
||||
it('passes sent invoices through when no payment rows exist for them', async () => {
|
||||
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
|
||||
const inv = {
|
||||
...makeInvoice({
|
||||
id: 'inv-clean',
|
||||
total: 1000,
|
||||
status: 'sent',
|
||||
remaining_amount: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
customer: makeCustomer({ name: 'Acme AB' }),
|
||||
}
|
||||
enqueue({ data: [inv], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null })
|
||||
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].invoice.id).toBe('inv-clean')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const chainCalls: Array<{ method: string; args: unknown[] }> = []
|
||||
|
||||
vi.mock('@supabase/ssr', () => {
|
||||
const buildChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: [], error: null, count: null })
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
chainCalls.push({ method: String(prop), args })
|
||||
return buildChain()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
createServerClient: vi.fn(() => ({
|
||||
from: vi.fn(() => buildChain()),
|
||||
rpc: vi.fn(() => buildChain()),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue({ success: true }),
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
processOverdueReminders,
|
||||
determineReminderLevel,
|
||||
calculateDaysOverdue,
|
||||
} from '../reminder-processor'
|
||||
|
||||
describe('determineReminderLevel', () => {
|
||||
it('returns null below the level-1 threshold', () => {
|
||||
expect(determineReminderLevel(10, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns 1 at 15 days overdue', () => {
|
||||
expect(determineReminderLevel(15, [])).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 2 at 30 days when level 1 already sent', () => {
|
||||
expect(determineReminderLevel(30, [1])).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 3 at 45 days when 1 and 2 already sent', () => {
|
||||
expect(determineReminderLevel(45, [1, 2])).toBe(3)
|
||||
})
|
||||
|
||||
it('returns null when all levels have been sent', () => {
|
||||
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDaysOverdue', () => {
|
||||
it('returns a positive number for a past due date', () => {
|
||||
const tenDaysAgo = new Date()
|
||||
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
|
||||
const days = calculateDaysOverdue(tenDaysAgo.toISOString().split('T')[0])
|
||||
expect(days).toBeGreaterThanOrEqual(9)
|
||||
expect(days).toBeLessThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOverdueReminders — credit-note filter', () => {
|
||||
beforeEach(() => {
|
||||
chainCalls.length = 0
|
||||
})
|
||||
|
||||
it('excludes credit notes via .is("credited_invoice_id", null)', async () => {
|
||||
await processOverdueReminders()
|
||||
|
||||
const isCall = chainCalls.find(
|
||||
(c) => c.method === 'is' && c.args[0] === 'credited_invoice_id',
|
||||
)
|
||||
|
||||
expect(
|
||||
isCall,
|
||||
'overdue-invoice query must filter out credit notes — credit notes have a negative total and must never trigger a payment reminder (e.g. KR-F2026002)',
|
||||
).toBeDefined()
|
||||
expect(isCall?.args[1]).toBeNull()
|
||||
})
|
||||
|
||||
it('combines the credit-note filter with status=sent and due_date cutoff', async () => {
|
||||
await processOverdueReminders()
|
||||
|
||||
const eqStatus = chainCalls.find(
|
||||
(c) => c.method === 'eq' && c.args[0] === 'status',
|
||||
)
|
||||
const isCreditedNull = chainCalls.find(
|
||||
(c) => c.method === 'is' && c.args[0] === 'credited_invoice_id',
|
||||
)
|
||||
const lteDueDate = chainCalls.find(
|
||||
(c) => c.method === 'lte' && c.args[0] === 'due_date',
|
||||
)
|
||||
|
||||
expect(eqStatus?.args[1]).toBe('sent')
|
||||
expect(isCreditedNull?.args[1]).toBeNull()
|
||||
expect(lteDueDate).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Detect a "soft duplicate" payment voucher for a bank transaction.
|
||||
*
|
||||
* Scenario: the user manually booked the receipt as a verifikation
|
||||
* (Dr 19xx / Cr 1510 or Cr 30xx) *outside* the match-invoice flow. The
|
||||
* invoice's status stays 'sent', no `invoice_payments` row exists, and the
|
||||
* matcher would happily propose a second payment voucher — double-booking
|
||||
* the bank receipt.
|
||||
*
|
||||
* Heuristic: a posted journal entry within a tight date window whose lines
|
||||
* debit a bank/cash account (BAS 19xx) for the same amount, and which is
|
||||
* not already linked to any transaction or invoice payment, is almost
|
||||
* certainly the manual booking. We surface it as a candidate; the API
|
||||
* refuses the match unless the caller passes `force: true`.
|
||||
*
|
||||
* Mirrors `findDuplicatePaymentCandidatesForInvoice` (which scans for the
|
||||
* reverse direction — unlinked transactions that look like a manually-marked
|
||||
* invoice payment).
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/** ± days around the transaction date considered "the same payment". */
|
||||
const DATE_WINDOW_DAYS = 7
|
||||
|
||||
/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */
|
||||
const BANK_ACCOUNT_LOW = 1910
|
||||
const BANK_ACCOUNT_HIGH = 1949
|
||||
|
||||
export interface DuplicateVoucherCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
bank_account_number: string
|
||||
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
|
||||
}
|
||||
|
||||
interface DetectArgs {
|
||||
companyId: string
|
||||
transactionId: string
|
||||
transactionDate: string
|
||||
transactionAmount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the single most likely manual verifikation that already books this
|
||||
* bank transaction. Returns null when no candidate is found.
|
||||
*
|
||||
* Filters applied:
|
||||
* - posted status (drafts cannot be a duplicate by definition)
|
||||
* - entry date within ±DATE_WINDOW_DAYS of transaction.date
|
||||
* - has a line that debits a BAS 19xx (kassa/bank) account for the same
|
||||
* rounded amount (within 0.01 SEK)
|
||||
* - not already linked from `transactions.journal_entry_id` (for any row)
|
||||
* - not already referenced by `invoice_payments.journal_entry_id`
|
||||
* - not the storno/correction entry for any prior original (source_type
|
||||
* excluded — those are valid second-line vouchers, not duplicates)
|
||||
*/
|
||||
export async function detectDuplicatePaymentVoucher(
|
||||
supabase: SupabaseClient,
|
||||
args: DetectArgs,
|
||||
): Promise<DuplicateVoucherCandidate | null> {
|
||||
const { companyId, transactionId, transactionDate, transactionAmount } = args
|
||||
const targetAmount = Math.round(Math.abs(transactionAmount) * 100) / 100
|
||||
if (targetAmount === 0) return null
|
||||
|
||||
const dateMs = new Date(transactionDate).getTime()
|
||||
if (Number.isNaN(dateMs)) return null
|
||||
const lowDate = new Date(dateMs - DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const highDate = new Date(dateMs + DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
// Query journal_entry_lines for bank-account debits within the window.
|
||||
// The join filters by company_id at the parent — RLS handles isolation,
|
||||
// but we filter explicitly as defense-in-depth.
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
`account_number,
|
||||
debit_amount,
|
||||
journal_entry:journal_entries!inner(
|
||||
id,
|
||||
entry_date,
|
||||
description,
|
||||
voucher_series,
|
||||
voucher_number,
|
||||
status,
|
||||
source_type,
|
||||
company_id
|
||||
)`,
|
||||
)
|
||||
.eq('journal_entry.company_id', companyId)
|
||||
.eq('journal_entry.status', 'posted')
|
||||
.gte('journal_entry.entry_date', lowDate)
|
||||
.lte('journal_entry.entry_date', highDate)
|
||||
.gte('account_number', String(BANK_ACCOUNT_LOW))
|
||||
.lte('account_number', String(BANK_ACCOUNT_HIGH))
|
||||
.gt('debit_amount', 0)
|
||||
.limit(50)
|
||||
|
||||
if (error || !lines || lines.length === 0) return null
|
||||
|
||||
// Narrow to lines whose debit matches the transaction amount within 0.01 SEK.
|
||||
type LineRow = {
|
||||
account_number: string
|
||||
debit_amount: number | string
|
||||
journal_entry: {
|
||||
id: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
status: string
|
||||
source_type: string | null
|
||||
}
|
||||
}
|
||||
const candidates = (lines as unknown as LineRow[])
|
||||
.filter((l) => {
|
||||
const debit = Math.round(Number(l.debit_amount) * 100) / 100
|
||||
return Math.abs(debit - targetAmount) < 0.01
|
||||
})
|
||||
// System-generated payment vouchers (invoice_paid etc.) ARE valid
|
||||
// duplicates to surface — those are exactly the case where the user
|
||||
// already booked through a different flow. Only exclude reversals
|
||||
// and corrections, which are bookkeeping noise rather than payment
|
||||
// candidates the user would want to link to.
|
||||
.filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction')
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
// Exclude entries already linked from invoice_payments or any transaction.
|
||||
const entryIds = candidates.map((l) => l.journal_entry.id)
|
||||
|
||||
const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
])
|
||||
|
||||
const linkedIds = new Set<string>()
|
||||
for (const row of (paymentLinks ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) {
|
||||
// A transaction can link to its own JE via the current match flow — but
|
||||
// we're called *before* that link is created, so the caller's own
|
||||
// transactionId shouldn't appear. Guard anyway in case of a retry.
|
||||
if (row.journal_entry_id && row.id !== transactionId) {
|
||||
linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
}
|
||||
|
||||
const unlinked = candidates.filter((l) => !linkedIds.has(l.journal_entry.id))
|
||||
if (unlinked.length === 0) return null
|
||||
|
||||
// Pick the best candidate: same-date beats within-window; otherwise pick
|
||||
// the closest by date difference.
|
||||
const targetDateMs = new Date(transactionDate).getTime()
|
||||
unlinked.sort((a, b) => {
|
||||
const aDiff = Math.abs(new Date(a.journal_entry.entry_date).getTime() - targetDateMs)
|
||||
const bDiff = Math.abs(new Date(b.journal_entry.entry_date).getTime() - targetDateMs)
|
||||
return aDiff - bDiff
|
||||
})
|
||||
|
||||
const best = unlinked[0]
|
||||
const sameDate = best.journal_entry.entry_date === transactionDate
|
||||
|
||||
return {
|
||||
journal_entry_id: best.journal_entry.id,
|
||||
voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`,
|
||||
entry_date: best.journal_entry.entry_date,
|
||||
description: best.journal_entry.description,
|
||||
amount: Math.round(Number(best.debit_amount) * 100) / 100,
|
||||
bank_account_number: best.account_number,
|
||||
reason: sameDate ? 'exact_amount_same_date' : 'exact_amount_within_window',
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,30 @@ export async function findMatchingInvoices(
|
||||
return []
|
||||
}
|
||||
|
||||
// Defensive filter: exclude invoices that already have a payment voucher
|
||||
// attached but whose status leaked (still 'sent'/'overdue'). Partially-paid
|
||||
// invoices can legitimately take more payments, so they pass through.
|
||||
// Without this, a status leak would double-book the receipt.
|
||||
const fullCandidateIds = invoices
|
||||
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
|
||||
.map((inv) => inv.id as string)
|
||||
const paidIds = new Set<string>()
|
||||
if (fullCandidateIds.length > 0) {
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullCandidateIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
for (const row of paymentRows ?? []) {
|
||||
paidIds.add((row as { invoice_id: string }).invoice_id)
|
||||
}
|
||||
}
|
||||
const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string))
|
||||
if (filteredInvoices.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches: InvoiceMatch[] = []
|
||||
|
||||
// OCR/Bankgiro reference matching — highest confidence
|
||||
@@ -150,7 +174,7 @@ export async function findMatchingInvoices(
|
||||
const txReference = (transaction as Transaction & { reference?: string | null }).reference
|
||||
if (txReference) {
|
||||
const normalizedRef = txReference.replace(/\s+/g, '')
|
||||
for (const invoice of invoices) {
|
||||
for (const invoice of filteredInvoices) {
|
||||
// Match against invoice_number (used as OCR reference in Swedish payments)
|
||||
const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '')
|
||||
if (invoiceRef && normalizedRef === invoiceRef) {
|
||||
@@ -168,7 +192,7 @@ export async function findMatchingInvoices(
|
||||
}
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
for (const invoice of filteredInvoices) {
|
||||
// Currency filter - must match or be SEK equivalent
|
||||
const currencyMatch =
|
||||
invoice.currency === transaction.currency ||
|
||||
|
||||
@@ -6,6 +6,7 @@ type MatchAction =
|
||||
| 'auto_suggested'
|
||||
| 'suggestion_cleared'
|
||||
| 'storno_conflict_resolved'
|
||||
| 'linked_to_existing_voucher'
|
||||
|
||||
/**
|
||||
* Log a payment match event to the append-only audit trail.
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
customer:customers(*)
|
||||
`)
|
||||
.eq('status', 'sent')
|
||||
.is('credited_invoice_id', null)
|
||||
.lte('due_date', cutoffDate.toISOString().split('T')[0])
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user