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:
@@ -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' } })
|
||||
|
||||
Reference in New Issue
Block a user