Files
accounted/app/api/webshop-orders/bulk-book/route.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

359 lines
13 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { BulkBookWebshopOrdersSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
buildOrderBookingLines,
orderBookingDescription,
resolvePaymentAccount,
unsupportedVatRates,
ROUNDING_ACCOUNT,
} from '@/lib/webshop-orders/booking-lines'
import {
assertOrderBookable,
bookOrderThroughEngine,
resolveOrderFx,
} from '@/lib/webshop-orders/book-order'
import type { WebshopOrder, WebshopStoreSettings } from '@/types'
ensureInitialized()
// Up to 50 sequential draft -> claim -> commit round trips against the
// engine; the default function window is not guaranteed to fit them, and a
// kill between claim and commit would leave an order pointing at an
// uncommitted draft (skeptic finding). Same budget as the other batch routes.
export const maxDuration = 300
/**
* A residual on 3740 above this magnitude (SEK) is not öresavrundning: it
* means the order's gross total does not match its VAT breakdown (gift
* cards, plugin-mangled orders). Legitimate per-bucket öre rounding and FX
* drift stay well below this; anything above needs the single-order dialog
* where the user sees the line.
*/
const MAX_RESIDUAL_SEK = 1
interface BulkBookFailure {
code: string
message: string
message_en: string
details?: Record<string, unknown>
}
interface BulkBookOrderResult {
order_id: string
order_number: string | null
success: boolean
journal_entry_id?: string
voucher_series?: string | null
voucher_number?: number | null
/** Orderunderlag PDF archived on the verifikat (#1881); never fatal. */
underlag_archived?: boolean
error?: BulkBookFailure
}
/** Failure envelope for a known structured-error code. */
function failureFromCode(
code: string,
details?: Record<string, unknown>,
): BulkBookFailure {
const entry = getErrorEntry(code)
return {
code,
message: entry?.message_sv ?? code,
message_en: entry?.message_en ?? code,
...(details ? { details } : {}),
}
}
/** Failure envelope for a thrown (usually typed bookkeeping) error. */
function failureFromError(err: unknown): BulkBookFailure {
const code =
(typeof err === 'object' &&
err !== null &&
typeof (err as { code?: unknown }).code === 'string' &&
(err as { code: string }).code) ||
'WEBSHOP_ORDER_BOOKING_FAILED'
return {
code,
message: getErrorMessage(err, { context: 'transaction' }),
message_en: getErrorMessage(err, { context: 'transaction', locale: 'en' }),
}
}
/**
* POST /api/webshop-orders/bulk-book
*
* Book N selected webshop order/refund rows in one sweep, each with the
* standard order template: payment account (per-store payment-method mapping,
* or the optional payment_account override) against revenue + output VAT per
* rate from the row's own vat_breakdown.
*
* Deliberately NOT a samlingsverifikation: every order books as its OWN
* verifikat through the exact same flow as POST /api/webshop-orders/[id]/book
* (lib/webshop-orders/book-order.ts): state guards, booking-time FX retry,
* chart repair, race-free draft -> claim -> commit through the engine. So
* period locks, balance, voucher numbering and anything later added to the
* single-order path (e.g. underlag anchoring) apply per order automatically.
*
* The sweep has no reviewing user, so it only books orders whose lines are
* DERIVED, never guessed: rows with an empty vat_breakdown (ratio-inferred
* fallback), invoice-mode payment methods, or a 3740 residual above öre
* scale are refused per order and pointed at the single-order dialog.
*
* Partial failure is expected and reported per order: one refused row (period
* locked, raced booking, unresolved FX, review-needed) never aborts the rest
* of the batch. The response is 200 with results[] as long as the request
* itself was valid and at least one requested order exists for the company.
*/
export const POST = withRouteContext(
'webshop_order.bulk_book',
async (request, { supabase, user, companyId, log, requestId }) => {
const validation = await validateBody(request, BulkBookWebshopOrdersSchema)
if (!validation.success) return validation.response
const { order_ids, payment_account } = validation.data
// Dedupe but keep the caller's order for the result list.
const ids = [...new Set(order_ids)]
const { data: orders, error: fetchError } = await supabase
.from('webshop_orders')
.select('*')
.in('id', ids)
.eq('company_id', companyId)
if (fetchError) {
log.error('bulk-book order fetch failed', fetchError)
return NextResponse.json(
{ error: getErrorMessage(fetchError, { context: 'transaction' }) },
{ status: 500 },
)
}
const orderById = new Map<string, WebshopOrder>(
((orders ?? []) as WebshopOrder[]).map((o) => [o.id, o]),
)
if (orderById.size === 0) {
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
}
// Per-store settings drive the payment-method -> account prefill exactly
// like the single-order dialog. A fetch failure ABORTS the sweep: falling
// back to the default clearing account here would silently book every
// order against 1686 while the user just confirmed a dialog showing their
// mapped accounts (skeptic finding). Failing loudly is recoverable;
// fifty wrong immutable verifikat are not.
const { data: settingsData, error: settingsError } = await supabase
.from('webshop_store_settings')
.select('*')
.eq('company_id', companyId)
if (settingsError) {
log.error('bulk-book settings fetch failed; aborting sweep', settingsError)
return NextResponse.json(
{ error: getErrorMessage(settingsError, { context: 'transaction' }) },
{ status: 500 },
)
}
const settingsRows = (settingsData ?? []) as WebshopStoreSettings[]
const settingsFor = (order: WebshopOrder): WebshopStoreSettings | null =>
settingsRows.find(
(s) => s.platform === order.platform && s.store_scope === order.store_scope,
) ?? null
// Sequential on purpose: each order is its own draft -> claim -> commit
// round trip through the engine, and voucher numbers are assigned
// atomically per commit. Parallelizing would only contend on the same
// voucher sequence; 50 orders (the schema cap) stay well inside the
// route budget.
const results: BulkBookOrderResult[] = []
for (const id of ids) {
const order = orderById.get(id)
if (!order) {
results.push({
order_id: id,
order_number: null,
success: false,
error: failureFromCode('WEBSHOP_ORDER_NOT_FOUND'),
})
continue
}
const guardFailure = await assertOrderBookable(supabase, companyId, order)
if (guardFailure) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode(guardFailure.code, guardFailure.details),
})
continue
}
// No VAT breakdown from the store: buildOrderBookingLines would fall
// back to a ratio-INFERRED single bucket, which the single-order dialog
// shows as an editable guess for the user to correct. There is no
// reviewing user in a sweep, so a guessed rate split must never become
// an immutable verifikat here (skeptic finding: a 25%+6% mixed sale
// classified as 12% books wrong revenue/VAT accounts and rutor, and an
// amount-only refund would reverse zero moms via 3004).
if (order.vat_breakdown.length === 0) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode('WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING'),
})
continue
}
// A bucket with a non-Swedish rate (e.g. a German 19% OSS bucket the
// sync stored raw) would silently fall back to the 25% accounts and
// book foreign VAT as Swedish utgaende moms. Only the single dialog
// may show that as an editable prefill (skeptic finding).
const badRates = unsupportedVatRates(order.vat_breakdown)
if (badRates.length > 0) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode('WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE', {
rates: badRates,
}),
})
continue
}
const settings = settingsFor(order)
// The store's own mapping routes this payment method through the
// invoice flow. Booking it directly would both post a wrong clearing
// leg and permanently foreclose Skapa faktura for the order (the claim
// sets journal_entry_id). The override does not bypass this: it
// changes the account, not the flow the merchant configured.
if (resolvePaymentAccount(order, settings).invoiceMode) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode('WEBSHOP_ORDER_INVOICE_MODE_METHOD'),
})
continue
}
const resolvedOrder = await resolveOrderFx(supabase, companyId, order, log)
if (!resolvedOrder) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode('WEBSHOP_ORDER_FX_UNRESOLVED'),
})
continue
}
const entryDate = resolvedOrder.paid_date ?? resolvedOrder.order_date
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode('NO_OPEN_PERIOD_FOR_DATE'),
})
continue
}
let lines
try {
lines = buildOrderBookingLines({
order: resolvedOrder,
settings,
paymentAccount: payment_account,
})
} catch (err) {
// buildOrderBookingLines throws only on an unresolved SEK amount,
// which resolveOrderFx already excluded; belt-and-braces per order.
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromError(err),
})
continue
}
// Residual bound: the 3740 line exists to absorb öre rounding and FX
// drift, both bounded by a few öre per bucket. A residual above
// MAX_RESIDUAL_SEK means the gross total and the VAT breakdown
// disagree (gift-card redemptions, mangled orders); in the single
// dialog the user sees the fat 3740 line and stops, so the sweep must
// refuse instead of booking the gap as "öresavrundning".
const residualLine = lines.find((l) => l.account_number === ROUNDING_ACCOUNT)
const residualAbs = residualLine
? Math.max(residualLine.debit_amount || 0, residualLine.credit_amount || 0)
: 0
if (residualAbs > MAX_RESIDUAL_SEK) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error: failureFromCode('WEBSHOP_ORDER_RESIDUAL_TOO_LARGE', {
residual: residualAbs,
}),
})
continue
}
const outcome = await bookOrderThroughEngine(
supabase,
companyId,
user.id,
resolvedOrder,
{
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description: orderBookingDescription(resolvedOrder),
lines,
},
log,
)
if (!outcome.ok) {
results.push({
order_id: id,
order_number: order.order_number,
success: false,
error:
outcome.kind === 'claimed_elsewhere'
? failureFromCode('WEBSHOP_ORDER_ALREADY_BOOKED')
: failureFromError(outcome.error),
})
continue
}
results.push({
order_id: id,
order_number: order.order_number,
success: true,
journal_entry_id: outcome.journalEntryId,
voucher_series: outcome.journalEntry?.voucher_series ?? null,
voucher_number: outcome.journalEntry?.voucher_number ?? null,
underlag_archived: outcome.underlagArchived,
})
}
const bookedCount = results.filter((r) => r.success).length
return NextResponse.json({
data: {
results,
booked_count: bookedCount,
failed_count: results.length - bookedCount,
},
success: true,
})
},
{ requireWrite: true },
)