fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)
A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning dialog + MCP path) had no öre handling, so a sub-krona overshoot was rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice in partially_paid forever. Invoices already stuck that way had no exit: the mark-paid dialog proposed clearing the full total (rejected with MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid outright. Fix, both halves: 1. New migration 20260824120000 replaces match_batch_allocate with the same öresavrundning band every single-payment path already uses (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff clears the FULL remaining off 1510/2440, books the residual to 3740 with correct polarity per side, records the full remaining as paid and flips the status to paid. >= 1 kr diffs keep today's behaviour. 2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual invoice gets a proposal clearing the actual remaining, and a sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so one click closes a stuck invoice. The mark-paid route and the invoice-detail button now accept partially_paid (the settle layer's CAS guard always did). Forward-only: already-stuck invoices are not auto-repaired; they are closed via the new dialog proposal. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
6aecbdc9b7
commit
62135c0c02
@@ -1171,6 +1171,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-22] Per-company invoice sending domains are gated by a manually granted capability (custom_sender_domain), deliberately NOT in PAID_CAPABILITIES: the opt-in must not be trial-seeded or written by the Stripe subscription sync, and non-grantees must see an unchanged invoicing settings page (the section hides on the 403 capability_blocked envelope). The sending-domain module has no Resend orphan-adoption path (a name that already exists is a 409), because the same Resend account holds the platform's own outbound domain. The delivery log was left untouched (no from_address column): adding it would re-open the hardened invoice_deliveries evidence triggers/redaction paths for a nice-to-have, and the log already measures delivered/bounced per send.
|
||||
[2026-08-22] company_sending_domains verification state (domain, status, resend_domain_id, dns_records, verified_at, last_checked_at) is service-role only via a BEFORE trigger keyed on the JWT role claim; tenant JWTs may only open a pending claim and edit sender_local_part/sender_name/enabled. Skeptic refutation: RLS alone let a granted admin insert {domain: platform sender domain, status: verified} through PostgREST and send invoice mail as the platform. The claim/verify helpers therefore take a separate service-role writer for those columns. Second refutation: a domain Resend later flips to failed made every invoice send for that company fail; the Resend adapter now retries once as the platform sender when an explicit company From is rejected (nothing was sent on the rejected attempt, so the retry cannot double-send).
|
||||
[2026-08-22] Sending-domain verification writes bind by (id, company_id, domain, resend_domain_id IS NULL) and verify/webhook compare Resend's domain name with the row before writing verified; resolveInvoiceSender additionally refuses reserved platform domains and non-hostnames at send time. Skeptic re-check: a tenant could delete and re-insert its pending row under the same id with a reserved domain during the claim's Resend round-trip (TOCTOU), and the service-role writer updated by id alone. Defense in depth over a single gate.
|
||||
[2026-08-24] #1717 öre settlement in match_batch_allocate replicates the ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr policy band in SQL (reject overshoot only at >= 1 kr; absorb sub-krona diffs to 3740 with full 1510/2440 clear) instead of routing the batch through planInvoicePayment: the RPC is one atomic transaction and must stay self-contained; the band constants are documented as mirrors of lib/money.ts. The mark-paid route and invoice-detail button now accept partially_paid (settleInvoicePayment's CAS guard always did) so a stuck invoice can be closed; the remaining-aware dialog proposal is deliberately scoped to SEK + accrual + no ROT/RUT deduction: cash partial completion is server-blocked (cashPartialBlockReason), FX needs a payment-day conversion the pure proposal lacks, and a ROT/RUT remainder is Skatteverket's 1513 share settled by the payout flow.
|
||||
[2026-08-24] Assistant empty answer is a typed failure (EmptyModelAnswerError, 502 'Assistenten gav inget svar. Försök igen.'), one manual retry, NO auto-retry: the silent 200 with an empty answer was the 'Tänker then nothing' bug after the RIP-3 cutover (1500-token cap on /api/agent/ask), and auto-retry would double model spend while hiding the regression. Related deliberate request-shape change: anthropic-family's step-exhausted fallback now keeps tools with tool_choice none, because replaying a transcript containing tool_use/tool_result without declaring tools is rejected by the Messages API (the old shape 400ed every step-exhausted answer). general.help stays on the single-call runtime (founder decision); the fix raises headroom to 5400 tokens and surfaces the failure instead.
|
||||
[2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior).
|
||||
[2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback.
|
||||
|
||||
@@ -1540,7 +1540,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{t('mark_as_sent')}
|
||||
</Button>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && !isCreditNote && (
|
||||
{/* partially_paid included (#1717): completes a stuck partial, e.g.
|
||||
a sub-krona öresavrundning remaining, via the same dialog. */}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'partially_paid') && isRealInvoice && !isCreditNote && (
|
||||
<Button
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
disabled={isUpdating || !canWrite}
|
||||
|
||||
@@ -567,6 +567,52 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.journal_entry_id).toBe('je-ore')
|
||||
})
|
||||
|
||||
it('accepts a partially_paid invoice and closes it with a bank-less öre write-off (#1717)', async () => {
|
||||
// An invoice stuck with a sub-krona remaining (öresavrundning on an
|
||||
// earlier payment path). The dialog proposes Dr 3740 / Cr 1510 for the
|
||||
// remaining; booking it flips the invoice to paid. No customer →
|
||||
// duplicate guard skips.
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'partially_paid',
|
||||
total: 12500.4,
|
||||
paid_amount: 12500,
|
||||
remaining_amount: 0.4,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-writeoff' })
|
||||
|
||||
const writeOffLines = [
|
||||
{ account_number: '3740', debit_amount: 0.4, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 0.4 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { lines: writeOffLines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(12500.4)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-writeoff')
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
|
||||
// No customer → duplicate guard skips; the overpayment guard must reject
|
||||
// BEFORE any journal entry is created (planInvoicePayment runs first).
|
||||
|
||||
@@ -60,7 +60,15 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
// partially_paid is payable (#1717): an invoice stuck with a sub-krona
|
||||
// remaining (öresavrundning) or an ordinary open partial is completed
|
||||
// here. settleInvoicePayment's plan math and CAS guard already handle
|
||||
// the state; only this route-level gate excluded it.
|
||||
if (
|
||||
invoice.status !== 'sent' &&
|
||||
invoice.status !== 'overdue' &&
|
||||
invoice.status !== 'partially_paid'
|
||||
) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
|
||||
@@ -166,6 +166,10 @@ export default function PaymentBookingDialog({
|
||||
default_dimensions: invoice.default_dimensions,
|
||||
ore_rounding: invoice.ore_rounding,
|
||||
deduction_total: invoice.deduction_total,
|
||||
// #1717: lets the proposal clear the actual remaining on a
|
||||
// partially_paid invoice (öre write-off when < 1 kr remains).
|
||||
paid_amount: invoice.paid_amount,
|
||||
remaining_amount: invoice.remaining_amount,
|
||||
},
|
||||
accountingMethod,
|
||||
entityType,
|
||||
|
||||
@@ -323,6 +323,160 @@ describe('proposePaymentLines: öresavrundning (3740)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposePaymentLines: remaining-aware partials (#1717)', () => {
|
||||
it('sub-krona remaining → bank-less öre write-off (Dr 3740 / Cr 1510)', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput({ total: 12500.4 }),
|
||||
paid_amount: 12500,
|
||||
remaining_amount: 0.4,
|
||||
},
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
account_number: '3740',
|
||||
debit_amount: '0.4',
|
||||
credit_amount: '',
|
||||
line_description: 'Öresavrundning',
|
||||
},
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: '',
|
||||
credit_amount: '0.4',
|
||||
line_description: 'Betalning faktura 2025-001',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('remaining >= 1 kr → clears the remaining, not the total', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput({ total: 12500 }),
|
||||
paid_amount: 5000,
|
||||
remaining_amount: 7500,
|
||||
},
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
paymentAccount: '1920',
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0]).toMatchObject({ account_number: '1920', debit_amount: '7500' })
|
||||
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '7500' })
|
||||
})
|
||||
|
||||
it('derives remaining from paid_amount when remaining_amount is absent', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: { ...makeInvoiceInput({ total: 12500.4 }), paid_amount: 12500 },
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines[0]).toMatchObject({ account_number: '3740', debit_amount: '0.4' })
|
||||
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '0.4' })
|
||||
})
|
||||
|
||||
it('fully unpaid invoice keeps the fresh proposal byte-identical (incl. öresavrundning)', () => {
|
||||
const fresh = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({ total: 1234.75 }),
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
const withZeroPaid = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput({ total: 1234.75 }),
|
||||
paid_amount: 0,
|
||||
remaining_amount: 1234.75,
|
||||
},
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
|
||||
expect(withZeroPaid).toEqual(fresh)
|
||||
expect(withZeroPaid[2]).toMatchObject({ account_number: '3740' })
|
||||
})
|
||||
|
||||
it('cash method partial keeps the legacy full-invoice proposal', () => {
|
||||
// The server refuses cash partial completion (cashPartialBlockReason), so
|
||||
// a remaining-based cash proposal would only dress up a rejected booking.
|
||||
const lines = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput(),
|
||||
paid_amount: 5000,
|
||||
remaining_amount: 7500,
|
||||
},
|
||||
accountingMethod: 'cash',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
|
||||
})
|
||||
|
||||
it('foreign-currency partial keeps the legacy proposal', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput({
|
||||
total: 1000,
|
||||
total_sek: 10000,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 10,
|
||||
}),
|
||||
paid_amount: 500,
|
||||
remaining_amount: 500,
|
||||
},
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '10000' })
|
||||
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
|
||||
})
|
||||
|
||||
it('ROT/RUT invoice with a partial keeps the deduction-aware proposal', () => {
|
||||
// The outstanding remainder on a ROT/RUT invoice is Skatteverket's share
|
||||
// on 1513, settled by the ROT/RUT payout flow: never proposed as a 1510
|
||||
// clearing here.
|
||||
const lines = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput(),
|
||||
deduction_total: 3750,
|
||||
paid_amount: 8750,
|
||||
remaining_amount: 3750,
|
||||
},
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'aktiebolag',
|
||||
})
|
||||
|
||||
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
|
||||
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
|
||||
})
|
||||
|
||||
it('write-off lines carry the invoice default dimensions', () => {
|
||||
const bag = { '6': 'P001' }
|
||||
const lines = proposePaymentLines({
|
||||
invoice: {
|
||||
...makeInvoiceInput({ total: 1000.25 }),
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0.25,
|
||||
default_dimensions: bag,
|
||||
},
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
for (const line of lines) {
|
||||
expect(line.dimensions).toEqual(bag)
|
||||
expect(line.dimensions).not.toBe(bag)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
|
||||
const bag = { '1': 'KS01', '6': 'P001' }
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* No DB or Supabase dependency: all inputs are plain data.
|
||||
*/
|
||||
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
|
||||
import {
|
||||
getRevenueAccount,
|
||||
getOutputVatAccount,
|
||||
@@ -47,6 +47,15 @@ export interface ProposePaymentLinesInput {
|
||||
* (the preview groups per rate); users can retag lines in the grid.
|
||||
*/
|
||||
default_dimensions?: Record<string, string> | null
|
||||
/**
|
||||
* Prior-payment state (#1717). When a partial payment exists the proposal
|
||||
* must clear what actually remains, not the full total: a full-total
|
||||
* proposal is rejected server-side with MATCH_AMOUNT_EXCEEDS_REMAINING,
|
||||
* which left invoices stuck in partially_paid with an öre remaining.
|
||||
* Absent or fully-unpaid values keep the proposal identical to before.
|
||||
*/
|
||||
paid_amount?: number | null
|
||||
remaining_amount?: number | null
|
||||
}
|
||||
accountingMethod: 'accrual' | 'cash'
|
||||
entityType: EntityType
|
||||
@@ -96,6 +105,11 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
|
||||
const paymentAccount = input.paymentAccount || '1930'
|
||||
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
|
||||
|
||||
// #1717: an invoice with a prior partial payment gets a proposal that
|
||||
// clears the actual remaining, never the full total.
|
||||
const remainingAware = proposeRemainingAwareLines(invoice, accountingMethod, paymentAccount, desc)
|
||||
if (remainingAware) return withInvoiceDimensions(remainingAware, invoice)
|
||||
|
||||
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
|
||||
// customer pays the rounded "Att betala" from the PDF, not the stored öre
|
||||
// total. Propose the bank leg at the rounded amount and let 3740 carry the
|
||||
@@ -114,8 +128,91 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
|
||||
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta, deductionSek)
|
||||
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta, deductionSek)
|
||||
|
||||
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
|
||||
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
|
||||
return withInvoiceDimensions(lines, invoice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining-aware proposal for an invoice with a prior partial payment
|
||||
* (#1717). Returns null whenever the legacy full-total proposal applies, so
|
||||
* fresh unpaid invoices keep a byte-identical proposal.
|
||||
*
|
||||
* Scope: SEK + accrual + no ROT/RUT deduction only.
|
||||
* - Cash-method partial completion is refused server-side
|
||||
* (cashPartialBlockReason: the generated cash entry always books the full
|
||||
* invoice), so a remaining-based cash proposal would only book a rejected
|
||||
* entry with a nicer preview.
|
||||
* - A foreign-currency remaining needs a payment-day FX conversion this
|
||||
* pure function does not carry; the dialog's FX path already handles it.
|
||||
* - On a ROT/RUT invoice the outstanding remainder is (or includes)
|
||||
* Skatteverket's share, which sits on 1513 and is settled by the ROT/RUT
|
||||
* payout flow, not by clearing 1510 here.
|
||||
*
|
||||
* Two shapes:
|
||||
* - 0 < remaining < 1 kr (the stuck öresavrundning case): a bank-less
|
||||
* write-off, Dr 3740 / Cr 1510, so one click closes the invoice. Polarity
|
||||
* per buildInvoicePaymentClearingLines: the customer under-paid, so 3740
|
||||
* takes the debit (öresavrundningsförlust).
|
||||
* - remaining >= 1 kr: a normal clearing of the remaining, Dr bank /
|
||||
* Cr 1510.
|
||||
*/
|
||||
function proposeRemainingAwareLines(
|
||||
invoice: ProposePaymentLinesInput['invoice'],
|
||||
accountingMethod: 'accrual' | 'cash',
|
||||
paymentAccount: string,
|
||||
desc: string,
|
||||
): FormLine[] | null {
|
||||
if (accountingMethod !== 'accrual') return null
|
||||
if (invoice.currency !== 'SEK') return null
|
||||
if ((invoice.deduction_total ?? 0) > 0) return null
|
||||
|
||||
const total = roundOre(invoice.total)
|
||||
const remaining = roundOre(
|
||||
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0),
|
||||
)
|
||||
const hasPartial = remaining > ORE_TOLERANCE && total - remaining > ORE_TOLERANCE
|
||||
if (!hasPartial) return null
|
||||
|
||||
if (remaining < ORE_ROUNDING_SETTLEMENT_MAX) {
|
||||
return [
|
||||
{
|
||||
account_number: '3740',
|
||||
debit_amount: toFormAmount(remaining),
|
||||
credit_amount: '',
|
||||
line_description: 'Öresavrundning',
|
||||
},
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: '',
|
||||
credit_amount: toFormAmount(remaining),
|
||||
line_description: desc,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
account_number: paymentAccount,
|
||||
debit_amount: toFormAmount(remaining),
|
||||
credit_amount: '',
|
||||
line_description: desc,
|
||||
},
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: '',
|
||||
credit_amount: toFormAmount(remaining),
|
||||
line_description: desc,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions PR7: re-propagate the invoice default onto every proposed leg
|
||||
* (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
|
||||
*/
|
||||
function withInvoiceDimensions(
|
||||
lines: FormLine[],
|
||||
invoice: ProposePaymentLinesInput['invoice'],
|
||||
): FormLine[] {
|
||||
const bag = invoice.default_dimensions
|
||||
if (bag && Object.keys(bag).length > 0) {
|
||||
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
-- match_batch_allocate: absorb sub-krona öresavrundning so a whole-krona
|
||||
-- settlement fully closes the invoice (issue #1717).
|
||||
--
|
||||
-- Swedish whole-krona payments (Bankgiro, Swish, kort) pay an öre-bearing
|
||||
-- invoice total rounded to the nearest krona. Every single-payment match path
|
||||
-- already absorbs that residual into BAS 3740 (Öres- och kronutjämning) via
|
||||
-- planInvoicePayment / buildInvoicePaymentClearingLines, but this RPC (the
|
||||
-- samlingsbetalning / batch dialog and the MCP pending-operations path) did
|
||||
-- not: a same-currency allocation more than half an öre over the remaining
|
||||
-- was rejected as BATCH_OVERSHOOT, and an allocation a few öre short cleared
|
||||
-- only the allocation off 1510/2440, leaving the invoice hanging in
|
||||
-- partially_paid with öre remaining forever.
|
||||
--
|
||||
-- Fix, mirroring the policy band in lib/money.ts (ORE_ROUNDING_SETTLEMENT_MAX
|
||||
-- = 1.00 SEK) and lib/invoices/apply-invoice-payment.ts:
|
||||
-- 1. Same-currency overshoot is rejected only when it is >= 1.00 kr
|
||||
-- (a real overpayment); a sub-krona overshoot is öresavrundning.
|
||||
-- 2. When 0 < |remaining - allocation| < 1.00 the FULL remaining is cleared
|
||||
-- off 1510 (customer) / 2440 (supplier) and a 3740 line carries the
|
||||
-- residual. Polarity per lib/bookkeeping/invoice-payment-lines.ts and
|
||||
-- supplier-payment-lines.ts: customer short-paid = Dr 3740 (förlust),
|
||||
-- over-paid = Cr 3740 (vinst); supplier is the mirror (bank paid less
|
||||
-- than owed = Cr 3740, more = Dr 3740). The bank leg is unchanged
|
||||
-- (tx_abs), so the verifikat stays balanced: the 3740 line takes exactly
|
||||
-- the difference moved onto the AR/AP leg.
|
||||
-- 3. The settlement loop records the full remaining as paid in that band,
|
||||
-- so paid_amount accumulates the whole remaining, remaining_amount lands
|
||||
-- on 0 and the status flips to 'paid' (same convention as
|
||||
-- planInvoicePayment: invoice_payments.amount = the full remaining).
|
||||
-- A shortfall or overshoot of >= 1.00 kr keeps today's behaviour exactly
|
||||
-- (real partial payment / BATCH_OVERSHOOT).
|
||||
--
|
||||
-- The body is otherwise byte-for-byte the 20260817150000 definition
|
||||
-- (service-actor resolution). Signature is unchanged, so CREATE OR REPLACE
|
||||
-- keeps the existing grants; they are re-asserted below anyway for clarity.
|
||||
--
|
||||
-- pg-test: tests/pg/match-batch-allocate.pg.test.ts
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
|
||||
p_tx_id uuid,
|
||||
p_allocations jsonb,
|
||||
p_company_id uuid,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
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;
|
||||
v_ore_diff numeric; -- remaining - allocation, öre-rounded (#1717)
|
||||
BEGIN
|
||||
-- Actor resolution. p_user_id is an assertion by the caller, so it is
|
||||
-- honored ONLY when the caller holds the service role (the cookieless
|
||||
-- server client used by the pending-operations commit path, where
|
||||
-- auth.uid() is NULL). Any other caller is pinned to its own auth.uid():
|
||||
-- otherwise an authenticated PostgREST caller could pass another user's
|
||||
-- UUID and walk through the membership gate below. Same shape as
|
||||
-- undo_sie_import (20260727121000).
|
||||
IF auth.role() = 'service_role' THEN
|
||||
v_caller := COALESCE(p_user_id, auth.uid());
|
||||
ELSE
|
||||
v_caller := auth.uid();
|
||||
END IF;
|
||||
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
|
||||
-- #1717: a sub-krona overshoot is öresavrundning (whole-krona
|
||||
-- settlement of an öre total), absorbed into 3740 below. Only an
|
||||
-- excess of >= 1.00 kr is a real overpayment. Mirrors
|
||||
-- planInvoicePayment (lib/invoices/apply-invoice-payment.ts).
|
||||
IF ROUND((v_alloc_amount - v_inv_remaining) * 100) / 100 >= 1.00 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
|
||||
-- #1717: same öresavrundning band as the customer side above.
|
||||
IF ROUND((v_alloc_amount - v_inv_remaining) * 100) / 100 >= 1.00 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
|
||||
-- #1717: inside the öresavrundning band, clear the FULL remaining off
|
||||
-- 1510 so the invoice reaches 'paid' and let 3740 carry the residual.
|
||||
-- Customer short-paid = Dr 3740 (förlust); over-paid = Cr 3740
|
||||
-- (vinst), same polarity as buildInvoicePaymentClearingLines. The
|
||||
-- bank leg (tx_abs) is unchanged, so the entry stays balanced: the
|
||||
-- net credit of 1510 + 3740 equals the allocation exactly.
|
||||
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
|
||||
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 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, ROUND(v_inv_remaining * 100) / 100, v_tx.currency, v_line_sort_order,
|
||||
'Faktura ' || v_inv_number_short);
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
IF v_ore_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, '3740', v_ore_diff, 0, v_tx.currency, v_line_sort_order,
|
||||
'Öresavrundning');
|
||||
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, '3740', 0, ABS(v_ore_diff), v_tx.currency, v_line_sort_order,
|
||||
'Öresavrundning');
|
||||
END IF;
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
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, '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;
|
||||
END IF;
|
||||
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
|
||||
-- #1717: supplier mirror of the customer branch above. Clear the FULL
|
||||
-- remaining off 2440; polarity per buildSupplierPaymentClearingLines:
|
||||
-- bank paid less than owed = Cr 3740 (vinst), more = Dr 3740
|
||||
-- (förlust).
|
||||
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
|
||||
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 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', ROUND(v_inv_remaining * 100) / 100, 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;
|
||||
IF v_ore_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, '3740', 0, v_ore_diff, v_tx.currency, v_line_sort_order,
|
||||
'Öresavrundning');
|
||||
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, '3740', ABS(v_ore_diff), 0, v_tx.currency, v_line_sort_order,
|
||||
'Öresavrundning');
|
||||
END IF;
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
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, '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;
|
||||
END IF;
|
||||
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
|
||||
-- #1717: inside the öresavrundning band the invoice settles in full
|
||||
-- (the 3740 line carries the difference), so the payment records the
|
||||
-- FULL remaining: paid_amount accumulates it, remaining lands on 0
|
||||
-- and the status flips to 'paid'. Same convention as
|
||||
-- planInvoicePayment.
|
||||
v_inv_remaining := ROUND(COALESCE(v_invoice.remaining_amount, v_invoice.total) * 100) / 100;
|
||||
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
|
||||
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 THEN
|
||||
v_paid_in_inv_currency := v_inv_remaining;
|
||||
ELSE
|
||||
v_paid_in_inv_currency := v_alloc_amount;
|
||||
END IF;
|
||||
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
|
||||
-- #1717: supplier mirror of the customer branch above.
|
||||
v_inv_remaining := ROUND(COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) * 100) / 100;
|
||||
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
|
||||
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 THEN
|
||||
v_paid_in_inv_currency := v_inv_remaining;
|
||||
ELSE
|
||||
v_paid_in_inv_currency := v_alloc_amount;
|
||||
END IF;
|
||||
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;
|
||||
$$;
|
||||
|
||||
-- Same grants as 20260817150000. CREATE OR REPLACE preserves the existing
|
||||
-- ACL, but they are re-asserted so this file stands on its own.
|
||||
REVOKE EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) IS
|
||||
'Books one bank transaction against N invoices/supplier invoices in a single samlingsverifikat. Sub-krona differences between an allocation and the invoice remaining are absorbed to 3740 (öresavrundning) so the invoice settles fully. p_user_id is honored only for service_role callers (the pending-operations commit path); every other caller resolves from its own auth.uid(). Not callable by anon.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
insertAuthUser,
|
||||
@@ -9,12 +10,16 @@ import {
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Covers 20260529120100_match_batch_allocate:
|
||||
* Covers 20260529120100_match_batch_allocate (latest body:
|
||||
* 20260824120000_match_batch_allocate_ore_settlement):
|
||||
* - 1 bank tx → N supplier invoices: builds ONE combined verifikat with
|
||||
* N × Dr 2440 + 1 × Cr 1930, inserts N supplier_invoice_payments rows
|
||||
* all pointing at the same JE.
|
||||
* - Per-invoice paid_amount/remaining_amount/status advance correctly.
|
||||
* - Overshoot guard returns BATCH_OVERSHOOT cleanly (no partial state).
|
||||
* - Overshoot guard returns BATCH_OVERSHOOT cleanly (no partial state)
|
||||
* for a >= 1 kr excess; a sub-krona excess is öresavrundning (#1717).
|
||||
* - Sub-krona |remaining - allocation| settles the invoice in full with a
|
||||
* 3740 line carrying the residual (#1717).
|
||||
* - Already-booked tx rejection.
|
||||
* - Direction mismatch rejection.
|
||||
* - Mixed customer + supplier kinds rejection.
|
||||
@@ -105,6 +110,48 @@ async function insertTransaction(params: {
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertCustomer(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
}): Promise<string> {
|
||||
const id = 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')`,
|
||||
[id, params.userId, params.companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertCustomerInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
customerId: string
|
||||
total: number
|
||||
status?: string
|
||||
}): Promise<string> {
|
||||
const id = 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', $6, 'SEK',
|
||||
$7, 0, $7, 0, $7, 'standard_25')`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.customerId,
|
||||
`F-${id.slice(0, 8)}`,
|
||||
params.status ?? 'sent',
|
||||
params.total,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function seedTenant(opts: { isClosed?: boolean } = {}) {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
@@ -318,7 +365,10 @@ describe('match_batch_allocate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining', async () => {
|
||||
// #1717 deliberately relaxed this guard: a SUB-krona excess is
|
||||
// öresavrundning (absorbed to 3740, see the öre suite below); only an
|
||||
// excess of >= 1.00 kr is still a real overshoot.
|
||||
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining by >= 1 kr', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
@@ -357,6 +407,29 @@ describe('match_batch_allocate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_OVERSHOOT at exactly 1 kr excess (band boundary)', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -1001 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1001 }]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_OVERSHOOT')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_UNAUTHORIZED when caller is not a member of the company', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
@@ -684,4 +757,295 @@ describe('match_batch_allocate', () => {
|
||||
expect(txRow.rows[0]!.journal_entry_id).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// #1717: öresavrundning. A whole-krona settlement of an öre-bearing
|
||||
// remaining (|diff| < 1 kr) must settle the invoice in FULL: the AR/AP leg
|
||||
// clears the whole remaining, 3740 carries the residual, status flips to
|
||||
// 'paid'. A diff of >= 1 kr keeps the legacy behaviour (real partial /
|
||||
// BATCH_OVERSHOOT above).
|
||||
describe('öresavrundning settlement (#1717)', () => {
|
||||
async function fetchLines(client: PoolClient, journalEntryId: string) {
|
||||
const lines = await client.query<{
|
||||
account_number: string
|
||||
debit_amount: string
|
||||
credit_amount: string
|
||||
}>(
|
||||
`SELECT account_number, debit_amount, credit_amount
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = $1
|
||||
ORDER BY sort_order`,
|
||||
[journalEntryId],
|
||||
)
|
||||
return lines.rows
|
||||
}
|
||||
|
||||
function assertBalanced(rows: Array<{ debit_amount: string; credit_amount: string }>) {
|
||||
const debits = rows.reduce((s, l) => s + Number(l.debit_amount), 0)
|
||||
const credits = rows.reduce((s, l) => s + Number(l.credit_amount), 0)
|
||||
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
|
||||
}
|
||||
|
||||
it('customer invoice paid a whole krona short settles to paid with Dr 3740', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const customerId = await insertCustomer({ userId, companyId })
|
||||
const invoiceId = await insertCustomerInvoice({
|
||||
userId, companyId, customerId, total: 1000.4,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = 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,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations![0]!.status).toBe('paid')
|
||||
expect(result.allocations![0]!.paid_amount).toBe(1000.4)
|
||||
expect(result.allocations![0]!.remaining_amount).toBe(0)
|
||||
|
||||
const rows = await fetchLines(client, result.journal_entry_id!)
|
||||
// Cr 1510 1000.40 (full remaining), Dr 3740 0.40, Dr 1930 1000.
|
||||
expect(rows).toHaveLength(3)
|
||||
const ar = rows.find((l) => l.account_number === '1510')!
|
||||
expect(Number(ar.credit_amount)).toBe(1000.4)
|
||||
const ore = rows.find((l) => l.account_number === '3740')!
|
||||
expect(Number(ore.debit_amount)).toBe(0.4)
|
||||
expect(Number(ore.credit_amount)).toBe(0)
|
||||
const bank = rows.find((l) => l.account_number === '1930')!
|
||||
expect(Number(bank.debit_amount)).toBe(1000)
|
||||
assertBalanced(rows)
|
||||
|
||||
const inv = await client.query<{
|
||||
status: string; paid_amount: string; remaining_amount: string; paid_at: string | null
|
||||
}>(
|
||||
`SELECT status, paid_amount, remaining_amount, paid_at FROM public.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(inv.rows[0]!.status).toBe('paid')
|
||||
expect(Number(inv.rows[0]!.paid_amount)).toBe(1000.4)
|
||||
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
|
||||
expect(inv.rows[0]!.paid_at).not.toBeNull()
|
||||
|
||||
// The payment row records the FULL remaining (planInvoicePayment
|
||||
// convention), not the raw bank allocation.
|
||||
const pay = await client.query<{ amount: string }>(
|
||||
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(Number(pay.rows[0]!.amount)).toBe(1000.4)
|
||||
})
|
||||
})
|
||||
|
||||
it('customer invoice over-paid by öre is accepted with Cr 3740', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const customerId = await insertCustomer({ userId, companyId })
|
||||
const invoiceId = await insertCustomerInvoice({
|
||||
userId, companyId, customerId, total: 1000.6,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: 1001 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = 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: 1001 }]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations![0]!.status).toBe('paid')
|
||||
|
||||
const rows = await fetchLines(client, result.journal_entry_id!)
|
||||
// Cr 1510 1000.60, Cr 3740 0.40 (vinst), Dr 1930 1001.
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(Number(rows.find((l) => l.account_number === '1510')!.credit_amount)).toBe(1000.6)
|
||||
const ore = rows.find((l) => l.account_number === '3740')!
|
||||
expect(Number(ore.credit_amount)).toBe(0.4)
|
||||
expect(Number(ore.debit_amount)).toBe(0)
|
||||
expect(Number(rows.find((l) => l.account_number === '1930')!.debit_amount)).toBe(1001)
|
||||
assertBalanced(rows)
|
||||
|
||||
const inv = await client.query<{ status: string; paid_amount: string }>(
|
||||
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(inv.rows[0]!.status).toBe('paid')
|
||||
expect(Number(inv.rows[0]!.paid_amount)).toBe(1000.6)
|
||||
})
|
||||
})
|
||||
|
||||
it('supplier invoice paid öre short settles to paid with Cr 3740 (mirror polarity)', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 2000.3,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 2000 }]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations![0]!.status).toBe('paid')
|
||||
|
||||
const rows = await fetchLines(client, result.journal_entry_id!)
|
||||
// Dr 2440 2000.30 (full remaining), Cr 3740 0.30 (vinst: paid less
|
||||
// than owed), Cr 1930 2000.
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(2000.3)
|
||||
const ore = rows.find((l) => l.account_number === '3740')!
|
||||
expect(Number(ore.credit_amount)).toBe(0.3)
|
||||
expect(Number(ore.debit_amount)).toBe(0)
|
||||
expect(Number(rows.find((l) => l.account_number === '1930')!.credit_amount)).toBe(2000)
|
||||
assertBalanced(rows)
|
||||
|
||||
const inv = await client.query<{
|
||||
status: string; paid_amount: string; remaining_amount: string
|
||||
}>(
|
||||
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
|
||||
[si],
|
||||
)
|
||||
expect(inv.rows[0]!.status).toBe('paid')
|
||||
expect(Number(inv.rows[0]!.paid_amount)).toBe(2000.3)
|
||||
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('supplier invoice over-paid by öre gets Dr 3740 (förlust)', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1999.6,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 2000 }]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations![0]!.status).toBe('paid')
|
||||
|
||||
const rows = await fetchLines(client, result.journal_entry_id!)
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(1999.6)
|
||||
const ore = rows.find((l) => l.account_number === '3740')!
|
||||
expect(Number(ore.debit_amount)).toBe(0.4)
|
||||
expect(Number(ore.credit_amount)).toBe(0)
|
||||
assertBalanced(rows)
|
||||
})
|
||||
})
|
||||
|
||||
it('a shortfall of exactly 1 kr stays a real partial (no 3740)', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -999 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 999 }]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations![0]!.status).toBe('partially_paid')
|
||||
|
||||
const rows = await fetchLines(client, result.journal_entry_id!)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.some((l) => l.account_number === '3740')).toBe(false)
|
||||
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(999)
|
||||
|
||||
const inv = await client.query<{
|
||||
status: string; paid_amount: string; remaining_amount: string
|
||||
}>(
|
||||
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
|
||||
[si],
|
||||
)
|
||||
expect(inv.rows[0]!.status).toBe('partially_paid')
|
||||
expect(Number(inv.rows[0]!.paid_amount)).toBe(999)
|
||||
expect(Number(inv.rows[0]!.remaining_amount)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('samlingsbetalning: whole-krona clump over two öre-bearing invoices settles both', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const customerId = await insertCustomer({ userId, companyId })
|
||||
// 500.25 + 499.75 = 1000.00 owed; the batch dialog splits a 1000 kr
|
||||
// payment 500/500, leaving +0.25 short on one and 0.25 over on the
|
||||
// other: both absorb to 3740 and both invoices reach 'paid'.
|
||||
const inv1 = await insertCustomerInvoice({
|
||||
userId, companyId, customerId, total: 500.25,
|
||||
})
|
||||
const inv2 = await insertCustomerInvoice({
|
||||
userId, companyId, customerId, total: 499.75,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([
|
||||
{ kind: 'customer_invoice', invoice_id: inv1, amount: 500 },
|
||||
{ kind: 'customer_invoice', invoice_id: inv2, amount: 500 },
|
||||
]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations).toHaveLength(2)
|
||||
for (const alloc of result.allocations!) {
|
||||
expect(alloc.status).toBe('paid')
|
||||
expect(alloc.remaining_amount).toBe(0)
|
||||
}
|
||||
|
||||
const rows = await fetchLines(client, result.journal_entry_id!)
|
||||
// 2 × Cr 1510 (full remainings) + Dr 3740 0.25 + Cr 3740 0.25
|
||||
// + Dr 1930 1000.
|
||||
expect(rows).toHaveLength(5)
|
||||
const arSum = rows
|
||||
.filter((l) => l.account_number === '1510')
|
||||
.reduce((s, l) => s + Number(l.credit_amount), 0)
|
||||
expect(Math.round(arSum * 100)).toBe(100000)
|
||||
const oreRows = rows.filter((l) => l.account_number === '3740')
|
||||
expect(oreRows).toHaveLength(2)
|
||||
assertBalanced(rows)
|
||||
|
||||
const invoices = await client.query<{ status: string }>(
|
||||
`SELECT status FROM public.invoices WHERE id IN ($1, $2)`,
|
||||
[inv1, inv2],
|
||||
)
|
||||
expect(invoices.rows.every((row) => row.status === 'paid')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user