@endora-commerce/mod-payments 0.100.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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/admin/index.d.ts +32 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +61 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/zones/OrderPaymentsTab.d.ts +42 -0
- package/dist/admin/zones/OrderPaymentsTab.d.ts.map +1 -0
- package/dist/admin/zones/OrderPaymentsTab.js +104 -0
- package/dist/admin/zones/OrderPaymentsTab.js.map +1 -0
- package/dist/backend/adapters/built-in-adapters.d.ts +58 -0
- package/dist/backend/adapters/built-in-adapters.d.ts.map +1 -0
- package/dist/backend/adapters/built-in-adapters.js +83 -0
- package/dist/backend/adapters/built-in-adapters.js.map +1 -0
- package/dist/backend/drivers/bank-transfer-driver.d.ts +28 -0
- package/dist/backend/drivers/bank-transfer-driver.d.ts.map +1 -0
- package/dist/backend/drivers/bank-transfer-driver.js +26 -0
- package/dist/backend/drivers/bank-transfer-driver.js.map +1 -0
- package/dist/backend/drivers/gateway-adapter-port.d.ts +71 -0
- package/dist/backend/drivers/gateway-adapter-port.d.ts.map +1 -0
- package/dist/backend/drivers/gateway-adapter-port.js +18 -0
- package/dist/backend/drivers/gateway-adapter-port.js.map +1 -0
- package/dist/backend/drivers/pickup-driver.d.ts +23 -0
- package/dist/backend/drivers/pickup-driver.d.ts.map +1 -0
- package/dist/backend/drivers/pickup-driver.js +19 -0
- package/dist/backend/drivers/pickup-driver.js.map +1 -0
- package/dist/backend/email-templates/transactional-defaults.d.ts +6 -0
- package/dist/backend/email-templates/transactional-defaults.d.ts.map +1 -0
- package/dist/backend/email-templates/transactional-defaults.js +27 -0
- package/dist/backend/email-templates/transactional-defaults.js.map +1 -0
- package/dist/backend/entities/payment.entity.d.ts +26 -0
- package/dist/backend/entities/payment.entity.d.ts.map +1 -0
- package/dist/backend/entities/payment.entity.js +100 -0
- package/dist/backend/entities/payment.entity.js.map +1 -0
- package/dist/backend/index.d.ts +88 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +318 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.customer.d.ts +20 -0
- package/dist/backend/routes.customer.d.ts.map +1 -0
- package/dist/backend/routes.customer.js +24 -0
- package/dist/backend/routes.customer.js.map +1 -0
- package/dist/backend/routes.d.ts +36 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +44 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/gateway-refund-registry.d.ts +122 -0
- package/dist/backend/services/gateway-refund-registry.d.ts.map +1 -0
- package/dist/backend/services/gateway-refund-registry.js +106 -0
- package/dist/backend/services/gateway-refund-registry.js.map +1 -0
- package/dist/backend/services/payment-email-notifier.d.ts +71 -0
- package/dist/backend/services/payment-email-notifier.d.ts.map +1 -0
- package/dist/backend/services/payment-email-notifier.js +73 -0
- package/dist/backend/services/payment-email-notifier.js.map +1 -0
- package/dist/backend/services/payment-email-renderer.d.ts +22 -0
- package/dist/backend/services/payment-email-renderer.d.ts.map +1 -0
- package/dist/backend/services/payment-email-renderer.js +17 -0
- package/dist/backend/services/payment-email-renderer.js.map +1 -0
- package/dist/backend/services/payment-placement-apply-port.d.ts +33 -0
- package/dist/backend/services/payment-placement-apply-port.d.ts.map +1 -0
- package/dist/backend/services/payment-placement-apply-port.js +68 -0
- package/dist/backend/services/payment-placement-apply-port.js.map +1 -0
- package/dist/backend/services/payment-read-port.d.ts +33 -0
- package/dist/backend/services/payment-read-port.d.ts.map +1 -0
- package/dist/backend/services/payment-read-port.js +64 -0
- package/dist/backend/services/payment-read-port.js.map +1 -0
- package/dist/backend/services/payment-reference-port.d.ts +29 -0
- package/dist/backend/services/payment-reference-port.d.ts.map +1 -0
- package/dist/backend/services/payment-reference-port.js +70 -0
- package/dist/backend/services/payment-reference-port.js.map +1 -0
- package/dist/backend/services/payment-refund.d.ts +55 -0
- package/dist/backend/services/payment-refund.d.ts.map +1 -0
- package/dist/backend/services/payment-refund.js +101 -0
- package/dist/backend/services/payment-refund.js.map +1 -0
- package/dist/backend/services/payment-retry-service.d.ts +45 -0
- package/dist/backend/services/payment-retry-service.d.ts.map +1 -0
- package/dist/backend/services/payment-retry-service.js +187 -0
- package/dist/backend/services/payment-retry-service.js.map +1 -0
- package/dist/backend/services/payment-service.d.ts +62 -0
- package/dist/backend/services/payment-service.d.ts.map +1 -0
- package/dist/backend/services/payment-service.js +134 -0
- package/dist/backend/services/payment-service.js.map +1 -0
- package/dist/backend/services/receive-payment-handler.d.ts +260 -0
- package/dist/backend/services/receive-payment-handler.d.ts.map +1 -0
- package/dist/backend/services/receive-payment-handler.js +356 -0
- package/dist/backend/services/receive-payment-handler.js.map +1 -0
- package/dist/backend/services/registry-singleton.d.ts +16 -0
- package/dist/backend/services/registry-singleton.d.ts.map +1 -0
- package/dist/backend/services/registry-singleton.js +17 -0
- package/dist/backend/services/registry-singleton.js.map +1 -0
- package/dist/manifest.d.ts +210 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +182 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260925T115728_payments_refunded_amount.d.ts +28 -0
- package/dist/migrations/20260925T115728_payments_refunded_amount.d.ts.map +1 -0
- package/dist/migrations/20260925T115728_payments_refunded_amount.js +32 -0
- package/dist/migrations/20260925T115728_payments_refunded_amount.js.map +1 -0
- package/dist/migrations/index.d.ts +27 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +29 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/payments.md +69 -0
- package/i18n/en.json +5 -0
- package/i18n/pl.json +5 -0
- package/package.json +96 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core';
|
|
2
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
3
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
4
|
+
import { Payment } from '../entities/payment.entity.js';
|
|
5
|
+
/**
|
|
6
|
+
* PaymentService (feature 034, FR-024) — opens retry Payments. The first
|
|
7
|
+
* Payment of an order is created by OrderService.placeOrder; a retry opens a
|
|
8
|
+
* new row against the same order with the next `attemptNo`, leaving prior
|
|
9
|
+
* (failed) attempts intact so the admin can see the full history.
|
|
10
|
+
*
|
|
11
|
+
* **A fresh attempt row is what makes a gateway able to start over**
|
|
12
|
+
* (issue #264), which is why the customer-facing retry runs through here rather
|
|
13
|
+
* than re-driving the failed row. Every gateway keys its own object on this
|
|
14
|
+
* platform's payment id: PayU sends it as `extOrderId`, which must be unique
|
|
15
|
+
* per POS; TPay resolves the newest `tpay_transactions` row for the order;
|
|
16
|
+
* Stripe records a `stripe_payment_intents` mapping per payment. Re-using the
|
|
17
|
+
* failed attempt asks all four to open a second session against an identifier
|
|
18
|
+
* they have already spent.
|
|
19
|
+
*/
|
|
20
|
+
export class PaymentService {
|
|
21
|
+
emFactory;
|
|
22
|
+
orderRead;
|
|
23
|
+
constructor(emFactory, orderRead) {
|
|
24
|
+
this.emFactory = emFactory;
|
|
25
|
+
this.orderRead = orderRead;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the order every method below is keyed on, through its owner.
|
|
29
|
+
*
|
|
30
|
+
* `Payment` is `@GlobalEntity` — no organization column, no filter — so
|
|
31
|
+
* `em.find(Payment, { orderId })` answers for every order on the platform.
|
|
32
|
+
* `Order` is `@OrgScoped`, and `orderReadPort.findById` goes through a
|
|
33
|
+
* filtered EntityManager, so this read is the whole tenant boundary for an
|
|
34
|
+
* order-keyed payment operation: an assignment-scoped administrator gets
|
|
35
|
+
* `null` for an order outside their organizations and the caller stops here.
|
|
36
|
+
*
|
|
37
|
+
* It sits in the service rather than in the two admin route handlers on
|
|
38
|
+
* purpose. A route-level guard protects the routes that exist on the day it
|
|
39
|
+
* is written; this one protects the next caller too, and both of the routes
|
|
40
|
+
* that shipped without it were written by someone who had no reason to know
|
|
41
|
+
* the child carried no filter.
|
|
42
|
+
*
|
|
43
|
+
* The buyer's own retry (`PaymentRetryService`) reads and authorises the
|
|
44
|
+
* order before it gets here, so for that path this is a second read of a row
|
|
45
|
+
* the identity map already holds — deliberately not optimised away, because
|
|
46
|
+
* the guarantee must not depend on which caller arrives.
|
|
47
|
+
*
|
|
48
|
+
* 404 rather than 403, matching the order-keyed routes in `orders`: an
|
|
49
|
+
* out-of-scope order must not be distinguishable from one that is not there.
|
|
50
|
+
*/
|
|
51
|
+
async #assertOrderInScope(orderId) {
|
|
52
|
+
const order = await this.orderRead.findById(orderId);
|
|
53
|
+
if (!order) {
|
|
54
|
+
throw new HttpError(404, ERROR_CODES.ORDER_NOT_FOUND, 'Order not found.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The next attempt for `orderId`, opening one only when the latest has
|
|
59
|
+
* failed.
|
|
60
|
+
*
|
|
61
|
+
* The latest attempt is read `FOR UPDATE`, so two callers racing on the same
|
|
62
|
+
* order serialise here and the second sees what the first wrote: a buyer
|
|
63
|
+
* double-clicking gets one new attempt, not two. It is the same row the
|
|
64
|
+
* settlement ingress resolves, so a retry and a late gateway callback cannot
|
|
65
|
+
* interleave halfway through each other.
|
|
66
|
+
*/
|
|
67
|
+
async openRetry(orderId) {
|
|
68
|
+
// command-coverage-ignore: opens a new payment attempt after a failure —
|
|
69
|
+
// checkout retry mechanics; the order/payment status transition is audited in
|
|
70
|
+
// the orders flow.
|
|
71
|
+
await this.#assertOrderInScope(orderId);
|
|
72
|
+
const em = this.emFactory();
|
|
73
|
+
return em.transactional(async (tx) => {
|
|
74
|
+
const latest = await tx.findOne(Payment, { orderId }, { orderBy: { attemptNo: 'desc' }, lockMode: LockMode.PESSIMISTIC_WRITE });
|
|
75
|
+
if (!latest) {
|
|
76
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'No payment exists for this order to retry.');
|
|
77
|
+
}
|
|
78
|
+
if (latest.status === 'paid') {
|
|
79
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, 'Order is already paid; nothing to retry.');
|
|
80
|
+
}
|
|
81
|
+
if (latest.status === 'refunded' || latest.status === 'partially_refunded') {
|
|
82
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, 'This payment has been refunded; it cannot be retried.');
|
|
83
|
+
}
|
|
84
|
+
if (latest.status === 'deferred') {
|
|
85
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, 'This order is settled out of band; there is nothing to retry.');
|
|
86
|
+
}
|
|
87
|
+
// Still open: hand back the attempt that exists rather than opening a
|
|
88
|
+
// second one beside it. Two live attempts for one order is two provider
|
|
89
|
+
// objects that can both settle.
|
|
90
|
+
if (latest.status === 'awaiting_payment') {
|
|
91
|
+
return { payment: latest, opened: false };
|
|
92
|
+
}
|
|
93
|
+
const next = tx.create(Payment, {
|
|
94
|
+
orderId,
|
|
95
|
+
paymentMethodId: latest.paymentMethodId,
|
|
96
|
+
amount: latest.amount,
|
|
97
|
+
currency: latest.currency,
|
|
98
|
+
attemptNo: latest.attemptNo + 1,
|
|
99
|
+
});
|
|
100
|
+
await tx.persistAndFlush(next);
|
|
101
|
+
return { payment: next, opened: true };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Close an attempt whose provider session could not be started.
|
|
106
|
+
*
|
|
107
|
+
* The compensating half of {@link openRetry}: the row is written before the
|
|
108
|
+
* adapter is asked (the adapter needs its id), so an adapter that throws
|
|
109
|
+
* would otherwise leave an `awaiting_payment` attempt no provider knows
|
|
110
|
+
* about — and the next retry would *resume* that phantom instead of opening a
|
|
111
|
+
* real one. Marking it failed puts the order back where the buyer can try
|
|
112
|
+
* again.
|
|
113
|
+
*/
|
|
114
|
+
async failAttempt(paymentId, reason) {
|
|
115
|
+
// command-coverage-ignore: closes an attempt whose provider session never
|
|
116
|
+
// started, so the buyer's next retry opens a real one — the same retry
|
|
117
|
+
// mechanics `openRetry` above is exempted for.
|
|
118
|
+
const em = this.emFactory();
|
|
119
|
+
await em.transactional(async (tx) => {
|
|
120
|
+
const payment = await tx.findOne(Payment, { id: paymentId });
|
|
121
|
+
if (!payment || payment.status !== 'awaiting_payment')
|
|
122
|
+
return;
|
|
123
|
+
payment.status = 'failed';
|
|
124
|
+
payment.failureReason = reason;
|
|
125
|
+
await tx.flush();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async listForOrder(orderId) {
|
|
129
|
+
await this.#assertOrderInScope(orderId);
|
|
130
|
+
const em = this.emFactory();
|
|
131
|
+
return em.find(Payment, { orderId }, { orderBy: { attemptNo: 'asc' } });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=payment-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"payment-service.js","sourceRoot":"","sources":["../../../src/backend/services/payment-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import { type ReceivePayment } from '@endora-commerce/contracts';
|
|
3
|
+
import type { OrderTransitionPort, PaymentMethodReadPort } from '@endora-commerce/contracts';
|
|
4
|
+
import type { EventBase, EventBus } from '@endora-commerce/platform/events';
|
|
5
|
+
import { Payment } from '../entities/payment.entity.js';
|
|
6
|
+
/**
|
|
7
|
+
* The one cross-module type this handler names, and the seam feature 075 kept
|
|
8
|
+
* here **permanently** (D-78 point 2) — now expressed as the owner's own
|
|
9
|
+
* published interface rather than as its entity class (feature 080, T048;
|
|
10
|
+
* D-169).
|
|
11
|
+
*
|
|
12
|
+
* `payments.order_id` carries a declared foreign key into `orders.id`
|
|
13
|
+
* (`payments_order_fk`, `on delete restrict`), so this is a genuinely
|
|
14
|
+
* co-transactional seam: a gateway callback moves the payment row and the
|
|
15
|
+
* order's `paymentStatus` in one `em.transactional`, and either both land or
|
|
16
|
+
* neither does. D-78 rules that such a seam keeps the caller's `EntityManager`
|
|
17
|
+
* and is *declared* — `orders` is in this module's manifest `dependencies` (the
|
|
18
|
+
* FK already required it), the ledger entry names the constraint, and this
|
|
19
|
+
* comment says which transaction the write runs in.
|
|
20
|
+
*
|
|
21
|
+
* **What T048 changed is who writes the statement, and nothing else.** It used
|
|
22
|
+
* to be `tx.findOne(Order, …)` here, followed by an assignment onto the managed
|
|
23
|
+
* entity — this module holding another module's aggregate open on a transaction
|
|
24
|
+
* it controls, free to move any column of it. It is now
|
|
25
|
+
* `orderPaymentStatusApplyPort.applyPaymentStatus(tx, …)`: the same transaction,
|
|
26
|
+
* the same constraint, the same one column, written by the module that owns the
|
|
27
|
+
* table and answered with a published record. D-168 leaves a packaged `orders`
|
|
28
|
+
* no entity class for a stranger to name, which is why the conversion had to
|
|
29
|
+
* happen before `orders` moves; the foreign key needs the **table** and never
|
|
30
|
+
* the class (D-169), so `payments_order_fk` is untouched.
|
|
31
|
+
*
|
|
32
|
+
* **The lifecycle half of that seam is gone since feature 085 Phase D**, and
|
|
33
|
+
* the ruling is unaffected by its going. `order.status` was written here too,
|
|
34
|
+
* on the same `tx` — a *transition*, with a graph, guards, an audit entry and
|
|
35
|
+
* side-effects, none of which a column assignment performs. It moved to
|
|
36
|
+
* `orderTransitionPort`, called after this transaction has committed. What is
|
|
37
|
+
* left in the transaction is the money axis, `order.paymentStatus`, which is
|
|
38
|
+
* the column `payments_order_fk` genuinely holds together with the payment row.
|
|
39
|
+
*
|
|
40
|
+
* **It is the only import left, and the other two went because their blocker
|
|
41
|
+
* did.** They were held open by a sentence that had stopped being true: that
|
|
42
|
+
* `stripe`, `payu`, `tpay` and `autopay` each construct this handler
|
|
43
|
+
* themselves, so its constructor could not take a port none of them can build.
|
|
44
|
+
* All four resolve `receivePaymentPort` today and the one construction left is
|
|
45
|
+
* in `payments/backend.ts`, where `ctx` is in hand — so the payment-method read
|
|
46
|
+
* is `paymentMethodReadPort` and the lifecycle write is `orderTransitionPort`.
|
|
47
|
+
* Neither shares the constraint above: the method read is a read of a row this
|
|
48
|
+
* transaction never writes (no FK obliges it to be co-transactional, and the
|
|
49
|
+
* identical read is a port in `shipments`' twin handler), and the transition
|
|
50
|
+
* runs **after** the commit, for the reason the port's own contract gives.
|
|
51
|
+
*/
|
|
52
|
+
import type { OrderPaymentStatusApplyPort } from '@endora-commerce/mod-orders/ports';
|
|
53
|
+
/**
|
|
54
|
+
* Re-exported so `backend.ts` names its own module for the same type. One seam,
|
|
55
|
+
* one ledger entry: a second import specifier in the composition file would be
|
|
56
|
+
* a second crossing of a boundary that has exactly one reason to be crossed,
|
|
57
|
+
* and the reason is stated above. `orders` does the same for the two
|
|
58
|
+
* `EntityManager`-taking interfaces it reads.
|
|
59
|
+
*/
|
|
60
|
+
export type { OrderPaymentStatusApplyPort };
|
|
61
|
+
export interface PaymentEvents extends Record<string, EventBase> {
|
|
62
|
+
'payment.received.v1': EventBase & {
|
|
63
|
+
orderId: string;
|
|
64
|
+
paymentId: string;
|
|
65
|
+
adapter: string;
|
|
66
|
+
externalReference: string | null;
|
|
67
|
+
attemptNo: number;
|
|
68
|
+
};
|
|
69
|
+
'payment.failed.v1': EventBase & {
|
|
70
|
+
orderId: string;
|
|
71
|
+
paymentId: string;
|
|
72
|
+
adapter: string;
|
|
73
|
+
failureReason: string | null;
|
|
74
|
+
attemptNo: number;
|
|
75
|
+
};
|
|
76
|
+
'payment.refunded.v1': EventBase & {
|
|
77
|
+
orderId: string;
|
|
78
|
+
paymentId: string;
|
|
79
|
+
refundedAmount: number;
|
|
80
|
+
currency: string;
|
|
81
|
+
fullyRefunded: boolean;
|
|
82
|
+
externalRefundId: string | null;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export type PaymentEventBus = EventBus<PaymentEvents>;
|
|
86
|
+
export interface ReceivePaymentResult {
|
|
87
|
+
paymentId: string;
|
|
88
|
+
status: Payment['status'];
|
|
89
|
+
/**
|
|
90
|
+
* Where the order stands once the ingress is done with it, which since
|
|
91
|
+
* feature 085 Phase D is the lifecycle's answer rather than this handler's:
|
|
92
|
+
* the target the method configured when the graph permitted it, the order's
|
|
93
|
+
* unchanged status when it did not, and `null` when the ingress asked for no
|
|
94
|
+
* move at all (a repeated settlement, or a method with no status configured).
|
|
95
|
+
*/
|
|
96
|
+
orderStatus: string | null;
|
|
97
|
+
idempotent: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The one message this handler logs, as little of a logger as it needs.
|
|
101
|
+
*
|
|
102
|
+
* `ctx.log` satisfies it structurally, so `payments/backend.ts` passes the
|
|
103
|
+
* module's own logger and a test passes a recorder. Declared here rather than
|
|
104
|
+
* imported so the handler carries no opinion about who is logging — the same
|
|
105
|
+
* shape `GatewayRefundRegistry` takes for its collision warning.
|
|
106
|
+
*/
|
|
107
|
+
export interface SettlementLogger {
|
|
108
|
+
warn(details: object, message: string): void;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* ReceivePaymentHandler (feature 034, FR-022/FR-023/FR-025).
|
|
112
|
+
*
|
|
113
|
+
* Resolves a Payment from the ingress payload, applies the outcome, and asks
|
|
114
|
+
* the order lifecycle for the move the method's `statusOnSuccess` /
|
|
115
|
+
* `statusOnFailure` names. Idempotent: a success after a terminal `paid` is a
|
|
116
|
+
* no-op; a failure after `paid` is rejected (no downgrade). Resolves a late
|
|
117
|
+
* event even when the adapter has since been de-registered (it keys on the
|
|
118
|
+
* persisted Payment, not the live registry).
|
|
119
|
+
*
|
|
120
|
+
* **The lifecycle answers; this handler does not decide** (feature 085 Phase
|
|
121
|
+
* D). It used to assign `order.status` after asking an `OrderStatusRegistry`
|
|
122
|
+
* whether the code existed, which is a different question from whether the
|
|
123
|
+
* order may go there — so the ingress wrote transitions the configured graph
|
|
124
|
+
* forbids, on the happy path, while an operator making the same move by hand
|
|
125
|
+
* was refused. Existence is now the port's `unknown_status` answer and
|
|
126
|
+
* reachability its `not_permitted` one, and both leave the order where it is.
|
|
127
|
+
*/
|
|
128
|
+
export declare class ReceivePaymentHandler {
|
|
129
|
+
private readonly emFactory;
|
|
130
|
+
/**
|
|
131
|
+
* The method behind the payment, as `payment_methods` publishes it. Read
|
|
132
|
+
* inside the settlement transaction, as `shipments` reads its delivery
|
|
133
|
+
* method: it is a read of a row this transaction never writes, so it is not
|
|
134
|
+
* held by `payments_order_fk` and carries none of the atomicity the `Order`
|
|
135
|
+
* import above does. When `payment_methods` is off it throws, inside the
|
|
136
|
+
* transaction, and the payment and the order roll back together.
|
|
137
|
+
*/
|
|
138
|
+
private readonly paymentMethodRead;
|
|
139
|
+
/**
|
|
140
|
+
* The lifecycle write (feature 085 Phase D). It validates against the
|
|
141
|
+
* configured graph, runs the veto guards, records the audit entry and
|
|
142
|
+
* applies the status's side-effects — the five things the assignment this
|
|
143
|
+
* replaced did none of, which is why a payment-driven cancellation used to
|
|
144
|
+
* leave the order's stock allocated forever and audited nowhere.
|
|
145
|
+
*
|
|
146
|
+
* **Called after this handler's transaction has committed**, never inside
|
|
147
|
+
* it: the port takes its own `EntityManager`, so a call from inside would
|
|
148
|
+
* write the order on a different pooled connection whose commit the
|
|
149
|
+
* enclosing rollback cannot reach (issue #200). The port's own doc block
|
|
150
|
+
* states the obligation.
|
|
151
|
+
*
|
|
152
|
+
* It announces the change too — `OrderTransitionService` emits the
|
|
153
|
+
* templated `order.status.*.after` events itself — so this handler no
|
|
154
|
+
* longer resolves `orderStatusAnnouncePort`. Doing both would emit every
|
|
155
|
+
* payment-driven status change twice.
|
|
156
|
+
*/
|
|
157
|
+
private readonly orderTransition;
|
|
158
|
+
/**
|
|
159
|
+
* The money axis of the order this payment settles (feature 080, T048).
|
|
160
|
+
*
|
|
161
|
+
* **Called inside this handler's transaction**, unlike `orderTransition`
|
|
162
|
+
* above, and the asymmetry is the whole reason there are two ports rather
|
|
163
|
+
* than one: `payments_order_fk` (`on delete restrict`) holds the payment row
|
|
164
|
+
* and `orders.payment_status` together, so that write may not commit
|
|
165
|
+
* separately from this one — while a *lifecycle* transition obtains its own
|
|
166
|
+
* `EntityManager` and may not run inside it. The interface takes the
|
|
167
|
+
* caller's `EntityManager` as a required parameter for exactly that reason
|
|
168
|
+
* (D-169), and the header above states the constraint in full.
|
|
169
|
+
*/
|
|
170
|
+
private readonly orderPaymentStatus;
|
|
171
|
+
/** Where a refused transition is recorded; see {@link SettlementLogger}. */
|
|
172
|
+
private readonly log;
|
|
173
|
+
private readonly events?;
|
|
174
|
+
constructor(emFactory: () => EntityManager,
|
|
175
|
+
/**
|
|
176
|
+
* The method behind the payment, as `payment_methods` publishes it. Read
|
|
177
|
+
* inside the settlement transaction, as `shipments` reads its delivery
|
|
178
|
+
* method: it is a read of a row this transaction never writes, so it is not
|
|
179
|
+
* held by `payments_order_fk` and carries none of the atomicity the `Order`
|
|
180
|
+
* import above does. When `payment_methods` is off it throws, inside the
|
|
181
|
+
* transaction, and the payment and the order roll back together.
|
|
182
|
+
*/
|
|
183
|
+
paymentMethodRead: PaymentMethodReadPort,
|
|
184
|
+
/**
|
|
185
|
+
* The lifecycle write (feature 085 Phase D). It validates against the
|
|
186
|
+
* configured graph, runs the veto guards, records the audit entry and
|
|
187
|
+
* applies the status's side-effects — the five things the assignment this
|
|
188
|
+
* replaced did none of, which is why a payment-driven cancellation used to
|
|
189
|
+
* leave the order's stock allocated forever and audited nowhere.
|
|
190
|
+
*
|
|
191
|
+
* **Called after this handler's transaction has committed**, never inside
|
|
192
|
+
* it: the port takes its own `EntityManager`, so a call from inside would
|
|
193
|
+
* write the order on a different pooled connection whose commit the
|
|
194
|
+
* enclosing rollback cannot reach (issue #200). The port's own doc block
|
|
195
|
+
* states the obligation.
|
|
196
|
+
*
|
|
197
|
+
* It announces the change too — `OrderTransitionService` emits the
|
|
198
|
+
* templated `order.status.*.after` events itself — so this handler no
|
|
199
|
+
* longer resolves `orderStatusAnnouncePort`. Doing both would emit every
|
|
200
|
+
* payment-driven status change twice.
|
|
201
|
+
*/
|
|
202
|
+
orderTransition: OrderTransitionPort,
|
|
203
|
+
/**
|
|
204
|
+
* The money axis of the order this payment settles (feature 080, T048).
|
|
205
|
+
*
|
|
206
|
+
* **Called inside this handler's transaction**, unlike `orderTransition`
|
|
207
|
+
* above, and the asymmetry is the whole reason there are two ports rather
|
|
208
|
+
* than one: `payments_order_fk` (`on delete restrict`) holds the payment row
|
|
209
|
+
* and `orders.payment_status` together, so that write may not commit
|
|
210
|
+
* separately from this one — while a *lifecycle* transition obtains its own
|
|
211
|
+
* `EntityManager` and may not run inside it. The interface takes the
|
|
212
|
+
* caller's `EntityManager` as a required parameter for exactly that reason
|
|
213
|
+
* (D-169), and the header above states the constraint in full.
|
|
214
|
+
*/
|
|
215
|
+
orderPaymentStatus: OrderPaymentStatusApplyPort,
|
|
216
|
+
/** Where a refused transition is recorded; see {@link SettlementLogger}. */
|
|
217
|
+
log: SettlementLogger, events?: PaymentEventBus | undefined);
|
|
218
|
+
receive(input: ReceivePayment): Promise<ReceivePaymentResult>;
|
|
219
|
+
/**
|
|
220
|
+
* Ask the lifecycle for the move the configured setting names, and answer
|
|
221
|
+
* where the order ended up.
|
|
222
|
+
*
|
|
223
|
+
* Every refusal is a 200 for the provider (contract §"What the caller does
|
|
224
|
+
* with each outcome"): a PSP retries a non-2xx callback indefinitely, and an
|
|
225
|
+
* order an operator has already advanced past payment must not be dragged
|
|
226
|
+
* backwards because a delayed webhook arrived. So the refusal is recorded
|
|
227
|
+
* here and the settlement stands.
|
|
228
|
+
*
|
|
229
|
+
* There is deliberately no `catch`. A `ModuleDisabledError` out of the port's
|
|
230
|
+
* gate is not a refusal — it is the owner being absent — and swallowing it
|
|
231
|
+
* would turn fail-closed into fail-open.
|
|
232
|
+
*/
|
|
233
|
+
private moveOrder;
|
|
234
|
+
/**
|
|
235
|
+
* Reflect a gateway refund onto the Payment + Order (feature 049). Driven by
|
|
236
|
+
* the `charge.refunded` webhook for BOTH Dashboard- and platform-initiated
|
|
237
|
+
* refunds. `refundedAmount` is the gateway's cumulative total, so this is
|
|
238
|
+
* idempotent by construction (re-applying the same total is a no-op) and never
|
|
239
|
+
* calls the gateway back (no refund loop). A fully-refunded payment is never
|
|
240
|
+
* downgraded to partial.
|
|
241
|
+
*/
|
|
242
|
+
reflectRefund(input: {
|
|
243
|
+
paymentId?: string;
|
|
244
|
+
orderId?: string;
|
|
245
|
+
/** PaymentIntent id (`pi_…`) — resolves the payment when no paymentId. */
|
|
246
|
+
externalReference?: string;
|
|
247
|
+
/** Cumulative refunded amount in major units. */
|
|
248
|
+
refundedAmount: number;
|
|
249
|
+
currency: string;
|
|
250
|
+
fullyRefunded: boolean;
|
|
251
|
+
externalRefundId?: string | null;
|
|
252
|
+
providerDetails?: Record<string, unknown>;
|
|
253
|
+
}): Promise<{
|
|
254
|
+
paymentId: string;
|
|
255
|
+
changed: boolean;
|
|
256
|
+
} | null>;
|
|
257
|
+
private resolvePaymentForRefund;
|
|
258
|
+
private resolvePayment;
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=receive-payment-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receive-payment-handler.d.ts","sourceRoot":"","sources":["../../../src/backend/services/receive-payment-handler.ts"],"names":[],"mappings":"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"}
|