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
@@ -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