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:
co-authored by
Claude Opus 4.8
parent
358c25094d
commit
f6ee0c2a82
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user