fix: preserve invoice payment dates (#1332)

Signed-off-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Mattsson
2026-08-02 20:44:59 +02:00
committed by GitHub
parent 18cdba3574
commit 9e54a8e400
28 changed files with 1975 additions and 73 deletions
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
function dateInTimeZone(timestamp: string, timeZone: string): string {
const parts = new Intl.DateTimeFormat('sv-SE', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(timestamp))
const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]))
return `${values.year}-${values.month}-${values.day}`
}
describe('paidAtFromDate', () => {
it('anchors date-only payments at UTC noon', () => {
expect(paidAtFromDate('2026-05-12')).toBe('2026-05-12T12:00:00Z')
})
it.each(['America/New_York', 'Europe/Stockholm', 'UTC'])(
'renders the original payment date in %s',
(timeZone) => {
const paidAt = paidAtFromDate('2026-05-12')
expect(dateInTimeZone(paidAt, timeZone)).toBe('2026-05-12')
},
)
})
@@ -365,7 +365,7 @@ describe('settleInvoicePayment', () => {
it('emits invoice.paid with the settled state', async () => {
const handler = vi.fn()
eventBus.on('invoice.paid', handler)
const { supabase, enqueue } = createQueuedMockSupabase()
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] })
await settleInvoicePayment(supabase as unknown as SupabaseClient, 'company-1', 'user-1', {
@@ -377,8 +377,14 @@ describe('settleInvoicePayment', () => {
expect.objectContaining({
companyId: 'company-1',
paymentAmount: 1250,
invoice: expect.objectContaining({ id: 'inv-1', status: 'paid' }),
invoice: expect.objectContaining({
id: 'inv-1',
status: 'paid',
paid_at: '2026-07-12T12:00:00Z',
}),
}),
)
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-07-12T12:00:00Z' })
})
})
@@ -333,19 +333,24 @@ describe('link_invoice_to_voucher RPC (atomic link: audit C2)', () => {
expect(Number(result.remaining_amount)).toBe(0)
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount, remaining_amount FROM public.invoices WHERE id = $1`,
`SELECT status, paid_amount, remaining_amount,
paid_at = TIMESTAMPTZ '2026-05-05 12:00:00+00' AS paid_at_matches
FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('paid')
expect(Number(inv[0].paid_amount)).toBe(1000)
expect(Number(inv[0].remaining_amount)).toBe(0)
expect(inv[0].paid_at_matches).toBe(true)
const { rows: pay } = await getPool().query(
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`,
`SELECT amount, payment_date = DATE '2026-05-05' AS payment_date_matches
FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`,
[invoiceId, voucherId],
)
expect(pay).toHaveLength(1)
expect(Number(pay[0].amount)).toBe(1000)
expect(pay[0].payment_date_matches).toBe(true)
})
it('links a partial payment as partially_paid with the right remaining', async () => {
@@ -543,3 +548,74 @@ describe('link_invoice_to_voucher RPC (kontantmetoden: 19xx debit)', () => {
expect(result.invoice_status).toBe('paid')
})
})
describe('link_supplier_invoice_to_voucher RPC payment date', () => {
it('anchors paid_at at UTC noon on the voucher entry date', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const supplierId = 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')`,
[supplierId, userId, companyId],
)
const supplierInvoiceId = randomUUID()
const arrivalNumber = Number.parseInt(supplierInvoiceId.slice(0, 8), 16) % 2_000_000_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,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, '2026-04-01', '2026-05-01', '2026-04-01',
'approved', 'SEK', 1000, 0, 1000, 0, 1000,
'standard_25', false, false)`,
[
supplierInvoiceId,
userId,
companyId,
supplierId,
arrivalNumber,
`LF-${supplierInvoiceId.slice(0, 8)}`,
],
)
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '2440',
creditAccount: '1930',
})
const { rows } = await getPool().query<{ result: RpcResult }>(
`SELECT public.link_supplier_invoice_to_voucher($1, $2, $3, $4, NULL) AS result`,
[supplierInvoiceId, voucherId, userId, companyId],
)
expect(rows[0].result.ok).toBe(true)
expect(rows[0].result.invoice_status).toBe('paid')
const { rows: invoiceRows } = await getPool().query<{ paid_at_matches: boolean }>(
`SELECT paid_at = TIMESTAMPTZ '2026-05-05 12:00:00+00' AS paid_at_matches
FROM public.supplier_invoices WHERE id = $1`,
[supplierInvoiceId],
)
expect(invoiceRows[0].paid_at_matches).toBe(true)
const { rows: paymentRows } = await getPool().query<{ payment_date_matches: boolean }>(
`SELECT payment_date = DATE '2026-05-05' AS payment_date_matches
FROM public.supplier_invoice_payments
WHERE supplier_invoice_id = $1 AND journal_entry_id = $2`,
[supplierInvoiceId, voucherId],
)
expect(paymentRows).toHaveLength(1)
expect(paymentRows[0].payment_date_matches).toBe(true)
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* Convert a date-only invoice payment date to the canonical `paid_at` value.
*
* `paid_at` is a Postgres `timestamptz`, while payment and transaction dates
* are calendar dates without a time zone. UTC noon keeps that calendar date
* stable in UTC, Europe/Stockholm, and every negative UTC offset through
* UTC-12. Midnight UTC would display as the previous day in American time
* zones when passed through the shared local-time date formatter.
*/
export function paidAtFromDate(paymentDate: string): string {
return `${paymentDate}T12:00:00Z`
}
+5 -5
View File
@@ -9,6 +9,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { eventBus } from '@/lib/events'
import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types'
@@ -112,8 +113,6 @@ export async function settleInvoicePayment(
}
}
const now = new Date().toISOString()
// Drive the JE shape from the invoice's actual booking state, not from
// the current accounting_method setting. If the invoice was booked at
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510:
@@ -147,6 +146,7 @@ export async function settleInvoicePayment(
}
}
const { newPaidAmount, newRemaining, newStatus } = payment.plan
const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
@@ -249,7 +249,7 @@ export async function settleInvoicePayment(
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
...(newStatus === 'paid' ? { paid_at: now } : {}),
...(paidAt ? { paid_at: paidAt } : {}),
})
.eq('id', invoice.id)
.eq('company_id', companyId)
@@ -305,7 +305,7 @@ export async function settleInvoicePayment(
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
paid_at: newStatus === 'paid' ? now : invoice.paid_at,
paid_at: paidAt ?? invoice.paid_at,
} as Invoice,
companyId,
userId,
@@ -323,6 +323,6 @@ export async function settleInvoicePayment(
newPaidAmount,
newRemaining,
journalEntryId,
paidAt: newStatus === 'paid' ? now : null,
paidAt,
}
}
@@ -105,7 +105,7 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () =>
})
it('zeroes remaining_amount and emits invoice.paid on full payment (issue #825)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
@@ -140,9 +140,17 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () =>
companyId: 'company-1',
userId: 'user-1',
paymentAmount: 525,
invoice: expect.objectContaining({ id: 'inv-1', status: 'paid', remaining_amount: 0, paid_amount: 525 }),
invoice: expect.objectContaining({
id: 'inv-1',
status: 'paid',
remaining_amount: 0,
paid_amount: 525,
paid_at: '2026-03-30T12:00:00Z',
}),
}),
)
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-03-30T12:00:00Z' })
// Issue #1259: the invoice is settled, so no transaction may keep pointing
// at it as a match suggestion. This flow is not driven by a bank
@@ -104,7 +104,9 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
})
it('credits the payment JE to the transaction\'s own linked cash account, not a hardcoded 1930', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
const matchedHandler = vi.fn()
eventBus.on('invoice.match_confirmed', matchedHandler)
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
@@ -157,6 +159,22 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
'1940',
)
expect(mockCreateCashEntry).not.toHaveBeenCalled()
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
expect(matchedHandler).toHaveBeenCalledWith(
expect.objectContaining({
invoice: expect.objectContaining({
status: 'paid',
paid_at: '2026-05-12T12:00:00Z',
paid_amount: 12500,
remaining_amount: 0,
}),
transaction: expect.objectContaining({
invoice_id: 'inv-1',
journal_entry_id: 'je-1',
}),
}),
)
// Issue #1259: the invoice is settled, so every OTHER transaction still
// carrying a suggestion pointer at it is retired; this op's own row is
// cleared by the link update.
+25 -7
View File
@@ -58,6 +58,7 @@ import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import {
clearSettledBatchAllocationSuggestions,
type BatchAllocationResult,
@@ -2012,7 +2013,7 @@ async function commitMarkInvoicePaid(
}
}
const now = new Date().toISOString()
const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null
// CAS guard: only flip from a payable status so a concurrently-settled
// invoice no-ops here instead of double-booking the payment.
const { data: updateResult, error: updateError } = await supabase
@@ -2021,7 +2022,7 @@ async function commitMarkInvoicePaid(
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
...(newStatus === 'paid' ? { paid_at: now } : {}),
...(paidAt ? { paid_at: paidAt } : {}),
})
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -2076,7 +2077,7 @@ async function commitMarkInvoicePaid(
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
paid_at: newStatus === 'paid' ? now : (invoice as Invoice).paid_at,
paid_at: paidAt ?? (invoice as Invoice).paid_at,
} as Invoice,
companyId,
userId,
@@ -2496,8 +2497,7 @@ async function commitMatchTransactionInvoice(
}
}
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
const now = new Date().toISOString()
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
// Read-only prevalidation, deliberately hoisted ABOVE the irreversible
// storno below (issue #842): resolveSettlementAccount can throw
@@ -2573,7 +2573,7 @@ async function commitMatchTransactionInvoice(
.from('invoices')
.update({
status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
@@ -2635,7 +2635,25 @@ async function commitMatchTransactionInvoice(
try {
await eventBus.emit({
type: 'invoice.match_confirmed',
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId },
payload: {
invoice: {
...(invoice as Invoice),
status: newStatus,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
} as Invoice,
transaction: {
...(transaction as Transaction),
invoice_id: invoiceId,
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services',
} as Transaction,
userId,
companyId,
},
})
} catch { /* non-critical */ }
+18 -4
View File
@@ -16,6 +16,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { createLogger } from '@/lib/logger'
import type { Invoice, Transaction } from '@/types'
@@ -332,14 +333,14 @@ export async function linkTransactionToJournalEntry(
}
}
const now = new Date().toISOString()
const paidAt = invoice && isFullyPaid ? paidAtFromDate(transaction.date) : null
if (invoice && invoiceId) {
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
@@ -428,8 +429,21 @@ export async function linkTransactionToJournalEntry(
eventBus.emit({
type: 'invoice.match_confirmed',
payload: {
invoice: invoice as Invoice,
transaction: transaction as Transaction,
invoice: {
...invoice,
status: newStatus,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
} as Invoice,
transaction: {
...transaction,
journal_entry_id: journalEntryId,
invoice_id: invoiceId,
potential_invoice_id: null,
potential_supplier_invoice_id: null,
is_business: true,
} as Transaction,
userId,
companyId,
},