fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision.
|
||||
|
||||
[2026-08-01] Invoice payment dates stored in timestamptz use a shared UTC-noon representation: `paid_at` has date-only business semantics, and noon preserves the selected or bank transaction date when formatted in UTC, Europe/Stockholm, and all negative UTC offsets through UTC-12; UTC midnight displays as the prior day in American time zones.
|
||||
[2026-08-01] Privately paid supplier-invoice creation stays outside the payment-date correction: that flow deliberately journals on the invoice date while its optional out-of-pocket payment date can differ, so changing only `paid_at` would require a separate Swedish accounting semantics decision.
|
||||
|
||||
[2026-07-02] Adopted this decision log: CLAUDE.md rewritten per config-over-prompt principles; decisions persist here instead of being re-derived each session.
|
||||
[2026-07-03] Prod constraint clobber (self-inflicted, repaired in ~10 min): applied pending_operations link_document_to_voucher migration from a checkout predating 20260702171000 (retag_line_dimensions): hand-copied CHECK lists clobber concurrent adds. Zero impact (no retag ops in window). Rule: before applying any expand-types migration to prod, diff the list against the LIVE prod constraint, not the local file history. Long-term fix queued in mcp_optimization_plan P0-1 follow-up (audit test now guards CI).
|
||||
[2026-07-03] Archived 4 completed/superseded plans to dev_docs/archive/ (dimensions_implementation_plan, specialized-agent-plan, api_ai_architecture/PLAN, mcp-apps-architecture-reference): moved, not deleted, because dev_docs is gitignored (no git history to recover from). Live remnants relocated first: PR10 backlog → dimensions_architecture.md; eval-harness spec → claude_surface_plan.md §2.1. agent_first_vision.md §8 marked superseded by claude_surface_plan.md (Skatteverket filing is BUILT, contra its P0 item 6).
|
||||
|
||||
@@ -175,7 +175,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
const paidHandler = vi.fn()
|
||||
eventBus.on('invoice.paid', paidHandler)
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-05-12' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
@@ -183,6 +186,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string | null
|
||||
paid_at: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
@@ -191,6 +195,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(body.paid_at).toBe('2026-05-12T12:00:00Z')
|
||||
// invoice.paid must fire so registered webhooks fan out (issue #825).
|
||||
expect(paidHandler).toHaveBeenCalledTimes(1)
|
||||
expect(paidHandler).toHaveBeenCalledWith(
|
||||
@@ -198,7 +203,13 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
paymentAmount: 12500,
|
||||
invoice: expect.objectContaining({ id: 'inv-1', status: 'paid', paid_amount: 12500, remaining_amount: 0 }),
|
||||
invoice: expect.objectContaining({
|
||||
id: 'inv-1',
|
||||
status: 'paid',
|
||||
paid_amount: 12500,
|
||||
remaining_amount: 0,
|
||||
paid_at: '2026-05-12T12:00:00Z',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
makeSupplier,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -135,9 +135,12 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
// Record payment
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const paidHandler = vi.fn()
|
||||
eventBus.on('supplier_invoice.paid', paidHandler)
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
body: { payment_date: '2026-05-12' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
@@ -155,6 +158,13 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled()
|
||||
const invoiceUpdate = findCalls('supplier_invoices', 'update').at(-1)?.[0]
|
||||
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
expect(paidHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
supplierInvoice: expect.objectContaining({ paid_at: '2026-05-12T12:00:00Z' }),
|
||||
}),
|
||||
)
|
||||
// Issue #1259: full settlement retires every transaction's suggestion
|
||||
// pointer at this invoice. No exceptTransactionId: mark-paid is not driven
|
||||
// by a bank transaction.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-en
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -66,7 +67,6 @@ export const POST = withRouteContext(
|
||||
|
||||
const paymentDate = body.payment_date || new Date().toISOString().split('T')[0]
|
||||
const paymentAmount = body.amount || invoice.remaining_amount
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (body.force) {
|
||||
opLog.warn('duplicate-payment guard bypassed', {
|
||||
@@ -310,6 +310,7 @@ export const POST = withRouteContext(
|
||||
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
const paidAt = isFullyPaid ? paidAtFromDate(paymentDate) : null
|
||||
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
@@ -317,7 +318,7 @@ export const POST = withRouteContext(
|
||||
status: newStatus,
|
||||
remaining_amount: Math.max(0, newRemaining),
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', id)
|
||||
@@ -419,7 +420,12 @@ export const POST = withRouteContext(
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.paid',
|
||||
payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, companyId: companyId!, userId: user.id },
|
||||
payload: {
|
||||
supplierInvoice: { ...invoice, paid_at: paidAt ?? invoice.paid_at } as SupplierInvoice,
|
||||
paymentAmount,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
opLog.warn('supplier_invoice.paid event emission failed', err as Error)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
makeInvoice,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -43,6 +43,7 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const TX_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const JE_UUID = '550e8400-e29b-41d4-a716-446655440001'
|
||||
@@ -258,6 +259,25 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
|
||||
expect(body.invoice_status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(1000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
|
||||
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-15T12:00:00Z' })
|
||||
expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'invoice.match_confirmed',
|
||||
payload: expect.objectContaining({
|
||||
invoice: expect.objectContaining({
|
||||
status: 'paid',
|
||||
paid_at: '2026-05-15T12:00:00Z',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
}),
|
||||
transaction: expect.objectContaining({
|
||||
invoice_id: INV_UUID,
|
||||
journal_entry_id: JE_UUID,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// Issue #1259: the invoice is settled, so no other transaction may keep
|
||||
// pointing at it as a match suggestion.
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -69,6 +69,7 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
// Mocked above: imported here as a spy handle to assert FX rate provenance
|
||||
// lands in the audit trail (PR #615 review).
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
@@ -478,6 +479,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string
|
||||
paid_at: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
@@ -486,6 +488,26 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(body.paid_at).toBe('2024-06-15T12:00:00Z')
|
||||
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
|
||||
expect(invoiceUpdate).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' })
|
||||
expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'invoice.match_confirmed',
|
||||
payload: expect.objectContaining({
|
||||
invoice: expect.objectContaining({
|
||||
status: 'paid',
|
||||
paid_at: '2024-06-15T12:00:00Z',
|
||||
paid_amount: 12500,
|
||||
remaining_amount: 0,
|
||||
}),
|
||||
transaction: expect.objectContaining({
|
||||
invoice_id: VALID_UUID,
|
||||
journal_entry_id: 'je-1',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// Clearing path now builds lines via buildInvoicePaymentClearingLines and
|
||||
// posts via createJournalEntry directly (FX fix PR #614 round 6). For a
|
||||
|
||||
@@ -16,6 +16,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Currency, EntityType, Invoice, Transaction } from '@/types'
|
||||
@@ -357,7 +358,6 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
// paidAmountInInvoiceCurrency is what gets accumulated into
|
||||
// invoice.paid_amount / remaining_amount and stored on the
|
||||
// invoice_payments row. For same-currency it's just tx.amount; for
|
||||
@@ -384,6 +384,7 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
|
||||
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -622,7 +623,7 @@ export const POST = withRouteContext(
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
})
|
||||
@@ -736,8 +737,21 @@ export const POST = withRouteContext(
|
||||
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,
|
||||
invoice_id,
|
||||
potential_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
category: 'income_services',
|
||||
} as Transaction,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
@@ -749,7 +763,7 @@ export const POST = withRouteContext(
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
|
||||
@@ -67,6 +67,7 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -459,6 +460,25 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', ()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.paid_amount).toBe(1000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
const invoiceUpdate = findCalls('supplier_invoices', 'update').at(-1)?.[0]
|
||||
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'supplier_invoice.match_confirmed',
|
||||
payload: expect.objectContaining({
|
||||
supplierInvoice: expect.objectContaining({
|
||||
status: 'paid',
|
||||
paid_at: '2026-05-12T12:00:00Z',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
}),
|
||||
transaction: expect.objectContaining({
|
||||
supplier_invoice_id: SI_UUID,
|
||||
journal_entry_id: 'je-1',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// The suggestion pointer must not survive the match that consumes it: this
|
||||
|
||||
@@ -16,6 +16,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
|
||||
@@ -214,8 +215,6 @@ export const POST = withRouteContext(
|
||||
// as paymentAmountSek - exchangeRateDifference internally.
|
||||
const paymentAmountSek = exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// A full settlement pays off the whole remaining balance. Cross-currency
|
||||
// matches always do (paymentAmountInvoiceCurrency is clamped to
|
||||
// invoice.remaining_amount above); same-currency does when the bank amount
|
||||
@@ -361,6 +360,7 @@ export const POST = withRouteContext(
|
||||
// reports remaining 0 / status paid even though the bank paid a sub-krona
|
||||
// less (or more): the residual lives on 3740, not the supplier ledger.
|
||||
const { newRemaining, newPaidAmount, isFullyPaid, newStatus } = paymentPlan.plan
|
||||
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
|
||||
|
||||
const { data: updatedRows, error: updateInvError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
@@ -368,7 +368,7 @@ export const POST = withRouteContext(
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
})
|
||||
@@ -489,8 +489,22 @@ export const POST = withRouteContext(
|
||||
eventBus.emit({
|
||||
type: 'supplier_invoice.match_confirmed',
|
||||
payload: {
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
transaction: transaction as Transaction,
|
||||
supplierInvoice: {
|
||||
...invoice,
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: paidAt,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
} as SupplierInvoice,
|
||||
transaction: {
|
||||
...transaction,
|
||||
supplier_invoice_id,
|
||||
potential_supplier_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
} as Transaction,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@ const PAID_INVOICE = {
|
||||
status: 'paid',
|
||||
remaining_amount: 0,
|
||||
paid_amount: 12500,
|
||||
paid_at: '2026-05-12',
|
||||
paid_at: '2026-05-12T12:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -159,6 +159,7 @@ beforeEach(() => {
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
it('books a full payment under faktureringsmetoden (accrual default)', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
@@ -167,7 +168,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
}),
|
||||
}, calls),
|
||||
)
|
||||
|
||||
const paidHandler = vi.fn()
|
||||
@@ -185,14 +186,22 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
expect(body.data.remaining_amount).toBe(0)
|
||||
expect(body.data.paid_at).toBe('2026-05-12T12:00:00Z')
|
||||
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
|
||||
expect(mockPayment).toHaveBeenCalled()
|
||||
expect(mockCash).not.toHaveBeenCalled()
|
||||
// invoice.paid must fire so registered webhooks fan out (issue #825).
|
||||
expect(paidHandler).toHaveBeenCalledTimes(1)
|
||||
expect(paidHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ companyId: COMPANY_ID, userId: USER_ID, paymentAmount: 12500 }),
|
||||
expect.objectContaining({
|
||||
companyId: COMPANY_ID,
|
||||
userId: USER_ID,
|
||||
paymentAmount: 12500,
|
||||
invoice: expect.objectContaining({ paid_at: '2026-05-12T12:00:00Z' }),
|
||||
}),
|
||||
)
|
||||
const invoiceUpdate = calls.find((call) => call.table === 'invoices' && call.method === 'update')
|
||||
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
// Issue #1259: the invoice is settled, so no transaction may keep pointing
|
||||
// at it as a match suggestion.
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -44,6 +44,7 @@ import { eventBus } from '@/lib/events'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
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 { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
|
||||
@@ -240,6 +241,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const paymentDate = bodyPaymentDate || today
|
||||
const paidAt = paidAtFromDate(paymentDate)
|
||||
|
||||
// Fetch settings for accounting method + entity type.
|
||||
const { data: settings } = await ctx.supabase
|
||||
@@ -388,7 +390,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
paid_at: paymentDate,
|
||||
paid_at: paidAt,
|
||||
would_create_journal_entry: !typed.document_type || typed.document_type === 'invoice',
|
||||
accounting_method: accountingMethod,
|
||||
would_use_custom_lines: customLines !== undefined,
|
||||
@@ -503,7 +505,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
if (newStatus === 'paid') {
|
||||
updatePayload.paid_at = paymentDate
|
||||
updatePayload.paid_at = paidAt
|
||||
}
|
||||
// Deliberately NOT writing journal_entry_id here: that column means "the
|
||||
// registration entry that booked this invoice at issuance" and drives the
|
||||
|
||||
+31
-4
@@ -64,7 +64,11 @@ const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
type RecordedCall = { table: string; method: string; args: unknown[] }
|
||||
function makeFlexibleSupabase(
|
||||
byTable: Record<string, MockResult | MockResult[]>,
|
||||
calls?: RecordedCall[],
|
||||
) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
@@ -79,7 +83,10 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return () => buildChain(table)
|
||||
return (...args: unknown[]) => {
|
||||
calls?.push({ table, method: String(prop), args })
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
@@ -151,26 +158,46 @@ beforeEach(() => {
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', () => {
|
||||
it('retires the settled invoice suggestions on a full payment (issue #1259)', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: [
|
||||
{ data: APPROVED_SI, error: null },
|
||||
{
|
||||
data: { ...APPROVED_SI, status: 'paid', paid_amount: 1000, remaining_amount: 0 },
|
||||
data: {
|
||||
...APPROVED_SI,
|
||||
status: 'paid',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
paid_at: '2026-05-12T12:00:00Z',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
}),
|
||||
}, calls),
|
||||
)
|
||||
|
||||
const paidHandler = vi.fn()
|
||||
eventBus.on('supplier_invoice.paid', paidHandler)
|
||||
|
||||
const res = await markPaid(makeRequest({ payment_date: '2026-05-12' }), detailParams())
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
expect(body.data.paid_at).toBe('2026-05-12T12:00:00Z')
|
||||
const invoiceUpdate = calls.find(
|
||||
(call) => call.table === 'supplier_invoices' && call.method === 'update',
|
||||
)
|
||||
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
expect(paidHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
supplierInvoice: expect.objectContaining({ paid_at: '2026-05-12T12:00:00Z' }),
|
||||
}),
|
||||
)
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearSuggestions).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookke
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -145,6 +146,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const paymentDate = bodyPaymentDate || today
|
||||
const paidAt = paidAtFromDate(paymentDate)
|
||||
|
||||
// Reject future payment_date at the schema layer. BFL 5 kap 2 §
|
||||
// requires bokföring to follow real cash movement; a payment booked
|
||||
@@ -313,17 +315,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// paid_at: the live UPDATE writes `new Date().toISOString()` (a full UTC
|
||||
// timestamp). Mirror that shape here so callers validating dry-run vs
|
||||
// live against the same regex don't see surprises. payment_date stays
|
||||
// ISO date because it represents the user-supplied calendar date.
|
||||
// Keep the preview aligned with the live date-only payment timestamp.
|
||||
return dryRunPreview(
|
||||
{
|
||||
...typed,
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
paid_at: newStatus === 'paid' ? new Date().toISOString() : null,
|
||||
paid_at: newStatus === 'paid' ? paidAt : null,
|
||||
payment_date: paymentDate,
|
||||
payment_amount: paymentAmount,
|
||||
would_create_payment_journal_entry: true,
|
||||
@@ -425,7 +424,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: newStatus === 'paid' ? new Date().toISOString() : null,
|
||||
paid_at: newStatus === 'paid' ? paidAt : null,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('company_id', ctx.companyId!)
|
||||
@@ -532,7 +531,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.paid',
|
||||
payload: {
|
||||
supplierInvoice: typed as unknown as SupplierInvoice,
|
||||
supplierInvoice: {
|
||||
...typed,
|
||||
paid_at: newStatus === 'paid' ? paidAt : (typed.paid_at ?? null),
|
||||
} as unknown as SupplierInvoice,
|
||||
paymentAmount,
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Regression coverage for the public customer-invoice bank-match route.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`match-invoice route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
|
||||
findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'),
|
||||
reverseEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { createJournalEntry as mockedCreateJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { POST as matchInvoice } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockCreateJournalEntry = mockedCreateJournalEntry as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
type RecordedCall = { table: string; method: string; args: unknown[] }
|
||||
|
||||
function makeFlexibleSupabase(
|
||||
byTable: Record<string, MockResult | MockResult[]>,
|
||||
calls?: RecordedCall[],
|
||||
) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [table, value] of Object.entries(byTable)) {
|
||||
queues.set(table, Array.isArray(value) ? [...value] : [value])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (value: unknown) => void) => {
|
||||
const queue = queues.get(table)
|
||||
const next = queue && queue.length > 1
|
||||
? queue.shift()!
|
||||
: (queue?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
calls?.push({ table, method: String(prop), args })
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const INVOICE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string, body?: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
const TRANSACTION = {
|
||||
id: TX_ID,
|
||||
company_id: COMPANY_ID,
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
date: '2024-06-15',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
category: null,
|
||||
}
|
||||
const SENT_INVOICE = {
|
||||
id: INVOICE_ID,
|
||||
invoice_number: '2024-0001',
|
||||
status: 'sent',
|
||||
document_type: 'invoice',
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
credited_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'Acme AB' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['transactions:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', () => {
|
||||
it('persists and returns the bank transaction date as paid_at', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const matchedHandler = vi.fn()
|
||||
eventBus.on('invoice.match_confirmed', matchedHandler)
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase(
|
||||
{
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: TRANSACTION, error: null },
|
||||
invoices: [
|
||||
{ data: SENT_INVOICE, error: null },
|
||||
{ data: [{ id: INVOICE_ID }], error: null },
|
||||
],
|
||||
company_settings: {
|
||||
data: { accounting_method: 'accrual', entity_type: 'enskild_firma' },
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
calls,
|
||||
),
|
||||
)
|
||||
|
||||
const response = await matchInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INVOICE_ID },
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.invoice_status).toBe('paid')
|
||||
expect(body.data.paid_at).toBe('2024-06-15T12:00:00Z')
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalled()
|
||||
const invoiceUpdate = calls.find(
|
||||
(call) => call.table === 'invoices' && call.method === 'update',
|
||||
)
|
||||
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' })
|
||||
expect(matchedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
invoice: expect.objectContaining({
|
||||
status: 'paid',
|
||||
paid_at: '2024-06-15T12:00:00Z',
|
||||
paid_amount: 12500,
|
||||
remaining_amount: 0,
|
||||
}),
|
||||
transaction: expect.objectContaining({
|
||||
invoice_id: INVOICE_ID,
|
||||
journal_entry_id: 'je-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 401 when no bearer token is supplied', async () => {
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const response = await matchInvoice(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem4041-4041-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify({ invoice_id: INVOICE_ID }),
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when invoice_id is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const response = await matchInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{},
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 404 when the invoice does not belong to the company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: TRANSACTION, error: null },
|
||||
invoices: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const response = await matchInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INVOICE_ID },
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect((await response.json()).error.code).toBe('MATCH_INVOICE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { Currency, EntityType, Invoice, Transaction } from '@/types'
|
||||
|
||||
@@ -409,6 +410,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
|
||||
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
try {
|
||||
@@ -432,8 +434,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
@@ -675,7 +675,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
})
|
||||
@@ -798,8 +798,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
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,
|
||||
invoice_id,
|
||||
potential_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
...(existingTxCategory ? { category: existingTxCategory } : {}),
|
||||
} as Transaction,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
@@ -812,7 +825,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
{
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Regression coverage for the public supplier-invoice bank-match route.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`match-supplier-invoice route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoicePaymentEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
|
||||
createSupplierInvoiceCashEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
|
||||
findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'),
|
||||
reverseEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { createSupplierInvoicePaymentEntry as mockedCreatePaymentEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { POST as matchSupplierInvoice } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockCreatePaymentEntry = mockedCreatePaymentEntry as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
type RecordedCall = { table: string; method: string; args: unknown[] }
|
||||
|
||||
function makeFlexibleSupabase(
|
||||
byTable: Record<string, MockResult | MockResult[]>,
|
||||
calls?: RecordedCall[],
|
||||
) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [table, value] of Object.entries(byTable)) {
|
||||
queues.set(table, Array.isArray(value) ? [...value] : [value])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (value: unknown) => void) => {
|
||||
const queue = queues.get(table)
|
||||
const next = queue && queue.length > 1
|
||||
? queue.shift()!
|
||||
: (queue?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
calls?.push({ table, method: String(prop), args })
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const SI_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string, body?: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
const TRANSACTION = {
|
||||
id: TX_ID,
|
||||
company_id: COMPANY_ID,
|
||||
amount: -1000,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
date: '2026-05-12',
|
||||
supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
document_id: null,
|
||||
}
|
||||
const REGISTERED_INVOICE = {
|
||||
id: SI_ID,
|
||||
supplier_invoice_number: 'F-2026001',
|
||||
status: 'registered',
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
total: 1000,
|
||||
total_sek: 1000,
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
registration_journal_entry_id: null,
|
||||
supplier: { name: 'Leverantoren AB', supplier_type: 'swedish_business' },
|
||||
items: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['transactions:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice', () => {
|
||||
it('persists the bank transaction date as paid_at', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const matchedHandler = vi.fn()
|
||||
eventBus.on('supplier_invoice.match_confirmed', matchedHandler)
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase(
|
||||
{
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: TRANSACTION, error: null },
|
||||
supplier_invoices: [
|
||||
{ data: REGISTERED_INVOICE, error: null },
|
||||
{ data: [{ id: SI_ID }], error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
)
|
||||
|
||||
const response = await matchSupplierInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.invoice_status).toBe('paid')
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalled()
|
||||
const invoiceUpdate = calls.find(
|
||||
(call) => call.table === 'supplier_invoices' && call.method === 'update',
|
||||
)
|
||||
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
expect(matchedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
supplierInvoice: expect.objectContaining({
|
||||
status: 'paid',
|
||||
paid_at: '2026-05-12T12:00:00Z',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
}),
|
||||
transaction: expect.objectContaining({
|
||||
supplier_invoice_id: SI_ID,
|
||||
journal_entry_id: 'je-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 401 when no bearer token is supplied', async () => {
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const response = await matchSupplierInvoice(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem4041-4041-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify({ supplier_invoice_id: SI_ID }),
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when supplier_invoice_id is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const response = await matchSupplierInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{},
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 404 when the supplier invoice does not belong to the company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: TRANSACTION, error: null },
|
||||
supplier_invoices: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const response = await matchSupplierInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
detailParams(COMPANY_ID, TX_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect((await response.json()).error.code).toBe('MATCH_SI_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-inv
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
|
||||
|
||||
@@ -266,8 +267,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
@@ -407,6 +406,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
|
||||
|
||||
const { data: updatedRows, error: updateInvErr } = await ctx.supabase
|
||||
.from('supplier_invoices')
|
||||
@@ -414,7 +414,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_at: paidAt,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
transaction_id: txId,
|
||||
})
|
||||
@@ -538,8 +538,22 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
eventBus.emit({
|
||||
type: 'supplier_invoice.match_confirmed',
|
||||
payload: {
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
transaction: transaction as Transaction,
|
||||
supplierInvoice: {
|
||||
...invoice,
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: paidAt,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
transaction_id: txId,
|
||||
} as SupplierInvoice,
|
||||
transaction: {
|
||||
...transaction,
|
||||
supplier_invoice_id,
|
||||
potential_supplier_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
} as Transaction,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
-- Preserve the business payment date in paid_at without UTC-midnight
|
||||
-- rendering as the previous day in negative-offset time zones. Journal and
|
||||
-- payment dates remain unchanged; only the date-only timestamptz projection is
|
||||
-- anchored at UTC noon.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
|
||||
p_invoice_id uuid,
|
||||
p_journal_entry_id uuid,
|
||||
p_user_id uuid,
|
||||
p_company_id uuid,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_invoice RECORD;
|
||||
v_voucher RECORD;
|
||||
v_ar_credit_total numeric := 0;
|
||||
v_line_currency text;
|
||||
v_remaining numeric;
|
||||
v_payment_amount numeric;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_is_fully_paid boolean;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
v_acting_user uuid := p_user_id;
|
||||
v_accounting_method text;
|
||||
-- Unit resolution (new): the currency the invoice's amounts are quoted in,
|
||||
-- plus the matched-side lines that cannot be expressed in it.
|
||||
v_invoice_currency text;
|
||||
v_account_prefix text;
|
||||
v_unreadable_count integer := 0;
|
||||
v_unreadable_currency text;
|
||||
BEGIN
|
||||
-- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act
|
||||
-- on their own companies; service_role / direct access bypasses. The
|
||||
-- NULL-safe caller_is_company_member() form (20260703180000), not the
|
||||
-- raw NOT-IN-over-user_company_ids() shape: that one skips the deny
|
||||
-- branch on UNKNOWN and is banned by the pg-real ratchet
|
||||
-- (tests/pg/null-safe-tenant-guards.pg.test.ts, which scans prosrc:
|
||||
-- comments included, so the banned shape must not even be spelled out
|
||||
-- here).
|
||||
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
||||
IF NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
|
||||
END IF;
|
||||
-- Attribution: the JWT sub is authoritative for user-session callers:
|
||||
-- p_user_id cannot point the payment row at someone else.
|
||||
v_acting_user := coalesce(
|
||||
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
|
||||
p_user_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_NOTES_TOO_LONG',
|
||||
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 1. Lock the invoice for the duration of this transaction. FOR UPDATE so a
|
||||
-- concurrent linker has to wait until we commit (or roll back).
|
||||
SELECT * INTO v_invoice
|
||||
FROM public.invoices
|
||||
WHERE id = p_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID',
|
||||
'details', jsonb_build_object('status', v_invoice.status)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_remaining := COALESCE(v_invoice.remaining_amount,
|
||||
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
|
||||
IF v_remaining <= 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID');
|
||||
END IF;
|
||||
|
||||
-- 2. Resolve the voucher.
|
||||
SELECT * INTO v_voucher
|
||||
FROM public.journal_entries
|
||||
WHERE id = p_journal_entry_id AND company_id = p_company_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_VOUCHER_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_voucher.status <> 'posted' THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_NOT_POSTED',
|
||||
'details', jsonb_build_object('status', v_voucher.status)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_NO_AR_CREDIT',
|
||||
'details', jsonb_build_object('source_type', v_voucher.source_type)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 3. Sum the matched amount across the voucher's lines, EXPRESSED IN THE
|
||||
-- INVOICE'S CURRENCY. Branch on the company's accounting method (defaults
|
||||
-- to accrual when no settings row).
|
||||
SELECT cs.accounting_method INTO v_accounting_method
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.company_id = p_company_id;
|
||||
v_accounting_method := COALESCE(v_accounting_method, 'accrual');
|
||||
|
||||
-- `invoices.currency` is `text default 'SEK'` and therefore NULLABLE; a
|
||||
-- missing code has always meant kronor, and must not be read as "not SEK".
|
||||
v_invoice_currency := COALESCE(v_invoice.currency, 'SEK');
|
||||
v_account_prefix := CASE WHEN v_accounting_method = 'cash' THEN '19' ELSE '151' END;
|
||||
|
||||
IF v_invoice_currency = 'SEK' THEN
|
||||
-- VERBATIM from 20260620130000. The ledger columns are kronor already, so
|
||||
-- the document label on the line is irrelevant here.
|
||||
IF v_accounting_method = 'cash' THEN
|
||||
-- Kontantmetoden: the payment verifikat debits a liquid-funds account (19xx).
|
||||
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
|
||||
INTO v_ar_credit_total, v_line_currency
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = p_journal_entry_id
|
||||
AND account_number LIKE '19%'
|
||||
AND debit_amount > 0;
|
||||
ELSE
|
||||
-- Faktureringsmetoden: the payment verifikat credits the AR account (151x).
|
||||
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
|
||||
INTO v_ar_credit_total, v_line_currency
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = p_journal_entry_id
|
||||
AND account_number LIKE '151%'
|
||||
AND credit_amount > 0;
|
||||
END IF;
|
||||
ELSE
|
||||
-- Foreign invoice: `amount_in_currency` is the only column quoted in the
|
||||
-- invoice's currency. Magnitude from ABS() because a handful of production
|
||||
-- rows store the foreign figure negatively while the debit/credit side is
|
||||
-- authoritative, and that side is already pinned by the `> 0` predicate.
|
||||
SELECT
|
||||
COALESCE(SUM(ABS(l.amount_in_currency)) FILTER (
|
||||
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
|
||||
), 0),
|
||||
MAX(l.currency) FILTER (
|
||||
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
|
||||
),
|
||||
COUNT(*) FILTER (
|
||||
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
|
||||
),
|
||||
MIN(l.currency) FILTER (
|
||||
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
|
||||
)
|
||||
INTO v_ar_credit_total, v_line_currency, v_unreadable_count, v_unreadable_currency
|
||||
FROM public.journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p_journal_entry_id
|
||||
AND l.account_number LIKE v_account_prefix || '%'
|
||||
AND (CASE WHEN v_accounting_method = 'cash' THEN l.debit_amount ELSE l.credit_amount END) > 0;
|
||||
|
||||
-- Fail CLOSED on a matched-side line we cannot read in the invoice's
|
||||
-- currency: summing only the readable ones would understate the voucher.
|
||||
IF COALESCE(v_unreadable_count, 0) > 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'invoice_currency', v_invoice.currency,
|
||||
'line_currency', v_unreadable_currency
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
|
||||
|
||||
IF v_ar_credit_total <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
|
||||
END IF;
|
||||
|
||||
-- Label guard, still load-bearing, but no longer as a unit check:
|
||||
-- v_ar_credit_total is already in the invoice's currency. What it catches
|
||||
-- now is a counterparty discriminator, a matched line stamped with some
|
||||
-- other document's currency. Always passes on a foreign invoice, because
|
||||
-- only same-labelled lines could be read at all. Both sides compare the
|
||||
-- RESOLVED v_invoice_currency, never the raw nullable column: with the raw
|
||||
-- column, a legacy NULL-currency invoice (which has always meant SEK) hit
|
||||
-- 'SEK' IS DISTINCT FROM NULL = true and an ordinary domestic payment
|
||||
-- raised LINK_VOUCHER_CURRENCY_MISMATCH forever.
|
||||
IF COALESCE(v_line_currency, v_invoice_currency) IS DISTINCT FROM v_invoice_currency THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'invoice_currency', v_invoice.currency,
|
||||
'line_currency', v_line_currency
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Both sides are now in the invoice's currency.
|
||||
IF v_ar_credit_total > v_remaining + 0.005 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
|
||||
'details', jsonb_build_object(
|
||||
'ar_credit', v_ar_credit_total,
|
||||
'remaining', ROUND(v_remaining * 100) / 100
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
|
||||
-- under the FOR UPDATE lock; the partial unique index
|
||||
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
|
||||
-- for non-RPC writers.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.invoice_payments
|
||||
WHERE company_id = p_company_id
|
||||
AND invoice_id = p_invoice_id
|
||||
AND journal_entry_id = p_journal_entry_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
|
||||
END IF;
|
||||
|
||||
-- 5. Compute the advance.
|
||||
v_payment_amount := LEAST(v_ar_credit_total, ROUND(v_remaining * 100) / 100);
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((v_remaining - v_payment_amount) * 100) / 100
|
||||
);
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
|
||||
v_is_fully_paid := v_new_remaining <= 0.005;
|
||||
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
|
||||
-- INSERT triggers PG's own rollback of the UPDATE: no manual rollback
|
||||
-- path needed.
|
||||
UPDATE public.invoices
|
||||
SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_is_fully_paid THEN
|
||||
((v_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
|
||||
ELSE paid_at END,
|
||||
paid_amount = v_new_paid,
|
||||
remaining_amount = v_new_remaining,
|
||||
updated_at = v_now
|
||||
WHERE id = p_invoice_id;
|
||||
|
||||
-- The payment row persists the RESOLVED currency: writing the raw column
|
||||
-- would store NULL for a legacy NULL-currency invoice, and the payment's
|
||||
-- unit is a fact this row must state, not inherit as "unknown".
|
||||
INSERT INTO public.invoice_payments (
|
||||
user_id, company_id, invoice_id, payment_date, amount, currency,
|
||||
exchange_rate, journal_entry_id, transaction_id, notes
|
||||
) VALUES (
|
||||
v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date,
|
||||
v_payment_amount, v_invoice_currency, v_invoice.exchange_rate,
|
||||
p_journal_entry_id, NULL, p_notes
|
||||
)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'payment_id', v_payment_id,
|
||||
'invoice_status', v_new_status,
|
||||
'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining,
|
||||
'payment_amount', v_payment_amount,
|
||||
'journal_entry_id', p_journal_entry_id,
|
||||
'currency', v_invoice_currency,
|
||||
'payment_date', v_voucher.entry_date
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
|
||||
p_supplier_invoice_id uuid,
|
||||
p_journal_entry_id uuid,
|
||||
p_user_id uuid,
|
||||
p_company_id uuid,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_invoice RECORD;
|
||||
v_voucher RECORD;
|
||||
v_ap_debit_total numeric := 0;
|
||||
v_line_currency text;
|
||||
v_remaining numeric;
|
||||
v_payment_amount numeric;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_is_fully_paid boolean;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
v_acting_user uuid := p_user_id;
|
||||
-- Unit resolution (new), as in link_invoice_to_voucher above.
|
||||
v_invoice_currency text;
|
||||
v_unreadable_count integer := 0;
|
||||
v_unreadable_currency text;
|
||||
BEGIN
|
||||
-- Tenant guard (mirrors 20260611140000): anon/authenticated may only act on
|
||||
-- their own companies; service_role / direct access bypasses. NULL-safe
|
||||
-- caller_is_company_member() form, as in link_invoice_to_voucher above.
|
||||
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
||||
IF NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
|
||||
END IF;
|
||||
-- Attribution: the JWT sub is authoritative for user-session callers:
|
||||
-- p_user_id cannot point the payment row at someone else.
|
||||
v_acting_user := coalesce(
|
||||
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
|
||||
p_user_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_SI_VOUCHER_NOTES_TOO_LONG',
|
||||
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_invoice
|
||||
FROM public.supplier_invoices
|
||||
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
|
||||
'details', jsonb_build_object('status', v_invoice.status));
|
||||
END IF;
|
||||
|
||||
v_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
|
||||
IF v_remaining <= 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_voucher
|
||||
FROM public.journal_entries
|
||||
WHERE id = p_journal_entry_id AND company_id = p_company_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_voucher.status <> 'posted' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NOT_POSTED',
|
||||
'details', jsonb_build_object('status', v_voucher.status));
|
||||
END IF;
|
||||
|
||||
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
|
||||
'details', jsonb_build_object('source_type', v_voucher.source_type));
|
||||
END IF;
|
||||
|
||||
-- Sum the AP debit across the full 244x range, EXPRESSED IN THE INVOICE'S
|
||||
-- CURRENCY. `supplier_invoices.currency` is NOT NULL DEFAULT 'SEK', but the
|
||||
-- COALESCE keeps this symmetric with the customer side.
|
||||
v_invoice_currency := COALESCE(v_invoice.currency, 'SEK');
|
||||
|
||||
IF v_invoice_currency = 'SEK' THEN
|
||||
-- VERBATIM from 20260615120000: the ledger column is kronor already.
|
||||
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
|
||||
INTO v_ap_debit_total, v_line_currency
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = p_journal_entry_id
|
||||
AND account_number LIKE '244%'
|
||||
AND debit_amount > 0;
|
||||
ELSE
|
||||
SELECT
|
||||
COALESCE(SUM(ABS(l.amount_in_currency)) FILTER (
|
||||
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
|
||||
), 0),
|
||||
MAX(l.currency) FILTER (
|
||||
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
|
||||
),
|
||||
COUNT(*) FILTER (
|
||||
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
|
||||
),
|
||||
MIN(l.currency) FILTER (
|
||||
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
|
||||
)
|
||||
INTO v_ap_debit_total, v_line_currency, v_unreadable_count, v_unreadable_currency
|
||||
FROM public.journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p_journal_entry_id
|
||||
AND l.account_number LIKE '244%'
|
||||
AND l.debit_amount > 0;
|
||||
|
||||
IF COALESCE(v_unreadable_count, 0) > 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'invoice_currency', v_invoice.currency,
|
||||
'line_currency', v_unreadable_currency
|
||||
));
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
|
||||
|
||||
IF v_ap_debit_total <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
|
||||
END IF;
|
||||
|
||||
-- Label guard: a counterparty discriminator, not a unit check. Compares the
|
||||
-- RESOLVED currency on both sides, as in link_invoice_to_voucher above.
|
||||
IF COALESCE(v_line_currency, v_invoice_currency) IS DISTINCT FROM v_invoice_currency THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object('invoice_currency', v_invoice.currency, 'line_currency', v_line_currency));
|
||||
END IF;
|
||||
|
||||
-- Both sides are now in the invoice's currency.
|
||||
IF v_ap_debit_total > v_remaining + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
|
||||
'details', jsonb_build_object('ap_debit', v_ap_debit_total, 'remaining', ROUND(v_remaining * 100) / 100));
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.supplier_invoice_payments
|
||||
WHERE company_id = p_company_id
|
||||
AND supplier_invoice_id = p_supplier_invoice_id
|
||||
AND journal_entry_id = p_journal_entry_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
|
||||
END IF;
|
||||
|
||||
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
|
||||
v_new_remaining := GREATEST(0, ROUND((v_remaining - v_payment_amount) * 100) / 100);
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
|
||||
v_is_fully_paid := v_new_remaining <= 0.005;
|
||||
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_is_fully_paid THEN
|
||||
((v_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
|
||||
ELSE paid_at END,
|
||||
paid_amount = v_new_paid,
|
||||
remaining_amount = v_new_remaining,
|
||||
updated_at = v_now
|
||||
WHERE id = p_supplier_invoice_id;
|
||||
|
||||
INSERT INTO public.supplier_invoice_payments (
|
||||
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id, notes
|
||||
) VALUES (
|
||||
v_acting_user, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
|
||||
v_payment_amount, v_invoice_currency, p_journal_entry_id, NULL, p_notes
|
||||
)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'payment_id', v_payment_id,
|
||||
'invoice_status', v_new_status,
|
||||
'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining,
|
||||
'payment_amount', v_payment_amount,
|
||||
'journal_entry_id', p_journal_entry_id,
|
||||
'currency', v_invoice_currency
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
|
||||
p_tx_id uuid,
|
||||
p_allocations jsonb,
|
||||
p_company_id uuid
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tx RECORD;
|
||||
v_tx_abs numeric;
|
||||
v_tx_date_short text;
|
||||
v_allocation jsonb;
|
||||
v_alloc_index int := 0;
|
||||
v_kind text;
|
||||
v_invoice_id uuid;
|
||||
v_supplier_invoice_id uuid;
|
||||
v_alloc_amount numeric;
|
||||
v_total_allocated numeric := 0;
|
||||
v_has_customer boolean := false;
|
||||
v_has_supplier boolean := false;
|
||||
v_seen_ids text[] := ARRAY[]::text[];
|
||||
v_target_id text;
|
||||
v_invoice RECORD;
|
||||
v_si_invoice RECORD;
|
||||
v_supplier_name text;
|
||||
v_supplier_invoice_number text;
|
||||
v_invoice_number text;
|
||||
v_fiscal_period_id uuid;
|
||||
v_period_is_closed boolean;
|
||||
v_period_locked_at timestamptz;
|
||||
v_journal_entry_id uuid := gen_random_uuid();
|
||||
v_voucher_series text := 'A';
|
||||
v_voucher_number int;
|
||||
v_entry_description text;
|
||||
v_source_type text;
|
||||
v_line_sort_order int := 0;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
v_results jsonb := '[]'::jsonb;
|
||||
v_inv_remaining numeric;
|
||||
v_inv_currency text;
|
||||
v_inv_fx_rate numeric;
|
||||
v_inv_total numeric;
|
||||
v_booked_sek numeric;
|
||||
v_fx_diff numeric;
|
||||
v_paid_in_inv_currency numeric;
|
||||
v_payment_rate numeric; -- round-3 (swedish-compliance traceability)
|
||||
v_inv_number_short text;
|
||||
v_caller uuid := auth.uid();
|
||||
BEGIN
|
||||
IF v_caller IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members
|
||||
WHERE user_id = v_caller AND company_id = p_company_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_tx FROM public.transactions
|
||||
WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF;
|
||||
IF v_tx.journal_entry_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED',
|
||||
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id));
|
||||
END IF;
|
||||
IF v_tx.amount = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT'); END IF;
|
||||
v_tx_abs := ABS(v_tx.amount);
|
||||
v_tx_date_short := LEFT(v_tx.date::text, 10);
|
||||
|
||||
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
|
||||
END IF;
|
||||
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_kind := v_allocation->>'kind';
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
|
||||
|
||||
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
|
||||
END IF;
|
||||
IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
|
||||
'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
|
||||
END IF;
|
||||
IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
|
||||
v_total_allocated := v_total_allocated + v_alloc_amount;
|
||||
|
||||
IF v_kind = 'customer_invoice' THEN
|
||||
v_has_customer := true;
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT * INTO v_invoice FROM public.invoices
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
|
||||
END IF;
|
||||
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
|
||||
END IF;
|
||||
|
||||
v_inv_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total);
|
||||
v_inv_currency := v_invoice.currency;
|
||||
v_inv_fx_rate := v_invoice.exchange_rate;
|
||||
|
||||
IF v_inv_currency = v_tx.currency THEN
|
||||
IF v_alloc_amount > v_inv_remaining + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'invoice_currency', v_inv_currency));
|
||||
END IF;
|
||||
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
|
||||
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
ELSIF v_kind = 'supplier_invoice' THEN
|
||||
v_has_supplier := true;
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices
|
||||
WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id));
|
||||
END IF;
|
||||
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status));
|
||||
END IF;
|
||||
|
||||
v_inv_remaining := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
|
||||
v_inv_currency := v_si_invoice.currency;
|
||||
v_inv_fx_rate := v_si_invoice.exchange_rate;
|
||||
|
||||
IF v_inv_currency = v_tx.currency THEN
|
||||
IF v_alloc_amount > v_inv_remaining + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'invoice_currency', v_inv_currency));
|
||||
END IF;
|
||||
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
|
||||
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
|
||||
END IF;
|
||||
END IF;
|
||||
ELSE
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind));
|
||||
END IF;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
IF v_has_customer AND v_has_supplier THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
|
||||
END IF;
|
||||
|
||||
IF v_total_allocated > v_tx_abs + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX',
|
||||
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
|
||||
END IF;
|
||||
IF v_total_allocated < v_tx_abs - 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_TX',
|
||||
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
|
||||
END IF;
|
||||
|
||||
IF v_has_customer AND v_tx.amount <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount));
|
||||
END IF;
|
||||
IF v_has_supplier AND v_tx.amount >= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount));
|
||||
END IF;
|
||||
|
||||
SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
|
||||
FROM public.fiscal_periods
|
||||
WHERE company_id = p_company_id AND v_tx.date BETWEEN period_start AND period_end
|
||||
ORDER BY period_start DESC LIMIT 1;
|
||||
IF v_fiscal_period_id IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_FISCAL_PERIOD',
|
||||
'details', jsonb_build_object('tx_date', v_tx.date));
|
||||
END IF;
|
||||
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_PERIOD_LOCKED',
|
||||
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id,
|
||||
'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at));
|
||||
END IF;
|
||||
|
||||
v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx_date_short ELSE 'Samlingsbetalning ' || v_tx_date_short END;
|
||||
v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END;
|
||||
|
||||
INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES
|
||||
(v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
|
||||
v_tx.date, v_entry_description, v_source_type, 'draft');
|
||||
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT invoice_number, currency, exchange_rate, remaining_amount, total
|
||||
INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_inv_total
|
||||
FROM public.invoices
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id;
|
||||
v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
|
||||
v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32);
|
||||
|
||||
IF v_inv_currency = v_tx.currency THEN
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
|
||||
'Faktura ' || v_inv_number_short);
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
ELSE
|
||||
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
|
||||
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
|
||||
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
|
||||
'Faktura ' || v_inv_number_short || ' (' || v_inv_currency || ')');
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
|
||||
IF ABS(v_fx_diff) > 0.005 THEN
|
||||
IF v_fx_diff > 0 THEN
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '7960', v_fx_diff, 0, v_tx.currency, v_line_sort_order,
|
||||
'Valutakursförlust ' || v_inv_number_short);
|
||||
ELSE
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
|
||||
'Valutakursvinst ' || v_inv_number_short);
|
||||
END IF;
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT si.supplier_invoice_number, s.name, si.currency, si.exchange_rate,
|
||||
si.remaining_amount, si.total
|
||||
INTO v_supplier_invoice_number, v_supplier_name, v_inv_currency, v_inv_fx_rate,
|
||||
v_inv_remaining, v_inv_total
|
||||
FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
|
||||
WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id;
|
||||
v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
|
||||
v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32);
|
||||
|
||||
IF v_inv_currency = v_tx.currency THEN
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order,
|
||||
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short));
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
ELSE
|
||||
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
|
||||
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
|
||||
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '2440', v_booked_sek, 0, v_tx.currency, v_line_sort_order,
|
||||
TRIM(BOTH ' - ' FROM
|
||||
COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short
|
||||
|| ' (' || v_inv_currency || ')'));
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
|
||||
IF ABS(v_fx_diff) > 0.005 THEN
|
||||
IF v_fx_diff > 0 THEN
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '3960', 0, v_fx_diff, v_tx.currency, v_line_sort_order,
|
||||
'Valutakursvinst ' || v_inv_number_short);
|
||||
ELSE
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
|
||||
'Valutakursförlust ' || v_inv_number_short);
|
||||
END IF;
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
IF v_has_customer THEN
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '1930', v_tx_abs, 0, v_tx.currency, v_line_sort_order,
|
||||
'Inbetalning ' || v_tx_date_short);
|
||||
ELSE
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
|
||||
sort_order, line_description)
|
||||
VALUES
|
||||
(v_journal_entry_id, '1930', 0, v_tx_abs, v_tx.currency, v_line_sort_order,
|
||||
'Utbetalning ' || v_tx_date_short);
|
||||
END IF;
|
||||
|
||||
SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
|
||||
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT * INTO v_invoice FROM public.invoices
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id;
|
||||
|
||||
IF v_invoice.currency = v_tx.currency THEN
|
||||
v_paid_in_inv_currency := v_alloc_amount;
|
||||
v_payment_rate := NULL; -- same-currency: no FX context
|
||||
ELSE
|
||||
v_paid_in_inv_currency := COALESCE(v_invoice.remaining_amount, v_invoice.total);
|
||||
-- Round-3: effective payment-day rate. SEK_paid / foreign_remaining.
|
||||
IF v_paid_in_inv_currency > 0 THEN
|
||||
v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
|
||||
ELSE
|
||||
v_payment_rate := NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
UPDATE public.invoices SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN
|
||||
((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
|
||||
ELSE paid_at END,
|
||||
paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id;
|
||||
|
||||
INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
|
||||
payment_exchange_rate, journal_entry_id, transaction_id)
|
||||
VALUES
|
||||
(v_caller, p_company_id, v_invoice_id, v_tx.date, v_paid_in_inv_currency, v_invoice.currency,
|
||||
v_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id,
|
||||
'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining,
|
||||
'amount', v_alloc_amount,
|
||||
'cross_currency', v_invoice.currency <> v_tx.currency));
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices
|
||||
WHERE id = v_supplier_invoice_id AND company_id = p_company_id;
|
||||
|
||||
IF v_si_invoice.currency = v_tx.currency THEN
|
||||
v_paid_in_inv_currency := v_alloc_amount;
|
||||
v_payment_rate := NULL;
|
||||
ELSE
|
||||
v_paid_in_inv_currency := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
|
||||
IF v_paid_in_inv_currency > 0 THEN
|
||||
v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
|
||||
ELSE
|
||||
v_payment_rate := NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
UPDATE public.supplier_invoices SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN
|
||||
((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
|
||||
ELSE paid_at END,
|
||||
paid_amount = v_new_paid, remaining_amount = v_new_remaining,
|
||||
payment_journal_entry_id = v_journal_entry_id, updated_at = v_now
|
||||
WHERE id = v_supplier_invoice_id AND company_id = p_company_id;
|
||||
|
||||
INSERT INTO public.supplier_invoice_payments
|
||||
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency, exchange_rate,
|
||||
payment_exchange_rate, journal_entry_id, transaction_id)
|
||||
VALUES
|
||||
(v_caller, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency,
|
||||
v_si_invoice.currency, v_si_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining, 'amount', v_alloc_amount,
|
||||
'cross_currency', v_si_invoice.currency <> v_tx.currency));
|
||||
END IF;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE,
|
||||
invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END,
|
||||
supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END,
|
||||
potential_invoice_id = NULL, potential_supplier_invoice_id = NULL,
|
||||
updated_at = v_now WHERE id = p_tx_id AND company_id = p_company_id;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id,
|
||||
'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number,
|
||||
'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated,
|
||||
'leftover', 0);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -204,21 +204,35 @@ describe('match_batch_allocate', () => {
|
||||
expect(apSum).toBe(6500)
|
||||
|
||||
// Verify all 3 supplier invoices flipped to 'paid'.
|
||||
const inv1 = await client.query<{ status: string; paid_amount: string; remaining_amount: string }>(
|
||||
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
|
||||
const inv1 = await client.query<{
|
||||
status: string
|
||||
paid_amount: string
|
||||
remaining_amount: string
|
||||
paid_at_matches: boolean
|
||||
}>(
|
||||
`SELECT status, paid_amount, remaining_amount,
|
||||
paid_at = TIMESTAMPTZ '2026-06-05 12:00:00+00' AS paid_at_matches
|
||||
FROM public.supplier_invoices WHERE id = $1`,
|
||||
[si1],
|
||||
)
|
||||
expect(inv1.rows[0]!.status).toBe('paid')
|
||||
expect(Number(inv1.rows[0]!.paid_amount)).toBe(2000)
|
||||
expect(Number(inv1.rows[0]!.remaining_amount)).toBe(0)
|
||||
expect(inv1.rows[0]!.paid_at_matches).toBe(true)
|
||||
|
||||
// Verify 3 supplier_invoice_payments rows all reference the same JE.
|
||||
const payments = await client.query<{ journal_entry_id: string; supplier_invoice_id: string }>(
|
||||
`SELECT journal_entry_id, supplier_invoice_id
|
||||
const payments = await client.query<{
|
||||
journal_entry_id: string
|
||||
supplier_invoice_id: string
|
||||
payment_date_matches: boolean
|
||||
}>(
|
||||
`SELECT journal_entry_id, supplier_invoice_id,
|
||||
payment_date = DATE '2026-06-05' AS payment_date_matches
|
||||
FROM public.supplier_invoice_payments WHERE transaction_id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(payments.rows).toHaveLength(3)
|
||||
expect(payments.rows.every((payment) => payment.payment_date_matches)).toBe(true)
|
||||
const jeIds = new Set(payments.rows.map((p) => p.journal_entry_id))
|
||||
expect(jeIds.size).toBe(1)
|
||||
expect(jeIds.has(result.journal_entry_id!)).toBe(true)
|
||||
@@ -248,6 +262,62 @@ describe('match_batch_allocate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('anchors customer paid_at at UTC noon on the bank transaction date', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const customerId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers
|
||||
(id, user_id, company_id, name, customer_type, country)
|
||||
VALUES ($1, $2, $3, 'Kund AB', 'swedish_business', 'SE')`,
|
||||
[customerId, userId, companyId],
|
||||
)
|
||||
|
||||
const invoiceId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount,
|
||||
vat_treatment)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-06-01', '2026-07-01', 'sent', 'SEK',
|
||||
1000, 0, 1000, 0, 1000, 'standard_25')`,
|
||||
[invoiceId, userId, companyId, customerId, `F-${invoiceId.slice(0, 8)}`],
|
||||
)
|
||||
const txId = await insertTransaction({
|
||||
userId,
|
||||
companyId,
|
||||
amount: 1000,
|
||||
date: '2026-06-07',
|
||||
})
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const response = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 }]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
expect(response.rows[0]!.match_batch_allocate.ok).toBe(true)
|
||||
|
||||
const invoice = await client.query<{ paid_at_matches: boolean }>(
|
||||
`SELECT paid_at = TIMESTAMPTZ '2026-06-07 12:00:00+00' AS paid_at_matches
|
||||
FROM public.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(invoice.rows[0]!.paid_at_matches).toBe(true)
|
||||
|
||||
const payment = await client.query<{ payment_date_matches: boolean }>(
|
||||
`SELECT payment_date = DATE '2026-06-07' AS payment_date_matches
|
||||
FROM public.invoice_payments
|
||||
WHERE invoice_id = $1 AND transaction_id = $2`,
|
||||
[invoiceId, txId],
|
||||
)
|
||||
expect(payment.rows).toHaveLength(1)
|
||||
expect(payment.rows[0]!.payment_date_matches).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
|
||||
Reference in New Issue
Block a user