@spinekit/purchase 0.1.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 +61 -0
- package/LICENSE +75 -0
- package/README.md +40 -0
- package/dist/bridges.d.mts +167 -0
- package/dist/bridges.mjs +151 -0
- package/dist/entity-model-D-03ovSg.mjs +23 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +140 -0
- package/dist/lifecycle/purchase-lifecycle.d.mts +17 -0
- package/dist/lifecycle/purchase-lifecycle.mjs +120 -0
- package/dist/lifecycle/purchase-lifecycle.types.d.mts +32 -0
- package/dist/lifecycle/purchase-lifecycle.types.mjs +1 -0
- package/dist/payment/purchase-payment.application.d.mts +8 -0
- package/dist/payment/purchase-payment.application.mjs +154 -0
- package/dist/payment/purchase-payment.tax.d.mts +30 -0
- package/dist/payment/purchase-payment.tax.mjs +37 -0
- package/dist/payment/purchase-payment.types.d.mts +2 -0
- package/dist/payment/purchase-payment.types.mjs +1 -0
- package/dist/purchase-payment.types-hVKAeW3G.d.mts +153 -0
- package/dist/receipt/purchase-stock-receipt.d.mts +12 -0
- package/dist/receipt/purchase-stock-receipt.mjs +225 -0
- package/dist/receipt/purchase-stock-receipt.types.d.mts +167 -0
- package/dist/receipt/purchase-stock-receipt.types.mjs +1 -0
- package/dist/repositories/purchase-order.repository.d.mts +40 -0
- package/dist/repositories/purchase-order.repository.mjs +111 -0
- package/dist/resources/purchase-order/purchase-order.resource.d.mts +56 -0
- package/dist/resources/purchase-order/purchase-order.resource.mjs +193 -0
- package/dist/resources/supplier/supplier.model.d.mts +2 -0
- package/dist/resources/supplier/supplier.model.mjs +178 -0
- package/dist/resources/supplier/supplier.repository.d.mts +20 -0
- package/dist/resources/supplier/supplier.repository.mjs +62 -0
- package/dist/resources/supplier/supplier.resource.d.mts +34 -0
- package/dist/resources/supplier/supplier.resource.mjs +129 -0
- package/dist/resources/supplier/supplier.types.d.mts +2 -0
- package/dist/resources/supplier/supplier.types.mjs +1 -0
- package/dist/supplier.model-BcAfgyHu.d.mts +88 -0
- package/dist/types-T9gsXOf_.d.mts +127 -0
- package/package.json +129 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PurchaseLifecycleDeps } from "./purchase-lifecycle.types.mjs";
|
|
2
|
+
import { PurchaseOrderDocument } from "@classytic/purchase";
|
|
3
|
+
//#region src/lifecycle/purchase-lifecycle.d.ts
|
|
4
|
+
declare function createPurchaseLifecycle(deps: PurchaseLifecycleDeps): {
|
|
5
|
+
/** Approve a draft purchase — `draft → approved`, atomically via the kernel's CAS. */
|
|
6
|
+
approve(purchaseId: string, actorId: string | undefined): Promise<PurchaseOrderDocument>;
|
|
7
|
+
/** Cancel a draft or approved purchase — one atomic claim, so no concurrent
|
|
8
|
+
* payment / approval / receive can slip past the FSM gate. */
|
|
9
|
+
cancel(purchaseId: string, actorId: string | undefined, reason?: string): Promise<PurchaseOrderDocument>;
|
|
10
|
+
/**
|
|
11
|
+
* Receive a purchase order. See the module header for the transaction boundary — the part
|
|
12
|
+
* that is genuinely subtle and the part a second host would get wrong.
|
|
13
|
+
*/
|
|
14
|
+
receive(purchaseId: string, actorId: string | undefined): Promise<Record<string, unknown>>;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { createPurchaseLifecycle };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { PENDING_EVENTS, flushPending } from "@classytic/purchase";
|
|
2
|
+
import { ConflictError, NotFoundError } from "@classytic/arc/utils";
|
|
3
|
+
//#region src/lifecycle/purchase-lifecycle.ts
|
|
4
|
+
/**
|
|
5
|
+
* Purchase-order LIFECYCLE verbs — approve · cancel · receive.
|
|
6
|
+
*
|
|
7
|
+
* Lifted from be-prod (`purchase-order/actions/{approve,cancel,receive}-purchase-order.ts` plus
|
|
8
|
+
* the state machine that lived in `purchase-order.service.ts`). Each verb is thin because the
|
|
9
|
+
* kernel's repository owns the atomic transition; what was worth extracting is the ORDER of
|
|
10
|
+
* operations around it, which is where every silent failure lives.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the pre-CAS read exists at all
|
|
13
|
+
*
|
|
14
|
+
* The kernel's `claim()` CAS already enforces the FSM — a cancel cannot race past an approve.
|
|
15
|
+
* But a CAS that finds no match returns "no match", which is indistinguishable from losing a
|
|
16
|
+
* concurrency race. The read before it exists purely so a caller learns *"only draft purchases
|
|
17
|
+
* can be approved"* instead of *"concurrent modification"*. It is a MESSAGE, never the guard:
|
|
18
|
+
* removing it costs clarity, and trusting it instead of the CAS would cost correctness.
|
|
19
|
+
*
|
|
20
|
+
* ## Receive: what is deliberately OUTSIDE the transaction
|
|
21
|
+
*
|
|
22
|
+
* The kernel's `receive()` does the CAS first, stamps `pendingStockReceipt` so a crash between
|
|
23
|
+
* the CAS and the bridge heals on the next receive, and compensates the status back if the
|
|
24
|
+
* bridge throws. The stock booking itself runs in the WMS's OWN unit of work — nested Mongo
|
|
25
|
+
* transactions do not exist — so a failure *after* the bridge rolls the PO back to `approved`
|
|
26
|
+
* while the booked stock REMAINS. That divergence is CONVERGED, not prevented: the bridge is
|
|
27
|
+
* idempotent on `purchaseId`, so the healing receive re-fires it as a no-op and completes the
|
|
28
|
+
* flip.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Prior states each verb accepts, for the friendly pre-CAS message only.
|
|
32
|
+
*
|
|
33
|
+
* The kernel's FSM is the authority. This map exists so the two cannot silently disagree in
|
|
34
|
+
* the direction that matters: it may only ever be a SUBSET of what the kernel permits — a
|
|
35
|
+
* superset would produce a helpful-looking message for a transition the CAS then refuses.
|
|
36
|
+
*/
|
|
37
|
+
const ALLOWED_PRIOR_STATES = {
|
|
38
|
+
approve: ["draft"],
|
|
39
|
+
cancel: ["draft", "approved"]
|
|
40
|
+
};
|
|
41
|
+
const REFUSAL = {
|
|
42
|
+
approve: "Only draft purchases can be approved",
|
|
43
|
+
cancel: "Only draft or approved purchases can be cancelled"
|
|
44
|
+
};
|
|
45
|
+
function createPurchaseLifecycle(deps) {
|
|
46
|
+
/** Load for the friendly message. Lean — nothing here mutates the document. */
|
|
47
|
+
async function readStatus(purchaseId) {
|
|
48
|
+
const purchase = await deps.engine().models.PurchaseOrder.findById(purchaseId).lean();
|
|
49
|
+
if (!purchase) throw new NotFoundError("Purchase");
|
|
50
|
+
return purchase.status ?? "unknown";
|
|
51
|
+
}
|
|
52
|
+
function assertPrior(verb, status) {
|
|
53
|
+
if (!ALLOWED_PRIOR_STATES[verb].includes(status)) throw new ConflictError(`${REFUSAL[verb]} (current status: ${status})`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
/** Approve a draft purchase — `draft → approved`, atomically via the kernel's CAS. */
|
|
57
|
+
async approve(purchaseId, actorId) {
|
|
58
|
+
assertPrior("approve", await readStatus(purchaseId));
|
|
59
|
+
return deps.engine().repositories.purchaseOrder.approve(purchaseId, actorId ? { actorId } : {});
|
|
60
|
+
},
|
|
61
|
+
/** Cancel a draft or approved purchase — one atomic claim, so no concurrent
|
|
62
|
+
* payment / approval / receive can slip past the FSM gate. */
|
|
63
|
+
async cancel(purchaseId, actorId, reason) {
|
|
64
|
+
assertPrior("cancel", await readStatus(purchaseId));
|
|
65
|
+
return deps.engine().repositories.purchaseOrder.cancel(purchaseId, reason, actorId ? { actorId } : {});
|
|
66
|
+
},
|
|
67
|
+
/**
|
|
68
|
+
* Receive a purchase order. See the module header for the transaction boundary — the part
|
|
69
|
+
* that is genuinely subtle and the part a second host would get wrong.
|
|
70
|
+
*/
|
|
71
|
+
async receive(purchaseId, actorId) {
|
|
72
|
+
let resolvedSupplierName;
|
|
73
|
+
/**
|
|
74
|
+
* Managed-session contract (purchase 0.2+): given `ctx.session` the kernel QUEUES its
|
|
75
|
+
* domain events rather than publishing mid-transaction. A subscriber must never observe
|
|
76
|
+
* events for a write that may still roll back.
|
|
77
|
+
*/
|
|
78
|
+
const pendingEvents = [];
|
|
79
|
+
return deps.withTransaction(async (session) => {
|
|
80
|
+
const engine = deps.engine();
|
|
81
|
+
const repo = engine.repositories.purchaseOrder;
|
|
82
|
+
const ctx = {
|
|
83
|
+
...actorId !== void 0 ? { actorId } : {},
|
|
84
|
+
...session ? {
|
|
85
|
+
session,
|
|
86
|
+
[PENDING_EVENTS]: pendingEvents
|
|
87
|
+
} : {}
|
|
88
|
+
};
|
|
89
|
+
const query = engine.models.PurchaseOrder.findById(purchaseId);
|
|
90
|
+
const existing = await (session ? query.session(session) : query);
|
|
91
|
+
if (!existing) throw new NotFoundError("Purchase");
|
|
92
|
+
/**
|
|
93
|
+
* Receiving a draft implies approval — it stamps approvedBy/At and an APPROVED
|
|
94
|
+
* history entry. Routing through the kernel verb rather than an inline status write
|
|
95
|
+
* is what makes the approval-chain gate apply: a PO with a pending multi-step chain
|
|
96
|
+
* can no longer be received *around* its chain.
|
|
97
|
+
*/
|
|
98
|
+
if (existing.status === "draft") await repo.approve(purchaseId, ctx);
|
|
99
|
+
const updated = await repo.receive(purchaseId, ctx);
|
|
100
|
+
const supplierId = updated.supplier ? String(updated.supplier) : void 0;
|
|
101
|
+
resolvedSupplierName = (await deps.supplier.getById(supplierId))?.name;
|
|
102
|
+
return typeof updated.toObject === "function" ? updated.toObject() : updated;
|
|
103
|
+
}, { onCommit: async (purchase) => {
|
|
104
|
+
await flushPending(deps.eventTransport, pendingEvents, deps.logger);
|
|
105
|
+
/**
|
|
106
|
+
* Host side effects — notifications, cache invalidation. Deliberately after the
|
|
107
|
+
* drain and deliberately a HOOK: which of these a deployment wants is not this
|
|
108
|
+
* package's business, and running them pre-commit would announce a receive that
|
|
109
|
+
* an abort un-makes.
|
|
110
|
+
*/
|
|
111
|
+
await deps.onReceived?.(purchase, {
|
|
112
|
+
actorId,
|
|
113
|
+
supplierName: resolvedSupplierName
|
|
114
|
+
});
|
|
115
|
+
} });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
export { createPurchaseLifecycle };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { c as PurchaseUnitOfWork, s as PurchaseSupplierLookup } from "../purchase-payment.types-hVKAeW3G.mjs";
|
|
2
|
+
import { PurchaseEngine } from "@classytic/purchase/engine";
|
|
3
|
+
import { flushPending } from "@classytic/purchase";
|
|
4
|
+
import { EventTransport } from "@classytic/primitives/events";
|
|
5
|
+
//#region src/lifecycle/purchase-lifecycle.types.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Host side effects for a COMMITTED receive — notifications, cache invalidation, anything
|
|
8
|
+
* outward-facing.
|
|
9
|
+
*
|
|
10
|
+
* A hook rather than ports for each concern: which side effects a deployment wants is not the
|
|
11
|
+
* package's business, and enumerating them here would mean editing the package every time a
|
|
12
|
+
* vertical adds one. It runs post-commit, so it can safely announce the receive.
|
|
13
|
+
*/
|
|
14
|
+
interface PurchaseReceivedHook {
|
|
15
|
+
(purchase: Record<string, unknown>, context: {
|
|
16
|
+
actorId?: string | undefined;
|
|
17
|
+
supplierName?: string | undefined;
|
|
18
|
+
}): Promise<void> | void;
|
|
19
|
+
}
|
|
20
|
+
interface PurchaseLifecycleDeps {
|
|
21
|
+
/** Getter, not a handle — nothing may allocate the engine during composition. */
|
|
22
|
+
engine: () => PurchaseEngine;
|
|
23
|
+
withTransaction: PurchaseUnitOfWork;
|
|
24
|
+
supplier: PurchaseSupplierLookup;
|
|
25
|
+
/** Where the kernel's queued `purchase:order.{approved,received}` are drained, post-commit. */
|
|
26
|
+
eventTransport: EventTransport;
|
|
27
|
+
/** Derived from the kernel's contract so it cannot drift from `flushPending`. */
|
|
28
|
+
logger: Parameters<typeof flushPending>[2];
|
|
29
|
+
onReceived?: PurchaseReceivedHook | undefined;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { PurchaseLifecycleDeps, PurchaseReceivedHook };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { n as PurchasePaymentApplicationDeps, t as PayPurchaseCommand } from "../purchase-payment.types-hVKAeW3G.mjs";
|
|
2
|
+
//#region src/payment/purchase-payment.application.d.ts
|
|
3
|
+
declare function createPurchasePaymentApplication(deps: PurchasePaymentApplicationDeps): {
|
|
4
|
+
/** Pay a purchase order — the whole compound operation, atomically. */
|
|
5
|
+
pay({ purchaseId, payment, actorId }: PayPurchaseCommand): Promise<unknown>;
|
|
6
|
+
};
|
|
7
|
+
//#endregion
|
|
8
|
+
export { createPurchasePaymentApplication };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { PENDING_EVENTS, flushPending } from "@classytic/purchase";
|
|
2
|
+
import { ConflictError, NotFoundError, ValidationError } from "@classytic/arc/utils";
|
|
3
|
+
import { convertMinorToBaseMinor } from "@classytic/purchase/domain";
|
|
4
|
+
//#region src/payment/purchase-payment.application.ts
|
|
5
|
+
/**
|
|
6
|
+
* Purchase-payment APPLICATION SERVICE — the compound "pay a PO" operation.
|
|
7
|
+
*
|
|
8
|
+
* Lifted from be-prod (`modules/inventory/purchase-order/purchase-payment.application.ts`),
|
|
9
|
+
* where it was ~210 lines no other host could pay a supplier with. The gym's equivalent gap
|
|
10
|
+
* — payment confirmation living in a host — is why an order could be created and never
|
|
11
|
+
* confirmed, with nothing erroring.
|
|
12
|
+
*
|
|
13
|
+
* Owns the unit of work (one `withTransaction`):
|
|
14
|
+
* 1. guards — exists, payable state, integer minor units, cap by amount due
|
|
15
|
+
* 2. tax attribution for partial payments, via the PACK's port
|
|
16
|
+
* 3. `PurchasePaymentRecordingPort.record()` — the payment-side write; revenue and ledger
|
|
17
|
+
* knowledge lives ONLY in the injected adapter
|
|
18
|
+
* 4. the kernel's `purchaseOrder.pay()` — atomic `$add` CAS, FSM-pinned
|
|
19
|
+
* 5. queued `purchase:order.paid` domain events, drained post-commit
|
|
20
|
+
*
|
|
21
|
+
* The kernel never sees revenue or ledger; an HTTP action never sees the orchestration. A
|
|
22
|
+
* test injects a fake recording port and exercises the whole unit.
|
|
23
|
+
*
|
|
24
|
+
* ORDER IS LOAD-BEARING. The payment record is written INSIDE the session and BEFORE the CAS,
|
|
25
|
+
* so a lost CAS rolls the record back. Recording after a successful CAS would leave a paid PO
|
|
26
|
+
* with no cash record if the second write failed — a reconciliation gap that nothing reports.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Guard the transaction date at the boundary — `new Date('garbage')` is an Invalid Date that
|
|
30
|
+
* survives every type check and only explodes later, inside accounting, on a document nobody
|
|
31
|
+
* can trace back to this request.
|
|
32
|
+
*/
|
|
33
|
+
function resolveTransactionDate(input) {
|
|
34
|
+
if (input === void 0) return /* @__PURE__ */ new Date();
|
|
35
|
+
const parsed = new Date(input);
|
|
36
|
+
if (Number.isNaN(parsed.getTime())) throw new ValidationError(`transactionDate ${JSON.stringify(input)} is not a valid date`);
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
function createPurchasePaymentApplication(deps) {
|
|
40
|
+
return {
|
|
41
|
+
/** Pay a purchase order — the whole compound operation, atomically. */
|
|
42
|
+
async pay({ purchaseId, payment, actorId }) {
|
|
43
|
+
/**
|
|
44
|
+
* Managed-session contract (purchase 0.2+): given `ctx.session`, the kernel QUEUES its
|
|
45
|
+
* domain events instead of publishing mid-transaction. Publishing inside the
|
|
46
|
+
* transaction announces a payment that a later abort un-makes.
|
|
47
|
+
*/
|
|
48
|
+
const pendingEvents = [];
|
|
49
|
+
return deps.withTransaction(async (session) => {
|
|
50
|
+
const engine = deps.engine();
|
|
51
|
+
const query = engine.models.PurchaseOrder.findById(purchaseId);
|
|
52
|
+
const purchase = await (session ? query.session(session) : query);
|
|
53
|
+
if (!purchase) throw new NotFoundError("Purchase");
|
|
54
|
+
/**
|
|
55
|
+
* A precise message before the CAS. The kernel's filter would also reject a
|
|
56
|
+
* cancelled PO, but as a no-match — indistinguishable from a concurrency loss.
|
|
57
|
+
*/
|
|
58
|
+
if (purchase.status === "cancelled") throw new ConflictError("Cancelled purchases cannot be paid");
|
|
59
|
+
/**
|
|
60
|
+
* `amount` is integer MINOR units (schemaVersion 2). Defaulting to `dueAmount` is
|
|
61
|
+
* unit-consistent because the document persists minor units too — the one place
|
|
62
|
+
* this could silently break is a host that still sends major units, which the
|
|
63
|
+
* integer guard below does NOT catch (12.50 fails, but 1250 major would pass as
|
|
64
|
+
* 12.50 base). That is why the wire schema, not this guard, is the unit authority.
|
|
65
|
+
*/
|
|
66
|
+
const amount = payment.amount ?? purchase.dueAmount;
|
|
67
|
+
if (!Number.isInteger(amount) || amount <= 0) throw new ValidationError("Payment amount must be a positive integer in minor units");
|
|
68
|
+
/**
|
|
69
|
+
* Cap guard — read against this snapshot. A concurrent payment still composes
|
|
70
|
+
* correctly in the kernel's `$add` CAS (no lost increment); a small overshoot
|
|
71
|
+
* between the read and the write is a UX issue, not a correctness one.
|
|
72
|
+
*/
|
|
73
|
+
if (amount > purchase.dueAmount) throw new ValidationError("Payment amount exceeds due amount");
|
|
74
|
+
const supplierId = purchase.supplier ? String(purchase.supplier) : void 0;
|
|
75
|
+
const supplier = await deps.supplier.getById(supplierId);
|
|
76
|
+
const purchaseTaxTotal = Number.isFinite(purchase.taxTotal) ? purchase.taxTotal : 0;
|
|
77
|
+
const dominantRatePercent = purchase.items.reduce((max, item) => (item.taxRate ?? 0) > max ? item.taxRate ?? 0 : max, 0);
|
|
78
|
+
const attributed = deps.taxAttribution.attribute({
|
|
79
|
+
purchaseTaxTotalMinor: purchaseTaxTotal,
|
|
80
|
+
paymentAmountMinor: amount,
|
|
81
|
+
purchaseGrandTotalMinor: purchase.grandTotal,
|
|
82
|
+
dominantRatePercent
|
|
83
|
+
});
|
|
84
|
+
const paymentTax = attributed.taxMinor;
|
|
85
|
+
const taxDetails = attributed.details;
|
|
86
|
+
/**
|
|
87
|
+
* A pack is host-supplied code, so its output is an INPUT here. An
|
|
88
|
+
* over-attribution overstates a recoverable-tax position that a filing depends on,
|
|
89
|
+
* and a non-integer minor amount corrupts every downstream sum — both must fail
|
|
90
|
+
* loudly rather than propagate a plausible number.
|
|
91
|
+
*/
|
|
92
|
+
if (!Number.isInteger(paymentTax) || paymentTax < 0) throw new ValidationError("Tax attribution must return a non-negative integer");
|
|
93
|
+
if (paymentTax > purchaseTaxTotal) throw new ValidationError("Tax attribution exceeds the order tax total");
|
|
94
|
+
/**
|
|
95
|
+
* Base-currency conversion via the PO's FROZEN snapshot — the same kernel VO the
|
|
96
|
+
* receive / GR-IR / vendor-bill legs use, so the cash record agrees with the
|
|
97
|
+
* inventory and A/P values. Re-fetching a live rate here would make them disagree.
|
|
98
|
+
*/
|
|
99
|
+
const fx = purchase.fx ?? null;
|
|
100
|
+
const baseAmountMinor = convertMinorToBaseMinor(amount, fx);
|
|
101
|
+
const baseTaxMinor = convertMinorToBaseMinor(paymentTax, fx);
|
|
102
|
+
const { transactionId } = await deps.recordPayment.record({
|
|
103
|
+
amountMinor: amount,
|
|
104
|
+
taxMinor: paymentTax,
|
|
105
|
+
baseAmountMinor,
|
|
106
|
+
baseTaxMinor,
|
|
107
|
+
fx,
|
|
108
|
+
currency: purchase.currency ?? deps.baseCurrency,
|
|
109
|
+
method: payment.method || "cash",
|
|
110
|
+
purchase: {
|
|
111
|
+
id: String(purchase._id),
|
|
112
|
+
invoiceNumber: purchase.invoiceNumber,
|
|
113
|
+
branchId: String(purchase.branch)
|
|
114
|
+
},
|
|
115
|
+
...supplier ? { supplier: {
|
|
116
|
+
id: String(supplier._id),
|
|
117
|
+
name: supplier.name
|
|
118
|
+
} } : {},
|
|
119
|
+
paymentDetails: payment.details ?? {},
|
|
120
|
+
taxDetails,
|
|
121
|
+
purchaseTaxTotalMinor: purchaseTaxTotal,
|
|
122
|
+
notes: [
|
|
123
|
+
`Purchase payment: ${purchase.invoiceNumber}`,
|
|
124
|
+
supplier?.name ? `Supplier: ${supplier.name}` : null,
|
|
125
|
+
payment.notes
|
|
126
|
+
].filter(Boolean).join(". "),
|
|
127
|
+
actorId,
|
|
128
|
+
date: resolveTransactionDate(payment.transactionDate),
|
|
129
|
+
session
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* 2. Atomic CAS via the kernel's `pay()` — `$add` paidAmount, recompute dueAmount
|
|
133
|
+
* and paymentStatus post-increment, push the transactionId. FSM-pinned and
|
|
134
|
+
* concurrency-safe by construction, which is why nothing here recomputes a
|
|
135
|
+
* payment status itself.
|
|
136
|
+
*/
|
|
137
|
+
return engine.repositories.purchaseOrder.pay(purchaseId, {
|
|
138
|
+
amount,
|
|
139
|
+
transactionId,
|
|
140
|
+
...payment.method ? { method: payment.method } : {}
|
|
141
|
+
}, {
|
|
142
|
+
...actorId ? { actorId } : {},
|
|
143
|
+
...session ? {
|
|
144
|
+
session,
|
|
145
|
+
[PENDING_EVENTS]: pendingEvents
|
|
146
|
+
} : {}
|
|
147
|
+
});
|
|
148
|
+
}, { onCommit: async () => {
|
|
149
|
+
await flushPending(deps.eventTransport, pendingEvents, deps.logger);
|
|
150
|
+
} });
|
|
151
|
+
} };
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { createPurchasePaymentApplication };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { i as PurchasePaymentTaxAttribution } from "../purchase-payment.types-hVKAeW3G.mjs";
|
|
2
|
+
//#region src/payment/purchase-payment.tax.d.ts
|
|
3
|
+
/** What the pack names the tax it is attributing. */
|
|
4
|
+
interface ProRataTaxLabels {
|
|
5
|
+
/** `vat`, `gst`, `sales_tax`, … */
|
|
6
|
+
type: string;
|
|
7
|
+
/** Taxing authority identifier as the host's books expect it. */
|
|
8
|
+
jurisdiction: string;
|
|
9
|
+
/** Whether the order's line rates are tax-inclusive. Defaults to exclusive. */
|
|
10
|
+
isInclusive?: boolean | undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* PAYMENT-basis attribution: `taxTotal × amount / grandTotal`, rounded once.
|
|
14
|
+
*
|
|
15
|
+
* Computed as a single expression rather than `total × (amount / grandTotal)` — the two-step
|
|
16
|
+
* form rounds a float ratio and then multiplies, which drifts by a minor unit on ordinary
|
|
17
|
+
* inputs and makes a sum of payments disagree with the order's own tax total.
|
|
18
|
+
*
|
|
19
|
+
* Zero when the order carries no tax or no total; a rate of zero yields an amount with no
|
|
20
|
+
* `details`, because there is nothing to describe.
|
|
21
|
+
*/
|
|
22
|
+
declare function proRataTaxAttribution(labels: ProRataTaxLabels): PurchasePaymentTaxAttribution;
|
|
23
|
+
/**
|
|
24
|
+
* INVOICE-basis attribution — a payment attributes no tax because posting the bill already
|
|
25
|
+
* did. Explicit rather than "just leave the port off": a host states its recognition basis,
|
|
26
|
+
* and a reader of the wiring can see which one.
|
|
27
|
+
*/
|
|
28
|
+
declare function invoiceBasisTaxAttribution(): PurchasePaymentTaxAttribution;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { ProRataTaxLabels, invoiceBasisTaxAttribution, proRataTaxAttribution };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/payment/purchase-payment.tax.ts
|
|
2
|
+
/**
|
|
3
|
+
* PAYMENT-basis attribution: `taxTotal × amount / grandTotal`, rounded once.
|
|
4
|
+
*
|
|
5
|
+
* Computed as a single expression rather than `total × (amount / grandTotal)` — the two-step
|
|
6
|
+
* form rounds a float ratio and then multiplies, which drifts by a minor unit on ordinary
|
|
7
|
+
* inputs and makes a sum of payments disagree with the order's own tax total.
|
|
8
|
+
*
|
|
9
|
+
* Zero when the order carries no tax or no total; a rate of zero yields an amount with no
|
|
10
|
+
* `details`, because there is nothing to describe.
|
|
11
|
+
*/
|
|
12
|
+
function proRataTaxAttribution(labels) {
|
|
13
|
+
return { attribute({ purchaseTaxTotalMinor, paymentAmountMinor, purchaseGrandTotalMinor, dominantRatePercent }) {
|
|
14
|
+
if (purchaseTaxTotalMinor <= 0 || purchaseGrandTotalMinor <= 0) return { taxMinor: 0 };
|
|
15
|
+
const taxMinor = Math.round(purchaseTaxTotalMinor * paymentAmountMinor / purchaseGrandTotalMinor);
|
|
16
|
+
if (dominantRatePercent <= 0) return { taxMinor };
|
|
17
|
+
return {
|
|
18
|
+
taxMinor,
|
|
19
|
+
details: {
|
|
20
|
+
type: labels.type,
|
|
21
|
+
rate: dominantRatePercent / 100,
|
|
22
|
+
isInclusive: labels.isInclusive ?? false,
|
|
23
|
+
jurisdiction: labels.jurisdiction
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
} };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* INVOICE-basis attribution — a payment attributes no tax because posting the bill already
|
|
30
|
+
* did. Explicit rather than "just leave the port off": a host states its recognition basis,
|
|
31
|
+
* and a reader of the wiring can see which one.
|
|
32
|
+
*/
|
|
33
|
+
function invoiceBasisTaxAttribution() {
|
|
34
|
+
return { attribute: () => ({ taxMinor: 0 }) };
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { invoiceBasisTaxAttribution, proRataTaxAttribution };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as PurchasePaymentTaxDetails, c as PurchaseUnitOfWork, i as PurchasePaymentTaxAttribution, l as RecordPurchasePaymentInput, n as PurchasePaymentApplicationDeps, o as PurchasePaymentTaxInput, r as PurchasePaymentRecordingPort, s as PurchaseSupplierLookup, t as PayPurchaseCommand } from "../purchase-payment.types-hVKAeW3G.mjs";
|
|
2
|
+
export { PayPurchaseCommand, PurchasePaymentApplicationDeps, PurchasePaymentRecordingPort, PurchasePaymentTaxAttribution, PurchasePaymentTaxDetails, PurchasePaymentTaxInput, PurchaseSupplierLookup, PurchaseUnitOfWork, RecordPurchasePaymentInput };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { PurchaseEngine } from "@classytic/purchase/engine";
|
|
2
|
+
import { flushPending } from "@classytic/purchase";
|
|
3
|
+
import { ClientSession } from "mongoose";
|
|
4
|
+
import { EventTransport } from "@classytic/primitives/events";
|
|
5
|
+
import { FxSnapshot } from "@classytic/primitives/currency";
|
|
6
|
+
//#region src/payment/purchase-payment.types.d.ts
|
|
7
|
+
/** How a pack describes an attributed tax amount. Shape only — no type or jurisdiction value. */
|
|
8
|
+
interface PurchasePaymentTaxDetails {
|
|
9
|
+
/** `vat`, `gst`, `sales_tax`, … — the PACK's vocabulary, never this package's. */
|
|
10
|
+
type: string;
|
|
11
|
+
/** Fractional rate (0.15), not percent. */
|
|
12
|
+
rate: number;
|
|
13
|
+
isInclusive: boolean;
|
|
14
|
+
/** Whatever identifies the taxing authority to the host's books. */
|
|
15
|
+
jurisdiction: string;
|
|
16
|
+
}
|
|
17
|
+
interface PurchasePaymentTaxInput {
|
|
18
|
+
/** Whole-order tax total, DOCUMENT-currency minor units. */
|
|
19
|
+
purchaseTaxTotalMinor: number;
|
|
20
|
+
/** This payment, DOCUMENT-currency minor units. */
|
|
21
|
+
paymentAmountMinor: number;
|
|
22
|
+
/** Order grand total, DOCUMENT-currency minor units — the pro-rata denominator. */
|
|
23
|
+
purchaseGrandTotalMinor: number;
|
|
24
|
+
/** Highest line rate on the order, as a PERCENT (15, not 0.15) — the kernel's field unit. */
|
|
25
|
+
dominantRatePercent: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* When tax is recognised and what it is called. See the neutrality note above.
|
|
29
|
+
*
|
|
30
|
+
* `taxMinor` MUST NOT exceed `purchaseTaxTotalMinor`, and MUST be an integer — the
|
|
31
|
+
* application service enforces both, because an over-attribution silently overstates a
|
|
32
|
+
* recoverable-tax position that a filing later depends on.
|
|
33
|
+
*/
|
|
34
|
+
interface PurchasePaymentTaxAttribution {
|
|
35
|
+
attribute(input: PurchasePaymentTaxInput): {
|
|
36
|
+
taxMinor: number;
|
|
37
|
+
details?: PurchasePaymentTaxDetails | undefined;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
interface RecordPurchasePaymentInput {
|
|
41
|
+
/** Payment amount in integer MINOR units of the DOCUMENT currency — what updates
|
|
42
|
+
* paidAmount/dueAmount on the PO. */
|
|
43
|
+
amountMinor: number;
|
|
44
|
+
/** Attributed tax portion of this payment, DOCUMENT-currency minor units. */
|
|
45
|
+
taxMinor: number;
|
|
46
|
+
/**
|
|
47
|
+
* Payment amount converted to the deployment's BASE currency via the PO's frozen
|
|
48
|
+
* FxSnapshot (equal to `amountMinor` on base-currency POs).
|
|
49
|
+
*
|
|
50
|
+
* Financial adapters (revenue / ledger) MUST consume the base fields. Recording a
|
|
51
|
+
* document amount as a book value is the mis-recording class this split exists to kill,
|
|
52
|
+
* and its magnitude is the FX rate — invisible on a base-currency deployment, ~120× on a
|
|
53
|
+
* USD document in a BDT book.
|
|
54
|
+
*/
|
|
55
|
+
baseAmountMinor: number;
|
|
56
|
+
/** Attributed tax in BASE-currency minor units. */
|
|
57
|
+
baseTaxMinor: number;
|
|
58
|
+
/** Frozen conversion record — null on base-currency POs. */
|
|
59
|
+
fx: FxSnapshot | null;
|
|
60
|
+
currency: string;
|
|
61
|
+
method: string;
|
|
62
|
+
purchase: {
|
|
63
|
+
id: string;
|
|
64
|
+
invoiceNumber: string;
|
|
65
|
+
branchId: string;
|
|
66
|
+
};
|
|
67
|
+
supplier?: {
|
|
68
|
+
id: string;
|
|
69
|
+
name?: string | undefined;
|
|
70
|
+
} | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Rail-specific references. Deliberately an open bag: `walletNumber` is a mobile-money
|
|
73
|
+
* concept, `bankName` a wire concept, and a pack adds its own without this package
|
|
74
|
+
* enumerating the world's payment rails.
|
|
75
|
+
*/
|
|
76
|
+
paymentDetails: Record<string, string | undefined>;
|
|
77
|
+
taxDetails?: PurchasePaymentTaxDetails | undefined;
|
|
78
|
+
/** Whole-order tax total (minor units) — provenance for reconciliation. */
|
|
79
|
+
purchaseTaxTotalMinor: number;
|
|
80
|
+
notes?: string | undefined;
|
|
81
|
+
actorId?: string | undefined;
|
|
82
|
+
date: Date;
|
|
83
|
+
/** Session of the surrounding unit of work — the write MUST enlist in it. */
|
|
84
|
+
session: ClientSession | null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Payment-side recording seam. The application service calls this INSIDE its transaction;
|
|
88
|
+
* the adapter's write must enlist in `input.session` so a later kernel-CAS failure rolls the
|
|
89
|
+
* payment record back with everything else.
|
|
90
|
+
*
|
|
91
|
+
* Revenue and ledger knowledge lives ONLY behind this port — the kernel never sees either.
|
|
92
|
+
*/
|
|
93
|
+
interface PurchasePaymentRecordingPort {
|
|
94
|
+
record(input: RecordPurchasePaymentInput): Promise<{
|
|
95
|
+
transactionId: string;
|
|
96
|
+
}>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The surrounding unit of work. A host owns whether its deployment has transactions at all
|
|
100
|
+
* (a standalone mongod has no sessions), which is why `session` is nullable and the port is
|
|
101
|
+
* injected rather than opened here.
|
|
102
|
+
*/
|
|
103
|
+
interface PurchaseUnitOfWork {
|
|
104
|
+
<T>(work: (session: ClientSession | null) => Promise<T>,
|
|
105
|
+
/**
|
|
106
|
+
* `onCommit` receives the work's RESULT — the lifecycle's receive hook needs the persisted
|
|
107
|
+
* document, and a callback that took nothing would force a host to smuggle it out through a
|
|
108
|
+
* closure variable assigned inside the transaction.
|
|
109
|
+
*/
|
|
110
|
+
options: {
|
|
111
|
+
onCommit: (result: T) => Promise<void>;
|
|
112
|
+
}): Promise<T>;
|
|
113
|
+
}
|
|
114
|
+
/** Supplier name/id for the payment record's provenance. Not a party contract — a lookup. */
|
|
115
|
+
interface PurchaseSupplierLookup {
|
|
116
|
+
getById(id: string | undefined): Promise<{
|
|
117
|
+
_id: unknown;
|
|
118
|
+
name?: string | undefined;
|
|
119
|
+
} | null>;
|
|
120
|
+
}
|
|
121
|
+
interface PurchasePaymentApplicationDeps {
|
|
122
|
+
/**
|
|
123
|
+
* Engine GETTER, not an engine. Captured as `slot.get`, never `slot.get()` — reading the
|
|
124
|
+
* slot during composition throws by name instead of allocating before arc owns the
|
|
125
|
+
* lifecycle.
|
|
126
|
+
*/
|
|
127
|
+
engine: () => PurchaseEngine;
|
|
128
|
+
recordPayment: PurchasePaymentRecordingPort;
|
|
129
|
+
withTransaction: PurchaseUnitOfWork;
|
|
130
|
+
supplier: PurchaseSupplierLookup;
|
|
131
|
+
taxAttribution: PurchasePaymentTaxAttribution;
|
|
132
|
+
/** ISO 4217 functional currency — the fallback for a PO that stored none. */
|
|
133
|
+
baseCurrency: string;
|
|
134
|
+
/** Where the kernel's queued `purchase:order.paid` is drained to, post-commit. */
|
|
135
|
+
eventTransport: EventTransport;
|
|
136
|
+
/** Derived from the kernel's own contract so it cannot drift from `flushPending`. */
|
|
137
|
+
logger: Parameters<typeof flushPending>[2];
|
|
138
|
+
}
|
|
139
|
+
interface PayPurchaseCommand {
|
|
140
|
+
purchaseId: string;
|
|
141
|
+
payment: {
|
|
142
|
+
/** Integer MINOR units. Defaults to the PO's `dueAmount` (same unit) when absent. */
|
|
143
|
+
amount?: number | undefined;
|
|
144
|
+
method?: string | undefined;
|
|
145
|
+
transactionDate?: string | undefined;
|
|
146
|
+
notes?: string | undefined;
|
|
147
|
+
/** Rail references — forwarded to the recording port verbatim. */
|
|
148
|
+
details?: Record<string, string | undefined> | undefined;
|
|
149
|
+
};
|
|
150
|
+
actorId?: string | undefined;
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
export { PurchasePaymentTaxDetails as a, PurchaseUnitOfWork as c, PurchasePaymentTaxAttribution as i, RecordPurchasePaymentInput as l, PurchasePaymentApplicationDeps as n, PurchasePaymentTaxInput as o, PurchasePaymentRecordingPort as r, PurchaseSupplierLookup as s, PayPurchaseCommand as t };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PurchaseReceiptContext, PurchaseReceiptLineError, PurchaseReceiptScope, PurchaseStockReceiptDeps, ReceivePurchaseIntoStockInput } from "./purchase-stock-receipt.types.mjs";
|
|
2
|
+
//#region src/receipt/purchase-stock-receipt.d.ts
|
|
3
|
+
declare function createPurchaseStockReceipt<TCtx extends PurchaseReceiptScope = PurchaseReceiptContext>(deps: PurchaseStockReceiptDeps<TCtx>): {
|
|
4
|
+
/**
|
|
5
|
+
* Book every receivable line, returning the lines that could not be resolved.
|
|
6
|
+
*
|
|
7
|
+
* Safe to retry: see the idempotency and convergence notes in the module header.
|
|
8
|
+
*/
|
|
9
|
+
receive({ purchase, supplierName }: ReceivePurchaseIntoStockInput): Promise<PurchaseReceiptLineError[]>;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { createPurchaseStockReceipt };
|