/** * Customs (CN23) tax note on C_Order.Description. * * The eBay import (laravel-middleware IdempiereOrderRepository::postOrder) writes, * when the marketplace collected the VAT itself (IOSS/OSS), exactly this sentence * into the order Description: * * Tax of {amount} {currency} has been paid via {paidVia} ({scheme}) with Code: {code} * * The only consumer is the DHL customs declaration: commission/parcels/dhl/{index,multi} * send the first 80 chars of C_Order.Description as CN23 `exportDescription`. This * composable lets staff set the same note manually (order popup + order edit) with the * identical format, so imported and manual orders look the same on the customs form. * * The note is always the FIRST line of the Description; any other text is kept after it. */ export interface CustomsTaxNote { paidVia: string scheme: string amount: string currency: string code: string } export const CUSTOMS_TAX_SCHEMES = ['IOSS', 'OSS', 'VAT', 'other'] as const /** DHL CN23 exportDescription cap (dhl/index.post.ts + dhl/multi.post.ts). */ export const CUSTOMS_EXPORT_DESCRIPTION_MAX = 80 // Exact eBay-import format; `m` so the note may be one line of a longer Description. // The code may contain spaces (real data: "GB 365 6085 76"); iDempiere appends " | ** Voided" // on void, which is excluded from the code and kept out of the match. const NOTE_RE = /^Tax of ([0-9]+(?:[.,][0-9]+)?) ([A-Za-z]{3}) has been paid via (.+?) \((.+?)\) with Code: (.+?)(?=[ \t]*\|[ \t]*\*\*|[ \t]*$)/m export const useCustomsTaxNote = () => { const { isDhlShipper } = useDhlShipper() /** * Carrier gate for the UI: only shippers whose customs path reads the note. * Today that is DHL-Paket only — extend here for DPD once its customs * request carries the order Description. */ const isCustomsTaxNoteShipper = (shipperId: any): boolean => isDhlShipper(shipperId) const formatAmount = (v: string | number | null | undefined): string => { const n = Number(String(v ?? '').trim().replace(',', '.')) return Number.isFinite(n) && String(v ?? '').trim() !== '' ? n.toFixed(2) : '' } const isComplete = (p?: CustomsTaxNote | null): boolean => !!p && formatAmount(p.amount) !== '' && /^[A-Za-z]{3}$/.test(String(p.currency || '').trim()) && String(p.paidVia || '').trim() !== '' && String(p.scheme || '').trim() !== '' && String(p.code || '').trim() !== '' const build = (p?: CustomsTaxNote | null): string => { if (!isComplete(p)) return '' const n = p as CustomsTaxNote return `Tax of ${formatAmount(n.amount)} ${String(n.currency).trim().toUpperCase()} has been paid via ${String(n.paidVia).trim()} (${String(n.scheme).trim()}) with Code: ${String(n.code).trim()}` } const parse = (text?: string | null): CustomsTaxNote | null => { const m = NOTE_RE.exec(String(text || '')) if (!m) return null return { amount: formatAmount(m[1]) || m[1], currency: m[2].toUpperCase(), paidVia: m[3].trim(), scheme: m[4].trim(), code: m[5].trim() } } /** Remove the note line (and the blank it leaves) from a Description. */ const strip = (text?: string | null): string => String(text || '') .replace(NOTE_RE, '') .split(/\r?\n/) .filter((line, idx, arr) => !(line.trim() === '' && (idx === 0 || idx === arr.length - 1))) .join('\n') .trim() /** * Returns the Description with the note as its first line (replacing an * existing one), or without any note when `p` is null / incomplete. Idempotent. */ const apply = (description: string | null | undefined, p: CustomsTaxNote | null): string => { const rest = strip(description) const note = build(p) if (!note) return rest return rest ? `${note}\n${rest}` : note } return { build, parse, apply, strip, isComplete, formatAmount, isCustomsTaxNoteShipper, schemes: CUSTOMS_TAX_SCHEMES, maxLength: CUSTOMS_EXPORT_DESCRIPTION_MAX } }