Files
accounted/lib/webshop-orders/book-order.ts
T
MattssonandClaude Fable 5 c634430677 feat(woo): select multiple orders and book them with one template sweep (#1900)
* feat(woo): select multiple orders and book them with one template sweep

Adds bulk booking to the orders page (issue #1880): hover-reveal checkbox
column, a bulkbar with select-all/clear, and a confirm dialog that books
every selected order with the standard order template (per-store payment-
method mapping, optionally one override account for the whole selection).

Server side, POST /api/webshop-orders/bulk-book books each order as its
OWN verifikat through the exact same flow as the single-order endpoint:
the guards, FX retry and race-free draft -> claim -> commit sequence are
extracted to lib/webshop-orders/book-order.ts and shared by both routes,
so nothing added to the single path can miss the bulk path. Partial
failure is reported per order and never aborts the batch.

Fixes #1880

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): replace mangled NUL byte in bulk dialog grouping key with a pipe

The account-group key template literal picked up a raw 0x00 byte during
generation (known escape-mangling hazard), making git treat the file as
binary. Same grouping semantics, plain '|' separator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): bulk sweep only books derived lines, never guessed ones (skeptic findings)

The sweep has no reviewing user, so everything the single dialog relies
on a human to catch is now refused per order or aborted:

- empty vat_breakdown: the ratio-inferred fallback split (a 25%+6% mixed
  sale classified as 12%, refunds reversing zero moms via 3004) is only
  allowed as the single dialog's editable prefill; bulk refuses with
  WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING
- invoice-mode payment methods: booking would foreclose Skapa faktura
  and post a wrong clearing leg; refused with
  WEBSHOP_ORDER_INVOICE_MODE_METHOD (the account override does not
  bypass the merchant's configured flow)
- 3740 residual above ore scale (gift-card gaps booked as
  'oresavrundning'): refused with WEBSHOP_ORDER_RESIDUAL_TOO_LARGE
- settings-fetch failure now aborts the sweep instead of silently
  rebooking every order to 1686 against the confirmed dialog
- maxDuration 300 so a platform kill cannot strand an order between
  claim and commit
- per-order guard details (e.g. journal_entry_id) survive into the
  failure envelope

The dialog mirrors the skip rules up front (named order numbers, not an
anonymous count) so the confirmation describes exactly what will book.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): refuse non-Swedish VAT-rate buckets in the bulk sweep

A foreign OSS bucket (e.g. German 19%) passes the non-empty breakdown
gate with zero residual, but the rate-to-account maps would fall back to
the 25% accounts and book foreign VAT as Swedish utgaende moms 2611
(skeptic finding). The sweep now refuses such orders per order with
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE (details.rates names the offending
rates); the dialog mirrors the rule and names the skipped orders. Only
the single dialog may show that prefill, as an editable guess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:34:34 +02:00

