feat(woo): select multiple orders and book them with one template sweep (#1900)
* feat(woo): select multiple orders and book them with one template sweep Adds bulk booking to the orders page (issue #1880): hover-reveal checkbox column, a bulkbar with select-all/clear, and a confirm dialog that books every selected order with the standard order template (per-store payment- method mapping, optionally one override account for the whole selection). Server side, POST /api/webshop-orders/bulk-book books each order as its OWN verifikat through the exact same flow as the single-order endpoint: the guards, FX retry and race-free draft -> claim -> commit sequence are extracted to lib/webshop-orders/book-order.ts and shared by both routes, so nothing added to the single path can miss the bulk path. Partial failure is reported per order and never aborts the batch. Fixes #1880 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): replace mangled NUL byte in bulk dialog grouping key with a pipe The account-group key template literal picked up a raw 0x00 byte during generation (known escape-mangling hazard), making git treat the file as binary. Same grouping semantics, plain '|' separator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): bulk sweep only books derived lines, never guessed ones (skeptic findings) The sweep has no reviewing user, so everything the single dialog relies on a human to catch is now refused per order or aborted: - empty vat_breakdown: the ratio-inferred fallback split (a 25%+6% mixed sale classified as 12%, refunds reversing zero moms via 3004) is only allowed as the single dialog's editable prefill; bulk refuses with WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING - invoice-mode payment methods: booking would foreclose Skapa faktura and post a wrong clearing leg; refused with WEBSHOP_ORDER_INVOICE_MODE_METHOD (the account override does not bypass the merchant's configured flow) - 3740 residual above ore scale (gift-card gaps booked as 'oresavrundning'): refused with WEBSHOP_ORDER_RESIDUAL_TOO_LARGE - settings-fetch failure now aborts the sweep instead of silently rebooking every order to 1686 against the confirmed dialog - maxDuration 300 so a platform kill cannot strand an order between claim and commit - per-order guard details (e.g. journal_entry_id) survive into the failure envelope The dialog mirrors the skip rules up front (named order numbers, not an anonymous count) so the confirmation describes exactly what will book. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): refuse non-Swedish VAT-rate buckets in the bulk sweep A foreign OSS bucket (e.g. German 19%) passes the non-empty breakdown gate with zero residual, but the rate-to-account maps would fall back to the 25% accounts and book foreign VAT as Swedish utgaende moms 2611 (skeptic finding). The sweep now refuses such orders per order with WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE (details.rates names the offending rates); the dialog mirrors the rule and names the skipped orders. Only the single dialog may show that prefill, as an editable guess. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5fc0be9ed7
commit
c634430677
@@ -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>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BookWebshopOrderSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { ensureWebshopPrefillAccounts } from '@/lib/webshop-orders/ensure-accounts'
|
||||
import { archiveWebshopOrderUnderlag } from '@/lib/webshop-orders/order-underlag'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { Currency, WebshopOrder } from '@/types'
|
||||
import {
|
||||
assertOrderBookable,
|
||||
bookOrderThroughEngine,
|
||||
resolveOrderFx,
|
||||
} from '@/lib/webshop-orders/book-order'
|
||||
import type { WebshopOrder } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -21,6 +21,11 @@ ensureInitialized()
|
||||
* re-guards state and routes everything through the engine
|
||||
* (source_type 'webshop_order'). Period/company locks and balance are
|
||||
* enforced by the engine + DB triggers as usual.
|
||||
*
|
||||
* The flow itself (guards, FX retry, draft -> claim -> commit, orderunderlag
|
||||
* archiving) lives in lib/webshop-orders/book-order.ts, shared verbatim with
|
||||
* the bulk endpoint (POST /api/webshop-orders/bulk-book) so the two paths
|
||||
* cannot drift.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'webshop_order.book',
|
||||
@@ -43,239 +48,61 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
if (order.journal_entry_id) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, {
|
||||
const guardFailure = await assertOrderBookable(supabase, companyId, order)
|
||||
if (guardFailure) {
|
||||
return errorResponseFromCode(guardFailure.code, log, {
|
||||
requestId,
|
||||
details: { journal_entry_id: order.journal_entry_id },
|
||||
...(guardFailure.details ? { details: guardFailure.details } : {}),
|
||||
})
|
||||
}
|
||||
if (order.invoice_id) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, {
|
||||
|
||||
// Also keeps the in-memory row in sync (total_sek/exchange_rate): the
|
||||
// underlag renders the SEK conversion facts from it after commit.
|
||||
const resolvedOrder = await resolveOrderFx(supabase, companyId, order, log)
|
||||
if (!resolvedOrder) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_FX_UNRESOLVED', log, {
|
||||
requestId,
|
||||
details: { invoice_id: order.invoice_id },
|
||||
details: { currency: order.currency },
|
||||
})
|
||||
}
|
||||
// Marked as booked outside the integration: booking it here would post
|
||||
// the same business event twice. The mark is user-reversible.
|
||||
if (order.manually_booked_at) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_MANUALLY_BOOKED', log, {
|
||||
requestId,
|
||||
details: { manually_booked_at: order.manually_booked_at },
|
||||
})
|
||||
}
|
||||
// Refunds of an invoiced order belong in the credit-note flow.
|
||||
if (order.row_type === 'refund' && order.parent_order_id) {
|
||||
const { data: parent } = await supabase
|
||||
.from('webshop_orders')
|
||||
.select('invoice_id')
|
||||
.eq('id', order.parent_order_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (parent?.invoice_id) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_REFUND_PARENT_INVOICED', log, {
|
||||
requestId,
|
||||
details: { invoice_id: parent.invoice_id },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!order.is_paid && order.row_type === 'order') {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_NOT_PAID', log, { requestId })
|
||||
}
|
||||
|
||||
// Double-booking lock against the legacy transactions feed: the same
|
||||
// money event may already sit in the inbox (imported before the Orders
|
||||
// switch-over). A booked feed row means this order IS booked via the
|
||||
// feed; an open one must be booked or IGNORED there first — and an
|
||||
// ignored row (is_ignored) unlocks order-side booking, exactly as the
|
||||
// error message instructs.
|
||||
if (order.legacy_transaction_id) {
|
||||
const { data: legacyTxn } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id, is_ignored')
|
||||
.eq('id', order.legacy_transaction_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (legacyTxn) {
|
||||
if (legacyTxn.journal_entry_id) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED', log, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_id: legacyTxn.id,
|
||||
journal_entry_id: legacyTxn.journal_entry_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!legacyTxn.is_ignored) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN', log, {
|
||||
requestId,
|
||||
details: { transaction_id: legacyTxn.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-SEK rows book in SEK; retry the rate once at booking time before
|
||||
// refusing (a sync-time Riksbanken hiccup should not strand the order).
|
||||
if (order.currency.toUpperCase() !== 'SEK' && order.total_sek === null) {
|
||||
let resolved = false
|
||||
try {
|
||||
const rate = await fetchExchangeRate(
|
||||
order.currency.toUpperCase() as Currency,
|
||||
new Date(`${order.paid_date ?? order.order_date}T00:00:00Z`),
|
||||
supabase,
|
||||
)
|
||||
if (rate?.rate) {
|
||||
const totalSek = roundOre(order.total * rate.rate)
|
||||
const { error: fxError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.update({ total_sek: totalSek, exchange_rate: rate.rate })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
resolved = !fxError
|
||||
if (resolved) {
|
||||
// Keep the in-memory row in sync: the underlag renders the SEK
|
||||
// conversion facts from it after commit.
|
||||
order.total_sek = totalSek
|
||||
order.exchange_rate = rate.rate
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('booking-time FX retry failed', err as Error)
|
||||
}
|
||||
if (!resolved) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_FX_UNRESOLVED', log, {
|
||||
requestId,
|
||||
details: { currency: order.currency },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Race-free booking: draft -> atomic claim -> commit. The read-then-book
|
||||
// pattern let two concurrent requests each post an immutable verifikat
|
||||
// for the same order (skeptic finding). Instead the order row is claimed
|
||||
// with a conditional update BEFORE anything gets a voucher number: the
|
||||
// loser's claim matches zero rows and its draft (no voucher yet, so no
|
||||
// series gap) is cancelled.
|
||||
// The prefill can legitimately reach 3004, 3740 and the 1686 clearing
|
||||
// account, none of which seed_chart_of_accounts() seeds. Without this the
|
||||
// first Bokför on a fresh company died on AccountsNotInChartError for an
|
||||
// account the user never chose. Only our own closed prefill set is added,
|
||||
// and failures here are swallowed so the engine's typed error still wins.
|
||||
await ensureWebshopPrefillAccounts(
|
||||
const outcome = await bookOrderThroughEngine(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
lines.map((l) => l.account_number),
|
||||
resolvedOrder,
|
||||
{ fiscal_period_id, entry_date, description, lines, voucher_series, notes },
|
||||
log,
|
||||
)
|
||||
|
||||
let draft
|
||||
try {
|
||||
draft = await createDraftEntry(supabase, companyId, user.id, {
|
||||
fiscal_period_id,
|
||||
entry_date,
|
||||
description,
|
||||
source_type: 'webshop_order',
|
||||
source_id: id,
|
||||
voucher_series,
|
||||
notes,
|
||||
lines,
|
||||
})
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
log.error('failed to draft journal entry for webshop order', err as Error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, { context: 'transaction' }) },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const cancelDraft = async () => {
|
||||
const { error: cancelError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', draft.id)
|
||||
.eq('status', 'draft')
|
||||
if (cancelError) {
|
||||
log.error('draft cleanup failed after claim/commit failure', cancelError, {
|
||||
entryId: draft.id,
|
||||
})
|
||||
if (!outcome.ok) {
|
||||
if (outcome.kind === 'claimed_elsewhere') {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
// The claim guards BOTH links plus the manual mark: a concurrent
|
||||
// create-invoice or mark-booked between our read and this update must
|
||||
// lose too (mutual exclusivity, not just no-double-booking).
|
||||
const { data: claimed, error: claimError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.update({ journal_entry_id: draft.id })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('invoice_id', null)
|
||||
.is('manually_booked_at', null)
|
||||
.select('id')
|
||||
if (claimError || !claimed || claimed.length === 0) {
|
||||
await cancelDraft()
|
||||
if (claimError) {
|
||||
log.error('webshop order claim failed', claimError, { orderId: id })
|
||||
if (outcome.kind === 'claim_error') {
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(claimError, { context: 'transaction' }) },
|
||||
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
// Zero rows matched: someone else booked it between our read and claim.
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
|
||||
}
|
||||
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await commitEntry(supabase, companyId, user.id, draft.id)
|
||||
} catch (err) {
|
||||
// Unlink so the row does not point at a cancelled draft, then cancel.
|
||||
// Order matters: the financial-freeze trigger keys on journal_entry_id
|
||||
// being set, but journal_entry_id itself is not in its protected list,
|
||||
// so the unlink passes.
|
||||
await supabase
|
||||
.from('webshop_orders')
|
||||
.update({ journal_entry_id: null })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', draft.id)
|
||||
await cancelDraft()
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
const typed = bookkeepingErrorResponse(outcome.error)
|
||||
if (typed) return typed
|
||||
log.error('failed to commit journal entry for webshop order', err as Error)
|
||||
log.error(
|
||||
outcome.stage === 'draft'
|
||||
? 'failed to draft journal entry for webshop order'
|
||||
: 'failed to commit journal entry for webshop order',
|
||||
outcome.error as Error,
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, { context: 'transaction' }) },
|
||||
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// No extra event here: commitEntry() already emits
|
||||
// journal_entry.committed from inside the engine.
|
||||
|
||||
// Archive the orderunderlag (lines, customer, payment method) on the
|
||||
// committed verifikat (#1881). Never fatal: the booking is immutable at
|
||||
// this point, and a verifikat left without underlag surfaces on the
|
||||
// "saknar underlag" worklist (webshop_order is a needs-doc source type),
|
||||
// where the user can attach a document by hand.
|
||||
const underlag = await archiveWebshopOrderUnderlag({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
order,
|
||||
journalEntryId: journalEntry?.id ?? draft.id,
|
||||
log,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: journalEntry,
|
||||
// commitEntry's post-commit fetch can theoretically return no row;
|
||||
// the entry still exists under draft.id.
|
||||
journal_entry_id: journalEntry?.id ?? draft.id,
|
||||
underlag_archived: underlag.ok,
|
||||
data: outcome.journalEntry,
|
||||
journal_entry_id: outcome.journalEntryId,
|
||||
underlag_archived: outcome.underlagArchived,
|
||||
success: true,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
const mockCreateDraftEntry = vi.fn()
|
||||
const mockCommitEntry = vi.fn()
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createDraftEntry: (...args: unknown[]) => mockCreateDraftEntry(...args),
|
||||
commitEntry: (...args: unknown[]) => mockCommitEntry(...args),
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
}))
|
||||
|
||||
const mockFetchExchangeRate = vi.fn()
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}))
|
||||
|
||||
const mockEnsureAccounts = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/lib/webshop-orders/ensure-accounts', () => ({
|
||||
ensureWebshopPrefillAccounts: (...args: unknown[]) => mockEnsureAccounts(...args),
|
||||
}))
|
||||
|
||||
// Underlag rendering/archiving behaviour lives in
|
||||
// lib/webshop-orders/__tests__/order-underlag.test.ts; here we only assert
|
||||
// that every booked order gets one archive call through the shared flow.
|
||||
const mockArchiveUnderlag = vi.fn()
|
||||
vi.mock('@/lib/webshop-orders/order-underlag', () => ({
|
||||
archiveWebshopOrderUnderlag: (...args: unknown[]) => mockArchiveUnderlag(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../bulk-book/route'
|
||||
|
||||
const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const ORDER_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const ORDER_2 = '22222222-2222-4222-8222-222222222222'
|
||||
const ORDER_3 = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
function makeOrderRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: ORDER_1,
|
||||
company_id: 'company-1',
|
||||
platform: 'woocommerce',
|
||||
store_scope: 'butik.example.se',
|
||||
row_type: 'order',
|
||||
parent_order_id: null,
|
||||
external_id: 'woo_butik.example.se_order_1001',
|
||||
order_number: '1001',
|
||||
status: 'processing',
|
||||
is_paid: true,
|
||||
order_date: '2026-08-01',
|
||||
paid_date: '2026-08-01',
|
||||
currency: 'SEK',
|
||||
total: 500,
|
||||
total_tax: 100,
|
||||
total_sek: 500,
|
||||
exchange_rate: 1,
|
||||
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
|
||||
line_items: [],
|
||||
payment_method: 'swish',
|
||||
payment_method_title: 'Swish',
|
||||
journal_entry_id: null,
|
||||
invoice_id: null,
|
||||
manually_booked_at: null,
|
||||
legacy_transaction_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface BulkResult {
|
||||
order_id: string
|
||||
order_number: string | null
|
||||
success: boolean
|
||||
journal_entry_id?: string
|
||||
underlag_archived?: boolean
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
message_en: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface BulkResponse {
|
||||
data: { results: BulkResult[]; booked_count: number; failed_count: number }
|
||||
}
|
||||
|
||||
function postBulk(body: unknown = { order_ids: [ORDER_1, ORDER_2] }) {
|
||||
const request = createMockRequest('/api/webshop-orders/bulk-book', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return POST(request)
|
||||
}
|
||||
|
||||
describe('POST /api/webshop-orders/bulk-book', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
let draftCounter = 0
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
draftCounter = 0
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
mockEnsureAccounts.mockResolvedValue(undefined)
|
||||
mockArchiveUnderlag.mockResolvedValue({ ok: true, documentId: 'doc-1' })
|
||||
mockCreateDraftEntry.mockImplementation(() => {
|
||||
draftCounter += 1
|
||||
return Promise.resolve(
|
||||
makeJournalEntry({ id: `draft-${draftCounter}`, status: 'draft' }),
|
||||
)
|
||||
})
|
||||
mockCommitEntry.mockImplementation((_s, _c, _u, entryId: string) =>
|
||||
Promise.resolve(
|
||||
makeJournalEntry({
|
||||
id: `je-${entryId}`,
|
||||
voucher_series: 'A',
|
||||
voucher_number: 100 + draftCounter,
|
||||
}),
|
||||
),
|
||||
)
|
||||
mockFindFiscalPeriod.mockResolvedValue(PERIOD_UUID)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const { status } = await parseJsonResponse(await postBulk())
|
||||
expect(status).toBe(401)
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when the caller is a viewer (requireWrite)', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
const { status } = await parseJsonResponse(await postBulk())
|
||||
expect(status).toBe(403)
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 on invalid body (empty selection)', async () => {
|
||||
const { status } = await parseJsonResponse(await postBulk({ order_ids: [] }))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 on a non-uuid order id', async () => {
|
||||
const { status } = await parseJsonResponse(
|
||||
await postBulk({ order_ids: ['not-a-uuid'] }),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when none of the orders exist for the company', async () => {
|
||||
enqueue({ data: [] }) // orders fetch
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await postBulk(),
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books every selected order as its own verifikat (happy path)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow(),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002', external_id: 'x2' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_1 }] }) // claim order 1
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.booked_count).toBe(2)
|
||||
expect(body.data.failed_count).toBe(0)
|
||||
expect(body.data.results).toHaveLength(2)
|
||||
expect(body.data.results.every((r) => r.success)).toBe(true)
|
||||
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(2)
|
||||
expect(mockCommitEntry).toHaveBeenCalledTimes(2)
|
||||
const firstInput = mockCreateDraftEntry.mock.calls[0][3] as {
|
||||
source_type: string
|
||||
source_id: string
|
||||
fiscal_period_id: string
|
||||
description: string
|
||||
lines: { account_number: string; debit_amount: number }[]
|
||||
}
|
||||
expect(firstInput.source_type).toBe('webshop_order')
|
||||
expect(firstInput.source_id).toBe(ORDER_1)
|
||||
expect(firstInput.fiscal_period_id).toBe(PERIOD_UUID)
|
||||
expect(firstInput.description).toBe('Order 1001 (Swish)')
|
||||
// No mapping saved: the payment leg defaults to the 1686 clearing account.
|
||||
expect(firstInput.lines[0]).toMatchObject({
|
||||
account_number: '1686',
|
||||
debit_amount: 500,
|
||||
})
|
||||
const secondInput = mockCreateDraftEntry.mock.calls[1][3] as { source_id: string }
|
||||
expect(secondInput.source_id).toBe(ORDER_2)
|
||||
// Each booked order gets its orderunderlag through the shared flow
|
||||
// (#1881); the per-order result reports it.
|
||||
expect(mockArchiveUnderlag).toHaveBeenCalledTimes(2)
|
||||
expect(mockArchiveUnderlag).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
order: expect.objectContaining({ id: ORDER_1 }),
|
||||
}),
|
||||
)
|
||||
expect(body.data.results.every((r) => r.underlag_archived === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('applies the payment_account override to every order', async () => {
|
||||
enqueue({ data: [makeOrderRow()] })
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_1 }] }) // claim
|
||||
const { status } = await parseJsonResponse(
|
||||
await postBulk({ order_ids: [ORDER_1], payment_account: '1930' }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
const input = mockCreateDraftEntry.mock.calls[0][3] as {
|
||||
lines: { account_number: string }[]
|
||||
}
|
||||
expect(input.lines[0].account_number).toBe('1930')
|
||||
})
|
||||
|
||||
it('uses the per-store payment-method mapping when no override is sent', async () => {
|
||||
enqueue({ data: [makeOrderRow()] })
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'settings-1',
|
||||
company_id: 'company-1',
|
||||
platform: 'woocommerce',
|
||||
store_scope: 'butik.example.se',
|
||||
payment_method_account_map: { swish: { mode: 'book', account: '1580' } },
|
||||
},
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: ORDER_1 }] }) // claim
|
||||
const { status } = await parseJsonResponse(await postBulk({ order_ids: [ORDER_1] }))
|
||||
expect(status).toBe(200)
|
||||
const input = mockCreateDraftEntry.mock.calls[0][3] as {
|
||||
lines: { account_number: string }[]
|
||||
}
|
||||
expect(input.lines[0].account_number).toBe('1580')
|
||||
})
|
||||
|
||||
it('reports per-order failure without aborting the batch (guard failure)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({ journal_entry_id: 'je-existing' }),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.booked_count).toBe(1)
|
||||
expect(body.data.failed_count).toBe(1)
|
||||
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
|
||||
expect(failed?.success).toBe(false)
|
||||
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
|
||||
expect(failed?.error?.message).toBeTruthy()
|
||||
// Guard details survive into the per-order envelope (skeptic finding).
|
||||
expect(failed?.error?.details?.journal_entry_id).toBe('je-existing')
|
||||
const succeeded = body.data.results.find((r) => r.order_id === ORDER_2)
|
||||
expect(succeeded?.success).toBe(true)
|
||||
expect(mockCommitEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses an order with no VAT breakdown instead of booking the guessed split', async () => {
|
||||
// Skeptic counterexample: 25%+6% mixed sale whose sync stored no per-rate
|
||||
// breakdown; the ratio fallback would classify it as a 12% sale. The
|
||||
// sweep must refuse it (only the single dialog may show the guess) and
|
||||
// still book the healthy order.
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({ total: 1117, total_tax: 117, vat_breakdown: [] }),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
|
||||
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING')
|
||||
expect(body.data.booked_count).toBe(1)
|
||||
// The guessed lines must never have reached the engine.
|
||||
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1)
|
||||
const input = mockCreateDraftEntry.mock.calls[0][3] as { source_id: string }
|
||||
expect(input.source_id).toBe(ORDER_2)
|
||||
})
|
||||
|
||||
it('refuses a refund row with no VAT breakdown (zero-moms reversal guard)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({
|
||||
row_type: 'refund',
|
||||
parent_order_id: null,
|
||||
total: -500,
|
||||
total_sek: -500,
|
||||
total_tax: 0,
|
||||
vat_breakdown: [],
|
||||
}),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1] }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING')
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a bucket with a non-Swedish VAT rate (foreign OSS bucket)', async () => {
|
||||
// Skeptic counterexample: a German 19% bucket passes the non-empty gate
|
||||
// and yields zero residual, but REVENUE/VAT_ACCOUNT_BY_RATE[19] would
|
||||
// fall back to the 25% accounts and book German VAT as Swedish
|
||||
// utgaende moms. The sweep must refuse; only the single dialog may show
|
||||
// that prefill for correction.
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({
|
||||
total: 1190,
|
||||
total_tax: 190,
|
||||
vat_breakdown: [{ rate: 19, net: 1000, tax: 190 }],
|
||||
}),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
|
||||
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE')
|
||||
expect(failed?.error?.details?.rates).toEqual([19])
|
||||
expect(body.data.booked_count).toBe(1)
|
||||
// The 19% prefill must never have reached the engine.
|
||||
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1)
|
||||
const input = mockCreateDraftEntry.mock.calls[0][3] as { source_id: string }
|
||||
expect(input.source_id).toBe(ORDER_2)
|
||||
})
|
||||
|
||||
it('refuses invoice-mode payment methods even with an account override', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({ payment_method: 'bacs', payment_method_title: 'Bank transfer' }),
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'settings-1',
|
||||
company_id: 'company-1',
|
||||
platform: 'woocommerce',
|
||||
store_scope: 'butik.example.se',
|
||||
payment_method_account_map: { bacs: { mode: 'invoice' } },
|
||||
},
|
||||
],
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1], payment_account: '1930' }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_INVOICE_MODE_METHOD')
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an order whose 3740 residual is above ore scale', async () => {
|
||||
// Gift-card-style gap: gross 880 but the breakdown sums to 1000, so the
|
||||
// builder would dump 120 kr on 3740 "oresavrundning". Not öre: refuse.
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({
|
||||
total: 880,
|
||||
total_tax: 200,
|
||||
vat_breakdown: [{ rate: 25, net: 800, tax: 200 }],
|
||||
}),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1] }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_RESIDUAL_TOO_LARGE')
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the whole sweep when the settings fetch fails (no silent 1686 fallback)', async () => {
|
||||
enqueue({ data: [makeOrderRow()] })
|
||||
enqueue({ data: null, error: { message: 'connection reset' } }) // settings fetch fails
|
||||
const { status } = await parseJsonResponse(await postBulk({ order_ids: [ORDER_1] }))
|
||||
expect(status).toBe(500)
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an order marked as booked outside the integration (per-order)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({ manually_booked_at: '2026-08-01T00:00:00Z' }),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
|
||||
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_MANUALLY_BOOKED')
|
||||
expect(body.data.booked_count).toBe(1)
|
||||
})
|
||||
|
||||
it('continues after an engine failure and cleans up that order alone', async () => {
|
||||
mockCommitEntry.mockRejectedValueOnce(new Error('period locked'))
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow(),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_1 }] }) // claim order 1
|
||||
enqueue({ data: null }) // unlink order 1
|
||||
enqueue({ data: null }) // cancel draft 1
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.booked_count).toBe(1)
|
||||
expect(body.data.failed_count).toBe(1)
|
||||
expect(body.data.results[0].success).toBe(false)
|
||||
expect(body.data.results[1].success).toBe(true)
|
||||
// The failed order was unlinked so it does not point at a cancelled draft.
|
||||
const orderUpdates = findCalls('webshop_orders', 'update')
|
||||
expect(
|
||||
orderUpdates.some(
|
||||
(args) => (args[0] as Record<string, unknown>).journal_entry_id === null,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('reports ids that do not exist for the company as per-order failures', async () => {
|
||||
enqueue({ data: [makeOrderRow()] }) // only ORDER_1 exists
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_1 }] }) // claim order 1
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1, ORDER_3] }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.booked_count).toBe(1)
|
||||
const missing = body.data.results.find((r) => r.order_id === ORDER_3)
|
||||
expect(missing?.success).toBe(false)
|
||||
expect(missing?.error?.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('fails the order when no open fiscal period covers its date', async () => {
|
||||
mockFindFiscalPeriod.mockResolvedValue(null)
|
||||
enqueue({ data: [makeOrderRow()] })
|
||||
enqueue({ data: [] }) // store settings
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1] }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.failed_count).toBe(1)
|
||||
expect(body.data.results[0].error?.code).toBe('NO_OPEN_PERIOD_FOR_DATE')
|
||||
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails a non-SEK order whose rate cannot be resolved, books the rest', async () => {
|
||||
mockFetchExchangeRate.mockResolvedValue(null)
|
||||
enqueue({
|
||||
data: [
|
||||
makeOrderRow({
|
||||
currency: 'EUR',
|
||||
total_sek: null,
|
||||
exchange_rate: null,
|
||||
}),
|
||||
makeOrderRow({ id: ORDER_2, order_number: '1002' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_2 }] }) // claim order 2
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(await postBulk())
|
||||
expect(status).toBe(200)
|
||||
const failed = body.data.results.find((r) => r.order_id === ORDER_1)
|
||||
expect(failed?.error?.code).toBe('WEBSHOP_ORDER_FX_UNRESOLVED')
|
||||
const succeeded = body.data.results.find((r) => r.order_id === ORDER_2)
|
||||
expect(succeeded?.success).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a raced claim as WEBSHOP_ORDER_ALREADY_BOOKED and cancels that draft', async () => {
|
||||
enqueue({ data: [makeOrderRow()] })
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [] }) // claim matched ZERO rows: raced
|
||||
enqueue({ data: null }) // draft cancel update
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1] }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results[0].error?.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
|
||||
expect(mockCommitEntry).not.toHaveBeenCalled()
|
||||
const cancels = findCalls('journal_entries', 'update')
|
||||
expect(cancels.length).toBeGreaterThan(0)
|
||||
expect((cancels[0][0] as Record<string, unknown>).status).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('deduplicates repeated ids in the selection', async () => {
|
||||
enqueue({ data: [makeOrderRow()] })
|
||||
enqueue({ data: [] }) // store settings
|
||||
enqueue({ data: [{ id: ORDER_1 }] }) // claim once
|
||||
const { status, body } = await parseJsonResponse<BulkResponse>(
|
||||
await postBulk({ order_ids: [ORDER_1, ORDER_1] }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results).toHaveLength(1)
|
||||
expect(mockCreateDraftEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BulkBookWebshopOrdersSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
buildOrderBookingLines,
|
||||
orderBookingDescription,
|
||||
resolvePaymentAccount,
|
||||
unsupportedVatRates,
|
||||
ROUNDING_ACCOUNT,
|
||||
} from '@/lib/webshop-orders/booking-lines'
|
||||
import {
|
||||
assertOrderBookable,
|
||||
bookOrderThroughEngine,
|
||||
resolveOrderFx,
|
||||
} from '@/lib/webshop-orders/book-order'
|
||||
import type { WebshopOrder, WebshopStoreSettings } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
// Up to 50 sequential draft -> claim -> commit round trips against the
|
||||
// engine; the default function window is not guaranteed to fit them, and a
|
||||
// kill between claim and commit would leave an order pointing at an
|
||||
// uncommitted draft (skeptic finding). Same budget as the other batch routes.
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* A residual on 3740 above this magnitude (SEK) is not öresavrundning: it
|
||||
* means the order's gross total does not match its VAT breakdown (gift
|
||||
* cards, plugin-mangled orders). Legitimate per-bucket öre rounding and FX
|
||||
* drift stay well below this; anything above needs the single-order dialog
|
||||
* where the user sees the line.
|
||||
*/
|
||||
const MAX_RESIDUAL_SEK = 1
|
||||
|
||||
interface BulkBookFailure {
|
||||
code: string
|
||||
message: string
|
||||
message_en: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface BulkBookOrderResult {
|
||||
order_id: string
|
||||
order_number: string | null
|
||||
success: boolean
|
||||
journal_entry_id?: string
|
||||
voucher_series?: string | null
|
||||
voucher_number?: number | null
|
||||
/** Orderunderlag PDF archived on the verifikat (#1881); never fatal. */
|
||||
underlag_archived?: boolean
|
||||
error?: BulkBookFailure
|
||||
}
|
||||
|
||||
/** Failure envelope for a known structured-error code. */
|
||||
function failureFromCode(
|
||||
code: string,
|
||||
details?: Record<string, unknown>,
|
||||
): BulkBookFailure {
|
||||
const entry = getErrorEntry(code)
|
||||
return {
|
||||
code,
|
||||
message: entry?.message_sv ?? code,
|
||||
message_en: entry?.message_en ?? code,
|
||||
...(details ? { details } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Failure envelope for a thrown (usually typed bookkeeping) error. */
|
||||
function failureFromError(err: unknown): BulkBookFailure {
|
||||
const code =
|
||||
(typeof err === 'object' &&
|
||||
err !== null &&
|
||||
typeof (err as { code?: unknown }).code === 'string' &&
|
||||
(err as { code: string }).code) ||
|
||||
'WEBSHOP_ORDER_BOOKING_FAILED'
|
||||
return {
|
||||
code,
|
||||
message: getErrorMessage(err, { context: 'transaction' }),
|
||||
message_en: getErrorMessage(err, { context: 'transaction', locale: 'en' }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webshop-orders/bulk-book
|
||||
*
|
||||
* Book N selected webshop order/refund rows in one sweep, each with the
|
||||
* standard order template: payment account (per-store payment-method mapping,
|
||||
* or the optional payment_account override) against revenue + output VAT per
|
||||
* rate from the row's own vat_breakdown.
|
||||
*
|
||||
* Deliberately NOT a samlingsverifikation: every order books as its OWN
|
||||
* verifikat through the exact same flow as POST /api/webshop-orders/[id]/book
|
||||
* (lib/webshop-orders/book-order.ts): state guards, booking-time FX retry,
|
||||
* chart repair, race-free draft -> claim -> commit through the engine. So
|
||||
* period locks, balance, voucher numbering and anything later added to the
|
||||
* single-order path (e.g. underlag anchoring) apply per order automatically.
|
||||
*
|
||||
* The sweep has no reviewing user, so it only books orders whose lines are
|
||||
* DERIVED, never guessed: rows with an empty vat_breakdown (ratio-inferred
|
||||
* fallback), invoice-mode payment methods, or a 3740 residual above öre
|
||||
* scale are refused per order and pointed at the single-order dialog.
|
||||
*
|
||||
* Partial failure is expected and reported per order: one refused row (period
|
||||
* locked, raced booking, unresolved FX, review-needed) never aborts the rest
|
||||
* of the batch. The response is 200 with results[] as long as the request
|
||||
* itself was valid and at least one requested order exists for the company.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'webshop_order.bulk_book',
|
||||
async (request, { supabase, user, companyId, log, requestId }) => {
|
||||
const validation = await validateBody(request, BulkBookWebshopOrdersSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { order_ids, payment_account } = validation.data
|
||||
|
||||
// Dedupe but keep the caller's order for the result list.
|
||||
const ids = [...new Set(order_ids)]
|
||||
|
||||
const { data: orders, error: fetchError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.select('*')
|
||||
.in('id', ids)
|
||||
.eq('company_id', companyId)
|
||||
if (fetchError) {
|
||||
log.error('bulk-book order fetch failed', fetchError)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(fetchError, { context: 'transaction' }) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
const orderById = new Map<string, WebshopOrder>(
|
||||
((orders ?? []) as WebshopOrder[]).map((o) => [o.id, o]),
|
||||
)
|
||||
if (orderById.size === 0) {
|
||||
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
// Per-store settings drive the payment-method -> account prefill exactly
|
||||
// like the single-order dialog. A fetch failure ABORTS the sweep: falling
|
||||
// back to the default clearing account here would silently book every
|
||||
// order against 1686 while the user just confirmed a dialog showing their
|
||||
// mapped accounts (skeptic finding). Failing loudly is recoverable;
|
||||
// fifty wrong immutable verifikat are not.
|
||||
const { data: settingsData, error: settingsError } = await supabase
|
||||
.from('webshop_store_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
if (settingsError) {
|
||||
log.error('bulk-book settings fetch failed; aborting sweep', settingsError)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(settingsError, { context: 'transaction' }) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
const settingsRows = (settingsData ?? []) as WebshopStoreSettings[]
|
||||
const settingsFor = (order: WebshopOrder): WebshopStoreSettings | null =>
|
||||
settingsRows.find(
|
||||
(s) => s.platform === order.platform && s.store_scope === order.store_scope,
|
||||
) ?? null
|
||||
|
||||
// Sequential on purpose: each order is its own draft -> claim -> commit
|
||||
// round trip through the engine, and voucher numbers are assigned
|
||||
// atomically per commit. Parallelizing would only contend on the same
|
||||
// voucher sequence; 50 orders (the schema cap) stay well inside the
|
||||
// route budget.
|
||||
const results: BulkBookOrderResult[] = []
|
||||
for (const id of ids) {
|
||||
const order = orderById.get(id)
|
||||
if (!order) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: null,
|
||||
success: false,
|
||||
error: failureFromCode('WEBSHOP_ORDER_NOT_FOUND'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const guardFailure = await assertOrderBookable(supabase, companyId, order)
|
||||
if (guardFailure) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode(guardFailure.code, guardFailure.details),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// No VAT breakdown from the store: buildOrderBookingLines would fall
|
||||
// back to a ratio-INFERRED single bucket, which the single-order dialog
|
||||
// shows as an editable guess for the user to correct. There is no
|
||||
// reviewing user in a sweep, so a guessed rate split must never become
|
||||
// an immutable verifikat here (skeptic finding: a 25%+6% mixed sale
|
||||
// classified as 12% books wrong revenue/VAT accounts and rutor, and an
|
||||
// amount-only refund would reverse zero moms via 3004).
|
||||
if (order.vat_breakdown.length === 0) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode('WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// A bucket with a non-Swedish rate (e.g. a German 19% OSS bucket the
|
||||
// sync stored raw) would silently fall back to the 25% accounts and
|
||||
// book foreign VAT as Swedish utgaende moms. Only the single dialog
|
||||
// may show that as an editable prefill (skeptic finding).
|
||||
const badRates = unsupportedVatRates(order.vat_breakdown)
|
||||
if (badRates.length > 0) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode('WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE', {
|
||||
rates: badRates,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const settings = settingsFor(order)
|
||||
// The store's own mapping routes this payment method through the
|
||||
// invoice flow. Booking it directly would both post a wrong clearing
|
||||
// leg and permanently foreclose Skapa faktura for the order (the claim
|
||||
// sets journal_entry_id). The override does not bypass this: it
|
||||
// changes the account, not the flow the merchant configured.
|
||||
if (resolvePaymentAccount(order, settings).invoiceMode) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode('WEBSHOP_ORDER_INVOICE_MODE_METHOD'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const resolvedOrder = await resolveOrderFx(supabase, companyId, order, log)
|
||||
if (!resolvedOrder) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode('WEBSHOP_ORDER_FX_UNRESOLVED'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const entryDate = resolvedOrder.paid_date ?? resolvedOrder.order_date
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
||||
if (!fiscalPeriodId) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode('NO_OPEN_PERIOD_FOR_DATE'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let lines
|
||||
try {
|
||||
lines = buildOrderBookingLines({
|
||||
order: resolvedOrder,
|
||||
settings,
|
||||
paymentAccount: payment_account,
|
||||
})
|
||||
} catch (err) {
|
||||
// buildOrderBookingLines throws only on an unresolved SEK amount,
|
||||
// which resolveOrderFx already excluded; belt-and-braces per order.
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromError(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Residual bound: the 3740 line exists to absorb öre rounding and FX
|
||||
// drift, both bounded by a few öre per bucket. A residual above
|
||||
// MAX_RESIDUAL_SEK means the gross total and the VAT breakdown
|
||||
// disagree (gift-card redemptions, mangled orders); in the single
|
||||
// dialog the user sees the fat 3740 line and stops, so the sweep must
|
||||
// refuse instead of booking the gap as "öresavrundning".
|
||||
const residualLine = lines.find((l) => l.account_number === ROUNDING_ACCOUNT)
|
||||
const residualAbs = residualLine
|
||||
? Math.max(residualLine.debit_amount || 0, residualLine.credit_amount || 0)
|
||||
: 0
|
||||
if (residualAbs > MAX_RESIDUAL_SEK) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error: failureFromCode('WEBSHOP_ORDER_RESIDUAL_TOO_LARGE', {
|
||||
residual: residualAbs,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const outcome = await bookOrderThroughEngine(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
resolvedOrder,
|
||||
{
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
description: orderBookingDescription(resolvedOrder),
|
||||
lines,
|
||||
},
|
||||
log,
|
||||
)
|
||||
|
||||
if (!outcome.ok) {
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: false,
|
||||
error:
|
||||
outcome.kind === 'claimed_elsewhere'
|
||||
? failureFromCode('WEBSHOP_ORDER_ALREADY_BOOKED')
|
||||
: failureFromError(outcome.error),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
order_id: id,
|
||||
order_number: order.order_number,
|
||||
success: true,
|
||||
journal_entry_id: outcome.journalEntryId,
|
||||
voucher_series: outcome.journalEntry?.voucher_series ?? null,
|
||||
voucher_number: outcome.journalEntry?.voucher_number ?? null,
|
||||
underlag_archived: outcome.underlagArchived,
|
||||
})
|
||||
}
|
||||
|
||||
const bookedCount = results.filter((r) => r.success).length
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
results,
|
||||
booked_count: bookedCount,
|
||||
failed_count: results.length - bookedCount,
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user