fix(webshop-orders): use a valid customer_type when creating the customer from an order (#1538)

* 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>
This commit is contained in:
Mattsson
2026-08-12 17:55:00 +02:00
committed by GitHub
parent 11995b1b0c
commit 2fb3667e8d
2 changed files with 46 additions and 5 deletions
@@ -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,
})
@@ -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' } })