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:
co-authored by
Claude Fable 5
parent
5fc0be9ed7
commit
c634430677
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user