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:
co-authored by
Claude Fable 5
parent
89cfa82f2f
commit
c35b2547fb
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user