@spinekit/purchase 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-08-24
4
+
5
+ ### Added
6
+
7
+ - **`@spinekit/purchase/import` subpath** — `PurchaseImportApplication` and `createPurchaseImportApplication`: drives the BD import clearance flow on a procurement order (claim the CAS slot, resolve AT/AIT split via `bd-tax`, post journal via ledger-bd, publish `ImportCleared` event). Application layer only; the resource action wiring lives in `@spinekit/inventory/integrations/import-clearance`.
8
+ - **`@spinekit/purchase/import/types` subpath** — `PurchaseImportInput`, `PurchaseImportResult`, `PurchaseImportDeps` type-only export for hosts that need the contract without the implementation.
9
+
10
+ ## 0.2.0 - 2026-08-23
11
+
12
+ ### Changed
13
+
14
+ - **`PurchaseStockReceiptDeps.skuRef` now accepts `string | Promise<string>`** — the correct key is the variant's stamped `skuRef` on the product document; a host resolving it must be allowed a read. Sync legacy derivations remain assignable — no existing host breaks. `createPurchaseStockReceipt` now `await`s the call.
15
+
3
16
  ## 0.1.1 - 2026-08-21
4
17
 
5
18
  ### Changed
@@ -0,0 +1,51 @@
1
+ import { ClearImportCommand, ClearImportResult, PurchaseImportApplicationDeps } from "./purchase-import.types.mjs";
2
+ //#region src/import/purchase-import.application.d.ts
3
+ /** Thrown for every refusal below, so a host maps one error type to a 4xx. */
4
+ declare class ImportClearanceError extends Error {
5
+ readonly code: 'PURCHASE_NOT_FOUND' | 'NOT_AN_IMPORT' | 'NOT_RECEIVED' | 'ALREADY_CLEARED' | 'INVALID_ASSESSMENT';
6
+ constructor(code: 'PURCHASE_NOT_FOUND' | 'NOT_AN_IMPORT' | 'NOT_RECEIVED' | 'ALREADY_CLEARED' | 'INVALID_ASSESSMENT', message: string);
7
+ }
8
+ /** Cost-line codes for the capitalized duties. Stable — a reversal matches on them. */
9
+ declare const IMPORT_DUTY_CODES: {
10
+ readonly customsDuty: 'CUSTOMS_DUTY';
11
+ readonly regulatoryDuty: 'REGULATORY_DUTY';
12
+ readonly supplementaryDuty: 'SUPPLEMENTARY_DUTY';
13
+ };
14
+ /**
15
+ * The clearance is identified by the purchase AND the Bill of Entry number.
16
+ *
17
+ * Deterministic, never random: a random fallback defeats deduplication while
18
+ * looking correctly wired. One purchase can legitimately clear under more than
19
+ * one BoE (a split consignment), so the purchase alone is too coarse; the BoE
20
+ * alone is too coarse across purchases.
21
+ */
22
+ declare function importClearanceKey(purchaseId: string, billOfEntryNumber: string): string;
23
+ declare function createPurchaseImportClearance(deps: PurchaseImportApplicationDeps): (command: ClearImportCommand) => Promise<ClearImportResult>;
24
+ /**
25
+ * The arc action handler for the clearance — body validation, command
26
+ * assembly, and the error-to-wire mapping. Host-independent: every host
27
+ * mounting `clear-import` needs exactly this sequence, so it lives with the
28
+ * action rather than being re-typed per deployment. The host supplies only
29
+ * the per-request builder (its engines and tenancy are in there) and the
30
+ * permission gate at the resource seam.
31
+ */
32
+ interface ClearImportWireBody {
33
+ billOfEntryNumber?: string;
34
+ billOfEntryDate?: string;
35
+ assessableValue?: number;
36
+ rates?: Record<string, number>;
37
+ }
38
+ declare function createClearImportActionHandler(deps: {
39
+ /** Build the request-scoped action — ports are tenant-bound per request. */
40
+ build: (req: unknown) => (command: ClearImportCommand) => Promise<ClearImportResult>;
41
+ /** Actor for the clearance stamp, resolved from the request. */
42
+ actorId: (req: unknown) => string;
43
+ /** Wire error constructors — arc's, supplied so this file stays arc-free. */
44
+ errors: {
45
+ validation: (message: string) => Error;
46
+ notFound: (message: string) => Error;
47
+ domain: (code: string, message: string, status: number) => Error;
48
+ };
49
+ }): (id: string, data: unknown, req: unknown) => Promise<ClearImportResult>;
50
+ //#endregion
51
+ export { ClearImportWireBody, IMPORT_DUTY_CODES, ImportClearanceError, createClearImportActionHandler, createPurchaseImportClearance, importClearanceKey };
@@ -0,0 +1,204 @@
1
+ //#region src/import/purchase-import.application.ts
2
+ /** Thrown for every refusal below, so a host maps one error type to a 4xx. */
3
+ var ImportClearanceError = class extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "ImportClearanceError";
9
+ }
10
+ };
11
+ /** Cost-line codes for the capitalized duties. Stable — a reversal matches on them. */
12
+ const IMPORT_DUTY_CODES = {
13
+ customsDuty: "CUSTOMS_DUTY",
14
+ regulatoryDuty: "REGULATORY_DUTY",
15
+ supplementaryDuty: "SUPPLEMENTARY_DUTY"
16
+ };
17
+ /**
18
+ * The clearance is identified by the purchase AND the Bill of Entry number.
19
+ *
20
+ * Deterministic, never random: a random fallback defeats deduplication while
21
+ * looking correctly wired. One purchase can legitimately clear under more than
22
+ * one BoE (a split consignment), so the purchase alone is too coarse; the BoE
23
+ * alone is too coarse across purchases.
24
+ */
25
+ function importClearanceKey(purchaseId, billOfEntryNumber) {
26
+ return `import-clearance:${purchaseId}:${billOfEntryNumber.trim().toUpperCase()}`;
27
+ }
28
+ /**
29
+ * Every amount the assessment must agree on before anything is written.
30
+ *
31
+ * The calculator is a port, so this action cannot assume it is the verified
32
+ * BD implementation — a host could wire a stub, a spreadsheet export, or a
33
+ * second jurisdiction's pack. An internally inconsistent cascade would post a
34
+ * balanced journal entry with the wrong split, which is unobservable
35
+ * downstream, so the arithmetic is checked HERE rather than trusted.
36
+ */
37
+ function assertCoherent(a, duties) {
38
+ const negatives = Object.entries(a).filter(([, v]) => typeof v === "number" && v < 0);
39
+ if (negatives.length > 0) throw new ImportClearanceError("INVALID_ASSESSMENT", `Assessment returned negative amounts: ${negatives.map(([k, v]) => `${k}=${v}`).join(", ")}.`);
40
+ const expectedBase = a.assessableValue + duties;
41
+ if (a.vatAtBase !== expectedBase) throw new ImportClearanceError("INVALID_ASSESSMENT", `vatAtBase ${a.vatAtBase} != assessableValue + duties ${expectedBase}. VAT and AT are assessed on the duty-inclusive value; a disagreeing base means the cascade was not applied in order.`);
42
+ const expectedTotal = duties + a.vat + a.advanceTax + a.advanceIncomeTax;
43
+ if (a.totalCustomsPayment !== expectedTotal) throw new ImportClearanceError("INVALID_ASSESSMENT", `totalCustomsPayment ${a.totalCustomsPayment} != duties + VAT + AT + AIT ${expectedTotal}. The goods value is paid to the supplier, never at customs, so it must not be in this total.`);
44
+ if (a.landedInventoryCost !== a.assessableValue + duties) throw new ImportClearanceError("INVALID_ASSESSMENT", `landedInventoryCost ${a.landedInventoryCost} != assessableValue + duties ${a.assessableValue + duties}.`);
45
+ }
46
+ function createPurchaseImportClearance(deps) {
47
+ const { order: orderPort, duty, landedCost, recordCustoms, receipt, withTransaction } = deps;
48
+ return async function clearImport(command) {
49
+ const { purchaseId, billOfEntry } = command;
50
+ const boeNumber = billOfEntry.number.trim();
51
+ if (!boeNumber) throw new ImportClearanceError("INVALID_ASSESSMENT", "Bill of Entry number is required.");
52
+ if (!Number.isInteger(billOfEntry.assessableValue) || billOfEntry.assessableValue < 0) throw new ImportClearanceError("INVALID_ASSESSMENT", `assessableValue must be a non-negative integer in minor units, received ${billOfEntry.assessableValue}. A major-unit value here would under-assess the whole cascade by the minor-unit factor.`);
53
+ return withTransaction(async (session) => {
54
+ const order = await orderPort.load(purchaseId, session);
55
+ if (!order) throw new ImportClearanceError("PURCHASE_NOT_FOUND", `Purchase ${purchaseId} not found.`);
56
+ /**
57
+ * Only an import clears customs. Refusing here rather than recording a
58
+ * zero-duty clearance keeps the two tax treatments from quietly
59
+ * converging: a domestic purchase with a Bill of Entry means one of the
60
+ * two documents is wrong, and guessing which one is not this action's
61
+ * call.
62
+ */
63
+ if (order.taxTreatment !== "import") throw new ImportClearanceError("NOT_AN_IMPORT", `Purchase ${purchaseId} has taxTreatment '${order.taxTreatment ?? "domestic"}'. A Bill of Entry applies only to an import; set the treatment on the order first if it was misfiled.`);
64
+ /**
65
+ * Pre-flight only — the real guard is the CAS in `markCleared` below.
66
+ * This exists so the ordinary case (an operator re-submitting a form)
67
+ * gets a message naming the existing Bill of Entry, rather than a
68
+ * lost-race error for a race it never entered.
69
+ */
70
+ if (order.alreadyClearedUnder) throw new ImportClearanceError("ALREADY_CLEARED", `Purchase ${purchaseId} is already cleared under Bill of Entry ${order.alreadyClearedUnder}.`);
71
+ /**
72
+ * Duties capitalize into the RECEIVED stock, so there must be some. A
73
+ * clearance before receipt is refused rather than parked: customs
74
+ * release the goods, so a Bill of Entry with nothing received means the
75
+ * receipt was missed, and allocating across zero lines would silently
76
+ * capitalize nothing while reporting success.
77
+ */
78
+ const received = await receipt.getReceived(purchaseId, session);
79
+ if (!received || received.pickingIds.length === 0) throw new ImportClearanceError("NOT_RECEIVED", `Purchase ${purchaseId} has no receipt to allocate duties across. Customs release the goods, so record the receipt before the clearance.`);
80
+ const assessment = duty.assess({
81
+ assessableValue: billOfEntry.assessableValue,
82
+ ...billOfEntry.rates
83
+ });
84
+ const dutiesCapitalized = assessment.customsDuty + assessment.regulatoryDuty + assessment.supplementaryDuty;
85
+ assertCoherent(assessment, dutiesCapitalized);
86
+ /**
87
+ * CLAIM BEFORE POSTING, and the ordering is deliberate.
88
+ *
89
+ * Claiming first means a crash between the claim and the postings
90
+ * leaves an order stamped as cleared with nothing posted — an operator
91
+ * has to reverse and re-enter. Posting first would mean a crash leaves
92
+ * the postings with no stamp, and the retry re-runs them: the customs
93
+ * recording port is idempotent on its key, but the landed-cost
94
+ * document is not, so the duties would capitalize twice.
95
+ *
96
+ * The asymmetry decides it. A blocked re-entry is a support ticket; a
97
+ * doubled customs posting is real money in the wrong place, found at
98
+ * filing. Inside a transaction both halves roll back together and the
99
+ * question is moot; on a deployment without sessions this is the safe
100
+ * failure.
101
+ */
102
+ const { claimed } = await orderPort.markCleared({
103
+ purchaseId,
104
+ billOfEntryNumber: boeNumber,
105
+ billOfEntryDate: billOfEntry.date,
106
+ assessedValue: assessment.assessableValue,
107
+ totalCustomsPayment: assessment.totalCustomsPayment,
108
+ actorId: command.actorId,
109
+ session
110
+ });
111
+ if (!claimed) throw new ImportClearanceError("ALREADY_CLEARED", `Purchase ${purchaseId} was cleared concurrently by another request. Nothing was posted by this one.`);
112
+ /**
113
+ * DUTIES ONLY. Not `landedInventoryCost` — that includes the assessable
114
+ * value, which the receipt already booked. Zero-amount components are
115
+ * dropped so a nil-rated duty does not create an allocation row that a
116
+ * reversal then has to reconcile.
117
+ */
118
+ const costLines = [
119
+ {
120
+ code: IMPORT_DUTY_CODES.customsDuty,
121
+ amount: assessment.customsDuty
122
+ },
123
+ {
124
+ code: IMPORT_DUTY_CODES.regulatoryDuty,
125
+ amount: assessment.regulatoryDuty
126
+ },
127
+ {
128
+ code: IMPORT_DUTY_CODES.supplementaryDuty,
129
+ amount: assessment.supplementaryDuty
130
+ }
131
+ ].filter((line) => line.amount > 0).map((line) => ({
132
+ ...line,
133
+ note: `BoE ${boeNumber}`
134
+ }));
135
+ const { landedCostId } = costLines.length > 0 ? await landedCost.applyDuties({
136
+ purchaseId,
137
+ pickingIds: received.pickingIds,
138
+ reference: `BoE ${boeNumber}`,
139
+ costLines,
140
+ session
141
+ }) : { landedCostId: "" };
142
+ const { postingRef } = await recordCustoms.record({
143
+ purchaseId,
144
+ billOfEntryNumber: boeNumber,
145
+ billOfEntryDate: billOfEntry.date,
146
+ assessableValue: billOfEntry.assessableValue,
147
+ rates: billOfEntry.rates,
148
+ vat: assessment.vat,
149
+ advanceTax: assessment.advanceTax,
150
+ advanceIncomeTax: assessment.advanceIncomeTax,
151
+ dutiesCapitalized,
152
+ totalCustomsPayment: assessment.totalCustomsPayment,
153
+ vatAtBase: assessment.vatAtBase,
154
+ idempotencyKey: importClearanceKey(purchaseId, boeNumber),
155
+ session
156
+ });
157
+ return {
158
+ purchaseId,
159
+ billOfEntryNumber: boeNumber,
160
+ assessment,
161
+ landedCostId,
162
+ postingRef,
163
+ dutiesCapitalized,
164
+ valuationVariance: assessment.assessableValue - received.bookedCost
165
+ };
166
+ }, { onCommit: async () => {} });
167
+ };
168
+ }
169
+ function createClearImportActionHandler(deps) {
170
+ const { build, actorId, errors } = deps;
171
+ return async function clearImportHandler(id, data, req) {
172
+ const body = data ?? {};
173
+ if (!body.billOfEntryNumber || !body.billOfEntryDate || body.assessableValue === void 0) throw errors.validation("billOfEntryNumber, billOfEntryDate and assessableValue (integer minor units) are required.");
174
+ const rates = body.rates ?? {};
175
+ if (typeof rates.cdRate !== "number") throw errors.validation("rates.cdRate is required (percent). A Bill of Entry always states the customs duty rate — 0 is a valid value for exempt goods, absence is not.");
176
+ try {
177
+ return await build(req)({
178
+ purchaseId: id,
179
+ billOfEntry: {
180
+ number: body.billOfEntryNumber,
181
+ date: new Date(body.billOfEntryDate),
182
+ assessableValue: body.assessableValue,
183
+ rates: {
184
+ cdRate: rates.cdRate,
185
+ ...rates.rdRate !== void 0 ? { rdRate: rates.rdRate } : {},
186
+ ...rates.sdRate !== void 0 ? { sdRate: rates.sdRate } : {},
187
+ ...rates.atRate !== void 0 ? { atRate: rates.atRate } : {},
188
+ ...rates.vatRate !== void 0 ? { vatRate: rates.vatRate } : {},
189
+ ...rates.aitRate !== void 0 ? { aitRate: rates.aitRate } : {}
190
+ }
191
+ },
192
+ actorId: actorId(req)
193
+ });
194
+ } catch (err) {
195
+ if (err instanceof ImportClearanceError) {
196
+ if (err.code === "PURCHASE_NOT_FOUND") throw errors.notFound(err.message);
197
+ throw errors.domain(err.code, err.message, err.code === "ALREADY_CLEARED" ? 409 : 400);
198
+ }
199
+ throw err;
200
+ }
201
+ };
202
+ }
203
+ //#endregion
204
+ export { IMPORT_DUTY_CODES, ImportClearanceError, createClearImportActionHandler, createPurchaseImportClearance, importClearanceKey };
@@ -0,0 +1,231 @@
1
+ import { ClientSession } from "mongoose";
2
+ //#region src/import/purchase-import.types.d.ts
3
+ /**
4
+ * The customs cascade, as computed by the country pack.
5
+ *
6
+ * Structurally identical to `@classytic/bd-tax`'s `ImportTaxStack`, declared
7
+ * here so this package takes no country dependency. A host wires the two
8
+ * together; a second jurisdiction supplies its own calculator against the
9
+ * same shape.
10
+ *
11
+ * Every amount is integer MINOR units, matching the rest of this package.
12
+ */
13
+ interface ImportDutyAssessment {
14
+ /** Customs-assessed value of the goods (CIF + landing charge). */
15
+ assessableValue: number;
16
+ customsDuty: number;
17
+ regulatoryDuty: number;
18
+ supplementaryDuty: number;
19
+ /** VAT prepayment. NOT input VAT — see the module docblock. */
20
+ advanceTax: number;
21
+ /** Reclaimable import VAT. */
22
+ vat: number;
23
+ /** The base VAT and AT were computed on (AV + CD + RD + SD). */
24
+ vatAtBase: number;
25
+ advanceIncomeTax: number;
26
+ /** AV + CD + RD + SD. Includes the goods; see the double-count note. */
27
+ landedInventoryCost: number;
28
+ /** What is actually paid at the customs point. Excludes the goods value. */
29
+ totalCustomsPayment: number;
30
+ }
31
+ /** Rates as declared on the Bill of Entry. Percentages, not fractions. */
32
+ interface ImportDutyRates {
33
+ cdRate: number;
34
+ rdRate?: number | undefined;
35
+ sdRate?: number | undefined;
36
+ /**
37
+ * Commercial importers and manufacturers importing their own raw materials
38
+ * attract different Advance Tax rates, and some items are exempt. Left
39
+ * undefined the country pack applies its own default — which is a rate, not
40
+ * a zero.
41
+ */
42
+ atRate?: number | undefined;
43
+ vatRate?: number | undefined;
44
+ aitRate?: number | undefined;
45
+ }
46
+ /**
47
+ * Computes the cascade. REQUIRED, never defaulted.
48
+ *
49
+ * A default calculator would have to invent a jurisdiction's duty structure,
50
+ * and the result would be a filing-grade number with no basis. The same rule
51
+ * the payment application applies to tax attribution: attributing something
52
+ * plausible is worse than refusing, because nothing downstream can tell the
53
+ * difference.
54
+ */
55
+ interface ImportDutyCalculator {
56
+ assess(input: ImportDutyRates & {
57
+ assessableValue: number;
58
+ }): ImportDutyAssessment;
59
+ }
60
+ /**
61
+ * Capitalizes the duty increment across the receipt's lines.
62
+ *
63
+ * Deliberately the EXISTING landed-cost machinery rather than a second
64
+ * allocator: it already owns per-line allocation by value/quantity/weight,
65
+ * FX-stamped cost lines, persisted allocations so a reversal undoes the exact
66
+ * same split, and the accounting bridge that capitalizes into inventory. A
67
+ * bespoke allocator here would be a second implementation of a solved problem
68
+ * and would drift from the one `reverse()` understands.
69
+ */
70
+ interface ImportLandedCostPort {
71
+ /**
72
+ * @param costLines Duty components only. The port must NOT be handed the
73
+ * assessable value — see the double-count note in the module docblock.
74
+ * @returns The applied landed-cost document id, for provenance.
75
+ */
76
+ applyDuties(input: {
77
+ purchaseId: string;
78
+ /** Receipt/picking refs the duties allocate across. */
79
+ pickingIds: readonly string[];
80
+ reference: string;
81
+ costLines: readonly {
82
+ code: string;
83
+ amount: number;
84
+ note?: string;
85
+ }[];
86
+ session: ClientSession | null;
87
+ }): Promise<{
88
+ landedCostId: string;
89
+ }>;
90
+ }
91
+ /**
92
+ * Posts the three NON-inventory legs and the customs credit.
93
+ *
94
+ * Kept as a port for the same reason `PurchasePaymentRecordingPort` is: the
95
+ * chart of accounts, the tax regime's eligibility rules, and whether a
96
+ * clearance is paid from a bank or accrued to a C&F agent are all deployment
97
+ * facts. The package owns which amounts go where CONCEPTUALLY; the host owns
98
+ * the account codes.
99
+ */
100
+ interface ImportCustomsRecordingPort {
101
+ record(input: {
102
+ purchaseId: string;
103
+ billOfEntryNumber: string;
104
+ billOfEntryDate: Date;
105
+ /**
106
+ * The declared assessment inputs, alongside the computed amounts. A
107
+ * recording implementation built on a posting RECIPE re-derives the
108
+ * cascade from these through the same country pack — carrying them means
109
+ * the recipe and the action cannot be fed different facts.
110
+ */
111
+ assessableValue: number;
112
+ rates: ImportDutyRates;
113
+ /** Reclaimable input VAT. */
114
+ vat: number;
115
+ /** VAT prepayment — a distinct account from input VAT. */
116
+ advanceTax: number;
117
+ /** Income-tax asset. */
118
+ advanceIncomeTax: number;
119
+ /** Duties capitalized via the landed-cost port. Credited, not debited. */
120
+ dutiesCapitalized: number;
121
+ /** Must equal duties + vat + advanceTax + advanceIncomeTax. */
122
+ totalCustomsPayment: number;
123
+ /** The base VAT was computed on — a ledger-derived return needs it. */
124
+ vatAtBase: number;
125
+ /** Deterministic; the same clearance recorded twice is one posting. */
126
+ idempotencyKey: string;
127
+ session: ClientSession | null;
128
+ }): Promise<{
129
+ postingRef: string;
130
+ }>;
131
+ }
132
+ /** What the receipt already booked, so the assessed value can be reconciled. */
133
+ interface ImportReceiptLookup {
134
+ /**
135
+ * @returns The picking refs and the cost the receipt booked (integer minor
136
+ * units), or null when the purchase has not been received. A clearance
137
+ * before receipt is refused rather than queued — customs release the
138
+ * goods, so a Bill of Entry without a receipt means the two documents
139
+ * disagree about reality.
140
+ */
141
+ getReceived(purchaseId: string, session: ClientSession | null): Promise<{
142
+ pickingIds: readonly string[];
143
+ bookedCost: number;
144
+ } | null>;
145
+ }
146
+ interface PurchaseUnitOfWork {
147
+ <T>(work: (session: ClientSession | null) => Promise<T>, options: {
148
+ onCommit: (result: T) => Promise<void>;
149
+ }): Promise<T>;
150
+ }
151
+ interface PurchaseImportApplicationDeps {
152
+ order: ImportClearancePort;
153
+ duty: ImportDutyCalculator;
154
+ landedCost: ImportLandedCostPort;
155
+ recordCustoms: ImportCustomsRecordingPort;
156
+ receipt: ImportReceiptLookup;
157
+ withTransaction: PurchaseUnitOfWork;
158
+ }
159
+ /**
160
+ * Reads the order's tax treatment and CLAIMS the clearance.
161
+ *
162
+ * A port rather than an engine handle because this deployment has two purchase
163
+ * documents — flow's procurement order (what the warehouse UI raises) and
164
+ * `@classytic/purchase`'s GR/IR order — and an import can arrive on either.
165
+ * Binding to one engine's repository shape would have silently restricted the
166
+ * action to that path while type-checking perfectly against it.
167
+ */
168
+ interface ImportClearancePort {
169
+ load(purchaseId: string, session: ClientSession | null): Promise<{
170
+ taxTreatment?: 'domestic' | 'import' | undefined;
171
+ /** The BoE this order was already cleared under, when it was. */
172
+ alreadyClearedUnder?: string | undefined;
173
+ } | null>;
174
+ /**
175
+ * Stamp the clearance, returning whether THIS caller won.
176
+ *
177
+ * Must be a compare-and-set in the query FILTER — `{ _id, importClearance:
178
+ * { $exists: false } }` — not a read followed by a write. The `load` check
179
+ * above is a pre-flight for a friendly error message, not the guard: two
180
+ * concurrent clearances both pass it, and the loser must be told it lost
181
+ * rather than post a second customs entry.
182
+ *
183
+ * @returns `claimed: false` when another clearance won the race.
184
+ */
185
+ markCleared(input: {
186
+ purchaseId: string;
187
+ billOfEntryNumber: string;
188
+ billOfEntryDate: Date;
189
+ assessedValue: number;
190
+ totalCustomsPayment: number;
191
+ actorId?: string | undefined;
192
+ session: ClientSession | null;
193
+ }): Promise<{
194
+ claimed: boolean;
195
+ }>;
196
+ }
197
+ interface ClearImportCommand {
198
+ purchaseId: string;
199
+ billOfEntry: {
200
+ /** The BoE number. Part of the idempotency identity. */
201
+ number: string;
202
+ /** Clearance date — the date the assessment is effective on. */
203
+ date: Date;
204
+ /**
205
+ * Customs-assessed value in integer MINOR units. Supplied, never derived
206
+ * from the PO: customs assess on their own valuation and the difference
207
+ * from the invoice is the thing worth seeing.
208
+ */
209
+ assessableValue: number;
210
+ rates: ImportDutyRates;
211
+ };
212
+ actorId?: string | undefined;
213
+ }
214
+ interface ClearImportResult {
215
+ purchaseId: string;
216
+ billOfEntryNumber: string;
217
+ assessment: ImportDutyAssessment;
218
+ landedCostId: string;
219
+ postingRef: string;
220
+ /** CD + RD + SD — what was capitalized. */
221
+ dutiesCapitalized: number;
222
+ /**
223
+ * Assessed value minus what the receipt booked. Non-zero is NORMAL (customs
224
+ * assess CIF plus a landing charge; the receipt booked the invoice), and it
225
+ * is surfaced rather than absorbed so an operator can see valuation drift
226
+ * instead of discovering it in a stock-vs-ledger reconciliation.
227
+ */
228
+ valuationVariance: number;
229
+ }
230
+ //#endregion
231
+ export { ClearImportCommand, ClearImportResult, ImportClearancePort, ImportCustomsRecordingPort, ImportDutyAssessment, ImportDutyCalculator, ImportDutyRates, ImportLandedCostPort, ImportReceiptLookup, PurchaseImportApplicationDeps, PurchaseUnitOfWork };
@@ -0,0 +1 @@
1
+ export {};
@@ -64,7 +64,7 @@ async receive({ purchase, supplierName }) {
64
64
  resolved.push({
65
65
  productId,
66
66
  variantSku,
67
- skuRef: deps.skuRef(productId, variantSku),
67
+ skuRef: await deps.skuRef(productId, variantSku),
68
68
  quantity: Number(item.quantity ?? 0),
69
69
  costMinor: Number(item.costPrice ?? 0),
70
70
  destinationLocationCode
@@ -147,8 +147,15 @@ interface PurchaseStockReceiptDeps<TCtx extends PurchaseReceiptScope = PurchaseR
147
147
  * implementation for a deployment that provisions ahead of time.
148
148
  */
149
149
  ensureBranchReady(organizationId: string): Promise<void>;
150
- /** The host's `productId` (+ variant) → `skuRef` convention. */
151
- skuRef(productId: string, variantSku: string | null): string;
150
+ /**
151
+ * The host's `productId` (+ variant) -> `skuRef` resolution.
152
+ *
153
+ * May be async: the correct key is the variant's STAMPED `skuRef` (ADR
154
+ * `spine/docs/stock-identity-and-product-variants.md`), which lives on the
155
+ * product DOC — a host resolving it must be allowed a read. A sync legacy
156
+ * derivation remains assignable, so no existing host breaks.
157
+ */
158
+ skuRef(productId: string, variantSku: string | null): string | Promise<string>;
152
159
  /** Counterparty location goods arrive FROM — unbounded by design. */
153
160
  vendorLocationId: string;
154
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spinekit/purchase",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Arc module for @classytic/purchase — supplier purchase orders (draft→approve→receive with CAS + compensation + pendingStockReceipt crash heal) composed into arc apps. Paisa money wire; catalog/stockReceipt/sequence kernel ports injected; accounting posting + approval stay host seams.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -66,6 +66,14 @@
66
66
  "./resources/supplier/types": {
67
67
  "types": "./dist/resources/supplier/supplier.types.d.mts",
68
68
  "default": "./dist/resources/supplier/supplier.types.mjs"
69
+ },
70
+ "./import": {
71
+ "types": "./dist/import/purchase-import.application.d.mts",
72
+ "default": "./dist/import/purchase-import.application.mjs"
73
+ },
74
+ "./import/types": {
75
+ "types": "./dist/import/purchase-import.types.d.mts",
76
+ "default": "./dist/import/purchase-import.types.mjs"
69
77
  }
70
78
  },
71
79
  "files": [
@@ -97,7 +105,7 @@
97
105
  "typescript": "^7.0.2",
98
106
  "vitest": "^3.2.4",
99
107
  "zod": "^4.3.6",
100
- "@spinekit/kit": "0.1.0"
108
+ "@spinekit/kit": "0.2.0"
101
109
  },
102
110
  "author": "Classytic",
103
111
  "homepage": "https://www.npmjs.com/package/@spinekit/purchase",