* feat(woo): mark an order as already booked outside the integration Orders booked by hand before the store was connected sat under Att bokfora forever: the only exits were the book and create-invoice routes. - Migration: manually_booked_at/_by + optional manually_booked_journal_entry_id on webshop_orders (informational link, no financial freeze; the mark produced no accounting objects). - POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional posted-verifikat reference (validated per company), conditional claim against concurrent booking/invoicing; unmark is a plain revert. - book and create-invoice routes refuse marked rows (409 WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims. - List route: booked/unbooked filters treat a manual mark as a closed exit, so marked rows leave the Att bokfora tab and join Bokforda. - Orders page: row overflow menu with Markera som bokford / Angra markering, MarkOrderBookedDialog with a searchable candidate list of posted entries near the order date, muted status text linking to the referenced verifikat. Fixes #1879 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): close skeptic findings on the manual-booked mark - mark-booked applies the same open-twin gate as book/create-invoice: an OPEN legacy feed transaction blocks the mark (409 WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows unlock it, so no open path to a duplicate remains. - ingest treats manually marked rows as frozen for drift purposes: remote financial deltas set remote_changed_after_freeze (same badge as booked rows) instead of silently refreshing the row under the user's assertion. - re-marking with a journal_entry_id updates the informational link instead of silently dropping it. - dialog: candidate amount computed from the returned lines (the list API does not return total_amount), newest-first ordering, cap hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): resolve PR review findings in one pass - freeze v3 migration: financial fields are frozen at the DB level while a row is manually marked as booked (review finding: the mark's freeze lived only in ingest.ts, so any other write path could silently mutate a marked row); unmark stays the escape hatch. pg test added. - pass the active locale to getErrorMessage in the orders page and MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
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 { EU_COUNTRIES } from '@/lib/vat/eu-countries'
|
|
import type { Currency, Customer, CustomerType, Invoice, WebshopOrder } from '@/types'
|
|
|
|
const EU_COUNTRY_CODES = new Set(EU_COUNTRIES.map((c) => c.code))
|
|
|
|
/**
|
|
* Business orders classify by the order's billing country so downstream VAT
|
|
* treatment (reverse charge for EU, export for non-EU) keys off the right
|
|
* customer_type from the start (Swedish compliance review, PR #1538). A
|
|
* missing country defaults to domestic; the draft review and the customer
|
|
* card remain the gate where the user corrects the classification.
|
|
*/
|
|
function customerTypeFromOrder(order: WebshopOrder): CustomerType {
|
|
if (!order.customer_company) return 'individual'
|
|
const country = order.customer_country?.toUpperCase()
|
|
if (!country || country === 'SE') return 'swedish_business'
|
|
return EU_COUNTRY_CODES.has(country) ? 'eu_business' : 'non_eu_business'
|
|
}
|
|
|
|
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 },
|
|
})
|
|
}
|
|
// Marked as booked outside the integration: an invoice for the same sale
|
|
// would double-count the revenue. The mark is user-reversible.
|
|
if (order.manually_booked_at) {
|
|
return errorResponseFromCode('WEBSHOP_ORDER_MANUALLY_BOOKED', log, {
|
|
requestId,
|
|
details: { manually_booked_at: order.manually_booked_at },
|
|
})
|
|
}
|
|
// 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: customerTypeFromOrder(order),
|
|
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)
|
|
.is('manually_booked_at', 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 },
|
|
)
|