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
+131 -3
View File
@@ -8,6 +8,7 @@ import { MoreHorizontal, ShoppingCart } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,7 +19,7 @@ import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { ContextPicker } from '@/components/common/ContextPicker'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -35,6 +36,27 @@ const MarkOrderBookedDialog = dynamic(
() => import('@/components/orders/MarkOrderBookedDialog'),
{ ssr: false },
)
const BulkOrderBookingDialog = dynamic(
() => import('@/components/orders/BulkOrderBookingDialog'),
{ ssr: false },
)
/**
* A row can join the bulk sweep exactly when its single-row Bokför button
* would render: unbooked, uninvoiced, not marked as booked outside the
* integration, and either a refund or a paid order. Legacy-overlap rows stay
* selectable on purpose: the server guard decides and the per-order failure
* report explains (no dead-end soft guard).
*/
function isBulkBookable(order: WebshopOrder, canWrite: boolean): boolean {
return (
canWrite &&
order.journal_entry_id === null &&
order.invoice_id === null &&
order.manually_booked_at === null &&
(order.row_type === 'refund' || order.is_paid)
)
}
interface StoreFacet {
platform: string
@@ -81,9 +103,17 @@ export default function OrdersPage() {
const [bookingOrder, setBookingOrder] = useState<WebshopOrder | null>(null)
const [invoicingOrder, setInvoicingOrder] = useState<WebshopOrder | null>(null)
const [markingOrder, setMarkingOrder] = useState<WebshopOrder | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Snapshot of the selection the bulk dialog opened with: the list refresh
// after a partial failure clears the live selection, but the dialog must
// keep showing its per-order report.
const [bulkOrders, setBulkOrders] = useState<WebshopOrder[] | null>(null)
const load = useCallback(async () => {
setLoading(true)
// A reload changes which rows exist and which are still bookable
// (tab/page/store switch, or a completed sweep): start selection over.
setSelectedIds(new Set())
try {
const storeParam = storeScope ? `&store_scope=${encodeURIComponent(storeScope)}` : ''
const res = await fetch(
@@ -165,6 +195,23 @@ export default function OrdersPage() {
[load, t, toast, errorLocale],
)
const selectableIds = visibleRows
.filter((o) => isBulkBookable(o, canWrite))
.map((o) => o.id)
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const openBulkBooking = useCallback(() => {
setBulkOrders(rows.filter((o) => selectedIds.has(o.id)))
}, [rows, selectedIds])
const tabs: Array<{ key: StatusTab; label: string }> = [
{ key: 'all', label: t('tab_all') },
{ key: 'unpaid', label: t('tab_unpaid') },
@@ -239,10 +286,44 @@ export default function OrdersPage() {
actionHref="/import"
/>
) : (
<div className="stagger-enter overflow-x-auto">
<div className="stagger-enter">
{/* Bulkbar (transactions-page pattern): hidden until at least one
bookable order is selected via the hover checkboxes. */}
{selectedIds.size > 0 && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulk_selected', { count: selectedIds.size })}
</span>
<Button size="sm" onClick={openBulkBooking}>
{t('bulk_book_selected')}
</Button>
{selectedIds.size < selectableIds.length && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set(selectableIds))}
>
{t('bulk_select_all', { count: selectableIds.length })}
</button>
)}
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set())}
>
{t('bulk_clear')}
</button>
</div>
)}
{/* Negative margin + matching padding: lets the hover-revealed
selection checkbox hang into the page margins without being
clipped by the overflow container (transactions-page pattern). */}
<div className="-mx-5 overflow-x-auto px-5 md:-mx-8 md:px-8">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-0 !p-0')} aria-hidden="true"></th>
<th className={TH_CLASS}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_order')}</th>
{multiStore && <th className={TH_CLASS}>{t('col_store')}</th>}
@@ -260,6 +341,9 @@ export default function OrdersPage() {
order={order}
multiStore={multiStore}
canWrite={canWrite}
selectable={isBulkBookable(order, canWrite)}
isSelected={selectedIds.has(order.id)}
onToggleSelect={toggleSelect}
onBook={() => setBookingOrder(order)}
onInvoice={() => setInvoicingOrder(order)}
onMarkBooked={() => setMarkingOrder(order)}
@@ -269,6 +353,7 @@ export default function OrdersPage() {
))}
</tbody>
</table>
</div>
{count > PAGE_SIZE && (
<div className="mt-4 flex items-center justify-between text-[12.5px] text-muted-foreground">
<span>
@@ -323,6 +408,20 @@ export default function OrdersPage() {
}}
/>
)}
{bulkOrders && (
<BulkOrderBookingDialog
open={!!bulkOrders}
onOpenChange={(open) => {
if (!open) setBulkOrders(null)
}}
orders={bulkOrders}
settingsFor={settingsFor}
onBooked={() => {
setSelectedIds(new Set())
void load()
}}
/>
)}
{invoicingOrder && (
<CreateInvoiceFromOrderDialog
open={!!invoicingOrder}
@@ -357,6 +456,9 @@ function OrderRow({
order,
multiStore,
canWrite,
selectable,
isSelected,
onToggleSelect,
onBook,
onInvoice,
onMarkBooked,
@@ -366,6 +468,9 @@ function OrderRow({
order: WebshopOrder
multiStore: boolean
canWrite: boolean
selectable: boolean
isSelected: boolean
onToggleSelect: (id: string) => void
onBook: () => void
onInvoice: () => void
onMarkBooked: () => void
@@ -389,7 +494,30 @@ function OrderRow({
const unmarkable = canWrite && manuallyMarked
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<tr
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
isSelected && 'bg-secondary/40',
)}
>
{/* Hover-revealed selection checkbox (transactions-page pattern):
zero-width cell, the checkbox hangs in the left page margin so the
date column stays where it was. Selected rows keep it visible. */}
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
{selectable && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(order.id)}
aria-label={t('select_order_aria', { number: order.order_number })}
className={cn(
'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6',
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100',
)}
/>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(order.order_date)}
</td>
+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 },
)
@@ -0,0 +1,407 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
resolveBookingWarnings,
resolvePaymentAccount,
unsupportedVatRates,
} from '@/lib/webshop-orders/booking-lines'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { roundOre } from '@/lib/money'
import { formatCurrency } from '@/lib/utils'
import type { WebshopOrder, WebshopStoreSettings } from '@/types'
interface BulkBookOrderResult {
order_id: string
order_number: string | null
success: boolean
journal_entry_id?: string
voucher_series?: string | null
voucher_number?: number | null
error?: { code: string; message: string; message_en?: string }
}
interface BulkOrderBookingDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
orders: WebshopOrder[]
settingsFor: (order: WebshopOrder) => WebshopStoreSettings | null
/** Called after the server processed the batch (also on partial failure). */
onBooked: () => void
}
/**
* Book N selected orders with the standard order template in one sweep
* (confirm up front, convention 10). Each order still becomes its own
* verifikat server-side via the same flow as the single-order dialog; this
* dialog only chooses the payment counter-account policy: per-store mapping
* (default) or one explicit account for the whole selection. Partial failure
* is surfaced per order in a result list instead of aborting the batch.
*/
export default function BulkOrderBookingDialog({
open,
onOpenChange,
orders,
settingsFor,
onBooked,
}: BulkOrderBookingDialogProps) {
const t = useTranslations('webshop_orders')
const locale = useLocale()
const { toast } = useToast()
const [overrideEnabled, setOverrideEnabled] = useState(false)
const [overrideAccount, setOverrideAccount] = useState('')
const [submitting, setSubmitting] = useState(false)
const [results, setResults] = useState<BulkBookOrderResult[] | null>(null)
// Client-side mirror of the server's review guards, so the confirmation
// describes exactly what will book (convention 10) instead of promising a
// sweep the server then partially refuses:
// - empty vat_breakdown: the prefill would be a ratio-inferred GUESS that
// only the single dialog's editable form may show;
// - a non-Swedish VAT rate (foreign OSS bucket): the account map would
// silently fall back to the 25% accounts;
// - invoice-mode mapping: the store routes this method through the invoice
// flow, and booking would foreclose Skapa faktura for the order.
// The server enforces the same rules for non-UI callers.
const {
bookableOrders,
skippedMissingBreakdown,
skippedUnsupportedRate,
skippedInvoiceMode,
} = useMemo(() => {
const missing: WebshopOrder[] = []
const badRate: WebshopOrder[] = []
const invoiceMode: WebshopOrder[] = []
const bookable: WebshopOrder[] = []
for (const order of orders) {
if (order.vat_breakdown.length === 0) missing.push(order)
else if (unsupportedVatRates(order.vat_breakdown).length > 0) badRate.push(order)
else if (resolvePaymentAccount(order, settingsFor(order)).invoiceMode)
invoiceMode.push(order)
else bookable.push(order)
}
return {
bookableOrders: bookable,
skippedMissingBreakdown: missing,
skippedUnsupportedRate: badRate,
skippedInvoiceMode: invoiceMode,
}
}, [orders, settingsFor])
// Signed sum per currency: refunds carry negative totals and reduce the
// batch total honestly instead of inflating it.
const currencyTotals = useMemo(() => {
const byCurrency = new Map<string, number>()
for (const order of bookableOrders) {
const code = order.currency.toUpperCase()
byCurrency.set(code, roundOre((byCurrency.get(code) ?? 0) + order.total))
}
return Array.from(byCurrency.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [bookableOrders])
// Payment-method groups with their resolved counter-account, so the user
// sees exactly which account each slice of the selection will book against
// before confirming.
const accountGroups = useMemo(() => {
const groups = new Map<string, { label: string; account: string; count: number }>()
for (const order of bookableOrders) {
const { account } = resolvePaymentAccount(order, settingsFor(order))
const label = order.payment_method_title || order.payment_method || ''
const key = `${label}|${account}`
const existing = groups.get(key)
if (existing) existing.count += 1
else groups.set(key, { label, account, count: 1 })
}
return Array.from(groups.values()).sort((a, b) => a.label.localeCompare(b.label))
}, [bookableOrders, settingsFor])
// Advisory VAT warnings stay per order, not an anonymous count: the user
// must be able to tell WHICH orders deserve the single-dialog review.
const warningOrderNumbers = useMemo(
() =>
bookableOrders
.filter((o) => resolveBookingWarnings(o).length > 0)
.map((o) => o.order_number),
[bookableOrders],
)
const uniformAccount =
accountGroups.length > 0 &&
accountGroups.every((g) => g.account === accountGroups[0].account)
? accountGroups[0].account
: null
// Reset per open so a second sweep starts clean.
useEffect(() => {
if (open) {
setOverrideEnabled(false)
setOverrideAccount(uniformAccount ?? '')
setSubmitting(false)
setResults(null)
}
// uniformAccount is derived from the selection the dialog opened with.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const overrideValid = ACCOUNT_NUMBER_RE.test(overrideAccount)
const canConfirm =
!submitting && bookableOrders.length > 0 && (!overrideEnabled || overrideValid)
async function handleConfirm() {
if (!canConfirm) return
setSubmitting(true)
try {
const response = await fetch('/api/webshop-orders/bulk-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_ids: bookableOrders.map((o) => o.id),
...(overrideEnabled && overrideValid
? { payment_account: overrideAccount }
: {}),
}),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
toast({
title: t('bulk_error_title'),
description: getErrorMessage(body, { statusCode: response.status }),
variant: 'destructive',
})
return
}
const body = (await response.json()) as {
data: {
results: BulkBookOrderResult[]
booked_count: number
failed_count: number
}
}
if (body.data.failed_count === 0) {
toast({
title: t('bulk_success_title'),
description: t('bulk_success_description', { count: body.data.booked_count }),
variant: 'success',
})
onBooked()
onOpenChange(false)
return
}
// Partial failure: keep the dialog open on the per-order report; the
// list behind refreshes so the booked rows leave "att bokföra".
setResults(body.data.results)
onBooked()
} catch (err) {
toast({
title: t('bulk_error_title'),
description: getErrorMessage(err),
variant: 'destructive',
})
} finally {
setSubmitting(false)
}
}
if (orders.length === 0) return null
const failures = (results ?? []).filter((r) => !r.success)
const bookedCount = (results ?? []).filter((r) => r.success).length
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] sm:max-w-[560px] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('bulk_title', { count: bookableOrders.length })}</DialogTitle>
<DialogDescription>{t('bulk_description')}</DialogDescription>
</DialogHeader>
{results ? (
<div className="space-y-4">
<p className="text-sm">
{t('bulk_partial_summary', {
booked: bookedCount,
failed: failures.length,
})}
</p>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{t('bulk_failed_heading')}
</p>
<ul className="space-y-1">
{failures.map((r) => (
<li key={r.order_id} className="text-xs" data-ph-mask="">
<span className="tabular-nums">{r.order_number ?? r.order_id}</span>
{': '}
<span className="text-muted-foreground">
{locale === 'en' && r.error?.message_en
? r.error.message_en
: (r.error?.message ?? t('bulk_unknown_error'))}
</span>
</li>
))}
</ul>
</div>
</div>
) : (
<div className="space-y-4">
{bookableOrders.length > 0 && (
<>
<div className="rounded-lg border border-border bg-card p-3">
<p className="text-xs font-medium text-muted-foreground">
{t('bulk_totals_label')}
</p>
<ul className="mt-2 space-y-1">
{currencyTotals.map(([code, total]) => (
<li
key={code}
className="flex items-center justify-between text-sm tabular-nums"
>
<span className="font-mono text-xs text-muted-foreground">{code}</span>
<span>{formatCurrency(total, code)}</span>
</li>
))}
</ul>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{t('bulk_account_plan_label')}
</p>
<ul className="space-y-1">
{accountGroups.map((g) => (
<li
key={`${g.label}-${g.account}`}
className="flex items-center justify-between text-sm"
>
<span className="truncate">
{g.label || t('bulk_no_method')}
</span>
<span className="tabular-nums text-muted-foreground">
{overrideEnabled && overrideValid ? overrideAccount : g.account}
{' · '}
{t('bulk_group_count', { count: g.count })}
</span>
</li>
))}
</ul>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Checkbox
checked={overrideEnabled}
onCheckedChange={(v) => setOverrideEnabled(v === true)}
/>
{t('bulk_override_label')}
</label>
{overrideEnabled && (
<div className="space-y-1">
<Label htmlFor="bulk-order-payment-account" className="text-xs">
{t('payment_account_label')}
</Label>
<Input
id="bulk-order-payment-account"
value={overrideAccount}
onChange={(e) => setOverrideAccount(e.target.value.trim())}
inputMode="numeric"
maxLength={4}
className="w-28 tabular-nums"
aria-invalid={!overrideValid}
aria-describedby={
!overrideValid ? 'bulk-order-payment-account-error' : undefined
}
/>
{!overrideValid && (
<p
id="bulk-order-payment-account-error"
className="text-xs text-destructive"
role="alert"
>
{t('invalid_account')}
</p>
)}
</div>
)}
</div>
</>
)}
{/* Skip notices: these selected orders will NOT be part of the
sweep (mirrored server-side); the escape hatch is the
single-order dialog where the lines are reviewable. */}
{skippedMissingBreakdown.length > 0 && (
<p className="attn text-[12.5px]" data-ph-mask="">
{t('bulk_skipped_missing_breakdown', {
numbers: skippedMissingBreakdown.map((o) => o.order_number).join(', '),
})}
</p>
)}
{skippedUnsupportedRate.length > 0 && (
<p className="attn text-[12.5px]" data-ph-mask="">
{t('bulk_skipped_unsupported_rate', {
numbers: skippedUnsupportedRate.map((o) => o.order_number).join(', '),
})}
</p>
)}
{skippedInvoiceMode.length > 0 && (
<p className="attn text-[12.5px]" data-ph-mask="">
{t('bulk_skipped_invoice_mode', {
numbers: skippedInvoiceMode.map((o) => o.order_number).join(', '),
})}
</p>
)}
{/* Advisory only (soft-guard rule): the sweep still books these;
the named orders are better reviewed one by one. */}
{warningOrderNumbers.length > 0 && (
<p className="attn text-[12.5px]" data-ph-mask="">
{t('bulk_warning_orders', { numbers: warningOrderNumbers.join(', ') })}
</p>
)}
{bookableOrders.length === 0 && (
<p className="text-sm text-muted-foreground">{t('bulk_none_bookable')}</p>
)}
</div>
)}
<DialogFooter>
{results ? (
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('bulk_close')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('cancel')}
</Button>
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('bulk_confirm', { count: bookableOrders.length })}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+14
View File
@@ -1625,6 +1625,20 @@ export const BookWebshopOrderSchema = z.object({
notes: z.string().max(2000).optional(),
})
/**
* Bulk booking of webshop orders: each order books as its OWN verifikat
* through the same server-side flow as the single-order endpoint (never one
* combined journal write). Max 50 = one orders-page of selection.
*/
export const BulkBookWebshopOrdersSchema = z.object({
order_ids: z.array(uuid).min(1).max(50),
/**
* Optional override: prefill every order's payment leg against this
* account instead of the per-store payment-method mapping.
*/
payment_account: accountNumber.optional(),
})
export const CreateInvoiceFromWebshopOrderSchema = z.object({
/** Omitted: match by email/orgnr within the company, else create. */
customer_id: uuid.optional(),
+28
View File
@@ -3900,6 +3900,34 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
message_en:
'The order was invoiced through a customer invoice. Handle the refund with a credit note instead of booking the refund row directly.',
},
WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING: {
httpStatus: 422,
message_sv:
'Ordern saknar momsuppdelning från butiken, så konteringen kan inte härledas säkert. Bokför ordern enskilt och granska raderna.',
message_en:
'The order has no VAT breakdown from the store, so the posting cannot be derived reliably. Book the order individually and review the lines.',
},
WEBSHOP_ORDER_INVOICE_MODE_METHOD: {
httpStatus: 409,
message_sv:
'Betalsättet är markerat som fakturaflöde i butiksinställningarna. Skapa faktura från ordern i stället, eller bokför den enskilt.',
message_en:
'The payment method is marked as invoice flow in the store settings. Create an invoice from the order instead, or book it individually.',
},
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE: {
httpStatus: 422,
message_sv:
'Ordern har en momssats som inte är en svensk sats (25/12/6/0 %), till exempel utländsk OSS-moms. Bokför ordern enskilt och granska raderna.',
message_en:
'The order has a VAT rate that is not a Swedish rate (25/12/6/0 %), for example foreign OSS VAT. Book the order individually and review the lines.',
},
WEBSHOP_ORDER_RESIDUAL_TOO_LARGE: {
httpStatus: 422,
message_sv:
'Orderns belopp stämmer inte med momsuppdelningen (differensen är större än öresavrundning). Bokför ordern enskilt och granska raderna.',
message_en:
'The order total does not match its VAT breakdown (the difference is larger than öre rounding). Book the order individually and review the lines.',
},
WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED: {
httpStatus: 500,
message_sv: 'Kunden kunde inte skapas från orderns uppgifter.',
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
buildOrderBookingLines,
fallbackVatBreakdown,
orderBookingDescription,
resolveBookingWarnings,
resolvePaymentAccount,
DEFAULT_PAYMENT_ACCOUNT,
@@ -311,3 +312,46 @@ describe('resolveBookingWarnings', () => {
).toEqual(['foreign_vat'])
})
})
describe('orderBookingDescription', () => {
it('labels an order with its payment method', () => {
expect(
orderBookingDescription({
row_type: 'order',
order_number: '1001',
payment_method: 'swish',
payment_method_title: 'Swish',
}),
).toBe('Order 1001 (Swish)')
})
it('falls back to the raw method key, then to no method', () => {
expect(
orderBookingDescription({
row_type: 'order',
order_number: '1001',
payment_method: 'swish',
payment_method_title: null,
}),
).toBe('Order 1001 (swish)')
expect(
orderBookingDescription({
row_type: 'order',
order_number: '1001',
payment_method: null,
payment_method_title: null,
}),
).toBe('Order 1001')
})
it('labels refunds without the method', () => {
expect(
orderBookingDescription({
row_type: 'refund',
order_number: '1001',
payment_method: 'swish',
payment_method_title: 'Swish',
}),
).toBe('Återbetalning order 1001')
})
})
+323
View File
@@ -0,0 +1,323 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { ensureWebshopPrefillAccounts } from '@/lib/webshop-orders/ensure-accounts'
import { archiveWebshopOrderUnderlag } from '@/lib/webshop-orders/order-underlag'
import { roundOre } from '@/lib/money'
import type {
CreateJournalEntryLineInput,
Currency,
JournalEntry,
WebshopOrder,
} from '@/types'
/**
* The server-side booking flow for one webshop order/refund row, extracted
* from POST /api/webshop-orders/[id]/book so the bulk endpoint runs the exact
* same code per order. Three composable steps, called in this order by both
* routes:
*
* 1. assertOrderBookable(): state guards (already booked/invoiced, unpaid,
* refund-of-invoiced-parent, legacy transactions-feed overlap).
* 2. resolveOrderFx(): booking-time retry for a missing SEK conversion on
* non-SEK rows.
* 3. bookOrderThroughEngine(): chart repair for our own prefill accounts,
* then the race-free draft -> atomic claim -> commit sequence through
* lib/bookkeeping/engine (source_type 'webshop_order').
*
* Anything added to the single-order path (e.g. underlag anchoring) belongs
* in these functions, never inline in one route, so single and bulk booking
* can not drift apart.
*/
/** Guard failure: a structured-error code plus optional envelope details. */
export interface OrderBookableFailure {
code:
| 'WEBSHOP_ORDER_ALREADY_BOOKED'
| 'WEBSHOP_ORDER_ALREADY_INVOICED'
| 'WEBSHOP_ORDER_MANUALLY_BOOKED'
| 'WEBSHOP_ORDER_REFUND_PARENT_INVOICED'
| 'WEBSHOP_ORDER_NOT_PAID'
| 'WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED'
| 'WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN'
details?: Record<string, unknown>
}
/**
* Re-check the order's state server-side (the client list can be stale).
* Returns null when the row may be booked, otherwise the structured-error
* code the route should answer with.
*/
export async function assertOrderBookable(
supabase: SupabaseClient,
companyId: string,
order: WebshopOrder,
): Promise<OrderBookableFailure | null> {
if (order.journal_entry_id) {
return {
code: 'WEBSHOP_ORDER_ALREADY_BOOKED',
details: { journal_entry_id: order.journal_entry_id },
}
}
if (order.invoice_id) {
return {
code: 'WEBSHOP_ORDER_ALREADY_INVOICED',
details: { invoice_id: order.invoice_id },
}
}
// Marked as booked outside the integration: booking it here would post
// the same business event twice. The mark is user-reversible.
if (order.manually_booked_at) {
return {
code: 'WEBSHOP_ORDER_MANUALLY_BOOKED',
details: { manually_booked_at: order.manually_booked_at },
}
}
// Refunds of an invoiced order belong in the credit-note flow.
if (order.row_type === 'refund' && order.parent_order_id) {
const { data: parent } = await supabase
.from('webshop_orders')
.select('invoice_id')
.eq('id', order.parent_order_id)
.eq('company_id', companyId)
.maybeSingle()
if (parent?.invoice_id) {
return {
code: 'WEBSHOP_ORDER_REFUND_PARENT_INVOICED',
details: { invoice_id: parent.invoice_id },
}
}
}
if (!order.is_paid && order.row_type === 'order') {
return { code: 'WEBSHOP_ORDER_NOT_PAID' }
}
// Double-booking lock against the legacy transactions feed: the same
// money event may already sit in the inbox (imported before the Orders
// switch-over). A booked feed row means this order IS booked via the
// feed; an open one must be booked or IGNORED there first, and an
// ignored row (is_ignored) unlocks order-side booking, exactly as the
// error message instructs.
if (order.legacy_transaction_id) {
const { data: legacyTxn } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', order.legacy_transaction_id)
.eq('company_id', companyId)
.maybeSingle()
if (legacyTxn) {
if (legacyTxn.journal_entry_id) {
return {
code: 'WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED',
details: {
transaction_id: legacyTxn.id,
journal_entry_id: legacyTxn.journal_entry_id,
},
}
}
if (!legacyTxn.is_ignored) {
return {
code: 'WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN',
details: { transaction_id: legacyTxn.id },
}
}
}
}
return null
}
/**
* Non-SEK rows book in SEK; retry the rate once at booking time before
* refusing (a sync-time Riksbanken hiccup should not strand the order).
* Returns the order with total_sek/exchange_rate resolved, or null when the
* rate still cannot be fetched (the route answers WEBSHOP_ORDER_FX_UNRESOLVED).
*/
export async function resolveOrderFx(
supabase: SupabaseClient,
companyId: string,
order: WebshopOrder,
log: Logger,
): Promise<WebshopOrder | null> {
if (order.currency.toUpperCase() === 'SEK' || order.total_sek !== null) {
return order
}
try {
const rate = await fetchExchangeRate(
order.currency.toUpperCase() as Currency,
new Date(`${order.paid_date ?? order.order_date}T00:00:00Z`),
supabase,
)
if (rate?.rate) {
const totalSek = roundOre(order.total * rate.rate)
const { error: fxError } = await supabase
.from('webshop_orders')
.update({ total_sek: totalSek, exchange_rate: rate.rate })
.eq('id', order.id)
.eq('company_id', companyId)
if (!fxError) {
return { ...order, total_sek: totalSek, exchange_rate: rate.rate }
}
}
} catch (err) {
log.warn('booking-time FX retry failed', err as Error)
}
return null
}
export interface BookOrderEngineInput {
fiscal_period_id: string
entry_date: string
description: string
lines: CreateJournalEntryLineInput[]
voucher_series?: string
notes?: string
}
export type BookOrderEngineOutcome =
/** Committed; journalEntry is commitEntry's post-commit fetch (may be null). */
| {
ok: true
journalEntry: JournalEntry | null
journalEntryId: string
/** Orderunderlag PDF archived on the verifikat (#1881); never fatal. */
underlagArchived: boolean
}
/** Another request booked/invoiced the row between our read and the claim. */
| { ok: false; kind: 'claimed_elsewhere' }
/** The conditional claim update itself errored (DB failure). */
| { ok: false; kind: 'claim_error'; error: unknown }
/** createDraftEntry/commitEntry threw; usually a typed bookkeeping error. */
| { ok: false; kind: 'engine_error'; stage: 'draft' | 'commit'; error: unknown }
/**
* Race-free booking: draft -> atomic claim -> commit. The read-then-book
* pattern let two concurrent requests each post an immutable verifikat
* for the same order (skeptic finding). Instead the order row is claimed
* with a conditional update BEFORE anything gets a voucher number: the
* loser's claim matches zero rows and its draft (no voucher yet, so no
* series gap) is cancelled.
*/
export async function bookOrderThroughEngine(
supabase: SupabaseClient,
companyId: string,
userId: string,
/**
* The order row, with FX already resolved (resolveOrderFx): the underlag
* archived after commit renders the SEK conversion facts from it.
*/
order: WebshopOrder,
input: BookOrderEngineInput,
log: Logger,
): Promise<BookOrderEngineOutcome> {
const orderId = order.id
// The prefill can legitimately reach 3004, 3740 and the 1686 clearing
// account, none of which seed_chart_of_accounts() seeds. Without this the
// first Bokför on a fresh company died on AccountsNotInChartError for an
// account the user never chose. Only our own closed prefill set is added,
// and failures here are swallowed so the engine's typed error still wins.
await ensureWebshopPrefillAccounts(
supabase,
companyId,
userId,
input.lines.map((l) => l.account_number),
log,
)
let draft: JournalEntry
try {
draft = await createDraftEntry(supabase, companyId, userId, {
fiscal_period_id: input.fiscal_period_id,
entry_date: input.entry_date,
description: input.description,
source_type: 'webshop_order',
source_id: orderId,
voucher_series: input.voucher_series,
notes: input.notes,
lines: input.lines,
})
} catch (err) {
return { ok: false, kind: 'engine_error', stage: 'draft', error: err }
}
const cancelDraft = async () => {
const { error: cancelError } = await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', draft.id)
.eq('status', 'draft')
if (cancelError) {
log.error('draft cleanup failed after claim/commit failure', cancelError, {
entryId: draft.id,
})
}
}
// The claim guards BOTH links plus the manual mark: a concurrent
// create-invoice or mark-booked between our read and this update must
// lose too (mutual exclusivity, not just no-double-booking).
const { data: claimed, error: claimError } = await supabase
.from('webshop_orders')
.update({ journal_entry_id: draft.id })
.eq('id', orderId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('invoice_id', null)
.is('manually_booked_at', null)
.select('id')
if (claimError || !claimed || claimed.length === 0) {
await cancelDraft()
if (claimError) {
log.error('webshop order claim failed', claimError, { orderId })
return { ok: false, kind: 'claim_error', error: claimError }
}
// Zero rows matched: someone else booked it between our read and claim.
return { ok: false, kind: 'claimed_elsewhere' }
}
let journalEntry: JournalEntry | null
try {
journalEntry = await commitEntry(supabase, companyId, userId, draft.id)
} catch (err) {
// Unlink so the row does not point at a cancelled draft, then cancel.
// Order matters: the financial-freeze trigger keys on journal_entry_id
// being set, but journal_entry_id itself is not in its protected list,
// so the unlink passes.
await supabase
.from('webshop_orders')
.update({ journal_entry_id: null })
.eq('id', orderId)
.eq('company_id', companyId)
.eq('journal_entry_id', draft.id)
await cancelDraft()
return { ok: false, kind: 'engine_error', stage: 'commit', error: err }
}
// No extra event here: commitEntry() already emits
// journal_entry.committed from inside the engine.
// Archive the orderunderlag (lines, customer, payment method) on the
// committed verifikat (#1881). Never fatal: the booking is immutable at
// this point, and a verifikat left without underlag surfaces on the
// "saknar underlag" worklist (webshop_order is a needs-doc source type),
// where the user can attach a document by hand. Living here, not in a
// route, so the single-order and bulk paths can never diverge on it.
const underlag = await archiveWebshopOrderUnderlag({
supabase,
companyId,
userId,
order,
journalEntryId: journalEntry?.id ?? draft.id,
log,
})
return {
ok: true,
journalEntry,
// commitEntry's post-commit fetch can theoretically return no row;
// the entry still exists under draft.id.
journalEntryId: journalEntry?.id ?? draft.id,
underlagArchived: underlag.ok,
}
}
+40 -9
View File
@@ -55,8 +55,10 @@ const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
6: '2631',
}
/** Öresavrundning. */
const ROUNDING_ACCOUNT = '3740'
/** Öresavrundning. Exported so the bulk route can find and bound the
* residual line it emits (a residual above öre scale means the order's
* totals do not match its VAT breakdown and needs per-order review). */
export const ROUNDING_ACCOUNT = '3740'
/**
* Every account this prefill can emit, as a closed set.
@@ -118,6 +120,21 @@ export function fallbackVatBreakdown(
return [{ rate: 25, net, tax }]
}
/**
* VAT-bucket rates the account maps above can express (Swedish rates). A
* bucket with any other rate (e.g. a German 19% OSS bucket stored raw by the
* sync) would fall back to the 25% accounts: acceptable only as the single
* dialog's editable prefill, never in an unreviewed sweep. Returns the
* distinct offending rates, empty when every bucket is representable.
*/
export function unsupportedVatRates(breakdown: WebshopVatBreakdownLine[]): number[] {
const bad = new Set<number>()
for (const bucket of breakdown) {
if (REVENUE_ACCOUNT_BY_RATE[bucket.rate] === undefined) bad.add(bucket.rate)
}
return Array.from(bad).sort((a, b) => a - b)
}
export type BookingWarning = 'zero_rate_foreign' | 'foreign_vat'
/**
@@ -153,6 +170,26 @@ export function resolveBookingWarnings(
return warnings
}
/**
* The default verifikat/line description for an order or refund row. Kept as
* a single helper so the dialog prefill, the single-order route and the bulk
* route all label the booking identically.
*/
export function orderBookingDescription(
order: Pick<
WebshopOrder,
'row_type' | 'order_number' | 'payment_method' | 'payment_method_title'
>,
): string {
if (order.row_type === 'refund') {
return `Återbetalning order ${order.order_number}`
}
const methodLabel = order.payment_method_title || order.payment_method || ''
return methodLabel
? `Order ${order.order_number} (${methodLabel})`
: `Order ${order.order_number}`
}
export interface OrderBookingLinesInput {
order: Pick<
WebshopOrder,
@@ -201,13 +238,7 @@ export function buildOrderBookingLines({
const toSek = (amount: number) => round(Math.abs(amount) * rate)
const methodLabel = order.payment_method_title || order.payment_method || ''
const baseDescription = methodLabel
? `Order ${order.order_number} (${methodLabel})`
: `Order ${order.order_number}`
const description = isRefund
? `Återbetalning order ${order.order_number}`
: baseDescription
const description = orderBookingDescription(order)
const currencyMeta = (amountAbs: number): Partial<CreateJournalEntryLineInput> =>
isSek
+26 -1
View File
@@ -6299,7 +6299,32 @@
"mapping_account_aria": "Account for {method}",
"mapping_note": "Swish is usually mapped to 1930, card and Klarna to a 15xx account. Stores using Stripe as the gateway should map to 1686 so Stripe payouts reconcile against the same account.",
"mapping_save": "Save",
"mapping_saving": "Saving…"
"mapping_saving": "Saving…",
"bulk_selected": "selected",
"bulk_book_selected": "Book selected",
"bulk_select_all": "Select all ({count})",
"bulk_clear": "Clear selection",
"select_order_aria": "Select order {number}",
"bulk_title": "Book {count, plural, one {# order} other {# orders}}",
"bulk_description": "Each order is booked as its own journal entry with the standard template: payment account against revenue and output VAT per rate.",
"bulk_totals_label": "Total per currency",
"bulk_account_plan_label": "Payment account per payment method",
"bulk_no_method": "No payment method",
"bulk_group_count": "{count, plural, one {# order} other {# orders}}",
"bulk_override_label": "Book all against the same payment account",
"bulk_confirm": "Book {count, plural, one {# order} other {# orders}}",
"bulk_error_title": "Booking failed",
"bulk_success_title": "Done",
"bulk_success_description": "{count, plural, one {# order was booked} other {# orders were booked}}",
"bulk_partial_summary": "{booked} booked, {failed} failed",
"bulk_failed_heading": "Could not be booked",
"bulk_unknown_error": "Unknown error",
"bulk_close": "Close",
"bulk_skipped_missing_breakdown": "Skipped (no VAT breakdown, book individually): {numbers}",
"bulk_skipped_invoice_mode": "Skipped (invoice flow per store settings): {numbers}",
"bulk_warning_orders": "VAT warning on order {numbers}. Book them individually if you want to review the lines.",
"bulk_none_bookable": "None of the selected orders can be booked in a sweep. Book them individually.",
"bulk_skipped_unsupported_rate": "Skipped (non-Swedish VAT rate, book individually): {numbers}"
},
"sales_orders": {
"title": "Sales orders",
+26 -1
View File
@@ -6299,7 +6299,32 @@
"mapping_account_aria": "Konto för {method}",
"mapping_note": "Swish brukar mappas till 1930, kort och Klarna till ett 15xx-konto. Butiker med Stripe som betalväxel bör mappa till 1686 så att Stripe-utbetalningarna stämmer av mot samma konto.",
"mapping_save": "Spara",
"mapping_saving": "Sparar…"
"mapping_saving": "Sparar…",
"bulk_selected": "{count, plural, one {markerad} other {markerade}}",
"bulk_book_selected": "Bokför valda",
"bulk_select_all": "Markera alla ({count})",
"bulk_clear": "Avmarkera",
"select_order_aria": "Markera order {number}",
"bulk_title": "Bokför {count, plural, one {# order} other {# ordrar}}",
"bulk_description": "Varje order bokförs som ett eget verifikat med standardmallen: betalkonto mot försäljning och utgående moms per momssats.",
"bulk_totals_label": "Summa per valuta",
"bulk_account_plan_label": "Betalkonto per betalsätt",
"bulk_no_method": "Utan betalsätt",
"bulk_group_count": "{count, plural, one {# order} other {# ordrar}}",
"bulk_override_label": "Bokför alla mot samma betalkonto",
"bulk_confirm": "Bokför {count, plural, one {# order} other {# ordrar}}",
"bulk_error_title": "Bokföringen misslyckades",
"bulk_success_title": "Klart",
"bulk_success_description": "{count, plural, one {# order bokfördes} other {# ordrar bokfördes}}",
"bulk_partial_summary": "{booked} bokförda, {failed} misslyckades",
"bulk_failed_heading": "Kunde inte bokföras",
"bulk_unknown_error": "Okänt fel",
"bulk_close": "Stäng",
"bulk_skipped_missing_breakdown": "Hoppas över (saknar momsuppdelning, bokför enskilt): {numbers}",
"bulk_skipped_invoice_mode": "Hoppas över (fakturaflöde enligt butiksinställningarna): {numbers}",
"bulk_warning_orders": "Momsvarning på order {numbers}. Bokför dem enskilt om du vill granska raderna.",
"bulk_none_bookable": "Inga av de markerade ordrarna kan bokföras i svep. Bokför dem enskilt.",
"bulk_skipped_unsupported_rate": "Hoppas över (momssats som inte är svensk, bokför enskilt): {numbers}"
},
"sales_orders": {
"title": "Order",