diff --git a/app/api/webshop-orders/[id]/create-invoice/route.ts b/app/api/webshop-orders/[id]/create-invoice/route.ts index 9d7cd1e1..b1552465 100644 --- a/app/api/webshop-orders/[id]/create-invoice/route.ts +++ b/app/api/webshop-orders/[id]/create-invoice/route.ts @@ -7,7 +7,24 @@ 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' +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() @@ -132,7 +149,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( company_id: companyId, user_id: user.id, name, - customer_type: order.customer_company ? 'business' : 'individual', + customer_type: customerTypeFromOrder(order), contact_person: order.customer_company ? order.customer_name : null, email: order.customer_email, }) diff --git a/app/api/webshop-orders/__tests__/create-invoice.test.ts b/app/api/webshop-orders/__tests__/create-invoice.test.ts index 468c8062..36b828d5 100644 --- a/app/api/webshop-orders/__tests__/create-invoice.test.ts +++ b/app/api/webshop-orders/__tests__/create-invoice.test.ts @@ -169,7 +169,7 @@ describe('POST /api/webshop-orders/[id]/create-invoice', () => { 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: 'cust-1', name: 'Testbolaget AB', customer_type: 'swedish_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 @@ -194,7 +194,7 @@ describe('POST /api/webshop-orders/[id]/create-invoice', () => { 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: 'cust-new', name: 'Testbolaget AB', customer_type: 'swedish_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 @@ -205,7 +205,10 @@ describe('POST /api/webshop-orders/[id]/create-invoice', () => { expect(customerInsert).toBeDefined() expect(customerInsert![0]).toMatchObject({ name: 'Testbolaget AB', - customer_type: 'business', + // Must be a value customers_customer_type_check accepts; 'business' is + // not one and made every business-order conversion 500 in production. + // No customer_country on the order defaults to domestic. + customer_type: 'swedish_business', contact_person: 'Test Person', }) // Scraped orgnr must NOT auto-land on the customer's legal field @@ -213,6 +216,27 @@ describe('POST /api/webshop-orders/[id]/create-invoice', () => { expect(customerInsert![0]).not.toHaveProperty('org_number') }) + // Reverse charge (EU) and export (non-EU) treatment key off customer_type, + // so the billing country must classify the created customer up front + // instead of stamping every business order as domestic. + it.each([ + ['SE', 'swedish_business'], + ['DE', 'eu_business'], + ['no', 'non_eu_business'], + ])('classifies a business order with country %s as %s', async (country, expected) => { + enqueue({ data: makeOrderRow({ customer_country: country }) }) // order fetch + enqueue({ data: null }) // email match: none + enqueue({ data: { id: 'cust-new', name: 'Testbolaget AB', customer_type: expected } }) + 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![0]).toMatchObject({ customer_type: expected }) + }) + it('rolls back the draft when the order link-back fails', async () => { enqueue({ data: makeOrderRow() }) enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } })