* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com>
193 lines
7.6 KiB
TypeScript
193 lines
7.6 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { ACCOUNT_TO_BOX, BOX_LABELS, type MomsBox } from '@/lib/vat/moms-box-mapping'
|
|
import {
|
|
defaultRateForVatTreatment,
|
|
isAccountVatTreatment,
|
|
resolveVatTreatmentRuta,
|
|
type AccountVatRutaMapping,
|
|
} from '@/lib/vat/account-vat-treatment'
|
|
|
|
const TAXABLE_RATES = [0.25, 0.12, 0.06]
|
|
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
|
|
'3211', '3212', '3220', '3913',
|
|
])
|
|
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
|
|
const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = { '1': 0.25, '2': 0.12, '3': 0.06 }
|
|
const CONTRADICTING_ACCOUNT_NAME =
|
|
/momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\boss\b|\b0\s*%/i
|
|
|
|
/**
|
|
* 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]]
|
|
const namedRates = new Set(
|
|
[...accountName.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map((match) => Number(match[1]) / 100),
|
|
)
|
|
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<string, AccountVatRutaMapping>
|
|
explicitAccounts: Set<string>
|
|
rateByAccount: Map<string, number>
|
|
staticRateByAccount: Map<string, number>
|
|
rcBasisRateByAccount: Map<string, number>
|
|
}
|
|
|
|
const emptyDynamicVatAccounts = (): DynamicVatAccounts => ({
|
|
accounts: [], mappingByAccount: new Map(), explicitAccounts: new Set(),
|
|
rateByAccount: new Map(), staticRateByAccount: new Map(),
|
|
rcBasisRateByAccount: new Map(),
|
|
})
|
|
|
|
/** Explicit account treatments win; accounts without one keep BAS fallback. */
|
|
export async function fetchDynamicVatAccounts(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<DynamicVatAccounts> {
|
|
const rows = await fetchAllRows<{
|
|
account_number: string
|
|
account_name: string
|
|
account_class: number
|
|
default_vat_rate: number | string | null
|
|
default_vat_treatment: string | null
|
|
}>(
|
|
({ from, to }) => supabase.from('chart_of_accounts')
|
|
.select('account_number, account_name, account_class, default_vat_rate, default_vat_treatment')
|
|
.eq('company_id', companyId)
|
|
.in('account_class', [3, 4, 5, 6])
|
|
.order('account_number', { ascending: true })
|
|
.range(from, to),
|
|
)
|
|
|
|
const result = emptyDynamicVatAccounts()
|
|
for (const row of rows) {
|
|
const account = row.account_number
|
|
|
|
if (isAccountVatTreatment(row.default_vat_treatment)) {
|
|
result.explicitAccounts.add(account)
|
|
const mapping = resolveVatTreatmentRuta(
|
|
row.default_vat_treatment,
|
|
row.account_class,
|
|
row.account_number,
|
|
)
|
|
if (!mapping) continue
|
|
result.mappingByAccount.set(account, mapping)
|
|
// Always include an explicit account in the ledger filter. The Set in
|
|
// 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 = 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)
|
|
}
|
|
if (
|
|
['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24'].includes(mapping.box) &&
|
|
rate !== null && TAXABLE_RATES.includes(rate)
|
|
) {
|
|
result.rcBasisRateByAccount.set(account, rate)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (row.account_class !== 3) continue
|
|
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)) {
|
|
result.staticRateByAccount.set(account, rate)
|
|
}
|
|
continue
|
|
}
|
|
if (RUTA_05_EXCLUDED_ACCOUNTS.has(account)) continue
|
|
result.accounts.push(account)
|
|
result.mappingByAccount.set(account, { box: 'ruta05', side: 'credit' })
|
|
result.rateByAccount.set(account, rate)
|
|
}
|
|
return result
|
|
}
|
|
|
|
export async function fetchDynamicRuta05Accounts(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<Pick<DynamicVatAccounts, 'accounts' | 'rateByAccount' | 'staticRateByAccount'>> {
|
|
const resolved = await fetchDynamicVatAccounts(supabase, companyId)
|
|
return {
|
|
accounts: [...resolved.mappingByAccount]
|
|
.filter(([account, mapping]) => mapping.box === 'ruta05' && !ACCOUNT_TO_BOX[account])
|
|
.map(([account]) => account),
|
|
rateByAccount: resolved.rateByAccount,
|
|
staticRateByAccount: resolved.staticRateByAccount,
|
|
}
|
|
}
|