* 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>
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
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 {
|
|
assertOrderBookable,
|
|
bookOrderThroughEngine,
|
|
resolveOrderFx,
|
|
} from '@/lib/webshop-orders/book-order'
|
|
import type { WebshopOrder } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* Book one webshop order/refund row as a verifikat. The dialog sends the
|
|
* (user-reviewed) lines built by lib/webshop-orders/booking-lines; the server
|
|
* 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',
|
|
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
|
|
const { id } = await params
|
|
|
|
const validation = await validateBody(request, BookWebshopOrderSchema)
|
|
if (!validation.success) return validation.response
|
|
const { fiscal_period_id, entry_date, description, lines, voucher_series, notes } =
|
|
validation.data
|
|
|
|
const { data: order, error: fetchError } = await supabase
|
|
.from('webshop_orders')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single<WebshopOrder>()
|
|
|
|
if (fetchError || !order) {
|
|
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
|
|
}
|
|
|
|
const guardFailure = await assertOrderBookable(supabase, companyId, order)
|
|
if (guardFailure) {
|
|
return errorResponseFromCode(guardFailure.code, log, {
|
|
requestId,
|
|
...(guardFailure.details ? { details: guardFailure.details } : {}),
|
|
})
|
|
}
|
|
|
|
// 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: { currency: order.currency },
|
|
})
|
|
}
|
|
|
|
const outcome = await bookOrderThroughEngine(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
resolvedOrder,
|
|
{ fiscal_period_id, entry_date, description, lines, voucher_series, notes },
|
|
log,
|
|
)
|
|
|
|
if (!outcome.ok) {
|
|
if (outcome.kind === 'claimed_elsewhere') {
|
|
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
|
|
}
|
|
if (outcome.kind === 'claim_error') {
|
|
return NextResponse.json(
|
|
{ error: getErrorMessage(outcome.error, { context: 'transaction' }) },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
const typed = bookkeepingErrorResponse(outcome.error)
|
|
if (typed) return typed
|
|
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(outcome.error, { context: 'transaction' }) },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: outcome.journalEntry,
|
|
journal_entry_id: outcome.journalEntryId,
|
|
underlag_archived: outcome.underlagArchived,
|
|
success: true,
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|