324 lines
11 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { ensureWebshopPrefillAccounts } from '@/lib/webshop-orders/ensure-accounts'
import { archiveWebshopOrderUnderlag } from '@/lib/webshop-orders/order-underlag'
import { roundOre } from '@/lib/money'
import type {
CreateJournalEntryLineInput,
Currency,
JournalEntry,
WebshopOrder,
} from '@/types'
/**
* The server-side booking flow for one webshop order/refund row, extracted
* from POST /api/webshop-orders/[id]/book so the bulk endpoint runs the exact
* same code per order. Three composable steps, called in this order by both
* routes:
*
* 1. assertOrderBookable(): state guards (already booked/invoiced, unpaid,
* refund-of-invoiced-parent, legacy transactions-feed overlap).
* 2. resolveOrderFx(): booking-time retry for a missing SEK conversion on
* non-SEK rows.
* 3. bookOrderThroughEngine(): chart repair for our own prefill accounts,
* then the race-free draft -> atomic claim -> commit sequence through
* lib/bookkeeping/engine (source_type 'webshop_order').
*
* Anything added to the single-order path (e.g. underlag anchoring) belongs
* in these functions, never inline in one route, so single and bulk booking
* can not drift apart.
*/
/** Guard failure: a structured-error code plus optional envelope details. */
export interface OrderBookableFailure {
code:
| 'WEBSHOP_ORDER_ALREADY_BOOKED'
| 'WEBSHOP_ORDER_ALREADY_INVOICED'
| 'WEBSHOP_ORDER_MANUALLY_BOOKED'
| 'WEBSHOP_ORDER_REFUND_PARENT_INVOICED'
| 'WEBSHOP_ORDER_NOT_PAID'
| 'WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED'
| 'WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN'
details?: Record<string, unknown>
}
/**
* Re-check the order's state server-side (the client list can be stale).
* Returns null when the row may be booked, otherwise the structured-error
* code the route should answer with.
*/
export async function assertOrderBookable(
supabase: SupabaseClient,
companyId: string,
order: WebshopOrder,
): Promise<OrderBookableFailure | null> {
if (order.journal_entry_id) {
return {
code: 'WEBSHOP_ORDER_ALREADY_BOOKED',
details: { journal_entry_id: order.journal_entry_id },
}
}
if (order.invoice_id) {
return {
code: 'WEBSHOP_ORDER_ALREADY_INVOICED',
details: { invoice_id: order.invoice_id },
}
}
// Marked as booked outside the integration: booking it here would post
// the same business event twice. The mark is user-reversible.
if (order.manually_booked_at) {
return {
code: 'WEBSHOP_ORDER_MANUALLY_BOOKED',
details: { manually_booked_at: order.manually_booked_at },
}
}
// Refunds of an invoiced order belong in the credit-note flow.
if (order.row_type === 'refund' && order.parent_order_id) {
const { data: parent } = await supabase
.from('webshop_orders')
.select('invoice_id')
.eq('id', order.parent_order_id)
.eq('company_id', companyId)
.maybeSingle()
if (parent?.invoice_id) {
return {
code: 'WEBSHOP_ORDER_REFUND_PARENT_INVOICED',
details: { invoice_id: parent.invoice_id },
}
}
}
if (!order.is_paid && order.row_type === 'order') {
return { code: 'WEBSHOP_ORDER_NOT_PAID' }
}
// Double-booking lock against the legacy transactions feed: the same
// money event may already sit in the inbox (imported before the Orders
// switch-over). A booked feed row means this order IS booked via the
// feed; an open one must be booked or IGNORED there first, and an
// ignored row (is_ignored) unlocks order-side booking, exactly as the
// error message instructs.
if (order.legacy_transaction_id) {
const { data: legacyTxn } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', order.legacy_transaction_id)
.eq('company_id', companyId)
.maybeSingle()
if (legacyTxn) {
if (legacyTxn.journal_entry_id) {
return {
code: 'WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED',
details: {
transaction_id: legacyTxn.id,
journal_entry_id: legacyTxn.journal_entry_id,
},
}
}
if (!legacyTxn.is_ignored) {
return {
code: 'WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN',
details: { transaction_id: legacyTxn.id },
}
}
}
}
return null
}
/**
* Non-SEK rows book in SEK; retry the rate once at booking time before
* refusing (a sync-time Riksbanken hiccup should not strand the order).
* Returns the order with total_sek/exchange_rate resolved, or null when the
* rate still cannot be fetched (the route answers WEBSHOP_ORDER_FX_UNRESOLVED).
*/
export async function resolveOrderFx(
supabase: SupabaseClient,
companyId: string,
order: WebshopOrder,
log: Logger,
): Promise<WebshopOrder | null> {
if (order.currency.toUpperCase() === 'SEK' || order.total_sek !== null) {
return order
}
try {
const rate = await fetchExchangeRate(
order.currency.toUpperCase() as Currency,
new Date(`${order.paid_date ?? order.order_date}T00:00:00Z`),
supabase,
)
if (rate?.rate) {
const totalSek = roundOre(order.total * rate.rate)
const { error: fxError } = await supabase
.from('webshop_orders')
.update({ total_sek: totalSek, exchange_rate: rate.rate })
.eq('id', order.id)
.eq('company_id', companyId)
if (!fxError) {
return { ...order, total_sek: totalSek, exchange_rate: rate.rate }
}
}
} catch (err) {
log.warn('booking-time FX retry failed', err as Error)
}
return null
}
export interface BookOrderEngineInput {
fiscal_period_id: string
entry_date: string
description: string
lines: CreateJournalEntryLineInput[]
voucher_series?: string
notes?: string
}
export type BookOrderEngineOutcome =
/** Committed; journalEntry is commitEntry's post-commit fetch (may be null). */
| {
ok: true
journalEntry: JournalEntry | null
journalEntryId: string
/** Orderunderlag PDF archived on the verifikat (#1881); never fatal. */
underlagArchived: boolean
}
/** Another request booked/invoiced the row between our read and the claim. */
| { ok: false; kind: 'claimed_elsewhere' }
/** The conditional claim update itself errored (DB failure). */
| { ok: false; kind: 'claim_error'; error: unknown }
/** createDraftEntry/commitEntry threw; usually a typed bookkeeping error. */
| { ok: false; kind: 'engine_error'; stage: 'draft' | 'commit'; error: unknown }
/**
* Race-free booking: draft -> atomic claim -> commit. The read-then-book
* pattern let two concurrent requests each post an immutable verifikat
* for the same order (skeptic finding). Instead the order row is claimed
* with a conditional update BEFORE anything gets a voucher number: the
* loser's claim matches zero rows and its draft (no voucher yet, so no
* series gap) is cancelled.
*/
export async function bookOrderThroughEngine(
supabase: SupabaseClient,
companyId: string,
userId: string,
/**
* The order row, with FX already resolved (resolveOrderFx): the underlag
* archived after commit renders the SEK conversion facts from it.
*/
order: WebshopOrder,
input: BookOrderEngineInput,
log: Logger,
): Promise<BookOrderEngineOutcome> {
const orderId = order.id
// The prefill can legitimately reach 3004, 3740 and the 1686 clearing
// account, none of which seed_chart_of_accounts() seeds. Without this the
// first Bokför on a fresh company died on AccountsNotInChartError for an
// account the user never chose. Only our own closed prefill set is added,
// and failures here are swallowed so the engine's typed error still wins.
await ensureWebshopPrefillAccounts(
supabase,
companyId,
userId,
input.lines.map((l) => l.account_number),
log,
)
let draft: JournalEntry
try {
draft = await createDraftEntry(supabase, companyId, userId, {
fiscal_period_id: input.fiscal_period_id,
entry_date: input.entry_date,
description: input.description,
source_type: 'webshop_order',
source_id: orderId,
voucher_series: input.voucher_series,
notes: input.notes,
lines: input.lines,
})
} catch (err) {
return { ok: false, kind: 'engine_error', stage: 'draft', error: err }
}
const cancelDraft = async () => {
const { error: cancelError } = await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', draft.id)
.eq('status', 'draft')
if (cancelError) {
log.error('draft cleanup failed after claim/commit failure', cancelError, {
entryId: draft.id,
})
}
}
// The claim guards BOTH links plus the manual mark: a concurrent
// create-invoice or mark-booked between our read and this update must
// lose too (mutual exclusivity, not just no-double-booking).
const { data: claimed, error: claimError } = await supabase
.from('webshop_orders')
.update({ journal_entry_id: draft.id })
.eq('id', orderId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('invoice_id', null)
.is('manually_booked_at', null)
.select('id')
if (claimError || !claimed || claimed.length === 0) {
await cancelDraft()
if (claimError) {
log.error('webshop order claim failed', claimError, { orderId })
return { ok: false, kind: 'claim_error', error: claimError }
}
// Zero rows matched: someone else booked it between our read and claim.
return { ok: false, kind: 'claimed_elsewhere' }
}
let journalEntry: JournalEntry | null
try {
journalEntry = await commitEntry(supabase, companyId, userId, draft.id)
} catch (err) {
// Unlink so the row does not point at a cancelled draft, then cancel.
// Order matters: the financial-freeze trigger keys on journal_entry_id
// being set, but journal_entry_id itself is not in its protected list,
// so the unlink passes.
await supabase
.from('webshop_orders')
.update({ journal_entry_id: null })
.eq('id', orderId)
.eq('company_id', companyId)
.eq('journal_entry_id', draft.id)
await cancelDraft()
return { ok: false, kind: 'engine_error', stage: 'commit', error: err }
}
// No extra event here: commitEntry() already emits
// journal_entry.committed from inside the engine.
// Archive the orderunderlag (lines, customer, payment method) on the
// committed verifikat (#1881). Never fatal: the booking is immutable at
// this point, and a verifikat left without underlag surfaces on the
// "saknar underlag" worklist (webshop_order is a needs-doc source type),
// where the user can attach a document by hand. Living here, not in a
// route, so the single-order and bulk paths can never diverge on it.
const underlag = await archiveWebshopOrderUnderlag({
supabase,
companyId,
userId,
order,
journalEntryId: journalEntry?.id ?? draft.id,
log,
})
return {
ok: true,
journalEntry,
// commitEntry's post-commit fetch can theoretically return no row;
// the entry still exists under draft.id.
journalEntryId: journalEntry?.id ?? draft.id,
underlagArchived: underlag.ok,
}
}