Files
accounted/app/api/webshop-orders/[id]/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

111 lines
3.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { BookWebshopOrderSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
assertOrderBookable,
bookOrderThroughEngine,
resolveOrderFx,
} from '@/lib/webshop-orders/book-order'
import type { WebshopOrder } from '@/types'
ensureInitialized()
/**
* Book one webshop order/refund row as a verifikat. The dialog sends the
* (user-reviewed) lines built by lib/webshop-orders/booking-lines; the server
* re-guards state and routes everything through the engine
* (source_type 'webshop_order'). Period/company locks and balance are
* enforced by the engine + DB triggers as usual.
*
* The flow itself (guards, FX retry, draft -> claim -> commit, orderunderlag
* archiving) lives in lib/webshop-orders/book-order.ts, shared verbatim with
* the bulk endpoint (POST /api/webshop-orders/bulk-book) so the two paths
* cannot drift.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'webshop_order.book',
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, BookWebshopOrderSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, entry_date, description, lines, voucher_series, notes } =
validation.data
const { data: order, error: fetchError } = await supabase
.from('webshop_orders')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single<WebshopOrder>()
if (fetchError || !order) {
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
}
const guardFailure = await assertOrderBookable(supabase, companyId, order)
if (guardFailure) {
return errorResponseFromCode(guardFailure.code, log, {
requestId,
...(guardFailure.details ? { details: guardFailure.details } : {}),
})
}
// Also keeps the in-memory row in sync (total_sek/exchange_rate): the
// underlag renders the SEK conversion facts from it after commit.
const resolvedOrder = await resolveOrderFx(supabase, companyId, order, log)
if (!resolvedOrder) {
return errorResponseFromCode('WEBSHOP_ORDER_FX_UNRESOLVED', log, {
requestId,
details: { currency: order.currency },
})
}
const outcome = await bookOrderThroughEngine(
supabase,
companyId,
user.id,
resolvedOrder,
{ fiscal_period_id, entry_date, description, lines, voucher_series, notes },
log,
)
if (!outcome.ok) {
if (outcome.kind === 'claimed_elsewhere') {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
}
if (outcome.kind === 'claim_error') {
return NextResponse.json(
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
{ status: 500 },
)
}
const typed = bookkeepingErrorResponse(outcome.error)
if (typed) return typed
log.error(
outcome.stage === 'draft'
? 'failed to draft journal entry for webshop order'
: 'failed to commit journal entry for webshop order',
outcome.error as Error,
)
return NextResponse.json(
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
{ status: 400 },
)
}
return NextResponse.json({
data: outcome.journalEntry,
journal_entry_id: outcome.journalEntryId,
underlag_archived: outcome.underlagArchived,
success: true,
})
},
{ requireWrite: true },
)