fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)
* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) The invoice-to-verifikat link is written on the invoice side only (invoices.journal_entry_id, invoice_payments.journal_entry_id), while the missing-underlag predicate and the periodisk sammanstallning resolved the invoice from the entry's own source columns. A SIE-imported sale matched to its invoice afterwards therefore kept warning "Underlag saknas" and was left out of the EU sales list, although the account-based momsdeklaration showed it and the verifikat page already listed the invoice as its underlag. - verifikat_without_documents / transactions_without_documents: customer- invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row; new migration 20260906135702, pinned by a pg-real test. - getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used by the journal-list filter and bulk exempt, /api/documents/counts (new invoice_references map) and the transactions list; the push cron mirrors it with its global reads. - Journal list: no "Underlag saknas" chip for a covered entry, matching the engine's own invoice rows and the verifikat detail page. - Periodisk sammanstallning: entries fetched by their EU-revenue lines and attributed through every link (engine source_id, invoices.journal_entry_id, invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment entries are filed too, which the old source_type filter dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review) - The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron) now require an ISSUED invoice: status not in ('draft', 'cancelled'), the schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_ STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the pg test pins a draft-linked and a cancelled-payment entry as still missing. - Periodisk sammanstallning: one verifikat linked to invoices of different customers is no longer attributed to the first invoice; it is left out of the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT naming the voucher, the customer count and the amount. Same-customer settlements are filed in full. - Transactions list: a failed invoice-reference lookup leaves that chunk's verdict unknown (no badges) and continues with the remaining chunks instead of abandoning them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
cce0de5704
commit
7448490fb7
@@ -99,55 +99,69 @@ interface InvoiceFx {
|
||||
} | null
|
||||
}
|
||||
|
||||
interface LineFx {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}
|
||||
|
||||
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
|
||||
const RECENT = new Date().toISOString()
|
||||
|
||||
// The generator fetches lines via the two-step entry-lines helper
|
||||
// (lib/bookkeeping/entry-lines.ts): journal_entries first, then
|
||||
// journal_entry_lines by entry id with the parent reattached under
|
||||
// `journal_entries`. Each fixture invoice gets one entry (je-<sourceId>).
|
||||
// The generator fetches the period's entries with their PS-account lines
|
||||
// embedded (journal_entries + journal_entry_lines!inner, one page here), then
|
||||
// resolves each entry's invoice: the engine's own entries by source_id,
|
||||
// everything else through getInvoiceReferencesForJournalEntries (invoices by
|
||||
// journal_entry_id, then invoice_payments), and finally loads the invoices
|
||||
// with their customer. Queue order per test:
|
||||
// 1. journal_entries page (with embedded lines)
|
||||
// 2. invoices by journal_entry_id only when a non-engine entry exists
|
||||
// 3. invoice_payments only when a non-engine entry exists
|
||||
// 4. invoices by id only when some invoice id resolved
|
||||
function je(sourceId: string) {
|
||||
return `je-${sourceId}`
|
||||
}
|
||||
|
||||
function entryEU(sourceId: string) {
|
||||
function entryEU(sourceId: string, lines: LineFx[] = []) {
|
||||
return {
|
||||
id: je(sourceId),
|
||||
company_id: 'c1',
|
||||
entry_date: '2025-05-15',
|
||||
status: 'posted',
|
||||
source_type: 'invoice_created',
|
||||
source_id: sourceId,
|
||||
journal_entry_lines: lines,
|
||||
}
|
||||
}
|
||||
|
||||
function entryCredit(sourceId: string) {
|
||||
function entryCredit(sourceId: string, lines: LineFx[] = []) {
|
||||
return {
|
||||
id: je(sourceId),
|
||||
company_id: 'c1',
|
||||
entry_date: '2025-05-20',
|
||||
status: 'posted',
|
||||
source_type: 'credit_note',
|
||||
source_id: sourceId,
|
||||
journal_entry_lines: lines,
|
||||
}
|
||||
}
|
||||
|
||||
function lineEU(account: string, credit: number, sourceId: string) {
|
||||
/** A verifikat that did not come from the invoice engine (SIE import, manual). */
|
||||
function entryOther(id: string, sourceType: string, lines: LineFx[] = []) {
|
||||
return {
|
||||
account_number: account,
|
||||
debit_amount: 0,
|
||||
credit_amount: credit,
|
||||
journal_entry_id: je(sourceId),
|
||||
id,
|
||||
entry_date: '2025-05-15',
|
||||
status: 'posted',
|
||||
source_type: sourceType,
|
||||
source_id: null as string | null,
|
||||
journal_entry_lines: lines,
|
||||
}
|
||||
}
|
||||
|
||||
function lineCredit(account: string, debit: number, sourceId: string) {
|
||||
return {
|
||||
account_number: account,
|
||||
debit_amount: debit,
|
||||
credit_amount: 0,
|
||||
journal_entry_id: je(sourceId),
|
||||
}
|
||||
function lineEU(account: string, credit: number): LineFx {
|
||||
return { account_number: account, debit_amount: 0, credit_amount: credit }
|
||||
}
|
||||
|
||||
function lineCredit(account: string, debit: number): LineFx {
|
||||
return { account_number: account, debit_amount: debit, credit_amount: 0 }
|
||||
}
|
||||
|
||||
function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx {
|
||||
@@ -166,7 +180,7 @@ function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat =
|
||||
|
||||
describe('generatePeriodiskSammanstallning', () => {
|
||||
it('empty period returns zero rows and zero warnings', async () => {
|
||||
// journal_entries: none match → the line fetch is skipped entirely.
|
||||
// journal_entries: none match → every lookup is skipped.
|
||||
results = [{ data: [], error: null }]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
@@ -176,13 +190,12 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
expect(report.totals.rowCount).toBe(0)
|
||||
expect(report.totals.grand).toBe(0)
|
||||
expect(report.period.label).toBe('Maj 2025')
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('single EU service sale → 1 row, type 3 only', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv-de')], error: null },
|
||||
{ data: [lineEU('3308', 10000, 'inv-de')], error: null },
|
||||
{ data: [entryEU('inv-de', [lineEU('3308', 10000)])], error: null },
|
||||
{ data: [invDE()], error: null },
|
||||
]
|
||||
|
||||
@@ -198,17 +211,17 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
})
|
||||
expect(report.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 })
|
||||
expect(report.warnings).toEqual([])
|
||||
// Engine entries resolve by source_id: no invoice-link round trips.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('aggregates multiple invoices to same customer', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1'), entryEU('inv2'), entryEU('inv3')], error: null },
|
||||
{
|
||||
data: [
|
||||
lineEU('3308', 4000, 'inv1'),
|
||||
lineEU('3308', 3500, 'inv2'),
|
||||
lineEU('3308', 2500, 'inv3'),
|
||||
entryEU('inv1', [lineEU('3308', 4000)]),
|
||||
entryEU('inv2', [lineEU('3308', 3500)]),
|
||||
entryEU('inv3', [lineEU('3308', 2500)]),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -230,12 +243,10 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('one customer with both services and goods → 1 row with both filled', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1'), entryEU('inv2')], error: null },
|
||||
{
|
||||
data: [
|
||||
lineEU('3308', 7000, 'inv1'),
|
||||
lineEU('3108', 5000, 'inv2'),
|
||||
entryEU('inv1', [lineEU('3308', 7000)]),
|
||||
entryEU('inv2', [lineEU('3108', 5000)]),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -253,12 +264,10 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('credit invoice nets against original in same period', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1'), entryCredit('cn1')], error: null },
|
||||
{
|
||||
data: [
|
||||
lineEU('3308', 10000, 'inv1'),
|
||||
lineCredit('3308', 3000, 'cn1'),
|
||||
entryEU('inv1', [lineEU('3308', 10000)]),
|
||||
entryCredit('cn1', [lineCredit('3308', 3000)]),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -276,12 +285,10 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1'), entryCredit('cn1')], error: null },
|
||||
{
|
||||
data: [
|
||||
lineEU('3308', 10000, 'inv1'),
|
||||
lineCredit('3308', 10000, 'cn1'),
|
||||
entryEU('inv1', [lineEU('3308', 10000)]),
|
||||
entryCredit('cn1', [lineCredit('3308', 10000)]),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -296,9 +303,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1')], error: null },
|
||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
||||
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||
{
|
||||
data: [{
|
||||
id: 'inv1',
|
||||
@@ -316,9 +321,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1')], error: null },
|
||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
||||
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||
{
|
||||
data: [{
|
||||
id: 'inv1',
|
||||
@@ -336,9 +339,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1')], error: null },
|
||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
||||
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||
{
|
||||
data: [{
|
||||
id: 'inv1',
|
||||
@@ -356,9 +357,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1')], error: null },
|
||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
||||
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||
{
|
||||
data: [{
|
||||
id: 'inv1',
|
||||
@@ -376,9 +375,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('Greek customer → country code emitted as EL', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1')], error: null },
|
||||
{ data: [lineEU('3308', 4200, 'inv1')], error: null },
|
||||
{ data: [entryEU('inv1', [lineEU('3308', 4200)])], error: null },
|
||||
{
|
||||
data: [{
|
||||
id: 'inv1',
|
||||
@@ -395,9 +392,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv1')], error: null },
|
||||
{ data: [lineEU('3108', 9000, 'inv1')], error: null },
|
||||
{ data: [entryEU('inv1', [lineEU('3108', 9000)])], error: null },
|
||||
{ data: [{ ...invDE('inv1') }], error: null },
|
||||
]
|
||||
|
||||
@@ -408,13 +403,11 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
|
||||
it('sorts rows by country then vat_number', async () => {
|
||||
results = [
|
||||
// journal_entries page for the two-step entry-lines fetch
|
||||
{ data: [entryEU('inv-fr'), entryEU('inv-de'), entryEU('inv-at')], error: null },
|
||||
{
|
||||
data: [
|
||||
lineEU('3308', 1000, 'inv-fr'),
|
||||
lineEU('3308', 2000, 'inv-de'),
|
||||
lineEU('3308', 3000, 'inv-at'),
|
||||
entryEU('inv-fr', [lineEU('3308', 1000)]),
|
||||
entryEU('inv-de', [lineEU('3308', 2000)]),
|
||||
entryEU('inv-at', [lineEU('3308', 3000)]),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -433,6 +426,19 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR'])
|
||||
})
|
||||
|
||||
it('an engine entry whose invoice is gone → CUSTOMER_NOT_FOUND error (a data defect, never silence)', async () => {
|
||||
results = [
|
||||
{ data: [entryEU('inv-gone', [lineEU('3308', 5000)])], error: null },
|
||||
{ data: [], error: null }, // invoices by id: nothing
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings.some(w => w.code === 'CUSTOMER_NOT_FOUND' && w.level === 'error')).toBe(true)
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0].hasBlockingIssue).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects yearly period type', async () => {
|
||||
await expect(
|
||||
generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1),
|
||||
@@ -440,6 +446,119 @@ describe('generatePeriodiskSammanstallning', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Invoice links beyond the engine's own source columns (#2298)
|
||||
// ============================================================
|
||||
|
||||
describe('invoice links beyond the engine source columns (#2298)', () => {
|
||||
it('files a SIE-imported sale matched to its invoice through invoice_payments', async () => {
|
||||
// The reported case: the importer wrote debit 1930 / credit 3308 with
|
||||
// source_type 'import', the user created the invoice in Accounted and
|
||||
// matched it to the imported verifikat (link_invoice_to_voucher). The link
|
||||
// lives on invoice_payments only; the entry keeps its source columns.
|
||||
results = [
|
||||
{ data: [entryOther('je-imp', 'import', [lineEU('3308', 12000)])], error: null },
|
||||
{ data: [], error: null }, // invoices by journal_entry_id: none
|
||||
{ data: [{ id: 'pay-1', invoice_id: 'inv-de', journal_entry_id: 'je-imp' }], error: null },
|
||||
{ data: [invDE()], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings).toEqual([])
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 12000 })
|
||||
expect(report.totals.services).toBe(12000)
|
||||
})
|
||||
|
||||
it('files a manual verifikat the invoice register points at through invoices.journal_entry_id', async () => {
|
||||
results = [
|
||||
{ data: [entryOther('je-man', 'manual', [lineEU('3308', 8000)])], error: null },
|
||||
{ data: [{ id: 'inv-de', journal_entry_id: 'je-man' }], error: null },
|
||||
{ data: [], error: null }, // invoice_payments: none
|
||||
{ data: [invDE()], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings).toEqual([])
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0]).toMatchObject({ country: 'DE', services: 8000 })
|
||||
})
|
||||
|
||||
it('files a kontantmetod inbetalning (invoice_cash_payment) by its source_id', async () => {
|
||||
// Cash-method companies book revenue at payment, so this is the only
|
||||
// entry that ever carries their 3308 postings.
|
||||
const entry = { ...entryOther('je-cash', 'invoice_cash_payment', [lineEU('3308', 6000)]), source_id: 'inv-de' }
|
||||
results = [
|
||||
{ data: [entry], error: null },
|
||||
{ data: [invDE()], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings).toEqual([])
|
||||
expect(report.rows[0]).toMatchObject({ country: 'DE', services: 6000 })
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('leaves an imported 3308 posting no invoice points at out of the filing, silently and without an invoice lookup', async () => {
|
||||
results = [
|
||||
{ data: [entryOther('je-loose', 'import', [lineEU('3308', 9000)])], error: null },
|
||||
{ data: [], error: null }, // invoices by journal_entry_id: none
|
||||
{ data: [], error: null }, // invoice_payments: none
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.rows).toEqual([])
|
||||
expect(report.warnings).toEqual([])
|
||||
// No invoice ids resolved → the invoices-by-id lookup is skipped.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('aggregates an engine invoice and a linked import to the same customer into one row', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
entryEU('inv-a', [lineEU('3308', 4000)]),
|
||||
entryOther('je-imp', 'import', [lineEU('3308', 6000)]),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // invoices by journal_entry_id
|
||||
{ data: [{ id: 'pay-1', invoice_id: 'inv-b', journal_entry_id: 'je-imp' }], error: null },
|
||||
{ data: [invDE('inv-a'), invDE('inv-b')], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0].services).toBe(10000)
|
||||
expect(report.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('does not double count an entry the engine tagged AND a payment row points at', async () => {
|
||||
// invoice_cash_payment entries carry source_id = invoice AND an
|
||||
// invoice_payments row: one posting, one attribution.
|
||||
const entry = { ...entryOther('je-cash', 'invoice_cash_payment', [lineEU('3308', 6000)]), source_id: 'inv-de' }
|
||||
results = [
|
||||
{ data: [entry, entryOther('je-imp', 'import', [lineEU('3308', 1000)])], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [
|
||||
{ id: 'pay-1', invoice_id: 'inv-de', journal_entry_id: 'je-cash' },
|
||||
{ id: 'pay-2', invoice_id: 'inv-de', journal_entry_id: 'je-imp' },
|
||||
], error: null },
|
||||
{ data: [invDE()], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0].services).toBe(7000)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Reconciliation
|
||||
// ============================================================
|
||||
@@ -459,8 +578,7 @@ describe('legacy country names on customers (#2028)', () => {
|
||||
const legacy = invDE()
|
||||
legacy.customer!.country = 'Germany'
|
||||
results = [
|
||||
{ data: [entryEU('inv-de')], error: null },
|
||||
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
|
||||
{ data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null },
|
||||
{ data: [legacy], error: null },
|
||||
]
|
||||
|
||||
@@ -475,8 +593,7 @@ describe('legacy country names on customers (#2028)', () => {
|
||||
const legacy = invDE()
|
||||
legacy.customer!.country = 'Atlantis'
|
||||
results = [
|
||||
{ data: [entryEU('inv-de')], error: null },
|
||||
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
|
||||
{ data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null },
|
||||
{ data: [legacy], error: null },
|
||||
]
|
||||
|
||||
@@ -486,3 +603,105 @@ describe('legacy country names on customers (#2028)', () => {
|
||||
expect(report.warnings.find((w) => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')?.message).toContain('ATLANTIS')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// One verifikat settling several invoices (#2298 review)
|
||||
// ============================================================
|
||||
|
||||
describe('one verifikat settling several invoices (#2298 review)', () => {
|
||||
function invFR(id: string): InvoiceFx {
|
||||
return {
|
||||
id,
|
||||
customer: {
|
||||
id: 'cust-fr',
|
||||
name: 'FR Customer',
|
||||
country: 'FR',
|
||||
vat_number: 'FR999',
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: RECENT,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An imported deposit (two PS lines) that a payment row links to two invoices. */
|
||||
function settlement(lines: LineFx[]) {
|
||||
return {
|
||||
...entryOther('je-imp', 'import', lines),
|
||||
voucher_series: 'A',
|
||||
voucher_number: 7,
|
||||
}
|
||||
}
|
||||
const twoPayments = [
|
||||
{ id: 'pay-1', invoice_id: 'inv-a', journal_entry_id: 'je-imp' },
|
||||
{ id: 'pay-2', invoice_id: 'inv-b', journal_entry_id: 'je-imp' },
|
||||
]
|
||||
|
||||
it('same customer on every linked invoice: filed once, in full', async () => {
|
||||
results = [
|
||||
{ data: [settlement([lineEU('3308', 10000)])], error: null },
|
||||
{ data: [], error: null }, // invoices by journal_entry_id
|
||||
{ data: twoPayments, error: null },
|
||||
{ data: [invDE('inv-a'), invDE('inv-b')], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings).toEqual([])
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 10000 })
|
||||
})
|
||||
|
||||
it('different customers on the linked invoices: blocking MIXED_CUSTOMER_SETTLEMENT, amount left out', async () => {
|
||||
results = [
|
||||
{ data: [settlement([lineEU('3308', 6000), lineEU('3108', 4000)])], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: twoPayments, error: null },
|
||||
{ data: [invDE('inv-a'), invFR('inv-b')], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.rows).toEqual([])
|
||||
expect(report.totals.grand).toBe(0)
|
||||
// Once per verifikat even though it carries two PS lines; blocking, so
|
||||
// the CSV route refuses the file (it keys on level === 'error').
|
||||
expect(report.warnings).toHaveLength(1)
|
||||
expect(report.warnings[0]).toMatchObject({
|
||||
level: 'error',
|
||||
code: 'MIXED_CUSTOMER_SETTLEMENT',
|
||||
journalEntryId: 'je-imp',
|
||||
amount: 10000,
|
||||
})
|
||||
expect(report.warnings[0].message).toContain('A7')
|
||||
expect(report.warnings[0].message).toContain('2 olika kunder')
|
||||
})
|
||||
|
||||
it('two customer rows with the same VAT number still count as different customers', async () => {
|
||||
results = [
|
||||
{ data: [settlement([lineEU('3308', 10000)])], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: twoPayments, error: null },
|
||||
{ data: [invDE('inv-a', 'cust-de'), invDE('inv-b', 'cust-de-duplicate')], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.rows).toEqual([])
|
||||
expect(report.warnings.map((w) => w.code)).toEqual(['MIXED_CUSTOMER_SETTLEMENT'])
|
||||
})
|
||||
|
||||
it('an engine entry is never a settlement: source_id names exactly one invoice', async () => {
|
||||
// Even if a payment row also points at it (invoice_cash_payment does),
|
||||
// the engine's own source_id wins and no settlement check runs.
|
||||
const entry = { ...entryOther('je-cash', 'invoice_cash_payment', [lineEU('3308', 5000)]), source_id: 'inv-a' }
|
||||
results = [
|
||||
{ data: [entry], error: null },
|
||||
{ data: [invDE('inv-a')], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings).toEqual([])
|
||||
expect(report.rows[0]).toMatchObject({ services: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { chunk } from '@/lib/utils'
|
||||
import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references'
|
||||
import { calculatePeriodDates, formatPeriodLabel } from './period-dates'
|
||||
import { calculateVatDeclaration } from './vat-declaration'
|
||||
import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
||||
@@ -17,6 +18,13 @@ import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
||||
* momsdeklaration Ruta 35/38/39 can never drift. See §1.2 of the plan.
|
||||
*
|
||||
* Notes:
|
||||
* - Which invoice a posting belongs to is resolved through every link the
|
||||
* register keeps (the engine's source_id, invoices.journal_entry_id,
|
||||
* invoice_payments.journal_entry_id), so a SIE-imported sale matched to
|
||||
* its invoice afterwards and a kontantmetod inbetalning are filed too
|
||||
* (#2298). A 3308/3108 posting no invoice points at is not filed (there
|
||||
* is no customer to name); the momsdeklaration reconciliation (ruta
|
||||
* 35/38/39) is where such a gap shows.
|
||||
* - Account 3305/3105 (non-EU export) are NOT in this report: they go to
|
||||
* Ruta 36/40 only.
|
||||
* - Trepartshandel (3107) is included so the report works if someone posts
|
||||
@@ -47,6 +55,8 @@ export type PsWarningCode =
|
||||
| 'CUSTOMER_NOT_FOUND'
|
||||
| 'ZERO_NET_EXCLUDED'
|
||||
| 'GOODS_SOLD_WITH_QUARTERLY_PERIOD'
|
||||
/** One verifikat linked to invoices of different customers: cannot be split per customer. */
|
||||
| 'MIXED_CUSTOMER_SETTLEMENT'
|
||||
|
||||
export interface PsWarning {
|
||||
level: 'error' | 'warning'
|
||||
@@ -55,6 +65,8 @@ export interface PsWarning {
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
invoiceId?: string
|
||||
/** The verifikat a MIXED_CUSTOMER_SETTLEMENT warning is about. */
|
||||
journalEntryId?: string
|
||||
amount?: number
|
||||
}
|
||||
|
||||
@@ -105,19 +117,37 @@ const ACCOUNT_TO_BUCKET: Record<string, 'services' | 'goods' | 'triangulation'>
|
||||
|
||||
const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET)
|
||||
|
||||
interface RawLine {
|
||||
/**
|
||||
* Source types the invoice engine writes with `source_id` = the register
|
||||
* invoice id AND that can carry EU revenue lines: issuance
|
||||
* (faktureringsmetod), credit notes, and the kontantmetod inbetalning, which
|
||||
* is where a cash-method company books its revenue at all.
|
||||
*/
|
||||
const INVOICE_SOURCED_ENTRY_TYPES = new Set(['invoice_created', 'credit_note', 'invoice_cash_payment'])
|
||||
|
||||
/** Ids per PostgREST `.in()` filter (URL-length convention, lib/worklist/categories.ts). */
|
||||
const LINK_LOOKUP_CHUNK = 100
|
||||
|
||||
interface RawEntryLine {
|
||||
account_number: string
|
||||
debit_amount: number | string
|
||||
credit_amount: number | string
|
||||
journal_entries: {
|
||||
company_id: string
|
||||
entry_date: string
|
||||
status: string
|
||||
source_type: string
|
||||
source_id: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
status: string
|
||||
source_type: string | null
|
||||
source_id: string | null
|
||||
/** Only the PS-account lines: the embed is filtered on account_number. */
|
||||
journal_entry_lines: RawEntryLine[] | null
|
||||
}
|
||||
|
||||
type FlatLine = RawEntryLine & { entry: RawEntry }
|
||||
|
||||
interface RawInvoice {
|
||||
id: string
|
||||
customer_id: string | null
|
||||
@@ -151,6 +181,34 @@ function round(value: number): number {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/** Voucher label for messages ("A123"), or the id when the entry has none. */
|
||||
function voucherLabel(entry: RawEntry): string {
|
||||
return entry.voucher_number != null
|
||||
? `${entry.voucher_series ?? ''}${entry.voucher_number}`
|
||||
: entry.id
|
||||
}
|
||||
|
||||
/** Net credit of the entry's PS-account lines: what the file would carry. */
|
||||
function entryNet(entry: RawEntry): number {
|
||||
let net = 0
|
||||
for (const line of entry.journal_entry_lines ?? []) {
|
||||
net += (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)
|
||||
}
|
||||
return net
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a linked invoice is filed under: the customer row plus the (country,
|
||||
* VAT number) pair its PS row would carry. Two invoices agree only when all
|
||||
* of it agrees; an invoice that could not be loaded is its own unknown party.
|
||||
*/
|
||||
function customerIdentity(invoice: RawInvoice | undefined, invoiceId: string): string {
|
||||
const customer = invoice?.customer
|
||||
if (!customer) return `unknown:${invoiceId}`
|
||||
const country = (customer.country ?? '').trim().toUpperCase()
|
||||
return `${customer.id}|${country}|${normalizeVatNumber(customer.vat_number)}`
|
||||
}
|
||||
|
||||
interface Accumulator {
|
||||
country: string
|
||||
vatNumber: string
|
||||
@@ -183,33 +241,58 @@ export async function generatePeriodiskSammanstallning(
|
||||
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
const lines = await fetchEntryLines<RawLine>({
|
||||
supabase,
|
||||
entryColumns: 'company_id, entry_date, status, source_type, source_id',
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
// Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales
|
||||
// always go through AR); excluded to avoid phantom rows.
|
||||
.in('source_type', ['invoice_created', 'credit_note'])
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end),
|
||||
filterLines: (q: EntryLinesQuery) => q.in('account_number', PS_ACCOUNTS),
|
||||
})
|
||||
|
||||
const invoiceIds = Array.from(
|
||||
new Set(
|
||||
lines
|
||||
.map(l => l.journal_entries?.source_id)
|
||||
.filter((id): id is string => typeof id === 'string'),
|
||||
),
|
||||
// Driven from journal_entries (company + date indexed) with the EU-revenue
|
||||
// condition as an inner embed: the planner probes journal_entry_lines per
|
||||
// entry, so only entries carrying a posting on a PS account come back, with
|
||||
// just those lines. Never the inverse shape (lines with an entries embed):
|
||||
// see lib/bookkeeping/entry-lines.ts. No source_type filter: which register
|
||||
// invoice a posting belongs to is resolved below through every link the
|
||||
// register keeps, not only the engine's own source columns.
|
||||
const entries = await fetchAllRows<RawEntry>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_series, voucher_number, entry_date, status, source_type, source_id, journal_entry_lines!inner(account_number, debit_amount, credit_amount)')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end)
|
||||
.in('journal_entry_lines.account_number', PS_ACCOUNTS)
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to) as unknown as PromiseLike<{ data: RawEntry[] | null; error: { message: string } | null }>,
|
||||
)
|
||||
|
||||
// Which register invoice does each posting belong to? Three links; only
|
||||
// the first lives on the entry itself:
|
||||
// 1. the engine's own entries: source_id IS the invoice id;
|
||||
// 2. invoices.journal_entry_id (registration booking, backfilled);
|
||||
// 3. invoice_payments.journal_entry_id: kontantmetod inbetalning,
|
||||
// delbetalning, and "matcha mot befintligt verifikat", which is how a
|
||||
// SIE-imported sale gets its invoice after migration (#2298).
|
||||
// Following 1 alone (the old source_type filter) dropped every linked
|
||||
// import and every kontantmetod sale from the filing while the
|
||||
// account-based momsdeklaration kept showing them in ruta 39.
|
||||
// Every invoice each entry resolves to. The engine's own entries name one
|
||||
// (source_id); a linked entry may name several when one inbetalning settled
|
||||
// several invoices. All of them are loaded so the loop below can tell "two
|
||||
// invoices, one customer" from "two customers on one posting".
|
||||
const invoiceIdsByEntry = new Map<string, string[]>()
|
||||
for (const entry of entries) {
|
||||
if (entry.source_id && INVOICE_SOURCED_ENTRY_TYPES.has(entry.source_type ?? '')) {
|
||||
invoiceIdsByEntry.set(entry.id, [entry.source_id])
|
||||
}
|
||||
}
|
||||
const unresolved = entries.filter((e) => !invoiceIdsByEntry.has(e.id)).map((e) => e.id)
|
||||
for (const ids of chunk(unresolved, LINK_LOOKUP_CHUNK)) {
|
||||
const refs = await getInvoiceReferencesForJournalEntries(supabase, companyId, ids)
|
||||
for (const [entryId, invoiceIds] of refs) invoiceIdsByEntry.set(entryId, invoiceIds)
|
||||
}
|
||||
|
||||
const allInvoiceIds = new Set<string>()
|
||||
for (const ids of invoiceIdsByEntry.values()) for (const id of ids) allInvoiceIds.add(id)
|
||||
|
||||
const invoiceMap = new Map<string, RawInvoice>()
|
||||
if (invoiceIds.length > 0) {
|
||||
for (const ids of chunk(Array.from(allInvoiceIds), LINK_LOOKUP_CHUNK)) {
|
||||
const invoices = await fetchAllRows<RawInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
@@ -225,7 +308,8 @@ export async function generatePeriodiskSammanstallning(
|
||||
vat_number_validated_at
|
||||
)
|
||||
`)
|
||||
.in('id', invoiceIds)
|
||||
.eq('company_id', companyId)
|
||||
.in('id', ids)
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>,
|
||||
@@ -233,19 +317,54 @@ export async function generatePeriodiskSammanstallning(
|
||||
for (const inv of invoices) invoiceMap.set(inv.id, inv)
|
||||
}
|
||||
|
||||
// One flat line list with its parent entry, in entry-id then line order.
|
||||
const lines: FlatLine[] = []
|
||||
for (const entry of entries) {
|
||||
for (const line of entry.journal_entry_lines ?? []) lines.push({ ...line, entry })
|
||||
}
|
||||
|
||||
const accumulators = new Map<string, Accumulator>()
|
||||
const warnings: PsWarning[] = []
|
||||
let goodsLineSeen = false
|
||||
// Verifikat already reported as MIXED_CUSTOMER_SETTLEMENT: one warning per
|
||||
// verifikat, not one per line.
|
||||
const mixedReported = new Set<string>()
|
||||
|
||||
for (const line of lines) {
|
||||
const je = line.journal_entries
|
||||
if (!je) continue
|
||||
const sourceId = je.source_id
|
||||
const invoice = sourceId ? invoiceMap.get(sourceId) : null
|
||||
const bucket = ACCOUNT_TO_BUCKET[line.account_number]
|
||||
if (!bucket) continue
|
||||
const invoiceIds = invoiceIdsByEntry.get(line.entry.id)
|
||||
// A manual or imported posting no register invoice points at is not
|
||||
// filed (see the header). The engine's own entries never take this exit:
|
||||
// an engine entry whose invoice is gone is a data defect and falls
|
||||
// through to CUSTOMER_NOT_FOUND below.
|
||||
if (!invoiceIds && !INVOICE_SOURCED_ENTRY_TYPES.has(line.entry.source_type ?? '')) continue
|
||||
if (bucket === 'goods' || bucket === 'triangulation') goodsLineSeen = true
|
||||
|
||||
// One posting, several invoices (a deposit settling more than one): fine
|
||||
// while they are the same customer, undecidable when they are not. The
|
||||
// ledger cannot split the line per customer, so the verifikat is kept out
|
||||
// of the file and reported as blocking, the way CUSTOMER_NOT_FOUND is.
|
||||
if (invoiceIds && invoiceIds.length > 1) {
|
||||
const customers = new Set(invoiceIds.map((id) => customerIdentity(invoiceMap.get(id), id)))
|
||||
if (customers.size > 1) {
|
||||
if (!mixedReported.has(line.entry.id)) {
|
||||
mixedReported.add(line.entry.id)
|
||||
warnings.push({
|
||||
level: 'error',
|
||||
code: 'MIXED_CUSTOMER_SETTLEMENT',
|
||||
message:
|
||||
`Verifikat ${voucherLabel(line.entry)} är kopplat till fakturor från ${customers.size} olika kunder ` +
|
||||
'och kan inte fördelas per kund i sammanställningen. Kontrollera kopplingarna innan inlämning.',
|
||||
journalEntryId: line.entry.id,
|
||||
amount: entryNet(line.entry),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
const invoice = invoiceIds ? invoiceMap.get(invoiceIds[0]) ?? null : null
|
||||
|
||||
const debit = Number(line.debit_amount) || 0
|
||||
const credit = Number(line.credit_amount) || 0
|
||||
const net = credit - debit
|
||||
|
||||
Reference in New Issue
Block a user