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
+41 -214
View File
@@ -1,17 +1,17 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine'
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 { 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 { Currency, WebshopOrder } from '@/types'
import {
assertOrderBookable,
bookOrderThroughEngine,
resolveOrderFx,
} from '@/lib/webshop-orders/book-order'
import type { WebshopOrder } from '@/types'
ensureInitialized()
@@ -21,6 +21,11 @@ ensureInitialized()
* 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',
@@ -43,239 +48,61 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
}
if (order.journal_entry_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, {
const guardFailure = await assertOrderBookable(supabase, companyId, order)
if (guardFailure) {
return errorResponseFromCode(guardFailure.code, log, {
requestId,
details: { journal_entry_id: order.journal_entry_id },
...(guardFailure.details ? { details: guardFailure.details } : {}),
})
}
if (order.invoice_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, {
// 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: { invoice_id: order.invoice_id },
details: { currency: order.currency },
})
}
// 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 errorResponseFromCode('WEBSHOP_ORDER_MANUALLY_BOOKED', log, {
requestId,
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 errorResponseFromCode('WEBSHOP_ORDER_REFUND_PARENT_INVOICED', log, {
requestId,
details: { invoice_id: parent.invoice_id },
})
}
}
if (!order.is_paid && order.row_type === 'order') {
return errorResponseFromCode('WEBSHOP_ORDER_NOT_PAID', log, { requestId })
}
// 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 errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED', log, {
requestId,
details: {
transaction_id: legacyTxn.id,
journal_entry_id: legacyTxn.journal_entry_id,
},
})
}
if (!legacyTxn.is_ignored) {
return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN', log, {
requestId,
details: { transaction_id: legacyTxn.id },
})
}
}
}
// 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).
if (order.currency.toUpperCase() !== 'SEK' && order.total_sek === null) {
let resolved = false
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', id)
.eq('company_id', companyId)
resolved = !fxError
if (resolved) {
// Keep the in-memory row in sync: the underlag renders the SEK
// conversion facts from it after commit.
order.total_sek = totalSek
order.exchange_rate = rate.rate
}
}
} catch (err) {
log.warn('booking-time FX retry failed', err as Error)
}
if (!resolved) {
return errorResponseFromCode('WEBSHOP_ORDER_FX_UNRESOLVED', log, {
requestId,
details: { currency: order.currency },
})
}
}
// 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.
// 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(
const outcome = await bookOrderThroughEngine(
supabase,
companyId,
user.id,
lines.map((l) => l.account_number),
resolvedOrder,
{ fiscal_period_id, entry_date, description, lines, voucher_series, notes },
log,
)
let draft
try {
draft = await createDraftEntry(supabase, companyId, user.id, {
fiscal_period_id,
entry_date,
description,
source_type: 'webshop_order',
source_id: id,
voucher_series,
notes,
lines,
})
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
log.error('failed to draft journal entry for webshop order', err as Error)
return NextResponse.json(
{ error: getErrorMessage(err, { context: 'transaction' }) },
{ status: 400 },
)
}
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,
})
if (!outcome.ok) {
if (outcome.kind === 'claimed_elsewhere') {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
}
}
// 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', id)
.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: id })
if (outcome.kind === 'claim_error') {
return NextResponse.json(
{ error: getErrorMessage(claimError, { context: 'transaction' }) },
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
{ status: 500 },
)
}
// Zero rows matched: someone else booked it between our read and claim.
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
}
let journalEntry
try {
journalEntry = await commitEntry(supabase, companyId, user.id, 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', id)
.eq('company_id', companyId)
.eq('journal_entry_id', draft.id)
await cancelDraft()
const typed = bookkeepingErrorResponse(err)
const typed = bookkeepingErrorResponse(outcome.error)
if (typed) return typed
log.error('failed to commit journal entry for webshop order', err as Error)
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(err, { context: 'transaction' }) },
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
{ status: 400 },
)
}
// 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.
const underlag = await archiveWebshopOrderUnderlag({
supabase,
companyId,
userId: user.id,
order,
journalEntryId: journalEntry?.id ?? draft.id,
log,
})
return NextResponse.json({
data: journalEntry,
// commitEntry's post-commit fetch can theoretically return no row;
// the entry still exists under draft.id.
journal_entry_id: journalEntry?.id ?? draft.id,
underlag_archived: underlag.ok,
data: outcome.journalEntry,
journal_entry_id: outcome.journalEntryId,
underlag_archived: outcome.underlagArchived,
success: true,
})
},
@@ -0,0 +1,556 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeJournalEntry,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const mockCreateDraftEntry = vi.fn()
const mockCommitEntry = vi.fn()
const mockFindFiscalPeriod = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createDraftEntry: (...args: unknown[]) => mockCreateDraftEntry(...args),
commitEntry: (...args: unknown[]) => mockCommitEntry(...args),
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
}))
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
const mockEnsureAccounts = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/webshop-orders/ensure-accounts', () => ({
ensureWebshopPrefillAccounts: (...args: unknown[]) => mockEnsureAccounts(...args),
}))
// Underlag rendering/archiving behaviour lives in
// lib/webshop-orders/__tests__/order-underlag.test.ts; here we only assert
// that every booked order gets one archive call through the shared flow.
const mockArchiveUnderlag = vi.fn()
vi.mock('@/lib/webshop-orders/order-underlag', () => ({
archiveWebshopOrderUnderlag: (...args: unknown[]) => mockArchiveUnderlag(...args),
}))
import { POST } from '../bulk-book/route'
const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000'
const ORDER_1 = '11111111-1111-4111-8111-111111111111'
const ORDER_2 = '22222222-2222-4222-8222-222222222222'
const ORDER_3 = '33333333-3333-4333-8333-333333333333'
function makeOrderRow(overrides: Record<string, unknown> = {}) {
return {
id: ORDER_1,
company_id: 'company-1',
platform: 'woocommerce',
store_scope: 'butik.example.se',
row_type: 'order',
parent_order_id: null,
external_id: 'woo_butik.example.se_order_1001',
order_number: '1001',
status: 'processing',
is_paid: true,
order_date: '2026-08-01',
paid_date: '2026-08-01',
currency: 'SEK',
total: 500,
total_tax: 100,
total_sek: 500,
exchange_rate: 1,
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
line_items: [],
payment_method: 'swish',
payment_method_title: 'Swish',
journal_entry_id: null,
invoice_id: null,
manually_booked_at: null,
legacy_transaction_id: null,
...overrides,
}
}
interface BulkResult {
order_id: string
order_number: string | null
success: boolean
journal_entry_id?: string
underlag_archived?: boolean
error?: {
code: string
message: string
message_en: string
details?: Record<string, unknown>
}
}
interface BulkResponse {
data: { results: BulkResult[]; booked_count: number; failed_count: number }
}
function postBulk(body: unknown = { order_ids: [ORDER_1, ORDER_2] }) {
const request = createMockRequest('/api/webshop-orders/bulk-book', {
method: 'POST',
body,
})
return POST(request)
}
describe('POST /api/webshop-orders/bulk-book', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
let draftCounter = 0
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
draftCounter = 0
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
mockEnsureAccounts.mockResolvedValue(undefined)
mockArchiveUnderlag.mockResolvedValue({ ok: true, documentId: 'doc-1' })
mockCreateDraftEntry.mockImplementation(() => {
draftCounter += 1
return Promise.resolve(
makeJournalEntry({ id: `draft-${draftCounter}`, status: 'draft' }),
)
})
mockCommitEntry.mockImplementation((_s, _c, _u, entryId: string) =>
Promise.resolve(
makeJournalEntry({
id: `je-${entryId}`,
voucher_series: 'A',
voucher_number: 100 + draftCounter,
}),
),
)
mockFindFiscalPeriod.mockResolvedValue(PERIOD_UUID)
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await postBulk())
expect(status).toBe(401)
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('returns 403 when the caller is a viewer (requireWrite)', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await postBulk())
expect(status).toBe(403)
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('returns 400 on invalid body (empty selection)', async () => {
const { status } = await parseJsonResponse(await postBulk({ order_ids: [] }))
expect(status).toBe(400)
})
it('returns 400 on a non-uuid order id', async () => {
const { status } = await parseJsonResponse(
await postBulk({ order_ids: ['not-a-uuid'] }),
)
expect(status).toBe(400)
})
it('returns 404 when none of the orders exist for the company', async () => {
enqueue({ data: [] }) // orders fetch
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBulk(),
)
expect(status).toBe(404)
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('books every selected order as its own verifikat (happy path)', async () => {
enqueue({
data: [
makeOrderRow(),
makeOrderRow({ id: ORDER_2, order_number: '1002', external_id: 'x2' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_1 }] }) // claim order 1
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
expect(body.data.booked_count).toBe(2)
expect(body.data.failed_count).toBe(0)
expect(body.data.results).toHaveLength(2)
expect(body.data.results.every((r) => r.success)).toBe(true)
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(2)
expect(mockCommitEntry).toHaveBeenCalledTimes(2)
const firstInput = mockCreateDraftEntry.mock.calls[0][3] as {
source_type: string
source_id: string
fiscal_period_id: string
description: string
lines: { account_number: string; debit_amount: number }[]
}
expect(firstInput.source_type).toBe('webshop_order')
expect(firstInput.source_id).toBe(ORDER_1)
expect(firstInput.fiscal_period_id).toBe(PERIOD_UUID)
expect(firstInput.description).toBe('Order 1001 (Swish)')
// No mapping saved: the payment leg defaults to the 1686 clearing account.
expect(firstInput.lines[0]).toMatchObject({
account_number: '1686',
debit_amount: 500,
})
const secondInput = mockCreateDraftEntry.mock.calls[1][3] as { source_id: string }
expect(secondInput.source_id).toBe(ORDER_2)
// Each booked order gets its orderunderlag through the shared flow
// (#1881); the per-order result reports it.
expect(mockArchiveUnderlag).toHaveBeenCalledTimes(2)
expect(mockArchiveUnderlag).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'company-1',
userId: 'user-1',
order: expect.objectContaining({ id: ORDER_1 }),
}),
)
expect(body.data.results.every((r) => r.underlag_archived === true)).toBe(true)
})
it('applies the payment_account override to every order', async () => {
enqueue({ data: [makeOrderRow()] })
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_1 }] }) // claim
const { status } = await parseJsonResponse(
await postBulk({ order_ids: [ORDER_1], payment_account: '1930' }),
)
expect(status).toBe(200)
const input = mockCreateDraftEntry.mock.calls[0][3] as {
lines: { account_number: string }[]
}
expect(input.lines[0].account_number).toBe('1930')
})
it('uses the per-store payment-method mapping when no override is sent', async () => {
enqueue({ data: [makeOrderRow()] })
enqueue({
data: [
{
id: 'settings-1',
company_id: 'company-1',
platform: 'woocommerce',
store_scope: 'butik.example.se',
payment_method_account_map: { swish: { mode: 'book', account: '1580' } },
},
],
})
enqueue({ data: [{ id: ORDER_1 }] }) // claim
const { status } = await parseJsonResponse(await postBulk({ order_ids: [ORDER_1] }))
expect(status).toBe(200)
const input = mockCreateDraftEntry.mock.calls[0][3] as {
lines: { account_number: string }[]
}
expect(input.lines[0].account_number).toBe('1580')
})
it('reports per-order failure without aborting the batch (guard failure)', async () => {
enqueue({
data: [
makeOrderRow({ journal_entry_id: 'je-existing' }),
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
expect(body.data.booked_count).toBe(1)
expect(body.data.failed_count).toBe(1)
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
expect(failed?.success).toBe(false)
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
expect(failed?.error?.message).toBeTruthy()
// Guard details survive into the per-order envelope (skeptic finding).
expect(failed?.error?.details?.journal_entry_id).toBe('je-existing')
const succeeded = body.data.results.find((r) => r.order_id === ORDER_2)
expect(succeeded?.success).toBe(true)
expect(mockCommitEntry).toHaveBeenCalledTimes(1)
})
it('refuses an order with no VAT breakdown instead of booking the guessed split', async () => {
// Skeptic counterexample: 25%+6% mixed sale whose sync stored no per-rate
// breakdown; the ratio fallback would classify it as a 12% sale. The
// sweep must refuse it (only the single dialog may show the guess) and
// still book the healthy order.
enqueue({
data: [
makeOrderRow({ total: 1117, total_tax: 117, vat_breakdown: [] }),
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING')
expect(body.data.booked_count).toBe(1)
// The guessed lines must never have reached the engine.
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1)
const input = mockCreateDraftEntry.mock.calls[0][3] as { source_id: string }
expect(input.source_id).toBe(ORDER_2)
})
it('refuses a refund row with no VAT breakdown (zero-moms reversal guard)', async () => {
enqueue({
data: [
makeOrderRow({
row_type: 'refund',
parent_order_id: null,
total: -500,
total_sek: -500,
total_tax: 0,
vat_breakdown: [],
}),
],
})
enqueue({ data: [] }) // store settings
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1] }),
)
expect(status).toBe(200)
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('refuses a bucket with a non-Swedish VAT rate (foreign OSS bucket)', async () => {
// Skeptic counterexample: a German 19% bucket passes the non-empty gate
// and yields zero residual, but REVENUE/VAT_ACCOUNT_BY_RATE[19] would
// fall back to the 25% accounts and book German VAT as Swedish
// utgaende moms. The sweep must refuse; only the single dialog may show
// that prefill for correction.
enqueue({
data: [
makeOrderRow({
total: 1190,
total_tax: 190,
vat_breakdown: [{ rate: 19, net: 1000, tax: 190 }],
}),
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE')
expect(failed?.error?.details?.rates).toEqual([19])
expect(body.data.booked_count).toBe(1)
// The 19% prefill must never have reached the engine.
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1)
const input = mockCreateDraftEntry.mock.calls[0][3] as { source_id: string }
expect(input.source_id).toBe(ORDER_2)
})
it('refuses invoice-mode payment methods even with an account override', async () => {
enqueue({
data: [
makeOrderRow({ payment_method: 'bacs', payment_method_title: 'Bank transfer' }),
],
})
enqueue({
data: [
{
id: 'settings-1',
company_id: 'company-1',
platform: 'woocommerce',
store_scope: 'butik.example.se',
payment_method_account_map: { bacs: { mode: 'invoice' } },
},
],
})
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1], payment_account: '1930' }),
)
expect(status).toBe(200)
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_INVOICE_MODE_METHOD')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('refuses an order whose 3740 residual is above ore scale', async () => {
// Gift-card-style gap: gross 880 but the breakdown sums to 1000, so the
// builder would dump 120 kr on 3740 "oresavrundning". Not öre: refuse.
enqueue({
data: [
makeOrderRow({
total: 880,
total_tax: 200,
vat_breakdown: [{ rate: 25, net: 800, tax: 200 }],
}),
],
})
enqueue({ data: [] }) // store settings
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1] }),
)
expect(status).toBe(200)
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_RESIDUAL_TOO_LARGE')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('aborts the whole sweep when the settings fetch fails (no silent 1686 fallback)', async () => {
enqueue({ data: [makeOrderRow()] })
enqueue({ data: null, error: { message: 'connection reset' } }) // settings fetch fails
const { status } = await parseJsonResponse(await postBulk({ order_ids: [ORDER_1] }))
expect(status).toBe(500)
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('refuses an order marked as booked outside the integration (per-order)', async () => {
enqueue({
data: [
makeOrderRow({ manually_booked_at: '2026-08-01T00:00:00Z' }),
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_MANUALLY_BOOKED')
expect(body.data.booked_count).toBe(1)
})
it('continues after an engine failure and cleans up that order alone', async () => {
mockCommitEntry.mockRejectedValueOnce(new Error('period locked'))
enqueue({
data: [
makeOrderRow(),
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_1 }] }) // claim order 1
enqueue({ data: null }) // unlink order 1
enqueue({ data: null }) // cancel draft 1
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
expect(body.data.booked_count).toBe(1)
expect(body.data.failed_count).toBe(1)
expect(body.data.results[0].success).toBe(false)
expect(body.data.results[1].success).toBe(true)
// The failed order was unlinked so it does not point at a cancelled draft.
const orderUpdates = findCalls('webshop_orders', 'update')
expect(
orderUpdates.some(
(args) => (args[0] as Record<string, unknown>).journal_entry_id === null,
),
).toBe(true)
})
it('reports ids that do not exist for the company as per-order failures', async () => {
enqueue({ data: [makeOrderRow()] }) // only ORDER_1 exists
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_1 }] }) // claim order 1
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1, ORDER_3] }),
)
expect(status).toBe(200)
expect(body.data.booked_count).toBe(1)
const missing = body.data.results.find((r) => r.order_id === ORDER_3)
expect(missing?.success).toBe(false)
expect(missing?.error?.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
})
it('fails the order when no open fiscal period covers its date', async () => {
mockFindFiscalPeriod.mockResolvedValue(null)
enqueue({ data: [makeOrderRow()] })
enqueue({ data: [] }) // store settings
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1] }),
)
expect(status).toBe(200)
expect(body.data.failed_count).toBe(1)
expect(body.data.results[0].error?.code).toBe('NO_OPEN_PERIOD_FOR_DATE')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('fails a non-SEK order whose rate cannot be resolved, books the rest', async () => {
mockFetchExchangeRate.mockResolvedValue(null)
enqueue({
data: [
makeOrderRow({
currency: 'EUR',
total_sek: null,
exchange_rate: null,
}),
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
],
})
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
expect(status).toBe(200)
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_FX_UNRESOLVED')
const succeeded = body.data.results.find((r) => r.order_id === ORDER_2)
expect(succeeded?.success).toBe(true)
})
it('reports a raced claim as WEBSHOP_ORDER_ALREADY_BOOKED and cancels that draft', async () => {
enqueue({ data: [makeOrderRow()] })
enqueue({ data: [] }) // store settings
enqueue({ data: [] }) // claim matched ZERO rows: raced
enqueue({ data: null }) // draft cancel update
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1] }),
)
expect(status).toBe(200)
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
expect(mockCommitEntry).not.toHaveBeenCalled()
const cancels = findCalls('journal_entries', 'update')
expect(cancels.length).toBeGreaterThan(0)
expect((cancels[0][0] as Record<string, unknown>).status).toBe('cancelled')
})
it('deduplicates repeated ids in the selection', async () => {
enqueue({ data: [makeOrderRow()] })
enqueue({ data: [] }) // store settings
enqueue({ data: [{ id: ORDER_1 }] }) // claim once
const { status, body } = await parseJsonResponse<BulkResponse>(
await postBulk({ order_ids: [ORDER_1, ORDER_1] }),
)
expect(status).toBe(200)
expect(body.data.results).toHaveLength(1)
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1)
})
})
+358
View File
@@ -0,0 +1,358 @@
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 },
)