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
@@ -13,6 +13,7 @@ function makeBuilder() {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
@@ -1050,3 +1051,65 @@ describe('calculateVatDeclaration — parent/summary accounts', () => {
expect(result.rutor.ruta49).toBe(2719.55) // 9768 − 7048.45, owed (was −7048.45 pre-fix)
})
})
describe('calculateVatDeclaration — annual VAT spans the räkenskapsår', () => {
it('uses the fiscal period bounds for yearly when a fiscalPeriodId is given', async () => {
// Förlängt räkenskapsår (extended first year, 18 months) — annual VAT
// (helårsmoms) must cover the whole period, not the calendar year that
// period_start falls in. The first queued result feeds the fiscal_periods
// lookup, the second the journal lines, the third the entry counts.
results = [
{ data: { period_start: '2025-07-03', period_end: '2026-12-31' }, error: null },
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 21600 },
{ account_number: '2610', debit_amount: 0, credit_amount: 9768 },
{ account_number: '2641', debit_amount: 7048.45, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'fp-1' },
)
expect(result.period.start).toBe('2025-07-03')
expect(result.period.end).toBe('2026-12-31')
expect(result.rutor.ruta05).toBe(21600)
expect(result.rutor.ruta10).toBe(9768)
expect(result.rutor.ruta48).toBe(7048.45)
})
it('falls back to the calendar year when the fiscal period cannot be resolved', async () => {
results = [
{ data: null, error: null }, // fiscal_periods lookup → not found
{ data: [], error: null }, // journal lines
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'missing' },
)
expect(result.period.start).toBe('2026-01-01')
expect(result.period.end).toBe('2026-12-31')
})
it('ignores fiscalPeriodId for monthly periods (calendar month, no lookup)', async () => {
// No fiscal_periods lookup is made for monthly, so the first queued result
// is the journal lines — proving the räkenskapsår path is yearly-only.
results = [
{ data: [], error: null }, // journal lines
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'monthly', 2026, 3, 'accrual', { fiscalPeriodId: 'fp-1' },
)
expect(result.period.start).toBe('2026-03-01')
expect(result.period.end).toBe('2026-03-31')
})
})
+6 -1
View File
@@ -296,12 +296,17 @@ async function generatePeriodReports(
let vatDeclaration: unknown = null
try {
const startDate = new Date(period.period_start)
// Annual VAT for an archive must cover the whole räkenskapsår, which may be
// extended/shortened — pass the fiscal period so the span isn't truncated to
// the calendar year that period_start happens to fall in.
vatDeclaration = await calculateVatDeclaration(
supabase,
companyId,
'yearly',
startDate.getFullYear(),
1
1,
'accrual',
{ fiscalPeriodId: period.id }
)
} catch {
// VAT declaration may fail if no relevant entries exist — skip gracefully
+45 -2
View File
@@ -185,6 +185,44 @@ function round(value: number): number {
return Math.round(value * 100) / 100
}
/**
* Resolve the start/end dates for a VAT period.
*
* Monthly and quarterly VAT periods are always calendar months/quarters
* (kalendermånad / kalenderkvartal per SFL 26 kap), so they use the plain
* calendar calculation.
*
* Annual VAT (helårsmoms), however, is reported per *räkenskapsår* — the
* beskattningsår — not per calendar year (SFL 26 kap 10–11 §§). A räkenskapsår
* can be extended or shortened (up to 18 months for a first/changed year per
* BFL 3 kap 3 §), so a calendar Jan–Dec span would silently drop part of an
* extended year (e.g. a first year 2025-07-03 → 2026-12-31). When the caller
* supplies the fiscal period we therefore use its actual bounds. If the period
* can't be resolved we fall back to the calendar span so behaviour degrades
* gracefully instead of erroring.
*/
async function resolvePeriodDates(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
fiscalPeriodId?: string
): Promise<{ start: string; end: string }> {
if (periodType === 'yearly' && fiscalPeriodId) {
const { data: fp } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
if (fp?.period_start && fp?.period_end) {
return { start: fp.period_start, end: fp.period_end }
}
}
return calculatePeriodDates(periodType, year, period)
}
/**
* Calculate VAT declaration from the general ledger.
*
@@ -203,9 +241,14 @@ export async function calculateVatDeclaration(
periodType: VatPeriodType,
year: number,
period: number,
_accountingMethod: AccountingMethod = 'accrual'
_accountingMethod: AccountingMethod = 'accrual',
options: { fiscalPeriodId?: string } = {}
): Promise<VatDeclaration> {
const { start, end } = calculatePeriodDates(periodType, year, period)
// For yearly VAT this resolves to the räkenskapsår bounds (when a fiscal
// period is supplied), not the calendar year — see resolvePeriodDates.
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
const lines = await fetchAllRows<{