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:
co-authored by
Claude Fable 5
parent
11995b1b0c
commit
2fb3667e8d
@@ -7,7 +7,24 @@ import { CreateInvoiceFromWebshopOrderSchema } from '@/lib/api/schemas'
|
|||||||
import { buildInvoiceWriteData, type InvoiceWriteInput } from '@/lib/invoices/build-invoice-write'
|
import { buildInvoiceWriteData, type InvoiceWriteInput } from '@/lib/invoices/build-invoice-write'
|
||||||
import { roundOre } from '@/lib/money'
|
import { roundOre } from '@/lib/money'
|
||||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
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()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -132,7 +149,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|||||||
company_id: companyId,
|
company_id: companyId,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
name,
|
name,
|
||||||
customer_type: order.customer_company ? 'business' : 'individual',
|
customer_type: customerTypeFromOrder(order),
|
||||||
contact_person: order.customer_company ? order.customer_name : null,
|
contact_person: order.customer_company ? order.customer_name : null,
|
||||||
email: order.customer_email,
|
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 () => {
|
it('creates an unnumbered draft from a matched customer and links back', async () => {
|
||||||
enqueue({ data: makeOrderRow() }) // order fetch
|
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: { id: 'inv-1', status: 'draft', invoice_number: null } }) // invoices insert
|
||||||
enqueue({ data: null }) // invoice_items insert
|
enqueue({ data: null }) // invoice_items insert
|
||||||
enqueue({ data: [{ id: 'order-1' }] }) // order link-back matched
|
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 () => {
|
it('creates a customer from the order billing data when none matches', async () => {
|
||||||
enqueue({ data: makeOrderRow() }) // order fetch
|
enqueue({ data: makeOrderRow() }) // order fetch
|
||||||
enqueue({ data: null }) // email match: none
|
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: { id: 'inv-1', status: 'draft', invoice_number: null } })
|
||||||
enqueue({ data: null }) // items
|
enqueue({ data: null }) // items
|
||||||
enqueue({ data: [{ id: 'order-1' }] }) // link-back matched
|
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).toBeDefined()
|
||||||
expect(customerInsert![0]).toMatchObject({
|
expect(customerInsert![0]).toMatchObject({
|
||||||
name: 'Testbolaget AB',
|
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',
|
contact_person: 'Test Person',
|
||||||
})
|
})
|
||||||
// Scraped orgnr must NOT auto-land on the customer's legal field
|
// 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')
|
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 () => {
|
it('rolls back the draft when the order link-back fails', async () => {
|
||||||
enqueue({ data: makeOrderRow() })
|
enqueue({ data: makeOrderRow() })
|
||||||
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } })
|
enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } })
|
||||||
|
|||||||
Reference in New Issue
Block a user