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>
This commit is contained in:
Mattsson
2026-08-25 15:34:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5fc0be9ed7
commit c634430677
12 changed files with 1994 additions and 228 deletions
+14
View File
@@ -1625,6 +1625,20 @@ export const BookWebshopOrderSchema = z.object({
notes: z.string().max(2000).optional(),
})
/**
* Bulk booking of webshop orders: each order books as its OWN verifikat
* through the same server-side flow as the single-order endpoint (never one
* combined journal write). Max 50 = one orders-page of selection.
*/
export const BulkBookWebshopOrdersSchema = z.object({
order_ids: z.array(uuid).min(1).max(50),
/**
* Optional override: prefill every order's payment leg against this
* account instead of the per-store payment-method mapping.
*/
payment_account: accountNumber.optional(),
})
export const CreateInvoiceFromWebshopOrderSchema = z.object({
/** Omitted: match by email/orgnr within the company, else create. */
customer_id: uuid.optional(),
+28
View File
@@ -3900,6 +3900,34 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
message_en:
'The order was invoiced through a customer invoice. Handle the refund with a credit note instead of booking the refund row directly.',
},
WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING: {
httpStatus: 422,
message_sv:
'Ordern saknar momsuppdelning från butiken, så konteringen kan inte härledas säkert. Bokför ordern enskilt och granska raderna.',
message_en:
'The order has no VAT breakdown from the store, so the posting cannot be derived reliably. Book the order individually and review the lines.',
},
WEBSHOP_ORDER_INVOICE_MODE_METHOD: {
httpStatus: 409,
message_sv:
'Betalsättet är markerat som fakturaflöde i butiksinställningarna. Skapa faktura från ordern i stället, eller bokför den enskilt.',
message_en:
'The payment method is marked as invoice flow in the store settings. Create an invoice from the order instead, or book it individually.',
},
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE: {
httpStatus: 422,
message_sv:
'Ordern har en momssats som inte är en svensk sats (25/12/6/0 %), till exempel utländsk OSS-moms. Bokför ordern enskilt och granska raderna.',
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_RESIDUAL_TOO_LARGE: {
httpStatus: 422,
message_sv:
'Orderns belopp stämmer inte med momsuppdelningen (differensen är större än öresavrundning). Bokför ordern enskilt och granska raderna.',
message_en:
'The order total does not match its VAT breakdown (the difference is larger than öre rounding). Book the order individually and review the lines.',
},
WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED: {
httpStatus: 500,
message_sv: 'Kunden kunde inte skapas från orderns uppgifter.',
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
buildOrderBookingLines,
fallbackVatBreakdown,
orderBookingDescription,
resolveBookingWarnings,
resolvePaymentAccount,
DEFAULT_PAYMENT_ACCOUNT,
@@ -311,3 +312,46 @@ describe('resolveBookingWarnings', () => {
).toEqual(['foreign_vat'])
})
})
describe('orderBookingDescription', () => {
it('labels an order with its payment method', () => {
expect(
orderBookingDescription({
row_type: 'order',
order_number: '1001',
payment_method: 'swish',
payment_method_title: 'Swish',
}),
).toBe('Order 1001 (Swish)')
})
it('falls back to the raw method key, then to no method', () => {
expect(
orderBookingDescription({
row_type: 'order',
order_number: '1001',
payment_method: 'swish',
payment_method_title: null,
}),
).toBe('Order 1001 (swish)')
expect(
orderBookingDescription({
row_type: 'order',
order_number: '1001',
payment_method: null,
payment_method_title: null,
}),
).toBe('Order 1001')
})
it('labels refunds without the method', () => {
expect(
orderBookingDescription({
row_type: 'refund',
order_number: '1001',
payment_method: 'swish',
payment_method_title: 'Swish',
}),
).toBe('Återbetalning order 1001')
})
})
+323
View File
@@ -0,0 +1,323 @@
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,
}
}
+40 -9
View File
@@ -55,8 +55,10 @@ const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
6: '2631',
}
/** Öresavrundning. */
const ROUNDING_ACCOUNT = '3740'
/** Öresavrundning. Exported so the bulk route can find and bound the
* residual line it emits (a residual above öre scale means the order's
* totals do not match its VAT breakdown and needs per-order review). */
export const ROUNDING_ACCOUNT = '3740'
/**
* Every account this prefill can emit, as a closed set.
@@ -118,6 +120,21 @@ export function fallbackVatBreakdown(
return [{ rate: 25, net, tax }]
}
/**
* VAT-bucket rates the account maps above can express (Swedish rates). A
* bucket with any other rate (e.g. a German 19% OSS bucket stored raw by the
* sync) would fall back to the 25% accounts: acceptable only as the single
* dialog's editable prefill, never in an unreviewed sweep. Returns the
* distinct offending rates, empty when every bucket is representable.
*/
export function unsupportedVatRates(breakdown: WebshopVatBreakdownLine[]): number[] {
const bad = new Set<number>()
for (const bucket of breakdown) {
if (REVENUE_ACCOUNT_BY_RATE[bucket.rate] === undefined) bad.add(bucket.rate)
}
return Array.from(bad).sort((a, b) => a - b)
}
export type BookingWarning = 'zero_rate_foreign' | 'foreign_vat'
/**
@@ -153,6 +170,26 @@ export function resolveBookingWarnings(
return warnings
}
/**
* The default verifikat/line description for an order or refund row. Kept as
* a single helper so the dialog prefill, the single-order route and the bulk
* route all label the booking identically.
*/
export function orderBookingDescription(
order: Pick<
WebshopOrder,
'row_type' | 'order_number' | 'payment_method' | 'payment_method_title'
>,
): string {
if (order.row_type === 'refund') {
return `Återbetalning order ${order.order_number}`
}
const methodLabel = order.payment_method_title || order.payment_method || ''
return methodLabel
? `Order ${order.order_number} (${methodLabel})`
: `Order ${order.order_number}`
}
export interface OrderBookingLinesInput {
order: Pick<
WebshopOrder,
@@ -201,13 +238,7 @@ export function buildOrderBookingLines({
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 description = orderBookingDescription(order)
const currencyMeta = (amountAbs: number): Partial<CreateJournalEntryLineInput> =>
isSek