fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+19
-1
@@ -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.
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user