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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user