Bug/customer invoice bug (#628)

* fix(supplier-invoices): self-assess reverse-charge VAT + link payments to vouchers

Reverse-charge supplier invoices now carry a per-item reverse_charge_rate (0.06/0.12/0.25). Under omvänd skattskyldighet the supplier charges 0% VAT, so the line vat_rate stays 0 and the buyer self-assesses fiktiv moms at the statutory rate. Centralizes rate resolution (resolveReverseChargeRate) and the ruta 20-24 basis-account guard (isReverseChargeBasisAccount) in vat-entries so the booking engine and review-dialog preview can no longer drift.

Adds the link_supplier_invoice_voucher pending operation: mark a leverantorsfaktura paid by linking an existing posted verifikat that debits 2440, with no new journal entry. Exposes find-candidates/link MCP tools and the bulk-reconcile helper, scoped under suppliers:read/write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vat): report yearly VAT over the rakenskapsar, not the calendar year

Annual VAT (helarsmoms) is filed per beskattningsar/rakenskapsar (SFL 26 kap), which can be extended or shortened up to 18 months. The previous Jan-Dec calendar span silently dropped part of an extended first year. calculateVatDeclaration now accepts a fiscalPeriodId and resolves the period's actual bounds for yearly; monthly/quarterly stay calendar. The reports UI passes the selected fiscal period, defaults the periodicity from the company's moms_period setting, and carries the period into the ruta drill-down. full-archive export threads the period id through too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): resolve supplier invoice status from payment amounts

The provider's lifecycle status and its payment status are computed independently upstream and can contradict each other (e.g. a Fortnox invoice marked booked but fully paid). Both the arcim entity-mapper and the Fortnox mapper now let payment state win: fully paid -> paid, partial -> partially_paid, otherwise the mapped lifecycle status, with credit notes forced terminal. Balance is compared numerically (never strict === 0) so float drift or a residual ore resolves cleanly, and an absent Balance is treated as unpaid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(enable-banking): only ingest booked transactions to stop re-import drift

Pending entries are skipped during sync: a pending row is unstable across syncs (a later 'synka nu' returns it still pending or finally booked, often with a different effective date). Because both the dedup external_id and the content-dedup key are date-derived, that drift minted a new id and re-imported a transaction that already existed - observed in production as the same amount+description landing twice with different dates. Gating the import set on a stable booking_date removes the drift at the source and leaves booked rows' ids byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(gitignore): ignore local SIE test fixtures

