@spinekit/purchase 0.2.0 → 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,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.2.0 - 2026-08-23
|
|
4
11
|
|
|
5
12
|
### 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 {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spinekit/purchase",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
108
|
+
"@spinekit/kit": "0.2.0"
|
|
101
109
|
},
|
|
102
110
|
"author": "Classytic",
|
|
103
111
|
"homepage": "https://www.npmjs.com/package/@spinekit/purchase",
|