* fix(webshop-orders): use a valid customer_type when creating the customer from an order Converting a business order (customer_company set) to an invoice inserted the customer with customer_type: 'business', which customers_customer_type_check rejects (allowed: individual, swedish_business, eu_business, non_eu_business). Every first-time business order conversion 500ed with WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED; individual orders and already-known customers were unaffected. Map to 'swedish_business': scraped store data carries no reliable country signal, and the draft review plus the customer card remain the gate where the user corrects the classification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop-orders): classify the created customer by the order's billing country Swedish compliance review on #1538: a flat 'swedish_business' default would treat EU and non-EU business customers as domestic, charging Swedish VAT where reverse charge (ML 17 kap 24 p.11) or export treatment applies. The order snapshot carries the billing country, so use it: SE or missing -> swedish_business, EU member -> eu_business, otherwise non_eu_business. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
325 lines
13 KiB
TypeScript
325 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 },
|
|
})
|
|
}
|
|
// 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)
|
|
.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 },
|
|
)
|