feat(woo): selectable revenue accounts per VAT rate in the bulk order sweep (#1908)
* feat(woo): selectable revenue accounts per VAT rate in the bulk order sweep The bulk sweep hardcoded the revenue side to the standard 3001-series, so a store selling both goods and services could not route tjansteordrar to its own revenue accounts (user request, follow-up to #1900). The bulk dialog now has a "bokforingsmall" section: per-VAT-rate revenue account inputs, shown only for rates present in the selection, prefilled with the effective defaults; only diffs from the default map are sent. Server side, BulkBookWebshopOrdersSchema gains an optional revenue_accounts map (class 3 accounts only) that buildOrderBookingLines routes each rate bucket's revenue line through; output VAT accounts stay derived from the rate and are not overridable. User-chosen accounts are never auto-created: the route verifies them against the company chart up front and aborts the whole sweep with WEBSHOP_ORDER_REVENUE_ACCOUNT_UNKNOWN naming the offenders, while accounts in the closed prefill set keep riding the existing chart repair. No hardcoded varor/tjanster preset on purpose: BAS 2026 has no standard 30xx goods/services subdivision (see DECISIONS.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): harden the bulk revenue template per skeptic and review findings Three findings from the adversarial review of the revenue-template commit, fixed in one pass: - Build breaker: revenueAccountByRate was typed Partial<Record<...>>, making Object.values() return (string | undefined)[] and failing the production build's type-check (Vitest and ESLint both miss it). Typed as Record<number, string>; only truthy strings are ever inserted. - 3740 template collision (two skeptics, independently): choosing 3740 as a revenue account passed the class-3 gate, skipped the chart guard (it is in the closed prefill set), and made the residual bound read the templated revenue line instead of the residual, so a mangled gift-card order the sweep must refuse could book a ~499 kr gap as "oresavrundning" in an immutable verifikat. 3740 is now banned by the schema and the dialog mirror, and the residual line is identified structurally (always the last line) instead of by account lookup, which also fixes the pre-existing misdiagnosis when 3740 is used as payment_account. - Rate-classification guard (Swedish accounting review): output VAT books 2611/2621/2631 per rate regardless of template, but a custom account counts toward ruta 05 only when configured for that rate (explicit momssats, rate-mapped treatment, or rate-conforming 30x1/2/3 number + name, i.e. exactly inferDomesticSalesRate, now exported and reused). A mismatched pair is refused up front with WEBSHOP_ORDER_REVENUE_ACCOUNT_RATE_MISMATCH naming the offenders; default-set accounts are valid only for the rate they are the default for; rate-0 buckets are exempt (no output VAT, legitimate momsfri/ export accounts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): explicit momssats wins over name inference in the revenue-template guard Two Swedish accounting review findings on the rate-classification guard: - Precedence: the OR check let number+name inference qualify an account whose explicit default_vat_rate says a DIFFERENT rate (6%-configured account passing a 25% slot on its name). The guard now resolves ONE effective rate exactly like fetchDynamicVatAccounts does (explicit momssats, then rate-mapped treatment, inference only when nothing is configured) and compares that. - Rate 0 slots no longer skip the check entirely: an account whose resolved rate is TAXABLE contradicts the 0% bucket and is refused, while unconfigured momsfri/export/EU accounts stay accepted (no contradicting configuration required, not positive proof of 0%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
85e039035d
commit
d035d283ef
@@ -1630,6 +1630,24 @@ export const BookWebshopOrderSchema = z.object({
|
||||
* through the same server-side flow as the single-order endpoint (never one
|
||||
* combined journal write). Max 50 = one orders-page of selection.
|
||||
*/
|
||||
/**
|
||||
* Revenue account for the bulk revenue template: class 3 only, and never
|
||||
* 3740. The template routes the revenue side of the sweep; a non-revenue
|
||||
* account here would put sales on a balance or cost account with no
|
||||
* reviewing user per line. 3740 (öresavrundning) is excluded because the
|
||||
* bulk route bounds the rounding residual by that account: a templated
|
||||
* revenue line on 3740 would both misbook real revenue as rounding and
|
||||
* blind that guard (skeptic finding). Orders needing an off-class-3 revenue
|
||||
* leg go through the single-order dialog, which is fully line-editable.
|
||||
*/
|
||||
const webshopRevenueAccount = accountNumber
|
||||
.refine((n) => n.startsWith('3'), {
|
||||
message: 'Intäktskontot måste vara ett konto i klass 3 (3000-3999)',
|
||||
})
|
||||
.refine((n) => n !== '3740', {
|
||||
message: 'Öresavrundningskontot 3740 kan inte användas som intäktskonto',
|
||||
})
|
||||
|
||||
export const BulkBookWebshopOrdersSchema = z.object({
|
||||
order_ids: z.array(uuid).min(1).max(50),
|
||||
/**
|
||||
@@ -1637,6 +1655,21 @@ export const BulkBookWebshopOrdersSchema = z.object({
|
||||
* account instead of the per-store payment-method mapping.
|
||||
*/
|
||||
payment_account: accountNumber.optional(),
|
||||
/**
|
||||
* Optional revenue template: revenue account per Swedish VAT rate, keyed
|
||||
* by the rate as a string. A missing rate falls back to the standard
|
||||
* 3001-series map. Output VAT accounts are never overridable: they are
|
||||
* derived from the rate.
|
||||
*/
|
||||
revenue_accounts: z
|
||||
.object({
|
||||
'25': webshopRevenueAccount.optional(),
|
||||
'12': webshopRevenueAccount.optional(),
|
||||
'6': webshopRevenueAccount.optional(),
|
||||
'0': webshopRevenueAccount.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const CreateInvoiceFromWebshopOrderSchema = z.object({
|
||||
|
||||
@@ -3921,6 +3921,20 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'The order has a VAT rate that is not a Swedish rate (25/12/6/0 %), for example foreign OSS VAT. Book the order individually and review the lines.',
|
||||
},
|
||||
WEBSHOP_ORDER_REVENUE_ACCOUNT_RATE_MISMATCH: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Ett valt intäktskonto är inte upplagt för momssatsen det ska ta emot, så försäljningen skulle falla ur momsdeklarationens ruta 05. Ange kontots momssats i kontoplanen (eller välj ett konto för rätt sats) och försök igen.',
|
||||
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_REVENUE_ACCOUNT_UNKNOWN: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Ett valt intäktskonto finns inte i kontoplanen eller är inaktivt. Lägg till eller aktivera kontot under Kontoplan och försök igen.',
|
||||
message_en:
|
||||
'A chosen revenue account is not in the chart of accounts or is inactive. Add or activate the account in the chart of accounts and try again.',
|
||||
},
|
||||
WEBSHOP_ORDER_RESIDUAL_TOO_LARGE: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
|
||||
@@ -17,7 +17,14 @@ const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = { '1': 0.25, '2':
|
||||
const CONTRADICTING_ACCOUNT_NAME =
|
||||
/momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\boss\b|\b0\s*%/i
|
||||
|
||||
function inferDomesticSalesRate(accountNumber: string, accountName: string): number | null {
|
||||
/**
|
||||
* Infer the domestic-sales VAT rate a class 3 account represents from its
|
||||
* number pattern (30x1/30x2/30x3) and a rate-naming account name. Exported
|
||||
* for the webshop bulk sweep's revenue-template guard, which must accept an
|
||||
* account for a rate exactly when this report logic would count it toward
|
||||
* ruta 05 for that rate.
|
||||
*/
|
||||
export function inferDomesticSalesRate(accountNumber: string, accountName: string): number | null {
|
||||
const accountMatch = /^30\d([123])$/.exec(accountNumber)
|
||||
if (!accountMatch || CONTRADICTING_ACCOUNT_NAME.test(accountName)) return null
|
||||
const expectedRate = DOMESTIC_SALES_RATE_BY_SUFFIX[accountMatch[1]]
|
||||
|
||||
@@ -260,6 +260,74 @@ describe('buildOrderBookingLines', () => {
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('routes revenue per rate through a revenue template, VAT untouched', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
total: 612,
|
||||
total_tax: 87,
|
||||
total_sek: 612,
|
||||
vat_breakdown: [
|
||||
{ rate: 25, net: 300, tax: 75 },
|
||||
{ rate: 12, net: 100, tax: 12 },
|
||||
{ rate: 6, net: 100, tax: 6 },
|
||||
{ rate: 0, net: 19, tax: 0 },
|
||||
],
|
||||
}),
|
||||
settings,
|
||||
revenueAccounts: { 25: '3041', 12: '3042', 6: '3043', 0: '3100' },
|
||||
})
|
||||
expect(lineFor(lines, '3041')?.credit_amount).toBe(300)
|
||||
expect(lineFor(lines, '3042')?.credit_amount).toBe(100)
|
||||
expect(lineFor(lines, '3043')?.credit_amount).toBe(100)
|
||||
expect(lineFor(lines, '3100')?.credit_amount).toBe(19)
|
||||
// The default accounts must not appear when every rate is templated.
|
||||
for (const account of ['3001', '3002', '3003', '3004']) {
|
||||
expect(lineFor(lines, account)).toBeUndefined()
|
||||
}
|
||||
// VAT accounts are always derived from the rate, never templated.
|
||||
expect(lineFor(lines, '2611')?.credit_amount).toBe(75)
|
||||
expect(lineFor(lines, '2621')?.credit_amount).toBe(12)
|
||||
expect(lineFor(lines, '2631')?.credit_amount).toBe(6)
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('falls back to the default revenue account for rates not in the template', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
total: 537,
|
||||
total_tax: 87,
|
||||
total_sek: 537,
|
||||
vat_breakdown: [
|
||||
{ rate: 25, net: 300, tax: 75 },
|
||||
{ rate: 12, net: 100, tax: 12 },
|
||||
],
|
||||
}),
|
||||
settings,
|
||||
revenueAccounts: { 25: '3041' },
|
||||
})
|
||||
expect(lineFor(lines, '3041')?.credit_amount).toBe(300)
|
||||
expect(lineFor(lines, '3002')?.credit_amount).toBe(100)
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('mirrors refunds through the revenue template (debit the chosen account)', () => {
|
||||
const lines = buildOrderBookingLines({
|
||||
order: makeOrder({
|
||||
row_type: 'refund',
|
||||
total: -500,
|
||||
total_tax: -100,
|
||||
total_sek: -500,
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
}),
|
||||
settings,
|
||||
revenueAccounts: { 25: '3041' },
|
||||
})
|
||||
expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 0, credit_amount: 500 })
|
||||
expect(lineFor(lines, '3041')).toMatchObject({ debit_amount: 400, credit_amount: 0 })
|
||||
expect(lineFor(lines, '2611')).toMatchObject({ debit_amount: 100, credit_amount: 0 })
|
||||
expect(sumDebits(lines)).toBe(sumCredits(lines))
|
||||
})
|
||||
|
||||
it('throws when a non-SEK order has no resolved SEK amount', () => {
|
||||
expect(() =>
|
||||
buildOrderBookingLines({
|
||||
|
||||
@@ -40,14 +40,24 @@ export const DEFAULT_PAYMENT_ACCOUNT = '1686'
|
||||
/** BAS 2026 name for DEFAULT_PAYMENT_ACCOUNT; used when adding it to a chart. */
|
||||
export const DEFAULT_PAYMENT_ACCOUNT_NAME = 'Fordringar för kontokort och kuponger'
|
||||
|
||||
/** Revenue account per Swedish VAT rate (BAS 2026). */
|
||||
const REVENUE_ACCOUNT_BY_RATE: Record<number, string> = {
|
||||
/**
|
||||
* Default revenue account per Swedish VAT rate: the standard BAS 2026
|
||||
* "Försäljning inom Sverige" accounts. Exported so the bulk dialog can
|
||||
* prefill its per-rate revenue pickers with the effective defaults. BAS 2026
|
||||
* has no standard goods/services subdivision of 30xx (such a split, e.g. an
|
||||
* own 3040-series, is company-specific), which is why the revenue template
|
||||
* is a per-rate account choice against the company's own chart rather than
|
||||
* a hardcoded varor/tjänster preset.
|
||||
*/
|
||||
export const DEFAULT_REVENUE_ACCOUNT_BY_RATE: Readonly<Record<number, string>> = {
|
||||
25: '3001',
|
||||
12: '3002',
|
||||
6: '3003',
|
||||
0: '3004',
|
||||
}
|
||||
|
||||
const REVENUE_ACCOUNT_BY_RATE = DEFAULT_REVENUE_ACCOUNT_BY_RATE
|
||||
|
||||
/** Output VAT account per rate. */
|
||||
const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
|
||||
25: '2611',
|
||||
@@ -207,6 +217,12 @@ export interface OrderBookingLinesInput {
|
||||
settings?: WebshopStoreSettings | null
|
||||
/** Explicit override of the payment counter-account (dialog edit). */
|
||||
paymentAccount?: string
|
||||
/**
|
||||
* Revenue template: revenue account per Swedish VAT rate. A rate not in
|
||||
* the map falls back to DEFAULT_REVENUE_ACCOUNT_BY_RATE. Only the revenue
|
||||
* side is templated; output VAT accounts are always derived from the rate.
|
||||
*/
|
||||
revenueAccounts?: Partial<Record<number, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,6 +233,7 @@ export function buildOrderBookingLines({
|
||||
order,
|
||||
settings,
|
||||
paymentAccount,
|
||||
revenueAccounts,
|
||||
}: OrderBookingLinesInput): CreateJournalEntryLineInput[] {
|
||||
const isSek = order.currency.toUpperCase() === 'SEK'
|
||||
const rate = isSek ? 1 : order.exchange_rate
|
||||
@@ -287,7 +304,9 @@ export function buildOrderBookingLines({
|
||||
}
|
||||
for (const bucket of breakdown) {
|
||||
const revenueAccount =
|
||||
REVENUE_ACCOUNT_BY_RATE[bucket.rate] ?? REVENUE_ACCOUNT_BY_RATE[25]
|
||||
revenueAccounts?.[bucket.rate] ??
|
||||
REVENUE_ACCOUNT_BY_RATE[bucket.rate] ??
|
||||
REVENUE_ACCOUNT_BY_RATE[25]
|
||||
const vatAccount = VAT_ACCOUNT_BY_RATE[bucket.rate] ?? VAT_ACCOUNT_BY_RATE[25]
|
||||
pushSigned(revenueAccount, round(bucket.net))
|
||||
pushSigned(vatAccount, round(bucket.tax))
|
||||
|
||||
Reference in New Issue
Block a user