tests/fixtures/sie/ may contain real or scrubbed company data and must never be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoice): handle errors during registration journal entry creation and ensure invoice rollback
feat(tests): add test for reverse charge rate handling on supplier invoice line items
feat(fortnox): ensure paid status reflects zero balance for fully paid invoices
chore(migrations): add reverse_charge_rate to supplier_invoice_items and backfill link_supplier_invoice_voucher

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-02 13:25:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 358c25094d
commit f6ee0c2a82
43 changed files with 2226 additions and 182 deletions
@@ -184,7 +184,7 @@ describe('syncAccountTransactions', () => {
it('passes raw transactions to ingest function', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' } }],
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
@@ -222,6 +222,83 @@ describe('syncAccountTransactions', () => {
expect(rawTxns[0].import_source).toBe('enable_banking')
})
it('skips pending entries (no booking_date) and ingests only booked ones', async () => {
// A pending PSD2 entry has no booking_date — only a value_date. Importing it
// is what produced the production duplicates: its date (hence its dedup id)
// differs from the same transaction's booked representation. Pending entries
// must be skipped; booked ones import as usual, keyed on booking_date.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'T',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [
{ transaction_amount: { amount: '50', currency: 'SEK' }, value_date: '2026-02-15' }, // pending → skipped
{ transaction_amount: { amount: '75', currency: 'SEK' }, booking_date: '2026-03-01' }, // booked → kept
],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const batch = mockIngest.mock.calls[0][3]
expect(batch).toHaveLength(1)
expect(batch[0].date).toBe('2026-03-01')
expect(batch[0].external_id).toBe('eb_acc-uid-1_2026-03-01_-7500_0')
})
it('does not re-import a booked transaction when a later sync returns it pending (no booking_date)', async () => {
// Regression for the 45→90 duplication. The same transaction is returned
// booked in sync 1 (booking_date 2026-04-21) and pending in sync 2 (only
// value_date 2026-02-15). Previously sync 2 ingested the pending copy with a
// value_date-derived id (different date → new id → duplicate). It must now
// be skipped, so sync 2 ingests nothing for it.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'AVI ÖVERDRAG',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// Sync 1 — booked
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-21', value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const firstBatch = mockIngest.mock.calls[0][3]
expect(firstBatch).toHaveLength(1)
expect(firstBatch[0].date).toBe('2026-04-21')
expect(firstBatch[0].external_id).toBe('eb_acc-uid-1_2026-04-21_-10000_0')
// Sync 2 — same transaction now returned pending (booking_date dropped)
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const secondBatch = mockIngest.mock.calls[1][3]
expect(secondBatch).toHaveLength(0)
})
it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => {
// Two genuinely distinct transactions that share date + amount must both be
// kept (distinct ids), and re-running the sync must reproduce the SAME set
+49 -13
View File
@@ -100,32 +100,68 @@ export async function syncAccountTransactions(
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
// Derive a stable, content-based external_id per transaction. We deliberately
// do NOT key off the bank's transaction id (entry_reference/transaction_id):
// many Swedish ASPSPs regenerate those across requests, so a repeat "synka nu"
// produced a fresh id and re-imported transactions the user had already
// booked. buildStableExternalIds derives the id from (account, date, amount)
// plus an occurrence index, so re-syncs collide on (company_id, external_id)
// and dedupe while genuinely identical transactions are still kept apart.
// Normalize the IBAN (strip whitespace, uppercase) so formatting variants
// from the ASPSP ("SE45 5000 …" vs "SE455000…") don't change the scope and
// orphan every prior external_id. Falls back to the provider account uid.
// Only ingest BOOKED transactions — those the ASPSP returned with a real
// booking_date. Pending entries are intentionally skipped: a pending row is
// unstable across syncs (a later "synka nu" returns the same transaction
// either still pending or finally booked, often with a *different* effective
// date). Because BOTH the dedup external_id and the content-dedup key are
// date-derived, that drift mints a brand-new id and re-imports a transaction
// that already exists. Observed in production as the same amount+description
// landing twice with different dates — the bank's value_date in one sync, its
// booking_date in another. Gating the import set on a stable booking_date
// removes the drift at the source, and leaves booked rows' ids byte-identical
// (so the existing rows are NOT re-orphaned).
//
// booking_date is read from the RAW transaction (transactions[i]), index-
// aligned with bankTransactions: convertTransaction's booking_date already
// falls back to value_date/today, so it cannot tell booked from pending.
const bookedEntries = bankTransactions.flatMap((tx, i) => {
const bookingDate = transactions[i]?.booking_date
return typeof bookingDate === 'string' && bookingDate.trim() !== ''
? [{ tx, bookingDate: bookingDate.trim() }]
: []
})
const skippedPending = bankTransactions.length - bookedEntries.length
if (skippedPending > 0) {
console.log('[enable-banking] Skipped pending transactions (no booking_date)', {
connectionId,
accountUid: account.uid,
skippedPending,
total: bankTransactions.length,
})
}
// Derive a stable, content-based external_id per booked transaction. We
// deliberately do NOT key off the bank's transaction id (entry_reference/
// transaction_id): many Swedish ASPSPs regenerate those across requests, so a
// repeat "synka nu" produced a fresh id and re-imported transactions the user
// had already booked. buildStableExternalIds derives the id from (account,
// booking_date, amount) plus an occurrence index, so re-syncs collide on
// (company_id, external_id) and dedupe while genuinely identical transactions
// are still kept apart. Normalize the IBAN (strip whitespace, uppercase) so
// formatting variants from the ASPSP ("SE45 5000 …" vs "SE455000…") don't
// change the scope and orphan every prior external_id. Falls back to the
// provider account uid.
const accountScope = account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid
const externalIds = buildStableExternalIds(
'eb',
accountScope,
bankTransactions.map((tx) => ({ date: tx.booking_date || tx.date, amount: tx.amount }))
bookedEntries.map(({ tx, bookingDate }) => ({ date: bookingDate, amount: tx.amount }))
)
// Convert Enable Banking format to generic RawTransaction. counterparty
// identification: prefer IBAN (international, normalized) over BBAN/BG
// numbers — the own-account detector matches on IBAN first, falling back
// to counterparty_account for Swedish domestic transfers.
const rawTransactions: RawTransaction[] = bankTransactions.map((tx, i) => {
const rawTransactions: RawTransaction[] = bookedEntries.map(({ tx, bookingDate }, i) => {
const cpAccount = tx.counterparty_account ?? null
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
return {
date: tx.booking_date || tx.date,
// The booked date is both the stable dedup anchor (see bookedEntries) and
// the accounting-correct ledger date; keep it identical to the value the
// external_id was derived from.
date: bookingDate,
// tx.description is already non-empty (convertTransaction guarantees a
// label); the trailing fallbacks are defensive. Ingest re-normalizes.
description: tx.description || tx.counterparty_name || FALLBACK_DESCRIPTION,