feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)

* feat(webshop-orders): schema, types and error codes for the orders surface

webshop_orders (order/refund rows, financial-freeze trigger, member
select/update RLS, no DELETE) + webshop_store_settings (per-store payment
method -> account map), source_type 'webshop_order', multi-store index drop,
customer_country, and a one-time woo cursor reset so the switch-over
backfills and cross-marks existing feed rows. Tables classified in the
full-archive export; pg-real coverage for RLS, freeze and CHECK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): core service (ingest, booking lines)

upsertWebshopOrders: two-phase order/refund upsert with FX enrichment,
legacy-feed cross-marking, frozen-row protection and field-wise jsonb
comparisons (Postgres does not preserve object key order). Booking-line
builder: per-rate VAT split with SIGNED buckets (discounts book as revenue
reductions), refund mirroring, 3740 residual, per-store account prefill,
and advisory export/EU + OSS warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): API routes for list, booking, invoicing and mapping

Booking is draft -> atomic claim -> commit (conditional link-back closes the
concurrent double-book race; a lost claim cancels the voucher-free draft).
Legacy-feed guard honors transactions.is_ignored on both the book and
create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an
unnumbered draft with dominant-rate fallback and drift-safe unit prices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav

/orders lists per-store orders with status tabs (server-side filters),
exception chips and one action per row. Booking dialog prefills from the
per-store payment-method mapping with an opt-in remember; invoice dialog
converts to a draft kundfaktura. The Order nav item renders only for
companies with an active WooCommerce connection or existing order rows
(Shopify deliberately excluded until its sync writes webshop_orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(woocommerce): switch the order sync to webshop_orders, multi-store

The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund
allocations with parent-prorated VAT fallback) and upserts order rows
instead of transactions-inbox rows; already-imported feed rows stay
bookable and get cross-marked. Multi-store: several active connections per
company, per-store panel cards with the account-mapping editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(webshop-orders): decision log entries and ratchet baseline

Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption;
hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webshop-orders): resolve PR #1525 review findings and CI failures

Review batch (Superagent, CodeRabbit, Swedish compliance review):
- Mutual-exclusion claims: booking guards invoice_id, invoice link-back
  guards journal_entry_id AND treats zero matched rows as the conflict it
  is (409 + rollback), closing both TOCTOU races.
- Freeze v2 migration (20260812124858): the link columns themselves are
  protected: invoice links immutable, journal links clearable only while
  the entry is still a draft (the booking rollback path).
- Scraped orgnr no longer auto-written to customers.org_number; rate
  fallback applies only on single-VAT-bucket orders; refunds get their own
  WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the
  invoice-mode hint in the booking dialog.
- Ingest compares every synced field (billing corrections no longer drop
  as unchanged); sync guards absent refunds arrays; /sync aggregates
  per-store results; panel disables all cards while a request runs; orders
  page separates load failure from empty; account field explains itself.

CI: regenerated skills/accounted-api; pg tests restructured for
transaction-abort/rollback semantics + freeze-link coverage; unresolvable-
expression ceiling 375 -> 378 with documented reason (partial-update
payloads in ingest, shapes covered by unit tests).

