import { deriveProductIdentifierLines } from '../shared/utils/productIdentifierLines'; import { shipmentRuleData } from '../shared/utils/requirementRules'; import type { CheckoutShipmentItem, Rule, RuleSets } from '../types'; const CAP = 12900; /** The `requiredIf` the quoting API sends for the product identifiers (test API, 2026-10-23). */ const requiredIf = { all: [ { field: 'customs.dutyTerms', value: ['ioss', 'ddp'] }, { any: [ { field: 'customs.exportReason', value: 'sale' }, { all: [ { field: 'customs.exportReason', value: 'gift' }, { any: [ { field: 'origin.companyStatus', value: 'business-registered', }, { all: [ { compare: 'NotEq', field: 'origin.companyStatus', value: [ 'individual', 'business-not-registered', ], }, { any: [ { compare: 'NotEq', field: 'origin.companyTaxNumber', value: null, }, { compare: 'NotEq', field: 'origin.taxId', value: null, }, ], }, ], }, ], }, ], }, ], }, { any: [ { all: [ { field: 'origin.companyTaxNumber', value: null }, { field: 'origin.taxId', value: null }, ], }, { field: 'destination.companyTaxNumber', value: null }, { field: 'destination.eori', value: null }, { field: 'origin.companyStatus', value: ['individual', 'business-not-registered'], }, { field: 'origin.eori', value: null }, ], }, { compare: 'Lte', field: 'parcels.value', value: CAP }, ], }; const rule = (key: string, withRequiredIf: boolean): Rule => ({ key, required: false, metadata: { maxParcelValue: CAP, ...(withRequiredIf && { requiredIf }), }, }) as Rule; const ruleSets: RuleSets = { parcelContent: { rules: [ rule('merchantProductId', true), rule('manufacturerProductId', true), rule('standardProductId', false), ], }, }; const address = (over: object = {}) => ({ useAddress: { contactName: 'A', postcode: 'X', property: '1', country: 'GB', ...over, }, }); /** A UK individual sending to a French consumer, as the plugin builds it. */ const shipment = ( over: Partial = {}, value = 5000 ): CheckoutShipmentItem => ({ serviceSlug: 'royalmail-international-tracked', origin: address({ companyStatus: 'individual', companyTaxNumber: null, eori: null, taxId: null, }), destination: address({ country: 'FR' }), parcels: [{ ref: '1', value }], customs: { exportReason: 'Sale', dutyTerms: null }, ...over, }) as CheckoutShipmentItem; const derive = (s: CheckoutShipmentItem, ioss: string | null = null) => deriveProductIdentifierLines({ items: [{ name: 'Mug', product_identifiers: {} }] as never, ruleSets, parcelValue: s.parcels[0].value, ruleData: shipmentRuleData(s, ioss), }); describe('shipmentRuleData', () => { it('reads the sections a rule can name from the shipment', () => { const data = shipmentRuleData( shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' } }), null ); expect(data.customs).toMatchObject({ exportReason: 'Sale', dutyTerms: 'ddp', ioss: null, }); expect(data.origin).toMatchObject({ companyStatus: 'individual', eori: null, }); expect(data.destination).toMatchObject({ country: 'FR' }); expect(data.parcels).toEqual({ value: 5000 }); }); it('is ddu by default, as ui-checkout derives it', () => { expect(shipmentRuleData(shipment(), null).customs?.dutyTerms).toBe( 'ddu' ); }); it('makes the duty terms ioss when an IOSS number is given, and ddp wins over it', () => { expect( shipmentRuleData(shipment(), 'IM1234567890').customs?.dutyTerms ).toBe('ioss'); const ddp = shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' }, }); expect(shipmentRuleData(ddp, 'IM1234567890').customs?.dutyTerms).toBe( 'ddp' ); }); }); describe('product identifiers against the API rule', () => { it('are not asked for on a DDU sale: the rule needs IOSS or DDP duty terms', () => { expect(derive(shipment()).lines).toEqual([]); }); it('are required once an IOSS number is given', () => { const { lines, blocking } = derive(shipment(), 'IM1234567890'); expect(lines[0].fields.map((f) => [f.key, f.required])).toEqual([ ['merchantProductId', true], ['manufacturerProductId', true], ['standardProductId', false], ]); expect(blocking).toBe(true); }); it('are required on a DDP sale', () => { const { lines } = derive( shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' } }) ); expect(lines).toHaveLength(1); }); it('are not offered above the cap', () => { const s = shipment( { customs: { exportReason: 'Sale', dutyTerms: 'ddp' } }, CAP + 1 ); expect(derive(s).lines).toEqual([]); }); it('are not required when every party has tax details on file', () => { const s = shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' }, origin: address({ companyStatus: 'business-registered', companyTaxNumber: 'GB123', eori: 'GB123456789000', taxId: null, }), destination: address({ country: 'FR', companyTaxNumber: 'FR123', eori: 'FR123456789', }), }); expect(derive(s).lines).toEqual([]); }); it('are not required for a gift from an unregistered sender', () => { const s = shipment({ customs: { exportReason: 'Gift', dutyTerms: 'ddp' }, }); expect(derive(s).lines).toEqual([]); }); }); describe('an open IOSS box', () => { const derivedOpen = (s: CheckoutShipmentItem) => deriveProductIdentifierLines({ items: [{ name: 'Mug', product_identifiers: {} }] as never, ruleSets, parcelValue: s.parcels[0].value, ruleData: shipmentRuleData(s, null, true), }); it('counts as IOSS, so the codes show before a number is added', () => { const { lines, blocking } = derivedOpen(shipment()); expect(lines).toHaveLength(1); expect(blocking).toBe(true); }); it('sets the duty terms to ioss, with ddp still winning', () => { expect( shipmentRuleData(shipment(), null, true).customs?.dutyTerms ).toBe('ioss'); const ddp = shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' }, }); expect(shipmentRuleData(ddp, null, true).customs?.dutyTerms).toBe( 'ddp' ); }); it('asks for nothing above the cap', () => { expect(derivedOpen(shipment({}, CAP + 1)).lines).toEqual([]); }); }); describe('a lane without the identifier rules', () => { // A non-EU lane, or one where the tenant setting is off: the API sends no identifier rules. const otherLane: RuleSets = { parcelContent: { rules: [ { key: 'description', required: true }, { key: 'tariffCode', required: true }, ] as Rule[], }, }; const derivedOn = ( s: CheckoutShipmentItem, ioss: string | null, intended: boolean ) => deriveProductIdentifierLines({ items: [{ name: 'Mug', product_identifiers: {} }] as never, ruleSets: otherLane, parcelValue: s.parcels[0].value, ruleData: shipmentRuleData(s, ioss, intended), }); it.each([ ['DDU', shipment(), null, false], [ 'DDP', shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' } }), null, false, ], ['an IOSS number', shipment(), 'IM1234567890', false], ['an open IOSS box', shipment(), null, true], ])('asks for nothing on %s', (_name, s, ioss, intended) => { const { lines, blocking } = derivedOn( s as CheckoutShipmentItem, ioss, intended ); expect(lines).toEqual([]); expect(blocking).toBe(false); }); }); describe('the rule that no longer depends on the duty terms (test API, 2026-10-25)', () => { // A sale (or a gift from a registered business) under the cap, unless both parties are // registered businesses. const newRequiredIf = { all: [ { any: [ { field: 'customs.exportReason', value: 'sale' }, { all: [ { field: 'customs.exportReason', value: 'gift' }, { field: 'origin.companyStatus', value: 'business-registered', }, ], }, ], }, { any: [ { compare: 'NotEq', field: 'origin.companyStatus', value: 'business-registered', }, { compare: 'NotEq', field: 'destination.companyStatus', value: 'business-registered', }, ], }, { compare: 'Lte', field: 'parcels.value', value: CAP }, ], }; const newRules: RuleSets = { parcelContent: { rules: [ { key: 'merchantProductId', required: false, metadata: { maxParcelValue: CAP, requiredIf: newRequiredIf, }, }, { key: 'manufacturerProductId', required: false, metadata: { maxParcelValue: CAP, requiredIf: newRequiredIf, }, }, { key: 'standardProductId', required: false, metadata: { maxParcelValue: CAP }, }, ] as Rule[], }, }; const derive2 = ( s: CheckoutShipmentItem, ioss: string | null = null, intended = false ) => deriveProductIdentifierLines({ items: [{ name: 'Mug', product_identifiers: {} }] as never, ruleSets: newRules, parcelValue: s.parcels[0].value, ruleData: shipmentRuleData(s, ioss, intended), }); const registered = (over: object = {}) => address({ companyStatus: 'business-registered', companyTaxNumber: 'X1', ...over, }); it('asks for the codes on a plain sale to a consumer, with no DDP or IOSS', () => { const { lines, blocking } = derive2(shipment()); expect(lines[0].fields.map((f) => [f.key, f.required])).toEqual([ ['merchantProductId', true], ['manufacturerProductId', true], ['standardProductId', false], ]); expect(blocking).toBe(true); }); it('asks the same whatever the duty terms', () => { const keys = (r: ReturnType) => r.lines.map((l) => l.fields.map((f) => f.key)); const base = keys(derive2(shipment())); const ddp = shipment({ customs: { exportReason: 'Sale', dutyTerms: 'ddp' }, }); expect(keys(derive2(ddp))).toEqual(base); expect(keys(derive2(shipment(), 'IM1234567890'))).toEqual(base); expect(keys(derive2(shipment(), null, true))).toEqual(base); }); it('does not ask when both parties are registered businesses, and asks when only one is', () => { const both = shipment({ origin: registered(), destination: registered({ country: 'FR' }), }); expect(derive2(both).lines).toEqual([]); const one = shipment({ origin: registered(), destination: address({ country: 'FR' }), }); expect(derive2(one).lines).toHaveLength(1); }); it('does not ask for a gift from an individual, and asks for one from a registered business', () => { expect( derive2(shipment({ customs: { exportReason: 'Gift' } })).lines ).toEqual([]); const s = shipment({ origin: registered(), customs: { exportReason: 'Gift' }, }); expect(derive2(s).lines).toHaveLength(1); }); it('does not ask above the cap', () => { expect(derive2(shipment({}, CAP + 1)).lines).toEqual([]); }); });