* 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>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
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,
|
|
}
|
|
}
|