From c6344306771962070e569308dad2b364e59d0afc Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:34:34 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/orders/page.tsx | 134 ++++- app/api/webshop-orders/[id]/book/route.ts | 255 ++------ .../__tests__/bulk-book.test.ts | 556 ++++++++++++++++++ app/api/webshop-orders/bulk-book/route.ts | 358 +++++++++++ components/orders/BulkOrderBookingDialog.tsx | 407 +++++++++++++ lib/api/schemas.ts | 14 + lib/errors/structured-errors.ts | 28 + .../__tests__/booking-lines.test.ts | 44 ++ lib/webshop-orders/book-order.ts | 323 ++++++++++ lib/webshop-orders/booking-lines.ts | 49 +- messages/en.json | 27 +- messages/sv.json | 27 +- 12 files changed, 1994 insertions(+), 228 deletions(-) create mode 100644 app/api/webshop-orders/__tests__/bulk-book.test.ts create mode 100644 app/api/webshop-orders/bulk-book/route.ts create mode 100644 components/orders/BulkOrderBookingDialog.tsx create mode 100644 lib/webshop-orders/book-order.ts diff --git a/app/(dashboard)/orders/page.tsx b/app/(dashboard)/orders/page.tsx index 383ae081..f429a754 100644 --- a/app/(dashboard)/orders/page.tsx +++ b/app/(dashboard)/orders/page.tsx @@ -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(null) const [invoicingOrder, setInvoicingOrder] = useState(null) const [markingOrder, setMarkingOrder] = useState(null) + const [selectedIds, setSelectedIds] = useState>(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(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" /> ) : ( -
+
+ {/* Bulkbar (transactions-page pattern): hidden until at least one + bookable order is selected via the hover checkboxes. */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size}{' '} + {t('bulk_selected', { count: selectedIds.size })} + + + {selectedIds.size < selectableIds.length && ( + + )} + +
+ )} + {/* 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). */} +
+ {multiStore && } @@ -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() { ))}
{t('col_date')} {t('col_order')}{t('col_store')}
+
{count > PAGE_SIZE && (
@@ -323,6 +408,20 @@ export default function OrdersPage() { }} /> )} + {bulkOrders && ( + { + if (!open) setBulkOrders(null) + }} + orders={bulkOrders} + settingsFor={settingsFor} + onBooked={() => { + setSelectedIds(new Set()) + void load() + }} + /> + )} {invoicingOrder && ( void onBook: () => void onInvoice: () => void onMarkBooked: () => void @@ -389,7 +494,30 @@ function OrderRow({ const unmarkable = canWrite && manuallyMarked return ( - + + {/* 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. */} + + {selectable && ( + 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', + )} + /> + )} + {formatDate(order.order_date)} diff --git a/app/api/webshop-orders/[id]/book/route.ts b/app/api/webshop-orders/[id]/book/route.ts index 5b0df737..cc3affd2 100644 --- a/app/api/webshop-orders/[id]/book/route.ts +++ b/app/api/webshop-orders/[id]/book/route.ts @@ -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, }) }, diff --git a/app/api/webshop-orders/__tests__/bulk-book.test.ts b/app/api/webshop-orders/__tests__/bulk-book.test.ts new file mode 100644 index 00000000..02fb0ef6 --- /dev/null +++ b/app/api/webshop-orders/__tests__/bulk-book.test.ts @@ -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 = {}) { + 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 + } +} + +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(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(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(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( + 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(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( + 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( + 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(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(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).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( + 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( + 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(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( + 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).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( + await postBulk({ order_ids: [ORDER_1, ORDER_1] }), + ) + expect(status).toBe(200) + expect(body.data.results).toHaveLength(1) + expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/api/webshop-orders/bulk-book/route.ts b/app/api/webshop-orders/bulk-book/route.ts new file mode 100644 index 00000000..746a75fc --- /dev/null +++ b/app/api/webshop-orders/bulk-book/route.ts @@ -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 +} + +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, +): 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( + ((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 }, +) diff --git a/components/orders/BulkOrderBookingDialog.tsx b/components/orders/BulkOrderBookingDialog.tsx new file mode 100644 index 00000000..b6d1e750 --- /dev/null +++ b/components/orders/BulkOrderBookingDialog.tsx @@ -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(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() + 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() + 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 ( + + + + {t('bulk_title', { count: bookableOrders.length })} + {t('bulk_description')} + + + {results ? ( +
+

+ {t('bulk_partial_summary', { + booked: bookedCount, + failed: failures.length, + })} +

+
+

+ {t('bulk_failed_heading')} +

+
    + {failures.map((r) => ( +
  • + {r.order_number ?? r.order_id} + {': '} + + {locale === 'en' && r.error?.message_en + ? r.error.message_en + : (r.error?.message ?? t('bulk_unknown_error'))} + +
  • + ))} +
+
+
+ ) : ( +
+ {bookableOrders.length > 0 && ( + <> +
+

+ {t('bulk_totals_label')} +

+
    + {currencyTotals.map(([code, total]) => ( +
  • + {code} + {formatCurrency(total, code)} +
  • + ))} +
+
+ +
+

+ {t('bulk_account_plan_label')} +

+
    + {accountGroups.map((g) => ( +
  • + + {g.label || t('bulk_no_method')} + + + {overrideEnabled && overrideValid ? overrideAccount : g.account} + {' · '} + {t('bulk_group_count', { count: g.count })} + +
  • + ))} +
+
+ +
+ + {overrideEnabled && ( +
+ + 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 && ( + + )} +
+ )} +
+ + )} + + {/* 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 && ( +

+ {t('bulk_skipped_missing_breakdown', { + numbers: skippedMissingBreakdown.map((o) => o.order_number).join(', '), + })} +

+ )} + {skippedUnsupportedRate.length > 0 && ( +

+ {t('bulk_skipped_unsupported_rate', { + numbers: skippedUnsupportedRate.map((o) => o.order_number).join(', '), + })} +

+ )} + {skippedInvoiceMode.length > 0 && ( +

+ {t('bulk_skipped_invoice_mode', { + numbers: skippedInvoiceMode.map((o) => o.order_number).join(', '), + })} +

+ )} + {/* Advisory only (soft-guard rule): the sweep still books these; + the named orders are better reviewed one by one. */} + {warningOrderNumbers.length > 0 && ( +

+ {t('bulk_warning_orders', { numbers: warningOrderNumbers.join(', ') })} +

+ )} + {bookableOrders.length === 0 && ( +

{t('bulk_none_bookable')}

+ )} +
+ )} + + + {results ? ( + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index d3c9dbbf..55e61a8b 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index e1f09879..94afdade 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3900,6 +3900,34 @@ const WEBSHOP_ORDERS: Record = { 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.', diff --git a/lib/webshop-orders/__tests__/booking-lines.test.ts b/lib/webshop-orders/__tests__/booking-lines.test.ts index 771c0dee..040eb300 100644 --- a/lib/webshop-orders/__tests__/booking-lines.test.ts +++ b/lib/webshop-orders/__tests__/booking-lines.test.ts @@ -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') + }) +}) diff --git a/lib/webshop-orders/book-order.ts b/lib/webshop-orders/book-order.ts new file mode 100644 index 00000000..c7ad32e1 --- /dev/null +++ b/lib/webshop-orders/book-order.ts @@ -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 +} + +/** + * 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 { + 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 { + 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 { + 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, + } +} diff --git a/lib/webshop-orders/booking-lines.ts b/lib/webshop-orders/booking-lines.ts index 1cabdca3..16471fad 100644 --- a/lib/webshop-orders/booking-lines.ts +++ b/lib/webshop-orders/booking-lines.ts @@ -55,8 +55,10 @@ const VAT_ACCOUNT_BY_RATE: Record = { 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() + 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 => isSek diff --git a/messages/en.json b/messages/en.json index 9ab3bf2a..6093a480 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 84fe0ab9..2bc5e37f 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",