Files
accounted/lib/webshop-orders/booking-lines.ts
T
MattssonandClaude Fable 5 c35b2547fb feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* 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>
2026-08-12 15:08:13 +02:00

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
}