Files
accounted/app/api/webshop-orders/[id]/create-invoice/route.ts
T
MattssonandClaude Fable 5 1f9578ca76 feat(woo): mark an order as already booked outside the integration (#1895)
* 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>
2026-08-25 14:23:56 +02:00

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 },
)