* feat(webshop-orders): schema, types and error codes for the orders surface webshop_orders (order/refund rows, financial-freeze trigger, member select/update RLS, no DELETE) + webshop_store_settings (per-store payment method -> account map), source_type 'webshop_order', multi-store index drop, customer_country, and a one-time woo cursor reset so the switch-over backfills and cross-marks existing feed rows. Tables classified in the full-archive export; pg-real coverage for RLS, freeze and CHECK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): core service (ingest, booking lines) upsertWebshopOrders: two-phase order/refund upsert with FX enrichment, legacy-feed cross-marking, frozen-row protection and field-wise jsonb comparisons (Postgres does not preserve object key order). Booking-line builder: per-rate VAT split with SIGNED buckets (discounts book as revenue reductions), refund mirroring, 3740 residual, per-store account prefill, and advisory export/EU + OSS warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): API routes for list, booking, invoicing and mapping Booking is draft -> atomic claim -> commit (conditional link-back closes the concurrent double-book race; a lost claim cancels the voucher-free draft). Legacy-feed guard honors transactions.is_ignored on both the book and create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an unnumbered draft with dominant-rate fallback and drift-safe unit prices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav /orders lists per-store orders with status tabs (server-side filters), exception chips and one action per row. Booking dialog prefills from the per-store payment-method mapping with an opt-in remember; invoice dialog converts to a draft kundfaktura. The Order nav item renders only for companies with an active WooCommerce connection or existing order rows (Shopify deliberately excluded until its sync writes webshop_orders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(woocommerce): switch the order sync to webshop_orders, multi-store The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund allocations with parent-prorated VAT fallback) and upserts order rows instead of transactions-inbox rows; already-imported feed rows stay bookable and get cross-marked. Multi-store: several active connections per company, per-store panel cards with the account-mapping editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(webshop-orders): decision log entries and ratchet baseline Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption; hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop-orders): resolve PR #1525 review findings and CI failures Review batch (Superagent, CodeRabbit, Swedish compliance review): - Mutual-exclusion claims: booking guards invoice_id, invoice link-back guards journal_entry_id AND treats zero matched rows as the conflict it is (409 + rollback), closing both TOCTOU races. - Freeze v2 migration (20260812124858): the link columns themselves are protected: invoice links immutable, journal links clearable only while the entry is still a draft (the booking rollback path). - Scraped orgnr no longer auto-written to customers.org_number; rate fallback applies only on single-VAT-bucket orders; refunds get their own WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the invoice-mode hint in the booking dialog. - Ingest compares every synced field (billing corrections no longer drop as unchanged); sync guards absent refunds arrays; /sync aggregates per-store results; panel disables all cards while a request runs; orders page separates load failure from empty; account field explains itself. CI: regenerated skills/accounted-api; pg tests restructured for transaction-abort/rollback semantics + freeze-link coverage; unresolvable- expression ceiling 375 -> 378 with documented reason (partial-update payloads in ingest, shapes covered by unit tests). Declined: CodeRabbit docstring-coverage advisory (house style: comments only where the code cannot say it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
252 lines
9.0 KiB
TypeScript
252 lines
9.0 KiB
TypeScript
import { roundOre as round } from '@/lib/money'
|
|
import type {
|
|
CreateJournalEntryLineInput,
|
|
WebshopOrder,
|
|
WebshopStoreSettings,
|
|
WebshopVatBreakdownLine,
|
|
} from '@/types'
|
|
|
|
/**
|
|
* Pure builder for the journal lines that PREFILL the order booking dialog.
|
|
* Never books anything on its own: the user reviews and can override every
|
|
* line (manual-base doctrine), and the server only validates what comes back
|
|
* through the engine.
|
|
*
|
|
* Shape for an order row (Swish 500 kr incl. 25%):
|
|
* Debit 1930 (mapped by payment_method) 500.00 gross
|
|
* Credit 3001 400.00 net per rate
|
|
* Credit 2611 100.00 VAT per rate
|
|
* Refund rows mirror (debit revenue/VAT, credit the payment account).
|
|
* A rounding residual goes to 3740 Öresavrundning so the entry balances.
|
|
*
|
|
* Non-SEK orders book in SEK via the row's stored exchange_rate (rate date =
|
|
* paid date, captured at sync); every line carries the currency metadata trio
|
|
* exactly like the transaction booking dialog does. Callers must not invoke
|
|
* this while total_sek is null (booking is blocked until FX resolves).
|
|
*/
|
|
|
|
/** Fallback counter-account when no mapping exists: the old feed's ledger. */
|
|
export const DEFAULT_PAYMENT_ACCOUNT = '1680'
|
|
|
|
/** Revenue account per Swedish VAT rate (BAS 2026). */
|
|
const REVENUE_ACCOUNT_BY_RATE: Record<number, string> = {
|
|
25: '3001',
|
|
12: '3002',
|
|
6: '3003',
|
|
0: '3004',
|
|
}
|
|
|
|
/** Output VAT account per rate. */
|
|
const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
|
|
25: '2611',
|
|
12: '2621',
|
|
6: '2631',
|
|
}
|
|
|
|
/** Öresavrundning. */
|
|
const ROUNDING_ACCOUNT = '3740'
|
|
|
|
/**
|
|
* Resolve the prefilled payment counter-account for an order from the
|
|
* per-store mapping. Returns the account plus whether the store marked this
|
|
* payment method as invoice-flow (the dialog then nudges toward Skapa
|
|
* faktura instead).
|
|
*/
|
|
export function resolvePaymentAccount(
|
|
order: Pick<WebshopOrder, 'payment_method'>,
|
|
settings: WebshopStoreSettings | null | undefined,
|
|
): { account: string; invoiceMode: boolean; mapped: boolean } {
|
|
const method = order.payment_method
|
|
const policy = method ? settings?.payment_method_account_map?.[method] : undefined
|
|
if (!policy) return { account: DEFAULT_PAYMENT_ACCOUNT, invoiceMode: false, mapped: false }
|
|
if (policy.mode === 'invoice') {
|
|
return { account: DEFAULT_PAYMENT_ACCOUNT, invoiceMode: true, mapped: true }
|
|
}
|
|
return { account: policy.account, invoiceMode: false, mapped: true }
|
|
}
|
|
|
|
/**
|
|
* When the sync could not build a per-rate breakdown (blocked tax endpoints,
|
|
* plugin-mangled orders), fall back to one bucket whose rate is inferred
|
|
* from the tax/net ratio; null rate when nothing matches, so the dialog
|
|
* shows an editable guess instead of silently wrong accounts.
|
|
*/
|
|
export function fallbackVatBreakdown(
|
|
total: number,
|
|
totalTax: number,
|
|
): WebshopVatBreakdownLine[] {
|
|
const net = round(Math.abs(total) - Math.abs(totalTax))
|
|
const tax = round(Math.abs(totalTax))
|
|
if (net <= 0) return [{ rate: 0, net: round(Math.abs(total)), tax: 0 }]
|
|
if (tax === 0) return [{ rate: 0, net, tax: 0 }]
|
|
const ratio = tax / net
|
|
for (const rate of [25, 12, 6]) {
|
|
if (Math.abs(ratio - rate / 100) < 0.005) return [{ rate, net, tax }]
|
|
}
|
|
// Unknown mix: present as 25% bucket for the user to correct.
|
|
return [{ rate: 25, net, tax }]
|
|
}
|
|
|
|
export type BookingWarning = 'zero_rate_foreign' | 'foreign_vat'
|
|
|
|
/**
|
|
* Advisory (never blocking, per the soft-guard rule) compliance hints for
|
|
* the booking dialog:
|
|
* - zero_rate_foreign: a 0%-rate amount on an order with a known non-SE
|
|
* billing country. The prefill's 3004 (ruta 42) is only right for
|
|
* domestic momsfri sales; export/EU sales belong on 31xx/33xx accounts.
|
|
* - foreign_vat: VAT charged on a non-SEK order. Swedish 2611-series output
|
|
* VAT may be wrong if the merchant is over the EU distance-selling
|
|
* threshold (OSS) — the store's tax setup decides, the user must check.
|
|
*/
|
|
export function resolveBookingWarnings(
|
|
order: Pick<
|
|
WebshopOrder,
|
|
'currency' | 'total_tax' | 'vat_breakdown' | 'customer_country'
|
|
>,
|
|
): BookingWarning[] {
|
|
const warnings: BookingWarning[] = []
|
|
const hasZeroRateAmount = order.vat_breakdown.some(
|
|
(b) => b.rate === 0 && b.net !== 0,
|
|
)
|
|
if (
|
|
hasZeroRateAmount &&
|
|
order.customer_country &&
|
|
order.customer_country.toUpperCase() !== 'SE'
|
|
) {
|
|
warnings.push('zero_rate_foreign')
|
|
}
|
|
if (order.currency.toUpperCase() !== 'SEK' && order.total_tax !== 0) {
|
|
warnings.push('foreign_vat')
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
export interface OrderBookingLinesInput {
|
|
order: Pick<
|
|
WebshopOrder,
|
|
| 'row_type'
|
|
| 'order_number'
|
|
| 'payment_method'
|
|
| 'payment_method_title'
|
|
| 'currency'
|
|
| 'total'
|
|
| 'total_tax'
|
|
| 'total_sek'
|
|
| 'exchange_rate'
|
|
| 'vat_breakdown'
|
|
>
|
|
settings?: WebshopStoreSettings | null
|
|
/** Explicit override of the payment counter-account (dialog edit). */
|
|
paymentAccount?: string
|
|
}
|
|
|
|
/**
|
|
* Build balanced prefill lines for one order/refund row. Throws if total_sek
|
|
* is required but unresolved: callers gate on it first.
|
|
*/
|
|
export function buildOrderBookingLines({
|
|
order,
|
|
settings,
|
|
paymentAccount,
|
|
}: OrderBookingLinesInput): CreateJournalEntryLineInput[] {
|
|
const isSek = order.currency.toUpperCase() === 'SEK'
|
|
const rate = isSek ? 1 : order.exchange_rate
|
|
const grossSek = isSek ? round(order.total) : order.total_sek
|
|
if (grossSek === null || grossSek === undefined || !rate) {
|
|
throw new Error('Order is missing a resolved SEK amount; booking is blocked until the exchange rate resolves')
|
|
}
|
|
|
|
const account = paymentAccount ?? resolvePaymentAccount(order, settings).account
|
|
// Refund rows carry negative totals; build everything from magnitudes and
|
|
// apply direction at the end so debit/credit never go negative.
|
|
const isRefund = order.row_type === 'refund'
|
|
const grossAbs = round(Math.abs(grossSek))
|
|
|
|
const breakdown =
|
|
order.vat_breakdown.length > 0
|
|
? order.vat_breakdown
|
|
: fallbackVatBreakdown(order.total, order.total_tax)
|
|
|
|
const toSek = (amount: number) => round(Math.abs(amount) * rate)
|
|
|
|
const methodLabel = order.payment_method_title || order.payment_method || ''
|
|
const baseDescription = methodLabel
|
|
? `Order ${order.order_number} (${methodLabel})`
|
|
: `Order ${order.order_number}`
|
|
const description = isRefund
|
|
? `Återbetalning order ${order.order_number}`
|
|
: baseDescription
|
|
|
|
const currencyMeta = (amountAbs: number): Partial<CreateJournalEntryLineInput> =>
|
|
isSek
|
|
? {}
|
|
: {
|
|
currency: order.currency.toUpperCase(),
|
|
amount_in_currency: amountAbs,
|
|
exchange_rate: rate,
|
|
}
|
|
|
|
const line = (
|
|
accountNumber: string,
|
|
sekAbs: number,
|
|
side: 'debit' | 'credit',
|
|
originalAbs: number,
|
|
): CreateJournalEntryLineInput => ({
|
|
account_number: accountNumber,
|
|
debit_amount: side === 'debit' ? sekAbs : 0,
|
|
credit_amount: side === 'credit' ? sekAbs : 0,
|
|
line_description: description,
|
|
...currencyMeta(originalAbs),
|
|
})
|
|
|
|
// Order: money in (debit payment account); refund: money out (credit).
|
|
const grossSide: 'debit' | 'credit' = isRefund ? 'credit' : 'debit'
|
|
const counterSide: 'debit' | 'credit' = isRefund ? 'debit' : 'credit'
|
|
|
|
const lines: CreateJournalEntryLineInput[] = [
|
|
line(account, grossAbs, grossSide, round(Math.abs(order.total))),
|
|
]
|
|
|
|
// Buckets are SIGNED: a discount/gift-card bucket carries a negative net
|
|
// and must book on the OPPOSITE side (a revenue reduction), never as
|
|
// abs-flipped extra revenue with the difference dumped on 3740 (skeptic
|
|
// finding). counterSum accumulates the signed counter-direction total so
|
|
// the residual stays a pure öre artifact.
|
|
let counterSum = 0
|
|
const pushSigned = (account: string, signedOriginal: number) => {
|
|
if (signedOriginal === 0) return
|
|
const amountAbs = round(Math.abs(signedOriginal))
|
|
const sekAbs = toSek(amountAbs)
|
|
if (sekAbs === 0) return
|
|
const side = signedOriginal > 0 ? counterSide : grossSide
|
|
lines.push(line(account, sekAbs, side, amountAbs))
|
|
counterSum = round(counterSum + (signedOriginal > 0 ? sekAbs : -sekAbs))
|
|
}
|
|
for (const bucket of breakdown) {
|
|
const revenueAccount =
|
|
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))
|
|
}
|
|
|
|
// Balance residual (per-line rounding, FX drift) to öresavrundning. The
|
|
// residual can fall on either side. No currency metadata: the residual is
|
|
// an SEK-conversion artifact, not an amount that exists in the order
|
|
// currency.
|
|
const residual = round(grossAbs - counterSum)
|
|
if (residual !== 0) {
|
|
const side: 'debit' | 'credit' = residual > 0 ? counterSide : grossSide
|
|
const residualAbs = round(Math.abs(residual))
|
|
lines.push({
|
|
account_number: ROUNDING_ACCOUNT,
|
|
debit_amount: side === 'debit' ? residualAbs : 0,
|
|
credit_amount: side === 'credit' ? residualAbs : 0,
|
|
line_description: description,
|
|
})
|
|
}
|
|
|
|
return lines
|
|
}
|