From 33a58bec5144cbeed913a353b30ae9c2dbb0ec8e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 28 Aug 2026 17:22:38 +0200 Subject: [PATCH] fix(webshop-orders): shared effective-rate helper and order-context refusal for the rate-0 slot (#1912) (#2008) * fix(webshop): share rate classification and check rate-0 order context in bulk book (#1912) The bulk revenue template's guard copied fetchDynamicVatAccounts' effective-rate precedence (explicit momssats > treatment > class-3 number+name inference), so the two could drift. Both now call one exported helper, resolveEffectiveVatRate, and a sibling resolveRevenueVatBox resolves the momsdeklaration box for a revenue account (treatment ruta first, then the static BAS map). The rate-0 slot also ignored order context: a domestic 0% order could be routed to an export account (ruta 36) and vice versa, misstating rutor 35-42 with no VAT amount to catch it. The sweep now refuses, per order, a 0% bucket whose billing country contradicts the chosen account's box: ruta 36 vs SE or an EU country, ruta 40 vs SE, ruta 35/38/39 vs SE or a non-EU country. Unknown country (Shopify), domestic boxes (42/41/07) and unclassified accounts are unchanged; the domestic-account + foreign- country direction stays advisory in the dialog. Item 1 of the issue (require a positive momsfri/export/EU classification for the slot) is deferred: most such accounts are unconfigured today and the strict rule needs a configure path first (DECISIONS.md). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(webshop): address review findings (#1912) - Finding 1: the rate-0 context guard keys on customer_country, which the WooCommerce sync stores from the billing address; the goods boxes 35/36/38 follow the delivery destination, so a Swedish-billed order shipped outside the EU is a legitimate ruta 36 export the sweep refuses. Soften the WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH copy (sv/en) to say the check is based on the billing country and the account may still be right for the delivery address, and ask the user to confirm rather than change the account. Document the limitation in the route comment; storing shipping country in the sync is a follow-up. Test pins the new wording. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../__tests__/bulk-book.test.ts | 255 ++++++++++++++++++ app/api/webshop-orders/bulk-book/route.ts | 105 ++++++-- lib/errors/structured-errors.ts | 7 + .../__tests__/vat-revenue-accounts.test.ts | 246 +++++++++++++++++ lib/reports/vat-revenue-accounts.ts | 67 ++++- lib/vat/__tests__/eu-countries.test.ts | 23 ++ lib/vat/eu-countries.ts | 11 + 8 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 lib/reports/__tests__/vat-revenue-accounts.test.ts create mode 100644 lib/vat/__tests__/eu-countries.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index e98329f2..37d436f7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1323,6 +1323,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-28] AR-PDF minus fix uses ASCII hyphen formatting, not font embedding: registering a Unicode TTF for react-pdf would change the whole document's typography and bundle size to fix one glyph; formatPdfKronor keeps built-in Helvetica and sidesteps WinAnsi's missing U+2212. [2026-08-28] Same-bank warning limited to observed one-session banks (SEB only): prod shows Handelsbanken tolerates 4 concurrent sessions, and the generic warning made a user abandon a legitimate renewal. Planned sync-death visibility work was dropped: already shipped via #1271 (health probe), #1727 (stale state), #1969 (cron unstarve). [2026-08-28] Same-bank warning revised to three tiers after skeptic refutation: hard warn SEB, silent/calm only for verified multi-session banks (Handelsbanken, 4 distinct session_ids observed), legacy hedged warning for unknown banks (fail closed), shared-session siblings exempt (fan-out carries them). +[2026-08-28] Bulk revenue rate-0 slot (#1912): item 1 (require a positive momsfri/export/EU classification) stays deferred because most real momsfri/export accounts are unconfigured today and refusing them needs a configure path first (the issue's own trade-off); resolveRevenueVatBox makes it a one-line switch later. The order-context check refuses only the export/EU-account direction (ruta 36 vs SE/EU country, ruta 40 vs SE, ruta 35/38/39 vs SE/non-EU), because the domestic-account + foreign-country direction is already advisory in the dialog (zero_rate_foreign) and hard-blocking it would regress every untemplated sweep; ruta 40 is 'omsatta utom landet', so an EU country does not contradict it. EXPORT_BOXES from moms-box-mapping was not reused because it merges EU and export boxes. [2026-08-28] Verifikat page promotes "Stryk rader i verifikatet" to a visible outline button only when the period-status endpoint answers open (anything else or unknown keeps it in the ⋯ menu only), and the ⋯ item stays: inline rättelse is the normal path in an open period (#1554), the promoted button must never invite an action the RPC will refuse, and the menu remains the complete action list per the 2026-08-20 detail-page grammar. Struck-line actor labels are resolved server-side via resolveUserLabelsFromProfiles (profiles RLS is self-only) rather than a new column on the log. [2026-08-28] Employee-save failure reported inline (role=alert in the dialog footer, carrying the requestId) in addition to the single destructive toast, and a missing PERSONNUMMER_ENCRYPTION_KEY typed as 503 PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED rather than INTERNAL_ERROR (#1996): the Radix modal aria-hides the root-layout Toaster while the dialog is open, so the toast is invisible to assistive tech and E2E drivers; TOAST_LIMIT is 1, so a second toast is not an option; and the missing key is a permanent configuration gap where "try again later" is wrong and "contact support" is right (same reasoning as CUSTOMER_PERSONAL_NUMBER_UNREADABLE and INVOICE_SEND_EMAIL_NOT_CONFIGURED). The shared postAction helper was not extended (it takes no body and exposes no requestId): keeping the change local to the dialog avoids widening a helper other panels rely on. [2026-08-28] /migrate SIE guard extended to every provider (Fortnox exemption removed) as "a completed SIE import must exist for the company", not "must be part of this run", plus a wizard hint that disables Start when SIE is unchecked and never imported; chose this over forcing the checkbox on because the route is the only seam a direct API call or a stale client cannot bypass, and "must exist" keeps entities-only re-runs after a full migration working (#2000). diff --git a/app/api/webshop-orders/__tests__/bulk-book.test.ts b/app/api/webshop-orders/__tests__/bulk-book.test.ts index b23f99ba..ed5edaa9 100644 --- a/app/api/webshop-orders/__tests__/bulk-book.test.ts +++ b/app/api/webshop-orders/__tests__/bulk-book.test.ts @@ -919,4 +919,259 @@ describe('POST /api/webshop-orders/bulk-book', () => { expect(body.data.results).toHaveLength(1) expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1) }) + + describe('rate-0 slot order context (#1912)', () => { + const ZERO_RATE_ORDER = { + total: 500, + total_sek: 500, + total_tax: 0, + vat_breakdown: [{ rate: 0, net: 500, tax: 0 }], + } + const zeroRateOrder = ( + id: string, + customer_country: string | null, + extra: Record = {}, + ) => makeOrderRow({ id, ...ZERO_RATE_ORDER, customer_country, ...extra }) + + const chartRow = ( + account_number: string, + account_name: string, + default_vat_treatment: string | null = null, + ) => ({ + account_number, + account_name, + is_active: true, + default_vat_rate: null, + default_vat_treatment, + }) + const EXPORT_GOODS = chartRow('3105', 'Försäljning varor till land utanför EU') + const EU_GOODS = chartRow('3108', 'Försäljning varor till annat EU-land') + const EXPORT_SERVICES = chartRow('3305', 'Försäljning tjänster utanför EU') + + async function runOne( + order: Record, + chart: Record, + account: string, + claim: boolean, + ) { + enqueue({ data: [order] }) + enqueue({ data: [] }) // store settings + enqueue({ data: [chart] }) // chart check + if (claim) enqueue({ data: [{ id: order.id }] }) + return parseJsonResponse( + await postBulk({ order_ids: [order.id], revenue_accounts: { '0': account } }), + ) + } + + it('refuses an export goods account (ruta 36) for a Swedish billing country', async () => { + const { status, body } = await runOne( + zeroRateOrder(ORDER_1, 'SE'), + EXPORT_GOODS, + '3105', + false, + ) + expect(status).toBe(200) + expect(body.data.failed_count).toBe(1) + expect(body.data.results[0].error?.code).toBe( + 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', + ) + expect(body.data.results[0].error?.details).toEqual({ + account: '3105', + box: '36', + customer_country: 'SE', + }) + expect(body.data.results[0].error?.message).toContain('ruta 35-42') + // The check keys on the billing country only; the copy must say so and + // must not assert that the account is wrong (a Swedish-billed order + // shipped outside the EU is a legitimate ruta 36 export). + expect(body.data.results[0].error?.message).toContain('faktureringslandet') + expect(body.data.results[0].error?.message).toContain('leveransadressen') + expect(body.data.results[0].error?.message).not.toContain('välj rätt konto') + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('refuses an export goods account (ruta 36) for an EU billing country', async () => { + // Varuförsäljning to another EU country is ruta 35, never ruta 36. + const { body } = await runOne(zeroRateOrder(ORDER_1, 'de'), EXPORT_GOODS, '3105', false) + expect(body.data.results[0].error?.code).toBe( + 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', + ) + expect(body.data.results[0].error?.details?.customer_country).toBe('DE') + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('books an export goods account for a non-EU billing country', async () => { + const { status, body } = await runOne( + zeroRateOrder(ORDER_1, 'US'), + EXPORT_GOODS, + '3105', + true, + ) + expect(status).toBe(200) + expect(body.data.booked_count).toBe(1) + const lines = ( + mockCreateDraftEntry.mock.calls[0][3] as { + lines: { account_number: string; credit_amount: number }[] + } + ).lines + expect(lines.find((l) => l.account_number === '3105')?.credit_amount).toBe(500) + }) + + it('refuses an EU goods account (ruta 35) for a non-EU or Swedish billing country', async () => { + const us = await runOne(zeroRateOrder(ORDER_1, 'US'), EU_GOODS, '3108', false) + expect(us.body.data.results[0].error?.code).toBe( + 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', + ) + expect(us.body.data.results[0].error?.details?.box).toBe('35') + + reset() + const se = await runOne(zeroRateOrder(ORDER_1, 'SE'), EU_GOODS, '3108', false) + expect(se.body.data.results[0].error?.code).toBe( + 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', + ) + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('books an EU goods account for another EU billing country', async () => { + const { body } = await runOne(zeroRateOrder(ORDER_1, 'DE'), EU_GOODS, '3108', true) + expect(body.data.booked_count).toBe(1) + }) + + it('refuses a services-abroad account (ruta 40) only for Sweden, not for EU', async () => { + // Ruta 40 is "omsatta utom landet": outside Sweden, which includes + // EU countries for services outside huvudregeln. Only SE contradicts. + const se = await runOne(zeroRateOrder(ORDER_1, 'SE'), EXPORT_SERVICES, '3305', false) + expect(se.body.data.results[0].error?.code).toBe( + 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', + ) + expect(se.body.data.results[0].error?.details?.box).toBe('40') + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + + reset() + const de = await runOne(zeroRateOrder(ORDER_1, 'DE'), EXPORT_SERVICES, '3305', true) + expect(de.body.data.booked_count).toBe(1) + }) + + it('resolves the box from a configured treatment on a custom account', async () => { + // A company-specific 3060 with treatment export_goods is ruta 36 + // even though the static BAS map knows nothing about it. + const { body } = await runOne( + zeroRateOrder(ORDER_1, 'SE'), + chartRow('3060', 'Konsultarvode utland', 'export_goods'), + '3060', + false, + ) + expect(body.data.results[0].error?.code).toBe( + 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', + ) + expect(body.data.results[0].error?.details?.box).toBe('36') + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('lets a configured treatment override the static box', async () => { + // 3105 is statically ruta 36, but the company configured it as + // exempt (ruta 42): a domestic order is then fine. + const { body } = await runOne( + zeroRateOrder(ORDER_1, 'SE'), + chartRow('3105', 'Momsfri försäljning', 'exempt'), + '3105', + true, + ) + expect(body.data.booked_count).toBe(1) + }) + + it('books when the billing country is unknown (Shopify stores null)', async () => { + const { body } = await runOne(zeroRateOrder(ORDER_1, null), EXPORT_GOODS, '3105', true) + expect(body.data.booked_count).toBe(1) + }) + + it('ignores the rate-0 template when the order has no 0% bucket', async () => { + // 25%-only order: the 3105 slot is never used, so no context to check. + const { body } = await runOne( + makeOrderRow({ customer_country: 'SE' }), + EXPORT_GOODS, + '3105', + true, + ) + expect(body.data.booked_count).toBe(1) + }) + + it('ignores a 0% bucket with zero net', async () => { + const { body } = await runOne( + zeroRateOrder(ORDER_1, 'SE', { + total: 500, + total_sek: 500, + total_tax: 100, + vat_breakdown: [ + { rate: 25, net: 400, tax: 100 }, + { rate: 0, net: 0, tax: 0 }, + ], + }), + EXPORT_GOODS, + '3105', + true, + ) + expect(body.data.booked_count).toBe(1) + }) + + it('keeps the domestic direction advisory: exempt account + foreign country books', async () => { + // The dialog already warns (zero_rate_foreign); refusing here would + // regress every untemplated sweep, so 3004/ruta 42 is never blocked. + const { body } = await runOne( + zeroRateOrder(ORDER_1, 'DE'), + chartRow('3060', 'Momsfri försäljning', 'exempt'), + '3060', + true, + ) + expect(body.data.booked_count).toBe(1) + }) + + it('never blocks the default 3004 slot on billing country', async () => { + enqueue({ data: [zeroRateOrder(ORDER_1, 'US')] }) + enqueue({ data: [] }) // store settings + // 3004 is in the prefill set: no chart check query. + enqueue({ data: [{ id: ORDER_1 }] }) // claim + const { body } = await parseJsonResponse( + await postBulk({ order_ids: [ORDER_1], revenue_accounts: { '0': '3004' } }), + ) + expect(body.data.booked_count).toBe(1) + }) + + it('leaves an unclassified custom account unchecked (no box)', async () => { + // Neither treatment nor the static BAS map classifies 3060; there is + // nothing to contradict, so the sweep books (deferred item 1). + const { body } = await runOne( + zeroRateOrder(ORDER_1, 'SE'), + chartRow('3060', 'Konsultarvode utland'), + '3060', + true, + ) + expect(body.data.booked_count).toBe(1) + }) + + it('refuses per order inside a mixed batch and books the rest', async () => { + enqueue({ + data: [zeroRateOrder(ORDER_1, 'SE'), zeroRateOrder(ORDER_2, 'US', { order_number: '1002' })], + }) + enqueue({ data: [] }) // store settings + enqueue({ data: [EXPORT_GOODS] }) // chart check + enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2 + const { status, body } = await parseJsonResponse( + await postBulk({ + order_ids: [ORDER_1, ORDER_2], + revenue_accounts: { '0': '3105' }, + }), + ) + expect(status).toBe(200) + expect(body.data.booked_count).toBe(1) + expect(body.data.failed_count).toBe(1) + expect(body.data.results[0]).toMatchObject({ + order_id: ORDER_1, + success: false, + error: { code: 'WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH' }, + }) + expect(body.data.results[1]).toMatchObject({ order_id: ORDER_2, success: true }) + expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/app/api/webshop-orders/bulk-book/route.ts b/app/api/webshop-orders/bulk-book/route.ts index e4d4fb39..c1a50c88 100644 --- a/app/api/webshop-orders/bulk-book/route.ts +++ b/app/api/webshop-orders/bulk-book/route.ts @@ -16,11 +16,12 @@ import { ROUNDING_ACCOUNT, WEBSHOP_PREFILL_ACCOUNTS, } from '@/lib/webshop-orders/booking-lines' -import { inferDomesticSalesRate } from '@/lib/reports/vat-revenue-accounts' import { - defaultRateForVatTreatment, - isAccountVatTreatment, -} from '@/lib/vat/account-vat-treatment' + resolveEffectiveVatRate, + resolveRevenueVatBox, +} from '@/lib/reports/vat-revenue-accounts' +import { getBoxForAccount, type MomsBox } from '@/lib/vat/moms-box-mapping' +import { isEuMemberCountry } from '@/lib/vat/eu-countries' import { assertOrderBookable, bookOrderThroughEngine, @@ -245,17 +246,18 @@ export const POST = withRouteContext( // finding): output VAT books on 2611/2621/2631 per rate regardless of // the template, and the momsdeklaration counts a custom account toward // ruta 05 only when the account resolves to that rate. The effective - // rate mirrors fetchDynamicVatAccounts EXACTLY, precedence included: an - // explicit momssats always wins, then a rate-mapped treatment, and - // number+name inference only when nothing is configured, so an account - // explicitly set to 6% can never pass a 25% slot on its name alone - // (review finding). A mismatched choice would silently drop the sale's - // base out of ruta 05 while its VAT lands in ruta 10-12, so the sweep - // refuses it and points at the fix. Rate 0 buckets carry no output VAT - // and span legitimate momsfri/export/EU accounts (usually unconfigured), - // so they only refuse an account whose resolved rate CONTRADICTS 0% - // (review finding). Accounts from our own default set are checked - // statically: each is valid only for the rate it is the default for. + // rate comes from resolveEffectiveVatRate, the SAME helper the ruta 05 + // report arithmetic uses (#1912), so the two cannot drift: an explicit + // momssats always wins, then a rate-mapped treatment, and number+name + // inference only when nothing is configured, so an account explicitly + // set to 6% can never pass a 25% slot on its name alone (review + // finding). A mismatched choice would silently drop the sale's base out + // of ruta 05 while its VAT lands in ruta 10-12, so the sweep refuses it + // and points at the fix. Rate 0 buckets carry no output VAT and span + // legitimate momsfri/export/EU accounts (usually unconfigured), so they + // only refuse an account whose resolved rate CONTRADICTS 0% (review + // finding). Accounts from our own default set are checked statically: + // each is valid only for the rate it is the default for. const mismatchedAccounts: { rate: number; account: string }[] = [] for (const { rate, account } of revenueTemplatePairs) { if (WEBSHOP_PREFILL_ACCOUNTS.includes(account)) { @@ -267,11 +269,11 @@ export const POST = withRouteContext( const row = chartRowByAccount.get(account) if (!row) continue // unreachable: the existence guard above returned const expected = rate / 100 - const configured = - row.default_vat_rate === null ? null : Number(row.default_vat_rate) - const effective = isAccountVatTreatment(row.default_vat_treatment) - ? (configured ?? defaultRateForVatTreatment(row.default_vat_treatment, 3)) - : (configured ?? inferDomesticSalesRate(account, row.account_name)) + const effective = resolveEffectiveVatRate({ + account_number: account, + account_class: 3, + ...row, + }) const mismatch = rate === 0 ? effective !== null && effective !== 0 @@ -286,6 +288,49 @@ export const POST = withRouteContext( ) } + // Rate-0 order-context guard (#1912): VAT amounts are unaffected by the + // 0% slot, but the momsdeklaration is not. Ruta 36 (varuförsäljning + // utanför EU) is wrong for a Swedish or EU billing country, ruta 40 + // (tjänster omsatta utom landet) is wrong for Sweden, and the EU boxes + // 35/38/39 are wrong for Sweden or a non-EU country. The box comes from + // the chosen account's configured treatment, else the static BAS map; + // an account neither classifies (the common unconfigured momsfri case) + // gets no context check. The opposite direction, a DOMESTIC 0% account + // (3004, ruta 42) receiving a foreign order, stays advisory: the dialog + // already warns (zero_rate_foreign) and refusing it would regress every + // untemplated sweep. Checked per order below, because the billing + // country is per order and the doctrine is partial failure per row. + // Known limitation: customer_country is the BILLING country (the + // WooCommerce sync never stores the shipping address), while the goods + // boxes 35/36/38 follow where the goods are transported. A Swedish-billed + // order shipped to Norway is a legitimate ruta 36 export that this guard + // refuses; the error copy therefore says the check is billing-based and + // sends the user to the single dialog to confirm rather than to change + // the account. Storing shipping country is a follow-up. + const zeroRateAccount = revenueAccountByRate[0] + const zeroRateChartRow = zeroRateAccount + ? chartRowByAccount.get(zeroRateAccount) + : undefined + const zeroRateBox: MomsBox | null = !zeroRateAccount + ? null + : WEBSHOP_PREFILL_ACCOUNTS.includes(zeroRateAccount) + ? (getBoxForAccount(zeroRateAccount) ?? null) + : zeroRateChartRow + ? resolveRevenueVatBox({ + account_number: zeroRateAccount, + account_class: 3, + ...zeroRateChartRow, + }) + : null // unreachable: the existence guard above returned + const zeroRateContextContradicted = (country: string): boolean => { + if (zeroRateBox === '36') return isEuMemberCountry(country) + if (zeroRateBox === '40') return country === 'SE' + if (zeroRateBox === '35' || zeroRateBox === '38' || zeroRateBox === '39') { + return country === 'SE' || !isEuMemberCountry(country) + } + return false + } + // Sequential on purpose: each order is its own draft -> claim -> commit // round trip through the engine, and voucher numbers are assigned // atomically per commit. Parallelizing would only contend on the same @@ -349,6 +394,26 @@ export const POST = withRouteContext( continue } + if (zeroRateAccount && order.customer_country) { + const country = order.customer_country.toUpperCase() + const hasZeroRateAmount = order.vat_breakdown.some( + (b) => b.rate === 0 && b.net !== 0, + ) + if (hasZeroRateAmount && zeroRateContextContradicted(country)) { + results.push({ + order_id: id, + order_number: order.order_number, + success: false, + error: failureFromCode('WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH', { + account: zeroRateAccount, + box: zeroRateBox, + customer_country: country, + }), + }) + continue + } + } + const settings = settingsFor(order) // The store's own mapping routes this payment method through the // invoice flow. Booking it directly would both post a wrong clearing diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 265919ef..bce3461c 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3967,6 +3967,13 @@ const WEBSHOP_ORDERS: Record = { message_en: 'A chosen revenue account is not configured for the VAT rate it would receive, so the sale would drop out of ruta 05 in the VAT declaration. Set the account VAT rate in the chart of accounts (or pick an account for the right rate) and try again.', }, + WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH: { + httpStatus: 422, + message_sv: + 'Ordern har en momsfri del men faktureringslandet stämmer inte med det valda 0 %-kontot (export- eller EU-konto). Kontrollen bygger på faktureringsadressen, inte leveransadressen: går varorna till ett annat land kan kontot ändå vara rätt. Bokför ordern enskilt och bekräfta kontot för ruta 35-42.', + message_en: + 'The order has a 0 % part but the billing country does not match the chosen 0 % account (export or EU account). The check uses the billing address, not the delivery address: if the goods ship to another country the account may still be right. Book the order individually and confirm the account for the right box (ruta 35-42).', + }, WEBSHOP_ORDER_REVENUE_ACCOUNT_UNKNOWN: { httpStatus: 422, message_sv: diff --git a/lib/reports/__tests__/vat-revenue-accounts.test.ts b/lib/reports/__tests__/vat-revenue-accounts.test.ts new file mode 100644 index 00000000..329c780e --- /dev/null +++ b/lib/reports/__tests__/vat-revenue-accounts.test.ts @@ -0,0 +1,246 @@ +/** + * Tests for the shared VAT classification helpers (#1912): the effective + * rate precedence (explicit momssats > treatment > class-3 number+name + * inference) and the revenue box resolver, plus a regression that + * fetchDynamicVatAccounts still produces the same rateByAccount after + * switching to the shared helper. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' + +const fetchAllRowsMock = vi.fn() +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: (...args: unknown[]) => fetchAllRowsMock(...args), +})) + +import { + fetchDynamicVatAccounts, + resolveEffectiveVatRate, + resolveRevenueVatBox, +} from '../vat-revenue-accounts' + +const supabase = {} as SupabaseClient + +function row( + account_number: string, + account_name: string, + overrides: { + account_class?: number + default_vat_rate?: number | string | null + default_vat_treatment?: string | null + } = {}, +) { + return { + account_number, + account_name, + account_class: overrides.account_class ?? Number(account_number.charAt(0)), + default_vat_rate: overrides.default_vat_rate ?? null, + default_vat_treatment: overrides.default_vat_treatment ?? null, + } +} + +describe('resolveEffectiveVatRate', () => { + it('lets an explicit momssats win over a rate-conforming name', () => { + expect( + resolveEffectiveVatRate( + row('3041', 'Försäljning tjänster 25 % moms', { default_vat_rate: 0.06 }), + ), + ).toBe(0.06) + }) + + it('accepts a numeric-string momssats (Postgres numeric)', () => { + expect( + resolveEffectiveVatRate(row('3050', 'Försäljning', { default_vat_rate: '0.12' })), + ).toBe(0.12) + }) + + it('lets an explicit momssats win over the treatment default', () => { + expect( + resolveEffectiveVatRate( + row('3050', 'Försäljning', { + default_vat_rate: 0.06, + default_vat_treatment: 'standard_25', + }), + ), + ).toBe(0.06) + }) + + it('falls back to the treatment default rate', () => { + expect( + resolveEffectiveVatRate( + row('3050', 'Försäljning', { default_vat_treatment: 'reduced_12' }), + ), + ).toBe(0.12) + expect( + resolveEffectiveVatRate( + row('3060', 'Konsultarvode utland', { default_vat_treatment: 'export_goods' }), + ), + ).toBe(0) + expect( + resolveEffectiveVatRate( + row('3060', 'Momsfri försäljning', { default_vat_treatment: 'exempt' }), + ), + ).toBe(0) + }) + + it('returns null for treatments without a single Swedish sats (vmb, oss)', () => { + expect( + resolveEffectiveVatRate(row('3200', 'VMB', { default_vat_treatment: 'vmb' })), + ).toBeNull() + expect( + resolveEffectiveVatRate(row('3106', 'OSS', { default_vat_treatment: 'oss' })), + ).toBeNull() + }) + + it('infers the rate from number + name for an unconfigured class 3 account', () => { + expect( + resolveEffectiveVatRate(row('3041', 'Försäljning tjänster 25 % moms')), + ).toBe(0.25) + expect( + resolveEffectiveVatRate(row('3042', 'Försäljning tjänster 12 % moms')), + ).toBe(0.12) + }) + + it('returns null for an unconfigured class 3 account without a conforming name', () => { + expect( + resolveEffectiveVatRate(row('3105', 'Försäljning varor till land utanför EU')), + ).toBeNull() + expect(resolveEffectiveVatRate(row('3051', 'Försäljning tjänster'))).toBeNull() + }) + + it('never infers for purchase classes: only an explicit momssats counts', () => { + expect(resolveEffectiveVatRate(row('4011', 'Inköp varor 25 % moms'))).toBeNull() + expect( + resolveEffectiveVatRate(row('4011', 'Inköp varor', { default_vat_rate: 0.25 })), + ).toBe(0.25) + }) + + it('derives the class from the account number when the row lacks it', () => { + expect( + resolveEffectiveVatRate({ + account_number: '3041', + account_name: 'Försäljning tjänster 25 % moms', + default_vat_rate: null, + default_vat_treatment: null, + }), + ).toBe(0.25) + expect( + resolveEffectiveVatRate({ + account_number: '5010', + account_name: 'Lokalhyra', + default_vat_rate: null, + default_vat_treatment: 'reverse_charge_eu_services', + }), + ).toBe(0.25) + }) +}) + +describe('resolveRevenueVatBox', () => { + it('maps a configured treatment to its momsdeklaration box', () => { + expect( + resolveRevenueVatBox(row('3060', 'Momsfri', { default_vat_treatment: 'exempt' })), + ).toBe('42') + expect( + resolveRevenueVatBox( + row('3060', 'Konsult utland', { default_vat_treatment: 'export_services' }), + ), + ).toBe('40') + expect( + resolveRevenueVatBox( + row('3060', 'Varor utanför EU', { default_vat_treatment: 'export_goods' }), + ), + ).toBe('36') + expect( + resolveRevenueVatBox( + row('3060', 'Varor EU', { default_vat_treatment: 'reverse_charge_eu_goods' }), + ), + ).toBe('35') + expect( + resolveRevenueVatBox( + row('3060', 'Tjänster EU', { default_vat_treatment: 'reverse_charge_eu_services' }), + ), + ).toBe('39') + expect( + resolveRevenueVatBox(row('3060', 'Standard', { default_vat_treatment: 'standard_25' })), + ).toBe('05') + }) + + it('returns null for OSS (declared outside the momsdeklaration)', () => { + expect( + resolveRevenueVatBox(row('3106', 'OSS', { default_vat_treatment: 'oss' })), + ).toBeNull() + }) + + it('lets the treatment override the static BAS box', () => { + // 3105 is statically ruta 36, but the company configured it as exempt. + expect( + resolveRevenueVatBox(row('3105', 'Export', { default_vat_treatment: 'exempt' })), + ).toBe('42') + }) + + it('falls back to the static BAS map without a treatment', () => { + expect(resolveRevenueVatBox(row('3105', 'Export varor'))).toBe('36') + expect(resolveRevenueVatBox(row('3305', 'Export tjänster'))).toBe('40') + expect(resolveRevenueVatBox(row('3108', 'Varor EU'))).toBe('35') + expect(resolveRevenueVatBox(row('3109', 'Trepartshandel'))).toBe('38') + expect(resolveRevenueVatBox(row('3308', 'Tjänster EU'))).toBe('39') + expect(resolveRevenueVatBox(row('3004', 'Momsfri försäljning'))).toBe('42') + expect(resolveRevenueVatBox(row('3001', 'Försäljning 25 %'))).toBe('05') + }) + + it('returns null for an account neither the treatment nor the BAS map classifies', () => { + expect(resolveRevenueVatBox(row('3050', 'Försäljning'))).toBeNull() + expect(resolveRevenueVatBox(row('3060', 'Konsultarvode utland'))).toBeNull() + }) +}) + +describe('fetchDynamicVatAccounts (shared helper regression)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps the same rate precedence as before the helper extraction', async () => { + fetchAllRowsMock.mockResolvedValue([ + // Explicit 6% wins over the "25 % moms" name. + row('3041', 'Försäljning tjänster 25 % moms', { default_vat_rate: 0.06 }), + // Unconfigured, inferred from number + name. + row('3042', 'Försäljning tjänster 12 % moms'), + // Static export account, unconfigured: no rate, never in ruta 05. + row('3105', 'Försäljning varor till land utanför EU'), + // Treatment-driven rate on a custom account. + row('3050', 'Försäljning', { default_vat_treatment: 'standard_25' }), + // Treatment with explicit rate on a purchase account: RC basis rate. + row('4056', 'Inköp tjänster EU', { + default_vat_rate: 0.12, + default_vat_treatment: 'reverse_charge_eu_services', + }), + // Class 3 without any classification: excluded. + row('3051', 'Försäljning tjänster'), + ]) + + const result = await fetchDynamicVatAccounts(supabase, 'company-1') + + expect(result.rateByAccount.get('3041')).toBe(0.06) + expect(result.rateByAccount.get('3042')).toBe(0.12) + expect(result.rateByAccount.has('3105')).toBe(false) + expect(result.rateByAccount.get('3050')).toBe(0.25) + expect(result.rateByAccount.has('3051')).toBe(false) + expect(result.rcBasisRateByAccount.get('4056')).toBe(0.12) + expect(result.explicitAccounts.has('3050')).toBe(true) + expect(result.explicitAccounts.has('3041')).toBe(false) + expect(result.mappingByAccount.get('3041')).toEqual({ box: 'ruta05', side: 'credit' }) + expect(result.accounts).toEqual(['3041', '3042', '3050', '4056']) + }) + + it('keeps static ruta 05 accounts out of the dynamic set but records the 3000 rate', async () => { + fetchAllRowsMock.mockResolvedValue([ + row('3000', 'Försäljning inom Sverige', { default_vat_rate: 0.25 }), + row('3001', 'Försäljning 25 %', { default_vat_rate: 0.25 }), + ]) + const result = await fetchDynamicVatAccounts(supabase, 'company-1') + expect(result.accounts).toEqual([]) + expect(result.staticRateByAccount.get('3000')).toBe(0.25) + expect(result.staticRateByAccount.has('3001')).toBe(false) + expect(result.rateByAccount.size).toBe(0) + }) +}) diff --git a/lib/reports/vat-revenue-accounts.ts b/lib/reports/vat-revenue-accounts.ts index d32879ff..78bd7620 100644 --- a/lib/reports/vat-revenue-accounts.ts +++ b/lib/reports/vat-revenue-accounts.ts @@ -1,6 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping' +import { ACCOUNT_TO_BOX, BOX_LABELS, type MomsBox } from '@/lib/vat/moms-box-mapping' import { defaultRateForVatTreatment, isAccountVatTreatment, @@ -34,6 +34,66 @@ export function inferDomesticSalesRate(accountNumber: string, accountName: strin return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null } +/** + * The chart_of_accounts columns that classify an account for VAT purposes. + * account_class is optional so callers holding only the number can pass a + * row straight from a narrower select; it then falls back to the number's + * leading digit (BAS class). + */ +export interface VatAccountClassificationRow { + account_number: string + account_name: string + account_class?: number + default_vat_rate: number | string | null + default_vat_treatment: string | null +} + +function accountClassOf(row: VatAccountClassificationRow): number { + return row.account_class ?? Number(row.account_number.charAt(0)) +} + +/** + * The single source of truth for an account's effective VAT rate, precedence + * included: an explicit momssats always wins, then the rate implied by a + * configured treatment, and number+name inference (class 3 only) when + * nothing is configured. fetchDynamicVatAccounts (ruta 05 arithmetic) and + * the webshop bulk sweep's revenue-template guard both call this, so an + * account is accepted for a rate exactly when the declaration would count + * it toward that rate (#1912). Returns null when no rate can be resolved. + */ +export function resolveEffectiveVatRate(row: VatAccountClassificationRow): number | null { + const accountClass = accountClassOf(row) + const configured = row.default_vat_rate === null ? null : Number(row.default_vat_rate) + if (isAccountVatTreatment(row.default_vat_treatment)) { + return configured ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass) + } + if (accountClass === 3) { + return configured ?? inferDomesticSalesRate(row.account_number, row.account_name) + } + return configured +} + +/** + * The momsdeklaration box a revenue (class 3) account feeds: a configured + * treatment wins (its ruta, or null for OSS which is declared outside the + * momsdeklaration), otherwise the static BAS map. Null when neither + * classifies the account, which is the common case for company-specific + * momsfri/export accounts that were never configured. + */ +export function resolveRevenueVatBox(row: VatAccountClassificationRow): MomsBox | null { + if (isAccountVatTreatment(row.default_vat_treatment)) { + const mapping = resolveVatTreatmentRuta( + row.default_vat_treatment, + accountClassOf(row), + row.account_number, + ) + if (!mapping) return null + const code = mapping.box.replace(/^ruta/, '') + return code in BOX_LABELS ? (code as MomsBox) : null + } + return ACCOUNT_TO_BOX[row.account_number] ?? null +} + export interface DynamicVatAccounts { accounts: string[] mappingByAccount: Map @@ -72,7 +132,6 @@ export async function fetchDynamicVatAccounts( const result = emptyDynamicVatAccounts() for (const row of rows) { const account = row.account_number - const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate) if (isAccountVatTreatment(row.default_vat_treatment)) { result.explicitAccounts.add(account) @@ -87,7 +146,7 @@ export async function fetchDynamicVatAccounts( // fetchVatAccountTotals deduplicates static BAS accounts, while this also // covers accounts that exist only in the separate moms-box mirror. result.accounts.push(account) - const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, row.account_class) + const rate = resolveEffectiveVatRate(row) if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) { const target = ACCOUNT_TO_BOX[account] ? result.staticRateByAccount : result.rateByAccount target.set(account, rate) @@ -102,7 +161,7 @@ export async function fetchDynamicVatAccounts( } if (row.account_class !== 3) continue - const rate = configuredRate ?? inferDomesticSalesRate(account, row.account_name) + const rate = resolveEffectiveVatRate(row) if (rate === null || !TAXABLE_RATES.includes(rate)) continue if (ACCOUNT_TO_BOX[account]) { if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) { diff --git a/lib/vat/__tests__/eu-countries.test.ts b/lib/vat/__tests__/eu-countries.test.ts new file mode 100644 index 00000000..41f424da --- /dev/null +++ b/lib/vat/__tests__/eu-countries.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { EU_COUNTRIES, isEuMemberCountry } from '../eu-countries' + +describe('isEuMemberCountry', () => { + it('is true for every listed member state, case insensitive', () => { + for (const country of EU_COUNTRIES) { + expect(isEuMemberCountry(country.code)).toBe(true) + expect(isEuMemberCountry(country.code.toLowerCase())).toBe(true) + } + }) + + it('counts Sweden as a member (callers test SE separately)', () => { + expect(isEuMemberCountry('SE')).toBe(true) + }) + + it('is false for non-EU countries and unknown codes', () => { + expect(isEuMemberCountry('US')).toBe(false) + expect(isEuMemberCountry('NO')).toBe(false) + expect(isEuMemberCountry('GB')).toBe(false) + expect(isEuMemberCountry('CH')).toBe(false) + expect(isEuMemberCountry('')).toBe(false) + }) +}) diff --git a/lib/vat/eu-countries.ts b/lib/vat/eu-countries.ts index 9e6cec5f..c6621fb0 100644 --- a/lib/vat/eu-countries.ts +++ b/lib/vat/eu-countries.ts @@ -49,3 +49,14 @@ export const EU_COUNTRIES: EUCountry[] = [ { code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' }, { code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' }, ] + +const EU_COUNTRY_CODES = new Set(EU_COUNTRIES.map((c) => c.code)) + +/** + * True when the ISO 3166-1 alpha-2 code is an EU member state. Case + * insensitive. Sweden (SE) counts as a member: callers that need "another + * EU country" must test SE separately first. + */ +export function isEuMemberCountry(code: string): boolean { + return EU_COUNTRY_CODES.has(code.toUpperCase()) +}