Declined: CodeRabbit docstring-coverage advisory (house style: comments
only where the code cannot say it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-12 15:08:13 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 89cfa82f2f
commit c35b2547fb
47 changed files with 6104 additions and 698 deletions
@@ -15,13 +15,13 @@ export const maxDuration = 300
/**
* GET /api/extensions/woocommerce/orders/cron
* Nightly order sync for connections that opted in (transaction_sync_enabled):
* imports each connected store's paid orders and refunds into the
* transactions inbox as a bank-style feed on the 1680 cash account.
* upserts each connected store's orders and refunds into webshop_orders
* (the Orders page), replacing the earlier transactions-inbox feed.
*
* Read-only against the stores, and it never posts to the journal: rows land
* unbooked; booking stays a human decision. Idempotent via the
* (company_id, external_id) unique index, so overlapping windows and re-runs
* are no-ops. Emits no events, so no ensureInitialized() is needed.
* unbooked; booking stays a human decision on the Orders page. Idempotent via
* the (company_id, external_id) unique index; overlap re-polls become status
* updates. Emits no events, so no ensureInitialized() is needed.
*/
export const GET = withCronContext('cron.woocommerce_order_sync', async (_request, ctx) => {
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
@@ -84,8 +84,8 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques
const results: Array<{
connectionId: string
imported: number
duplicates: number
inserted: number
updated: number
status: 'synced' | 'revoked' | 'error'
}> = []
@@ -109,8 +109,8 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques
}
results.push({
connectionId: connection.id,
imported: summary.imported,
duplicates: summary.duplicates,
inserted: summary.inserted,
updated: summary.updated,
status: summary.revoked ? 'revoked' : 'synced',
})
} catch (error) {
@@ -120,19 +120,19 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques
})
results.push({
connectionId: connection.id,
imported: 0,
duplicates: 0,
inserted: 0,
updated: 0,
status: 'error',
})
}
}
const totalImported = results.reduce((acc, r) => acc + r.imported, 0)
const totalInserted = results.reduce((acc, r) => acc + r.inserted, 0)
ctx.log.info('woocommerce order sync summary', {
processed: results.length,
totalImported,
totalInserted,
failed: results.filter((r) => r.status === 'error').length,
})
return NextResponse.json({ processed: results.length, imported: totalImported, results })
return NextResponse.json({ processed: results.length, inserted: totalInserted, results })
})
+238
View File
@@ -0,0 +1,238 @@
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 { roundOre } from '@/lib/money'
import type { Currency, 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.
*/
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 })
}
if (order.journal_entry_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, {
requestId,
details: { journal_entry_id: order.journal_entry_id },
})
}
if (order.invoice_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, {
requestId,
details: { invoice_id: order.invoice_id },
})
}
// 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
}
} 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.
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,
})
}
}
// The claim guards BOTH links: a concurrent create-invoice 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)
.select('id')
if (claimError || !claimed || claimed.length === 0) {
await cancelDraft()
if (claimError) {
log.error('webshop order claim failed', claimError, { orderId: id })
return NextResponse.json(
{ error: getErrorMessage(claimError, { 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)
if (typed) return typed
log.error('failed to commit journal entry for webshop order', err as Error)
return NextResponse.json(
{ error: getErrorMessage(err, { context: 'transaction' }) },
{ status: 400 },
)
}
// No extra event here: commitEntry() already emits
// journal_entry.committed from inside the engine.
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,
success: true,
})
},
{ requireWrite: true },
)
@@ -0,0 +1,307 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateInvoiceFromWebshopOrderSchema } from '@/lib/api/schemas'
import { buildInvoiceWriteData, type InvoiceWriteInput } from '@/lib/invoices/build-invoice-write'
import { roundOre } from '@/lib/money'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Currency, Customer, Invoice, WebshopOrder } from '@/types'
ensureInitialized()
/**
* Convert one webshop order into a pre-filled DRAFT kundfaktura. Manual,
* per-order, never automatic (invoice-of-record stays the user's decision):
* the user reviews the draft and sends it through the normal invoice flow,
* which also books it. Follows the invoice-inbox convert pattern: guard
* already-converted, map items, reuse the shared invoice write path, link
* back, roll back the draft if the link-back fails.
*
* Numbering: none here (unnumbered draft, save_as_draft semantics). The
* F-number is allocated at finalize/send, so an abandoned draft can be
* hard-deleted without an F-series gap (ML 17 kap 24§).
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'webshop_order.create_invoice',
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, CreateInvoiceFromWebshopOrderSchema)
if (!validation.success) return validation.response
const { customer_id } = 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 })
}
if (order.invoice_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, {
requestId,
details: { invoice_id: order.invoice_id },
})
}
if (order.journal_entry_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, {
requestId,
details: { journal_entry_id: order.journal_entry_id },
})
}
// Refund rows never convert (kreditfaktura is created from the invoice).
if (order.row_type === 'refund') {
return errorResponseFromCode('WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE', log, { requestId })
}
// Same legacy double-booking lock as the book route: an invoice created
// for a sale whose feed row is later booked in the transactions inbox
// would double-count the revenue. An ignored feed row unlocks the path.
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 },
})
}
if (!legacyTxn.is_ignored) {
return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN', log, {
requestId,
details: { transaction_id: legacyTxn.id },
})
}
}
}
// Resolve the customer: explicit id, else match by email within the
// company, else create from the order's billing snapshot. The orgnr is
// best-effort scraped data and is deliberately NOT written to a customer
// the user did not review; the dialog shows it as a review hint.
let customer: Customer | null = null
if (customer_id) {
const { data } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('company_id', companyId)
.single<Customer>()
if (!data) {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', log, {
requestId,
details: { customerId: customer_id },
})
}
customer = data
} else {
if (order.customer_email) {
const { data } = await supabase
.from('customers')
.select('*')
.eq('company_id', companyId)
.ilike('email', order.customer_email)
.limit(1)
.maybeSingle<Customer>()
customer = data ?? null
}
if (!customer) {
const name = order.customer_company || order.customer_name
if (!name) {
return errorResponseFromCode('WEBSHOP_ORDER_CREATE_INVOICE_MISSING_CUSTOMER', log, {
requestId,
})
}
// The scraped orgnr is deliberately NOT written to the customer's
// legal org_number field: it is best-effort store data and must be
// user-confirmed on the customer card before it lands anywhere legal
// (Swedish compliance review, PR #1525). The dialog shows it as a
// review hint.
const { data: created, error: createError } = await supabase
.from('customers')
.insert({
company_id: companyId,
user_id: user.id,
name,
customer_type: order.customer_company ? 'business' : 'individual',
contact_person: order.customer_company ? order.customer_name : null,
email: order.customer_email,
})
.select('*')
.single<Customer>()
if (createError || !created) {
log.error('failed to create customer from webshop order', createError ?? undefined, {
orderId: id,
})
return errorResponseFromCode('WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED', log, {
requestId,
})
}
customer = created
try {
await eventBus.emit({
type: 'customer.created',
payload: { customer, userId: user.id, companyId },
})
} catch {
// Non-critical
}
}
}
// Items from the order's line snapshot (products + shipping + fees; the
// sync stores everything inside order.total). Where the net does not
// divide evenly by the quantity, the line collapses to quantity 1 at its
// exact total: buildInvoiceWriteData recomputes
// line_total = quantity * unit_price, and an öre of division drift would
// make the invoice total diverge from what the customer actually paid.
// Rate fallback only when the order is UNAMBIGUOUS (a single VAT bucket):
// on mixed-rate orders a "dominant" guess could stamp the wrong
// tillämpad skattesats on a line (ML 17 kap 24 §); those lines fall back
// to the customer-type default and the draft review is the gate.
const singleRate =
order.vat_breakdown.length === 1 ? order.vat_breakdown[0].rate : undefined
const items: InvoiceWriteInput['items'] =
order.line_items.length > 0
? order.line_items.map((item) => {
const unitPrice =
item.quantity > 0 ? roundOre(item.total / item.quantity) : item.total
const dividesEvenly =
item.quantity > 0 && roundOre(unitPrice * item.quantity) === item.total
return dividesEvenly
? {
description: item.name,
quantity: item.quantity,
unit: 'st',
unit_price: unitPrice,
vat_rate: item.vat_rate ?? singleRate,
}
: {
description:
item.quantity > 1 ? `${item.name} (${item.quantity} st)` : item.name,
quantity: 1,
unit: 'st',
unit_price: item.total,
vat_rate: item.vat_rate ?? singleRate,
}
})
: [
{
description: `Order ${order.order_number}`,
quantity: 1,
unit: 'st',
unit_price: roundOre(order.total - order.total_tax),
vat_rate: singleRate,
},
]
const invoiceDate = new Date().toISOString().split('T')[0]
const paymentTermsDays = customer.default_payment_terms ?? 30
const due = new Date()
due.setDate(due.getDate() + paymentTermsDays)
const dueDate = due.toISOString().split('T')[0]
const input: InvoiceWriteInput = {
customer_id: customer.id,
invoice_date: invoiceDate,
due_date: dueDate,
currency: order.currency.toUpperCase() as Currency,
your_reference: order.customer_name ?? undefined,
notes: `Webshoporder ${order.order_number} (${order.store_label || order.store_scope})`,
items,
}
const build = await buildInvoiceWriteData({
supabase,
companyId,
customer,
documentType: 'invoice',
input,
})
if (!build.ok) {
if ('dbError' in build) {
log.error('invoice write build failed on a DB lookup', build.dbError as Error)
return errorResponse(build.dbError, log, { requestId })
}
return errorResponseFromCode(build.code, log, { requestId, details: build.details })
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
invoice_number: null,
...build.invoiceFields,
})
.select()
.single()
if (invoiceError || !invoice) {
log.error('invoice insert from webshop order failed', invoiceError ?? undefined)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
details: { pgCode: invoiceError?.code },
})
}
const itemRows = build.items.map((item) => ({ ...item, invoice_id: invoice.id }))
const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('invoice items insert failed; rolled back draft', itemsError, {
invoiceId: invoice.id,
})
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
requestId,
details: { pgCode: itemsError.code },
})
}
// Link back; roll back the draft on failure so no orphan invoice exists
// without its order marked (unnumbered draft: hard delete leaves no gap).
// Guards BOTH links (mutual exclusivity with a concurrent booking) and
// treats zero matched rows as the conflict it is: another request linked
// first, and answering success would leave a duplicate sendable draft.
const { data: linked, error: linkError } = await supabase
.from('webshop_orders')
.update({ invoice_id: invoice.id })
.eq('id', id)
.eq('company_id', companyId)
.is('invoice_id', null)
.is('journal_entry_id', null)
.select('id')
if (linkError || !linked || linked.length === 0) {
await supabase.from('invoice_items').delete().eq('invoice_id', invoice.id)
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('order link-back failed; rolled back draft invoice', linkError ?? undefined, {
orderId: id,
invoiceId: invoice.id,
})
if (linkError) return errorResponse(linkError, log, { requestId })
// Zero rows matched: the order was invoiced or booked concurrently.
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, { requestId })
}
try {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: invoice as Invoice, userId: user.id, companyId },
})
} catch {
// Non-critical
}
return NextResponse.json({ data: invoice, invoice_id: invoice.id, success: true })
},
{ requireWrite: true },
)
@@ -0,0 +1,301 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeJournalEntry,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall, 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()
vi.mock('@/lib/bookkeeping/engine', () => ({
createDraftEntry: (...args: unknown[]) => mockCreateDraftEntry(...args),
commitEntry: (...args: unknown[]) => mockCommitEntry(...args),
}))
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
import { POST } from '../[id]/book/route'
const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000'
function makeOrderRow(overrides: Record<string, unknown> = {}) {
return {
id: 'order-1',
company_id: 'company-1',
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',
journal_entry_id: null,
invoice_id: null,
legacy_transaction_id: null,
...overrides,
}
}
const validBody = {
fiscal_period_id: PERIOD_UUID,
entry_date: '2026-08-01',
description: 'Order 1001 (Swish)',
lines: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 400 },
{ account_number: '2611', debit_amount: 0, credit_amount: 100 },
],
}
function postBook(body: unknown = validBody, id = 'order-1') {
const request = createMockRequest(`/api/webshop-orders/${id}/book`, {
method: 'POST',
body,
})
return POST(request, createMockRouteParams({ id }))
}
describe('POST /api/webshop-orders/[id]/book', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
mockCreateDraftEntry.mockResolvedValue(makeJournalEntry({ id: 'draft-1', status: 'draft' }))
mockCommitEntry.mockResolvedValue(makeJournalEntry({ id: 'je-1' }))
})
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 postBook())
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 postBook())
expect(status).toBe(403)
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('returns 400 on invalid body', async () => {
const { status } = await parseJsonResponse(
await postBook({ fiscal_period_id: PERIOD_UUID }),
)
expect(status).toBe(400)
})
it('returns 404 when the order does not exist for the company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(404)
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
})
it('returns 409 when already booked', async () => {
enqueue({ data: makeOrderRow({ journal_entry_id: 'je-9' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
})
it('returns 409 when linked to an invoice', async () => {
enqueue({ data: makeOrderRow({ invoice_id: 'inv-1' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED')
})
it('returns 409 for unpaid orders', async () => {
enqueue({ data: makeOrderRow({ is_paid: false, paid_date: null }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_PAID')
})
it('returns 409 when the legacy feed transaction is still open', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: false } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('returns 409 when the legacy feed transaction is already booked', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: 'je-77', is_ignored: false } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED')
})
it('books when the legacy feed transaction was IGNORED (the 409 message honored)', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: true } }) // legacy check
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postBook())
expect(status).toBe(200)
expect(mockCommitEntry).toHaveBeenCalled()
})
it('returns 422 when a non-SEK order has no rate and the retry fails', async () => {
enqueue({ data: makeOrderRow({ currency: 'EUR', total_sek: null, exchange_rate: null }) })
mockFetchExchangeRate.mockResolvedValue(null)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(422)
expect(body.error.code).toBe('WEBSHOP_ORDER_FX_UNRESOLVED')
})
it('drafts, claims atomically, then commits with source_type webshop_order', async () => {
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim matched one row
const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>(
await postBook(),
)
expect(status).toBe(200)
expect(body.journal_entry_id).toBe('je-1')
expect(mockCreateDraftEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
source_type: 'webshop_order',
source_id: 'order-1',
fiscal_period_id: PERIOD_UUID,
}),
)
expect(mockCommitEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'draft-1',
)
})
it('returns 409 and cancels the draft when another request wins the claim', async () => {
enqueue({ data: makeOrderRow() }) // fetch (sees unbooked)
enqueue({ data: [] }) // claim matched ZERO rows: raced
enqueue({ data: null }) // draft cancel update
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
expect(mockCommitEntry).not.toHaveBeenCalled()
const cancel = findCall('journal_entries', 'update')
expect(cancel).toBeDefined()
expect((cancel![0] as Record<string, unknown>).status).toBe('cancelled')
})
it('unlinks and cancels the draft when the commit fails', async () => {
mockCommitEntry.mockRejectedValueOnce(new Error('period locked'))
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim
enqueue({ data: null }) // unlink
enqueue({ data: null }) // cancel draft
const { status } = await parseJsonResponse(await postBook())
expect(status).toBeGreaterThanOrEqual(400)
const orderUpdates = findCalls('webshop_orders', 'update')
expect(
orderUpdates.some(
(args) => (args[0] as Record<string, unknown>).journal_entry_id === null,
),
).toBe(true)
})
it('books a refund row whose parent is not invoiced', async () => {
enqueue({
data: makeOrderRow({
row_type: 'refund',
parent_order_id: 'parent-1',
total: -500,
total_sek: -500,
}),
})
enqueue({ data: { invoice_id: null } }) // parent check
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postBook())
expect(status).toBe(200)
})
it('refuses a refund row whose parent was invoiced', async () => {
enqueue({
data: makeOrderRow({
row_type: 'refund',
parent_order_id: 'parent-1',
total: -500,
total_sek: -500,
}),
})
enqueue({ data: { invoice_id: 'inv-5' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_REFUND_PARENT_INVOICED')
})
})
@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall } = 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 mockBuildInvoiceWriteData = vi.fn()
vi.mock('@/lib/invoices/build-invoice-write', () => ({
buildInvoiceWriteData: (...args: unknown[]) => mockBuildInvoiceWriteData(...args),
}))
import { POST } from '../[id]/create-invoice/route'
function makeOrderRow(overrides: Record<string, unknown> = {}) {
return {
id: 'order-1',
company_id: 'company-1',
row_type: 'order',
external_id: 'woo_butik.example.se_order_1001',
order_number: '1001',
status: 'processing',
is_paid: false,
order_date: '2026-08-01',
paid_date: null,
currency: 'SEK',
total: 500,
total_tax: 100,
total_sek: 500,
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
line_items: [
{ name: 'Produkt A', quantity: 2, total: 400, total_tax: 100, vat_rate: 25 },
],
customer_name: 'Test Person',
customer_company: 'Testbolaget AB',
customer_email: 'kund@example.se',
customer_orgnr: '556677-8899',
payment_method: 'bacs',
payment_method_title: 'Faktura',
journal_entry_id: null,
invoice_id: null,
legacy_transaction_id: null,
store_label: 'Butiken',
store_scope: 'butik.example.se',
...overrides,
}
}
const okBuild = {
ok: true,
invoiceFields: {
customer_id: 'cust-1',
invoice_date: '2026-08-10',
due_date: '2026-09-09',
currency: 'SEK',
subtotal: 400,
vat_amount: 100,
total: 500,
},
items: [
{
sort_order: 0,
description: 'Produkt A',
quantity: 2,
unit: 'st',
unit_price: 200,
line_total: 400,
vat_rate: 25,
vat_amount: 100,
},
],
}
function postCreate(body: unknown = {}, id = 'order-1') {
const request = createMockRequest(`/api/webshop-orders/${id}/create-invoice`, {
method: 'POST',
body,
})
return POST(request, createMockRouteParams({ id }))
}
describe('POST /api/webshop-orders/[id]/create-invoice', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
mockBuildInvoiceWriteData.mockResolvedValue(okBuild)
})
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 postCreate())
expect(status).toBe(401)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(404)
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
})
it('returns 409 when already invoiced', async () => {
enqueue({ data: makeOrderRow({ invoice_id: 'inv-1' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED')
})
it('returns 409 when already booked', async () => {
enqueue({ data: makeOrderRow({ journal_entry_id: 'je-1' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
})
it('returns 422 when the order carries no customer data and none is chosen', async () => {
enqueue({
data: makeOrderRow({
customer_name: null,
customer_company: null,
customer_email: null,
}),
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(422)
expect(body.error.code).toBe('WEBSHOP_ORDER_CREATE_INVOICE_MISSING_CUSTOMER')
})
it('creates an unnumbered draft from a matched customer and links back', async () => {
enqueue({ data: makeOrderRow() }) // order fetch
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB', customer_type: 'business' } }) // email match
enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) // invoices insert
enqueue({ data: null }) // invoice_items insert
enqueue({ data: [{ id: 'order-1' }] }) // order link-back matched
const { status, body } = await parseJsonResponse<{ invoice_id: string }>(
await postCreate(),
)
expect(status).toBe(200)
expect(body.invoice_id).toBe('inv-1')
const invoiceInsert = findCall('invoices', 'insert')
expect(invoiceInsert).toBeDefined()
expect((invoiceInsert![0] as Record<string, unknown>).invoice_number).toBeNull()
expect(mockBuildInvoiceWriteData).toHaveBeenCalledWith(
expect.objectContaining({ documentType: 'invoice' }),
)
const linkUpdate = findCall('webshop_orders', 'update')
expect(linkUpdate).toBeDefined()
expect((linkUpdate![0] as Record<string, unknown>).invoice_id).toBe('inv-1')
})
it('creates a customer from the order billing data when none matches', async () => {
enqueue({ data: makeOrderRow() }) // order fetch
enqueue({ data: null }) // email match: none
enqueue({ data: { id: 'cust-new', name: 'Testbolaget AB', customer_type: 'business' } }) // customer insert
enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } })
enqueue({ data: null }) // items
enqueue({ data: [{ id: 'order-1' }] }) // link-back matched
const { status } = await parseJsonResponse(await postCreate())
expect(status).toBe(200)
const customerInsert = findCall('customers', 'insert')
expect(customerInsert).toBeDefined()
expect(customerInsert![0]).toMatchObject({
name: 'Testbolaget AB',
customer_type: 'business',
contact_person: 'Test Person',
})
// Scraped orgnr must NOT auto-land on the customer's legal field
// (Swedish compliance review): the dialog shows it for manual review.
expect(customerInsert![0]).not.toHaveProperty('org_number')
})
it('rolls back the draft when the order link-back fails', async () => {
enqueue({ data: makeOrderRow() })
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } })
enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } })
enqueue({ data: null }) // items insert ok
enqueue({ data: null, error: { message: 'link failed' } }) // link-back DB error
enqueue({ data: null }) // items delete
enqueue({ data: null }) // invoice delete
const { status } = await parseJsonResponse(await postCreate())
expect(status).toBeGreaterThanOrEqual(500)
const deletes = findCall('invoices', 'delete')
expect(deletes).toBeDefined()
})
it('refuses refund rows', async () => {
enqueue({ data: makeOrderRow({ row_type: 'refund' }) })
const { status } = await parseJsonResponse(await postCreate())
expect(status).toBe(409)
})
it('returns 409 while the legacy feed transaction is open (double-booking lock)', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: false } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN')
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
})
it('creates the draft when the legacy feed transaction was ignored', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: true } })
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } }) // email match
enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } })
enqueue({ data: null }) // items
enqueue({ data: [{ id: 'order-1' }] }) // link-back matched
const { status } = await parseJsonResponse(await postCreate())
expect(status).toBe(200)
})
it('returns 409 and rolls back when the link-back matches zero rows (raced)', async () => {
enqueue({ data: makeOrderRow() })
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } })
enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } })
enqueue({ data: null }) // items insert ok
enqueue({ data: [] }) // link-back matched ZERO rows
enqueue({ data: null }) // items delete
enqueue({ data: null }) // invoice delete
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED')
expect(findCall('invoices', 'delete')).toBeDefined()
})
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 postCreate())
expect(status).toBe(403)
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
})
it('collapses non-divisible lines to quantity 1 and applies the single-bucket rate', async () => {
enqueue({
data: makeOrderRow({
line_items: [
{ name: 'Produkt B', quantity: 3, total: 100, total_tax: 25, vat_rate: null },
],
vat_breakdown: [{ rate: 25, net: 400, tax: 100 }],
}),
})
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } })
enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } })
enqueue({ data: null })
enqueue({ data: [{ id: 'order-1' }] })
const { status } = await parseJsonResponse(await postCreate())
expect(status).toBe(200)
const input = mockBuildInvoiceWriteData.mock.calls[0][0] as {
input: { items: Array<Record<string, unknown>> }
}
// 100/3 does not divide evenly in öre: exact total at quantity 1 instead
// of 3 x 33.33 = 99.99 silently shrinking the invoice.
expect(input.input.items[0]).toMatchObject({
quantity: 1,
unit_price: 100,
description: 'Produkt B (3 st)',
vat_rate: 25,
})
})
})
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall } = 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),
}))
import { GET as listOrders } from '../route'
import { GET as getSettings, PUT as putSettings } from '../settings/route'
describe('GET /api/webshop-orders', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await listOrders(createMockRequest('/api/webshop-orders'))
expect(response.status).toBe(401)
})
it('returns 400 on an invalid filter value', async () => {
const response = await listOrders(
createMockRequest('/api/webshop-orders?paid=maybe'),
)
expect(response.status).toBe(400)
})
it('lists rows with a deduped store facet', async () => {
enqueue({
data: [{ id: 'o1' }, { id: 'o2' }],
count: 2,
})
enqueue({
data: [
{ platform: 'woocommerce', store_scope: 'a.se', store_label: 'A' },
{ platform: 'woocommerce', store_scope: 'a.se', store_label: 'A' },
{ platform: 'woocommerce', store_scope: 'b.se', store_label: 'B' },
],
})
const { status, body } = await parseJsonResponse<{
data: unknown[]
count: number
stores: Array<{ store_scope: string }>
}>(await listOrders(createMockRequest('/api/webshop-orders?paid=paid&booked=unbooked')))
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.stores.map((s) => s.store_scope)).toEqual(['a.se', 'b.se'])
})
})
describe('GET|PUT /api/webshop-orders/settings', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated (GET and PUT)', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const getRes = await getSettings(createMockRequest('/api/webshop-orders/settings'))
expect(getRes.status).toBe(401)
const putRes = await putSettings(
createMockRequest('/api/webshop-orders/settings', {
method: 'PUT',
body: {
platform: 'woocommerce',
store_scope: 'a.se',
payment_method_account_map: {},
},
}),
)
expect(putRes.status).toBe(401)
})
it('PUT returns 403 for viewers (requireWrite)', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const res = await putSettings(
createMockRequest('/api/webshop-orders/settings', {
method: 'PUT',
body: {
platform: 'woocommerce',
store_scope: 'a.se',
payment_method_account_map: {},
},
}),
)
expect(res.status).toBe(403)
})
it('GET returns the stored mappings', async () => {
enqueue({
data: [
{
platform: 'woocommerce',
store_scope: 'a.se',
payment_method_account_map: { swish: { mode: 'book', account: '1930' } },
},
],
})
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await getSettings(createMockRequest('/api/webshop-orders/settings?platform=woocommerce')),
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
})
it('PUT rejects an invalid account number', async () => {
const response = await putSettings(
createMockRequest('/api/webshop-orders/settings', {
method: 'PUT',
body: {
platform: 'woocommerce',
store_scope: 'a.se',
payment_method_account_map: { swish: { mode: 'book', account: 'not-an-account' } },
},
}),
)
expect(response.status).toBe(400)
})
it('PUT upserts on (company, platform, store_scope)', async () => {
enqueue({
data: {
platform: 'woocommerce',
store_scope: 'a.se',
payment_method_account_map: { swish: { mode: 'book', account: '1930' } },
},
})
const { status } = await parseJsonResponse(
await putSettings(
createMockRequest('/api/webshop-orders/settings', {
method: 'PUT',
body: {
platform: 'woocommerce',
store_scope: 'a.se',
payment_method_account_map: {
swish: { mode: 'book', account: '1930' },
bacs: { mode: 'invoice' },
},
},
}),
),
)
expect(status).toBe(200)
const upsert = findCall('webshop_store_settings', 'upsert')
expect(upsert).toBeDefined()
expect((upsert![0] as Record<string, unknown>).company_id).toBe('company-1')
})
})
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { WebshopOrdersListQuerySchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
ensureInitialized()
const DEFAULT_LIMIT = 50
/**
* List webshop order rows for the Orders page, with a store facet for the
* per-shop filter. Refund rows are returned inline (they are independent
* bookable rows) and grouped client-side under their parent.
*/
export const GET = withRouteContext(
'webshop_order.list',
async (request, { supabase, companyId, log, requestId }) => {
const validation = validateQuery(request, WebshopOrdersListQuerySchema)
if (!validation.success) return validation.response
const { platform, store_scope, status, row_type, paid, booked } = validation.data
const limit = validation.data.limit ?? DEFAULT_LIMIT
const offset = validation.data.offset ?? 0
let query = supabase
.from('webshop_orders')
.select('*', { count: 'exact' })
.eq('company_id', companyId)
.order('order_date', { ascending: false })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (platform) query = query.eq('platform', platform)
if (store_scope) query = query.eq('store_scope', store_scope)
if (status) query = query.eq('status', status)
if (row_type) query = query.eq('row_type', row_type)
if (paid) query = query.eq('is_paid', paid === 'paid')
if (booked === 'booked') query = query.not('journal_entry_id', 'is', null)
if (booked === 'unbooked') query = query.is('journal_entry_id', null)
const { data, error, count } = await query
if (error) {
log.error('failed to list webshop orders', error)
return errorResponse(error, log, { requestId })
}
// Store facet for the filter dropdown: cheap distinct over the company's
// stores (small cardinality; the select is capped defensively).
const { data: facetRows, error: facetError } = await supabase
.from('webshop_orders')
.select('platform, store_scope, store_label')
.eq('company_id', companyId)
.eq('row_type', 'order')
.order('store_scope')
.limit(5000)
if (facetError) {
log.error('failed to build store facet', facetError)
return errorResponse(facetError, log, { requestId })
}
const seen = new Set<string>()
const stores: Array<{ platform: string; store_scope: string; store_label: string | null }> = []
for (const row of facetRows ?? []) {
const key = `${row.platform}:${row.store_scope}`
if (seen.has(key)) continue
seen.add(key)
stores.push(row)
}
return NextResponse.json({ data: data ?? [], count, stores })
},
)
+66
View File
@@ -0,0 +1,66 @@
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 { WebshopStoreSettingsUpdateSchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
ensureInitialized()
/**
* Per-store payment-method -> account mapping (webshop_store_settings).
* The mapping only PREFILLS the booking dialog; nothing here auto-books.
*/
export const GET = withRouteContext(
'webshop_order.settings.read',
async (request, { supabase, companyId, log, requestId }) => {
const { searchParams } = new URL(request.url)
const platform = searchParams.get('platform')
const storeScope = searchParams.get('store_scope')
let query = supabase
.from('webshop_store_settings')
.select('*')
.eq('company_id', companyId)
if (platform) query = query.eq('platform', platform)
if (storeScope) query = query.eq('store_scope', storeScope)
const { data, error } = await query
if (error) {
log.error('failed to read webshop store settings', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data: data ?? [] })
},
)
export const PUT = withRouteContext(
'webshop_order.settings.update',
async (request, { supabase, user, companyId, log, requestId }) => {
const validation = await validateBody(request, WebshopStoreSettingsUpdateSchema)
if (!validation.success) return validation.response
const { platform, store_scope, payment_method_account_map } = validation.data
const { data, error } = await supabase
.from('webshop_store_settings')
.upsert(
{
company_id: companyId,
user_id: user.id,
platform,
store_scope,
payment_method_account_map,
},
{ onConflict: 'company_id,platform,store_scope' },
)
.select()
.single()
if (error) {
log.error('failed to upsert webshop store settings', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)