@classytic/revenue 1.1.2 → 1.1.4
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/README.md +8 -7
- package/dist/application/services/index.d.mts +4 -0
- package/dist/application/services/index.mjs +3 -0
- package/dist/base-CsTlVQJe.d.mts +136 -0
- package/dist/base-DCoyIUj6.mjs +152 -0
- package/dist/category-resolver-DV83N8ok.mjs +284 -0
- package/dist/commission-split-BzB8cd39.mjs +485 -0
- package/dist/core/events.d.mts +294 -0
- package/dist/core/events.mjs +100 -0
- package/dist/core/index.d.mts +9 -0
- package/dist/core/index.mjs +8 -0
- package/dist/enums/index.d.mts +157 -0
- package/dist/enums/index.mjs +56 -0
- package/dist/errors-rRdOqnWx.d.mts +787 -0
- package/dist/escrow.enums-CZGrrdg7.mjs +101 -0
- package/dist/{escrow.enums-CE0VQsfe.d.ts → escrow.enums-DwdLuuve.d.mts} +30 -28
- package/dist/idempotency-DaYcUGY1.mjs +172 -0
- package/dist/index-Dsp7H5Wb.d.mts +471 -0
- package/dist/index.d.mts +9 -0
- package/dist/index.mjs +38 -0
- package/dist/infrastructure/plugins/{index.d.ts → index.d.mts} +81 -109
- package/dist/infrastructure/plugins/index.mjs +345 -0
- package/dist/money-CvrDOijQ.mjs +271 -0
- package/dist/money-DPG8AtJ8.d.mts +112 -0
- package/dist/{payment.enums-C1BiGlRa.d.ts → payment.enums-HAuAS9Pp.d.mts} +14 -13
- package/dist/payment.enums-tEFVa-Xp.mjs +69 -0
- package/dist/plugin-BbK0OVHy.d.mts +327 -0
- package/dist/plugin-Cd_V04Em.mjs +210 -0
- package/dist/providers/index.d.mts +3 -0
- package/dist/providers/index.mjs +3 -0
- package/dist/reconciliation/{index.d.ts → index.d.mts} +90 -112
- package/dist/reconciliation/index.mjs +192 -0
- package/dist/retry-HHCOXYdn.d.mts +186 -0
- package/dist/revenue-BhdS7nXh.mjs +553 -0
- package/dist/schemas/index.d.mts +2665 -0
- package/dist/schemas/index.mjs +717 -0
- package/dist/schemas/validation.d.mts +375 -0
- package/dist/schemas/validation.mjs +325 -0
- package/dist/{settlement.enums-ByC1x0ye.d.ts → settlement.enums-DFhkqZEY.d.mts} +31 -29
- package/dist/settlement.schema-DnNSFpGd.d.mts +344 -0
- package/dist/settlement.service-DjzAjezU.d.mts +594 -0
- package/dist/settlement.service-DmdKv0Zu.mjs +2511 -0
- package/dist/split.enums-BrjabxIX.mjs +86 -0
- package/dist/split.enums-DmskfLOM.d.mts +43 -0
- package/dist/tax-BoCt5cEd.d.mts +61 -0
- package/dist/tax-EQ15DO81.mjs +162 -0
- package/dist/transaction.enums-pCyMFT4Z.mjs +96 -0
- package/dist/utils/{index.d.ts → index.d.mts} +91 -161
- package/dist/utils/index.mjs +346 -0
- package/package.json +39 -37
- package/dist/application/services/index.d.ts +0 -6
- package/dist/application/services/index.js +0 -3288
- package/dist/application/services/index.js.map +0 -1
- package/dist/core/events.d.ts +0 -455
- package/dist/core/events.js +0 -122
- package/dist/core/events.js.map +0 -1
- package/dist/core/index.d.ts +0 -13
- package/dist/core/index.js +0 -4591
- package/dist/core/index.js.map +0 -1
- package/dist/enums/index.d.ts +0 -159
- package/dist/enums/index.js +0 -296
- package/dist/enums/index.js.map +0 -1
- package/dist/index-DxIK0UmZ.d.ts +0 -633
- package/dist/index-EnfKzDbs.d.ts +0 -806
- package/dist/index-cLJBLUvx.d.ts +0 -478
- package/dist/index.d.ts +0 -43
- package/dist/index.js +0 -4864
- package/dist/index.js.map +0 -1
- package/dist/infrastructure/plugins/index.js +0 -292
- package/dist/infrastructure/plugins/index.js.map +0 -1
- package/dist/money-widWVD7r.d.ts +0 -111
- package/dist/plugin-Bb9HOE10.d.ts +0 -336
- package/dist/providers/index.d.ts +0 -145
- package/dist/providers/index.js +0 -141
- package/dist/providers/index.js.map +0 -1
- package/dist/reconciliation/index.js +0 -140
- package/dist/reconciliation/index.js.map +0 -1
- package/dist/retry-D4hFUwVk.d.ts +0 -194
- package/dist/schemas/index.d.ts +0 -2655
- package/dist/schemas/index.js +0 -841
- package/dist/schemas/index.js.map +0 -1
- package/dist/schemas/validation.d.ts +0 -384
- package/dist/schemas/validation.js +0 -303
- package/dist/schemas/validation.js.map +0 -1
- package/dist/settlement.schema-CpamV7ZY.d.ts +0 -343
- package/dist/split.enums-DG3TxQf9.d.ts +0 -42
- package/dist/tax-CV8A0sxl.d.ts +0 -60
- package/dist/utils/index.js +0 -1202
- package/dist/utils/index.js.map +0 -1
|
@@ -0,0 +1,2511 @@
|
|
|
1
|
+
import { a as reverseCommission, c as retry, i as calculateCommission, n as calculateSplits, t as calculateOrganizationPayout } from "./commission-split-BzB8cd39.mjs";
|
|
2
|
+
import { C as ValidationError, S as TransactionNotFoundError, _ as RefundNotSupportedError, a as InvalidAmountError, b as SubscriptionNotActiveError, c as ModelNotRegisteredError, d as PaymentIntentCreationError, f as PaymentVerificationError, g as RefundError, h as ProviderNotFoundError, m as ProviderError, n as AlreadyVerifiedError, o as InvalidStateTransitionError, p as ProviderCapabilityError, s as MissingRequiredFieldError, t as resolveCategory, x as SubscriptionNotFoundError } from "./category-resolver-DV83N8ok.mjs";
|
|
3
|
+
import { a as TRANSACTION_STATUS, r as TRANSACTION_FLOW } from "./transaction.enums-pCyMFT4Z.mjs";
|
|
4
|
+
import { a as RELEASE_REASON, f as SETTLEMENT_TYPE, g as MONETIZATION_TYPES, r as HOLD_STATUS, t as HOLD_REASON, u as SETTLEMENT_STATUS } from "./escrow.enums-CZGrrdg7.mjs";
|
|
5
|
+
import { f as SUBSCRIPTION_STATUS, r as SPLIT_STATUS } from "./split.enums-BrjabxIX.mjs";
|
|
6
|
+
import { nanoid } from "nanoid";
|
|
7
|
+
|
|
8
|
+
//#region src/infrastructure/config/resolver.ts
|
|
9
|
+
/**
|
|
10
|
+
* Resolve builder options to service config
|
|
11
|
+
*
|
|
12
|
+
* Converts singular commission rates to category/gateway maps
|
|
13
|
+
* Uses '*' as global default key (applies to all categories/gateways)
|
|
14
|
+
*
|
|
15
|
+
* @param options - Builder options
|
|
16
|
+
* @returns Service-ready config
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const options = {
|
|
21
|
+
* commissionRate: 0.10, // 10% global default
|
|
22
|
+
* gatewayFeeRate: 0.029, // 2.9% global default
|
|
23
|
+
* };
|
|
24
|
+
*
|
|
25
|
+
* const config = resolveConfig(options);
|
|
26
|
+
* // {
|
|
27
|
+
* // commissionRates: { '*': 0.10 },
|
|
28
|
+
* // gatewayFeeRates: { '*': 0.029 }
|
|
29
|
+
* // }
|
|
30
|
+
*
|
|
31
|
+
* // Services can then:
|
|
32
|
+
* const rate = config.commissionRates?.['course_purchase']
|
|
33
|
+
* ?? config.commissionRates?.['*'] // Fallback to global default
|
|
34
|
+
* ?? 0;
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
function resolveConfig(options) {
|
|
38
|
+
const config = {
|
|
39
|
+
targetModels: [],
|
|
40
|
+
categoryMappings: {}
|
|
41
|
+
};
|
|
42
|
+
if (options.commissionRate !== void 0) config.commissionRates = { "*": options.commissionRate };
|
|
43
|
+
if (options.gatewayFeeRate !== void 0) config.gatewayFeeRates = { "*": options.gatewayFeeRate };
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get commission rate for a category
|
|
48
|
+
*
|
|
49
|
+
* Follows precedence: specific category → global default → 0
|
|
50
|
+
*
|
|
51
|
+
* @param config - Revenue config
|
|
52
|
+
* @param category - Transaction category
|
|
53
|
+
* @returns Commission rate (0-1)
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* const rate = getCommissionRate(config, 'course_purchase');
|
|
58
|
+
* // Checks:
|
|
59
|
+
* // 1. config.commissionRates?.['course_purchase']
|
|
60
|
+
* // 2. config.commissionRates?.['*']
|
|
61
|
+
* // 3. 0 (fallback)
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function getCommissionRate(config, category) {
|
|
65
|
+
if (!config?.commissionRates) return 0;
|
|
66
|
+
if (category in config.commissionRates) return config.commissionRates[category];
|
|
67
|
+
return config.commissionRates["*"] ?? 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get gateway fee rate for a gateway
|
|
71
|
+
*
|
|
72
|
+
* Follows precedence: specific gateway → global default → 0
|
|
73
|
+
*
|
|
74
|
+
* @param config - Revenue config
|
|
75
|
+
* @param gateway - Gateway name (e.g., 'stripe', 'paypal')
|
|
76
|
+
* @returns Gateway fee rate (0-1)
|
|
77
|
+
*/
|
|
78
|
+
function getGatewayFeeRate(config, gateway) {
|
|
79
|
+
if (!config?.gatewayFeeRates) return 0;
|
|
80
|
+
if (gateway in config.gatewayFeeRates) return config.gatewayFeeRates[gateway];
|
|
81
|
+
return config.gatewayFeeRates["*"] ?? 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/core/state-machine/StateMachine.ts
|
|
86
|
+
/**
|
|
87
|
+
* Generic State Machine Validator
|
|
88
|
+
* @classytic/revenue
|
|
89
|
+
*
|
|
90
|
+
* Inspired by Stripe's state transition validation
|
|
91
|
+
* Provides centralized, type-safe state transition management
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* Generic State Machine for validating state transitions
|
|
95
|
+
*
|
|
96
|
+
* @template TState - The state type (typically a union of string literals)
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* const stateMachine = new StateMachine(
|
|
101
|
+
* new Map([
|
|
102
|
+
* ['pending', new Set(['processing', 'failed'])],
|
|
103
|
+
* ['processing', new Set(['completed', 'failed'])],
|
|
104
|
+
* ['completed', new Set([])], // Terminal state
|
|
105
|
+
* ['failed', new Set([])], // Terminal state
|
|
106
|
+
* ]),
|
|
107
|
+
* 'payment'
|
|
108
|
+
* );
|
|
109
|
+
*
|
|
110
|
+
* // Validate transition
|
|
111
|
+
* stateMachine.validate('pending', 'processing', 'pay_123'); // ✅ OK
|
|
112
|
+
* stateMachine.validate('completed', 'pending', 'pay_123'); // ❌ Throws InvalidStateTransitionError
|
|
113
|
+
*
|
|
114
|
+
* // Check without throwing
|
|
115
|
+
* stateMachine.canTransition('pending', 'processing'); // true
|
|
116
|
+
* stateMachine.canTransition('completed', 'pending'); // false
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
var StateMachine = class {
|
|
120
|
+
/**
|
|
121
|
+
* @param transitions - Map of state → allowed next states
|
|
122
|
+
* @param resourceType - Type of resource (for error messages)
|
|
123
|
+
*/
|
|
124
|
+
constructor(transitions, resourceType) {
|
|
125
|
+
this.transitions = transitions;
|
|
126
|
+
this.resourceType = resourceType;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Validate state transition is allowed
|
|
130
|
+
*
|
|
131
|
+
* @param from - Current state
|
|
132
|
+
* @param to - Target state
|
|
133
|
+
* @param resourceId - ID of the resource being transitioned
|
|
134
|
+
* @throws InvalidStateTransitionError if transition is invalid
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* try {
|
|
139
|
+
* stateMachine.validate('pending', 'completed', 'tx_123');
|
|
140
|
+
* } catch (error) {
|
|
141
|
+
* if (error instanceof InvalidStateTransitionError) {
|
|
142
|
+
* console.error('Invalid transition:', error.message);
|
|
143
|
+
* }
|
|
144
|
+
* }
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
validate(from, to, resourceId) {
|
|
148
|
+
if (!this.transitions.get(from)?.has(to)) throw new InvalidStateTransitionError(this.resourceType, resourceId, from, to);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Check if transition is valid (non-throwing)
|
|
152
|
+
*
|
|
153
|
+
* @param from - Current state
|
|
154
|
+
* @param to - Target state
|
|
155
|
+
* @returns true if transition is allowed
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```typescript
|
|
159
|
+
* if (stateMachine.canTransition('pending', 'processing')) {
|
|
160
|
+
* // Safe to proceed with transition
|
|
161
|
+
* transaction.status = 'processing';
|
|
162
|
+
* }
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
canTransition(from, to) {
|
|
166
|
+
return this.transitions.get(from)?.has(to) ?? false;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Get all allowed next states from current state
|
|
170
|
+
*
|
|
171
|
+
* @param from - Current state
|
|
172
|
+
* @returns Array of allowed next states
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* const nextStates = stateMachine.getAllowedTransitions('pending');
|
|
177
|
+
* console.log(nextStates); // ['processing', 'failed']
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
getAllowedTransitions(from) {
|
|
181
|
+
return Array.from(this.transitions.get(from) ?? []);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Check if state is terminal (no outgoing transitions)
|
|
185
|
+
*
|
|
186
|
+
* @param state - State to check
|
|
187
|
+
* @returns true if state has no outgoing transitions
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```typescript
|
|
191
|
+
* stateMachine.isTerminalState('completed'); // true
|
|
192
|
+
* stateMachine.isTerminalState('pending'); // false
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
isTerminalState(state) {
|
|
196
|
+
const transitions = this.transitions.get(state);
|
|
197
|
+
return !transitions || transitions.size === 0;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get the resource type this state machine manages
|
|
201
|
+
*
|
|
202
|
+
* @returns Resource type string
|
|
203
|
+
*/
|
|
204
|
+
getResourceType() {
|
|
205
|
+
return this.resourceType;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Validate state transition and create audit event
|
|
209
|
+
*
|
|
210
|
+
* This is a convenience method that combines validation with audit event creation.
|
|
211
|
+
* Use this when you want to both validate a transition and record it in the audit trail.
|
|
212
|
+
*
|
|
213
|
+
* @param from - Current state
|
|
214
|
+
* @param to - Target state
|
|
215
|
+
* @param resourceId - ID of the resource being transitioned
|
|
216
|
+
* @param context - Optional audit context (who, why, metadata)
|
|
217
|
+
* @returns StateChangeEvent ready to be appended to document metadata
|
|
218
|
+
* @throws InvalidStateTransitionError if transition is invalid
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```typescript
|
|
222
|
+
* import { appendAuditEvent } from '@classytic/revenue';
|
|
223
|
+
*
|
|
224
|
+
* // Validate and create audit event
|
|
225
|
+
* const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
226
|
+
* transaction.status,
|
|
227
|
+
* 'verified',
|
|
228
|
+
* transaction._id.toString(),
|
|
229
|
+
* {
|
|
230
|
+
* changedBy: 'admin_123',
|
|
231
|
+
* reason: 'Payment verified by payment gateway',
|
|
232
|
+
* metadata: { verificationId: 'ver_abc' }
|
|
233
|
+
* }
|
|
234
|
+
* );
|
|
235
|
+
*
|
|
236
|
+
* // Apply state change
|
|
237
|
+
* transaction.status = 'verified';
|
|
238
|
+
*
|
|
239
|
+
* // Append audit event to metadata
|
|
240
|
+
* Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
241
|
+
*
|
|
242
|
+
* // Save
|
|
243
|
+
* await transaction.save();
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
validateAndCreateAuditEvent(from, to, resourceId, context) {
|
|
247
|
+
this.validate(from, to, resourceId);
|
|
248
|
+
return {
|
|
249
|
+
resourceType: this.resourceType,
|
|
250
|
+
resourceId,
|
|
251
|
+
fromState: from,
|
|
252
|
+
toState: to,
|
|
253
|
+
changedAt: /* @__PURE__ */ new Date(),
|
|
254
|
+
changedBy: context?.changedBy,
|
|
255
|
+
reason: context?.reason,
|
|
256
|
+
metadata: context?.metadata
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/core/state-machine/definitions.ts
|
|
263
|
+
/**
|
|
264
|
+
* State Machine Definitions
|
|
265
|
+
* @classytic/revenue
|
|
266
|
+
*
|
|
267
|
+
* Centralized state transition rules for all entities
|
|
268
|
+
* Inspired by Stripe, PayPal, and Shopify state management patterns
|
|
269
|
+
*/
|
|
270
|
+
/**
|
|
271
|
+
* Transaction State Machine
|
|
272
|
+
*
|
|
273
|
+
* Flow:
|
|
274
|
+
* ```
|
|
275
|
+
* pending → payment_initiated → processing → verified → completed
|
|
276
|
+
* ↓ ↓ ↓
|
|
277
|
+
* failed requires_action refunded
|
|
278
|
+
* ↓ partially_refunded
|
|
279
|
+
* failed
|
|
280
|
+
* ```
|
|
281
|
+
*
|
|
282
|
+
* Terminal states: failed, refunded, cancelled, expired
|
|
283
|
+
*/
|
|
284
|
+
const TRANSACTION_STATE_MACHINE = new StateMachine(new Map([
|
|
285
|
+
[TRANSACTION_STATUS.PENDING, new Set([
|
|
286
|
+
TRANSACTION_STATUS.PAYMENT_INITIATED,
|
|
287
|
+
TRANSACTION_STATUS.PROCESSING,
|
|
288
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
289
|
+
TRANSACTION_STATUS.FAILED,
|
|
290
|
+
TRANSACTION_STATUS.CANCELLED
|
|
291
|
+
])],
|
|
292
|
+
[TRANSACTION_STATUS.PAYMENT_INITIATED, new Set([
|
|
293
|
+
TRANSACTION_STATUS.PROCESSING,
|
|
294
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
295
|
+
TRANSACTION_STATUS.REQUIRES_ACTION,
|
|
296
|
+
TRANSACTION_STATUS.FAILED,
|
|
297
|
+
TRANSACTION_STATUS.CANCELLED
|
|
298
|
+
])],
|
|
299
|
+
[TRANSACTION_STATUS.PROCESSING, new Set([
|
|
300
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
301
|
+
TRANSACTION_STATUS.REQUIRES_ACTION,
|
|
302
|
+
TRANSACTION_STATUS.FAILED
|
|
303
|
+
])],
|
|
304
|
+
[TRANSACTION_STATUS.REQUIRES_ACTION, new Set([
|
|
305
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
306
|
+
TRANSACTION_STATUS.FAILED,
|
|
307
|
+
TRANSACTION_STATUS.CANCELLED,
|
|
308
|
+
TRANSACTION_STATUS.EXPIRED
|
|
309
|
+
])],
|
|
310
|
+
[TRANSACTION_STATUS.VERIFIED, new Set([
|
|
311
|
+
TRANSACTION_STATUS.COMPLETED,
|
|
312
|
+
TRANSACTION_STATUS.REFUNDED,
|
|
313
|
+
TRANSACTION_STATUS.PARTIALLY_REFUNDED,
|
|
314
|
+
TRANSACTION_STATUS.CANCELLED
|
|
315
|
+
])],
|
|
316
|
+
[TRANSACTION_STATUS.COMPLETED, new Set([TRANSACTION_STATUS.REFUNDED, TRANSACTION_STATUS.PARTIALLY_REFUNDED])],
|
|
317
|
+
[TRANSACTION_STATUS.PARTIALLY_REFUNDED, new Set([TRANSACTION_STATUS.REFUNDED])],
|
|
318
|
+
[TRANSACTION_STATUS.FAILED, /* @__PURE__ */ new Set([])],
|
|
319
|
+
[TRANSACTION_STATUS.REFUNDED, /* @__PURE__ */ new Set([])],
|
|
320
|
+
[TRANSACTION_STATUS.CANCELLED, /* @__PURE__ */ new Set([])],
|
|
321
|
+
[TRANSACTION_STATUS.EXPIRED, /* @__PURE__ */ new Set([])]
|
|
322
|
+
]), "transaction");
|
|
323
|
+
/**
|
|
324
|
+
* Subscription State Machine
|
|
325
|
+
*
|
|
326
|
+
* Flow:
|
|
327
|
+
* ```
|
|
328
|
+
* pending → active → paused → active
|
|
329
|
+
* ↓ ↓
|
|
330
|
+
* cancelled cancelled
|
|
331
|
+
* ↓
|
|
332
|
+
* expired
|
|
333
|
+
* ```
|
|
334
|
+
*
|
|
335
|
+
* Terminal states: cancelled, expired
|
|
336
|
+
*/
|
|
337
|
+
const SUBSCRIPTION_STATE_MACHINE = new StateMachine(new Map([
|
|
338
|
+
[SUBSCRIPTION_STATUS.PENDING, new Set([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.CANCELLED])],
|
|
339
|
+
[SUBSCRIPTION_STATUS.ACTIVE, new Set([
|
|
340
|
+
SUBSCRIPTION_STATUS.PAUSED,
|
|
341
|
+
SUBSCRIPTION_STATUS.PENDING_RENEWAL,
|
|
342
|
+
SUBSCRIPTION_STATUS.CANCELLED,
|
|
343
|
+
SUBSCRIPTION_STATUS.EXPIRED
|
|
344
|
+
])],
|
|
345
|
+
[SUBSCRIPTION_STATUS.PENDING_RENEWAL, new Set([
|
|
346
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
347
|
+
SUBSCRIPTION_STATUS.CANCELLED,
|
|
348
|
+
SUBSCRIPTION_STATUS.EXPIRED
|
|
349
|
+
])],
|
|
350
|
+
[SUBSCRIPTION_STATUS.PAUSED, new Set([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.CANCELLED])],
|
|
351
|
+
[SUBSCRIPTION_STATUS.INACTIVE, new Set([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.CANCELLED])],
|
|
352
|
+
[SUBSCRIPTION_STATUS.CANCELLED, /* @__PURE__ */ new Set([])],
|
|
353
|
+
[SUBSCRIPTION_STATUS.EXPIRED, /* @__PURE__ */ new Set([])]
|
|
354
|
+
]), "subscription");
|
|
355
|
+
/**
|
|
356
|
+
* Settlement State Machine
|
|
357
|
+
*
|
|
358
|
+
* Flow:
|
|
359
|
+
* ```
|
|
360
|
+
* pending → processing → completed
|
|
361
|
+
* ↓
|
|
362
|
+
* failed → pending (retry allowed)
|
|
363
|
+
* ```
|
|
364
|
+
*
|
|
365
|
+
* Terminal states: completed, cancelled
|
|
366
|
+
* Note: failed can retry to pending
|
|
367
|
+
*/
|
|
368
|
+
const SETTLEMENT_STATE_MACHINE = new StateMachine(new Map([
|
|
369
|
+
[SETTLEMENT_STATUS.PENDING, new Set([SETTLEMENT_STATUS.PROCESSING, SETTLEMENT_STATUS.CANCELLED])],
|
|
370
|
+
[SETTLEMENT_STATUS.PROCESSING, new Set([SETTLEMENT_STATUS.COMPLETED, SETTLEMENT_STATUS.FAILED])],
|
|
371
|
+
[SETTLEMENT_STATUS.FAILED, new Set([SETTLEMENT_STATUS.PENDING, SETTLEMENT_STATUS.CANCELLED])],
|
|
372
|
+
[SETTLEMENT_STATUS.COMPLETED, /* @__PURE__ */ new Set([])],
|
|
373
|
+
[SETTLEMENT_STATUS.CANCELLED, /* @__PURE__ */ new Set([])]
|
|
374
|
+
]), "settlement");
|
|
375
|
+
/**
|
|
376
|
+
* Escrow Hold State Machine
|
|
377
|
+
*
|
|
378
|
+
* Flow:
|
|
379
|
+
* ```
|
|
380
|
+
* held → partially_released → released
|
|
381
|
+
* ↓
|
|
382
|
+
* cancelled
|
|
383
|
+
* ```
|
|
384
|
+
*
|
|
385
|
+
* Terminal states: released, cancelled, expired
|
|
386
|
+
*/
|
|
387
|
+
const HOLD_STATE_MACHINE = new StateMachine(new Map([
|
|
388
|
+
[HOLD_STATUS.HELD, new Set([
|
|
389
|
+
HOLD_STATUS.RELEASED,
|
|
390
|
+
HOLD_STATUS.PARTIALLY_RELEASED,
|
|
391
|
+
HOLD_STATUS.CANCELLED,
|
|
392
|
+
HOLD_STATUS.EXPIRED
|
|
393
|
+
])],
|
|
394
|
+
[HOLD_STATUS.PARTIALLY_RELEASED, new Set([HOLD_STATUS.RELEASED, HOLD_STATUS.CANCELLED])],
|
|
395
|
+
[HOLD_STATUS.RELEASED, /* @__PURE__ */ new Set([])],
|
|
396
|
+
[HOLD_STATUS.CANCELLED, /* @__PURE__ */ new Set([])],
|
|
397
|
+
[HOLD_STATUS.EXPIRED, /* @__PURE__ */ new Set([])]
|
|
398
|
+
]), "escrow_hold");
|
|
399
|
+
/**
|
|
400
|
+
* Split Payment State Machine
|
|
401
|
+
*
|
|
402
|
+
* Flow:
|
|
403
|
+
* ```
|
|
404
|
+
* pending → due → paid
|
|
405
|
+
* ↓
|
|
406
|
+
* waived
|
|
407
|
+
* cancelled
|
|
408
|
+
* ```
|
|
409
|
+
*
|
|
410
|
+
* Terminal states: paid, waived, cancelled
|
|
411
|
+
*/
|
|
412
|
+
const SPLIT_STATE_MACHINE = new StateMachine(new Map([
|
|
413
|
+
[SPLIT_STATUS.PENDING, new Set([
|
|
414
|
+
SPLIT_STATUS.DUE,
|
|
415
|
+
SPLIT_STATUS.PAID,
|
|
416
|
+
SPLIT_STATUS.WAIVED,
|
|
417
|
+
SPLIT_STATUS.CANCELLED
|
|
418
|
+
])],
|
|
419
|
+
[SPLIT_STATUS.DUE, new Set([
|
|
420
|
+
SPLIT_STATUS.PAID,
|
|
421
|
+
SPLIT_STATUS.WAIVED,
|
|
422
|
+
SPLIT_STATUS.CANCELLED
|
|
423
|
+
])],
|
|
424
|
+
[SPLIT_STATUS.PAID, /* @__PURE__ */ new Set([])],
|
|
425
|
+
[SPLIT_STATUS.WAIVED, /* @__PURE__ */ new Set([])],
|
|
426
|
+
[SPLIT_STATUS.CANCELLED, /* @__PURE__ */ new Set([])]
|
|
427
|
+
]), "split");
|
|
428
|
+
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/infrastructure/audit/types.ts
|
|
431
|
+
/**
|
|
432
|
+
* Helper to append audit event to document metadata
|
|
433
|
+
*
|
|
434
|
+
* This is a metadata-based approach that stores audit trail
|
|
435
|
+
* directly in the document's metadata.stateHistory array.
|
|
436
|
+
*
|
|
437
|
+
* Example:
|
|
438
|
+
* ```typescript
|
|
439
|
+
* const transaction = await Transaction.findById(id);
|
|
440
|
+
*
|
|
441
|
+
* const auditEvent = {
|
|
442
|
+
* resourceType: 'transaction',
|
|
443
|
+
* resourceId: transaction._id.toString(),
|
|
444
|
+
* fromState: 'pending',
|
|
445
|
+
* toState: 'verified',
|
|
446
|
+
* changedAt: new Date(),
|
|
447
|
+
* changedBy: 'admin_123',
|
|
448
|
+
* };
|
|
449
|
+
*
|
|
450
|
+
* const updated = appendAuditEvent(transaction, auditEvent);
|
|
451
|
+
* await updated.save();
|
|
452
|
+
* ```
|
|
453
|
+
*
|
|
454
|
+
* @param document - Mongoose document or plain object with optional metadata field
|
|
455
|
+
* @param event - State change event to append
|
|
456
|
+
* @returns Updated document with event in metadata.stateHistory
|
|
457
|
+
*/
|
|
458
|
+
function appendAuditEvent(document, event) {
|
|
459
|
+
const stateHistory = document.metadata?.stateHistory ?? [];
|
|
460
|
+
return {
|
|
461
|
+
...document,
|
|
462
|
+
metadata: {
|
|
463
|
+
...document.metadata,
|
|
464
|
+
stateHistory: [...stateHistory, event]
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Helper to get audit trail from document metadata
|
|
470
|
+
*
|
|
471
|
+
* Example:
|
|
472
|
+
* ```typescript
|
|
473
|
+
* const transaction = await Transaction.findById(id);
|
|
474
|
+
* const history = getAuditTrail(transaction);
|
|
475
|
+
*
|
|
476
|
+
* console.log(history);
|
|
477
|
+
* // [
|
|
478
|
+
* // { fromState: 'pending', toState: 'processing', changedAt: ... },
|
|
479
|
+
* // { fromState: 'processing', toState: 'verified', changedAt: ... },
|
|
480
|
+
* // ]
|
|
481
|
+
* ```
|
|
482
|
+
*
|
|
483
|
+
* @param document - Document to extract audit trail from
|
|
484
|
+
* @returns Array of state change events (empty array if none)
|
|
485
|
+
*/
|
|
486
|
+
function getAuditTrail(document) {
|
|
487
|
+
return document.metadata?.stateHistory ?? [];
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Helper to get the last state change from audit trail
|
|
491
|
+
*
|
|
492
|
+
* @param document - Document to extract last change from
|
|
493
|
+
* @returns Last state change event or undefined if none
|
|
494
|
+
*/
|
|
495
|
+
function getLastStateChange(document) {
|
|
496
|
+
const history = getAuditTrail(document);
|
|
497
|
+
return history[history.length - 1];
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Helper to find state changes by criteria
|
|
501
|
+
*
|
|
502
|
+
* Example:
|
|
503
|
+
* ```typescript
|
|
504
|
+
* const transaction = await Transaction.findById(id);
|
|
505
|
+
*
|
|
506
|
+
* // Find all changes made by a specific user
|
|
507
|
+
* const userChanges = filterAuditTrail(transaction, {
|
|
508
|
+
* changedBy: 'admin_123'
|
|
509
|
+
* });
|
|
510
|
+
*
|
|
511
|
+
* // Find all transitions to 'failed' state
|
|
512
|
+
* const failures = filterAuditTrail(transaction, {
|
|
513
|
+
* toState: 'failed'
|
|
514
|
+
* });
|
|
515
|
+
* ```
|
|
516
|
+
*
|
|
517
|
+
* @param document - Document to filter audit trail from
|
|
518
|
+
* @param criteria - Filter criteria
|
|
519
|
+
* @returns Filtered array of state change events
|
|
520
|
+
*/
|
|
521
|
+
function filterAuditTrail(document, criteria) {
|
|
522
|
+
return getAuditTrail(document).filter((event) => {
|
|
523
|
+
return Object.entries(criteria).every(([key, value]) => {
|
|
524
|
+
return event[key] === value;
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/application/services/monetization.service.ts
|
|
531
|
+
/**
|
|
532
|
+
* Monetization Service
|
|
533
|
+
* @classytic/revenue
|
|
534
|
+
*
|
|
535
|
+
* Framework-agnostic monetization management service with DI
|
|
536
|
+
* Handles purchases, subscriptions, and free items using provider system
|
|
537
|
+
*/
|
|
538
|
+
/**
|
|
539
|
+
* Monetization Service
|
|
540
|
+
* Uses DI container for all dependencies
|
|
541
|
+
*
|
|
542
|
+
* Architecture:
|
|
543
|
+
* - PluginManager: Wraps operations with lifecycle hooks (before/after)
|
|
544
|
+
* - EventBus: Fire-and-forget notifications for completed operations
|
|
545
|
+
*/
|
|
546
|
+
var MonetizationService = class {
|
|
547
|
+
models;
|
|
548
|
+
providers;
|
|
549
|
+
config;
|
|
550
|
+
plugins;
|
|
551
|
+
logger;
|
|
552
|
+
events;
|
|
553
|
+
retryConfig;
|
|
554
|
+
circuitBreaker;
|
|
555
|
+
constructor(container) {
|
|
556
|
+
this.models = container.get("models");
|
|
557
|
+
this.providers = container.get("providers");
|
|
558
|
+
this.config = container.get("config");
|
|
559
|
+
this.plugins = container.get("plugins");
|
|
560
|
+
this.logger = container.get("logger");
|
|
561
|
+
this.events = container.get("events");
|
|
562
|
+
this.retryConfig = container.get("retryConfig");
|
|
563
|
+
this.circuitBreaker = container.get("circuitBreaker");
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Create plugin context for hook execution
|
|
567
|
+
* @private
|
|
568
|
+
*/
|
|
569
|
+
getPluginContext(idempotencyKey) {
|
|
570
|
+
return {
|
|
571
|
+
events: this.events,
|
|
572
|
+
logger: this.logger,
|
|
573
|
+
storage: /* @__PURE__ */ new Map(),
|
|
574
|
+
meta: {
|
|
575
|
+
idempotencyKey: idempotencyKey ?? void 0,
|
|
576
|
+
requestId: nanoid(),
|
|
577
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Execute provider call with retry and circuit breaker protection
|
|
583
|
+
* @private
|
|
584
|
+
*/
|
|
585
|
+
async executeProviderCall(operation, operationName) {
|
|
586
|
+
const withCircuitBreaker = this.circuitBreaker ? () => this.circuitBreaker.execute(operation) : operation;
|
|
587
|
+
if (this.retryConfig && Object.keys(this.retryConfig).length > 0) return retry(withCircuitBreaker, {
|
|
588
|
+
...this.retryConfig,
|
|
589
|
+
onRetry: (error, attempt, delay) => {
|
|
590
|
+
this.logger.warn(`[${operationName}] Retry attempt ${attempt} after ${delay}ms:`, error);
|
|
591
|
+
this.retryConfig.onRetry?.(error, attempt, delay);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
return withCircuitBreaker();
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Create a new monetization (purchase, subscription, or free item)
|
|
598
|
+
*
|
|
599
|
+
* @param params - Monetization parameters
|
|
600
|
+
*
|
|
601
|
+
* @example
|
|
602
|
+
* // One-time purchase
|
|
603
|
+
* await revenue.monetization.create({
|
|
604
|
+
* data: {
|
|
605
|
+
* organizationId: '...',
|
|
606
|
+
* customerId: '...',
|
|
607
|
+
* sourceId: order._id,
|
|
608
|
+
* sourceModel: 'Order',
|
|
609
|
+
* },
|
|
610
|
+
* planKey: 'one_time',
|
|
611
|
+
* monetizationType: 'purchase',
|
|
612
|
+
* gateway: 'bkash',
|
|
613
|
+
* amount: 1500,
|
|
614
|
+
* });
|
|
615
|
+
*
|
|
616
|
+
* // Recurring subscription
|
|
617
|
+
* await revenue.monetization.create({
|
|
618
|
+
* data: {
|
|
619
|
+
* organizationId: '...',
|
|
620
|
+
* customerId: '...',
|
|
621
|
+
* sourceId: subscription._id,
|
|
622
|
+
* sourceModel: 'Subscription',
|
|
623
|
+
* },
|
|
624
|
+
* planKey: 'monthly',
|
|
625
|
+
* monetizationType: 'subscription',
|
|
626
|
+
* gateway: 'stripe',
|
|
627
|
+
* amount: 2000,
|
|
628
|
+
* });
|
|
629
|
+
*
|
|
630
|
+
* @returns Result with subscription, transaction, and paymentIntent
|
|
631
|
+
*/
|
|
632
|
+
async create(params) {
|
|
633
|
+
return this.plugins.executeHook("monetization.create.before", this.getPluginContext(params.idempotencyKey), params, async () => {
|
|
634
|
+
const { data, planKey, amount, currency = "BDT", gateway = "manual", entity = null, monetizationType = MONETIZATION_TYPES.SUBSCRIPTION, paymentData, metadata = {}, idempotencyKey = null } = params;
|
|
635
|
+
if (!planKey) throw new MissingRequiredFieldError("planKey");
|
|
636
|
+
if (amount < 0) throw new InvalidAmountError(amount);
|
|
637
|
+
const isFree = amount === 0;
|
|
638
|
+
const provider = this.providers[gateway];
|
|
639
|
+
if (!provider) throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
640
|
+
let paymentIntent = null;
|
|
641
|
+
let transaction = null;
|
|
642
|
+
if (!isFree) {
|
|
643
|
+
try {
|
|
644
|
+
paymentIntent = await this.executeProviderCall(() => provider.createIntent({
|
|
645
|
+
amount,
|
|
646
|
+
currency,
|
|
647
|
+
metadata: {
|
|
648
|
+
...metadata,
|
|
649
|
+
type: "subscription",
|
|
650
|
+
planKey
|
|
651
|
+
}
|
|
652
|
+
}), `${gateway}.createIntent`);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
655
|
+
}
|
|
656
|
+
const category = resolveCategory(entity, monetizationType, this.config.categoryMappings);
|
|
657
|
+
const transactionFlow = this.config.transactionTypeMapping?.[category] ?? this.config.transactionTypeMapping?.[monetizationType] ?? TRANSACTION_FLOW.INFLOW;
|
|
658
|
+
const commission = calculateCommission(amount, getCommissionRate(this.config, category), getGatewayFeeRate(this.config, gateway));
|
|
659
|
+
const tax = params.tax;
|
|
660
|
+
const TransactionModel = this.models.Transaction;
|
|
661
|
+
const baseAmount = tax?.pricesIncludeTax ? tax.baseAmount : amount;
|
|
662
|
+
const feeAmount = commission?.gatewayFeeAmount || 0;
|
|
663
|
+
const taxAmount = tax?.taxAmount || 0;
|
|
664
|
+
const netAmount = baseAmount - feeAmount - taxAmount;
|
|
665
|
+
transaction = await TransactionModel.create({
|
|
666
|
+
organizationId: data.organizationId,
|
|
667
|
+
customerId: data.customerId ?? null,
|
|
668
|
+
type: category,
|
|
669
|
+
flow: transactionFlow,
|
|
670
|
+
tags: category === "subscription" ? ["recurring", "subscription"] : [],
|
|
671
|
+
amount: baseAmount,
|
|
672
|
+
currency,
|
|
673
|
+
fee: feeAmount,
|
|
674
|
+
tax: taxAmount,
|
|
675
|
+
net: netAmount,
|
|
676
|
+
...tax && { taxDetails: {
|
|
677
|
+
type: tax.type === "collected" ? "sales_tax" : tax.type === "paid" ? "vat" : "none",
|
|
678
|
+
rate: tax.rate || 0,
|
|
679
|
+
isInclusive: tax.pricesIncludeTax || false
|
|
680
|
+
} },
|
|
681
|
+
method: paymentData?.method ?? "manual",
|
|
682
|
+
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
683
|
+
gateway: {
|
|
684
|
+
type: gateway,
|
|
685
|
+
provider: gateway,
|
|
686
|
+
sessionId: paymentIntent.sessionId,
|
|
687
|
+
paymentIntentId: paymentIntent.paymentIntentId,
|
|
688
|
+
chargeId: paymentIntent.id,
|
|
689
|
+
metadata: paymentIntent.metadata
|
|
690
|
+
},
|
|
691
|
+
paymentDetails: { ...paymentData },
|
|
692
|
+
...commission && { commission },
|
|
693
|
+
...data.sourceId && { sourceId: data.sourceId },
|
|
694
|
+
...data.sourceModel && { sourceModel: data.sourceModel },
|
|
695
|
+
metadata: {
|
|
696
|
+
...metadata,
|
|
697
|
+
planKey,
|
|
698
|
+
entity,
|
|
699
|
+
monetizationType,
|
|
700
|
+
paymentIntentId: paymentIntent.id
|
|
701
|
+
},
|
|
702
|
+
idempotencyKey: idempotencyKey ?? `sub_${nanoid(16)}`
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
let subscription = null;
|
|
706
|
+
if (this.models.Subscription) {
|
|
707
|
+
const SubscriptionModel = this.models.Subscription;
|
|
708
|
+
subscription = (await this.plugins.executeHook("subscription.create.before", this.getPluginContext(idempotencyKey), {
|
|
709
|
+
subscriptionId: void 0,
|
|
710
|
+
planKey,
|
|
711
|
+
customerId: data.customerId,
|
|
712
|
+
organizationId: data.organizationId,
|
|
713
|
+
entity
|
|
714
|
+
}, async () => {
|
|
715
|
+
const subscriptionData = {
|
|
716
|
+
organizationId: data.organizationId,
|
|
717
|
+
customerId: data.customerId ?? null,
|
|
718
|
+
planKey,
|
|
719
|
+
amount,
|
|
720
|
+
currency,
|
|
721
|
+
status: isFree ? "active" : "pending",
|
|
722
|
+
isActive: isFree,
|
|
723
|
+
gateway,
|
|
724
|
+
transactionId: transaction?._id ?? null,
|
|
725
|
+
paymentIntentId: paymentIntent?.id ?? null,
|
|
726
|
+
metadata: {
|
|
727
|
+
...metadata,
|
|
728
|
+
isFree,
|
|
729
|
+
entity,
|
|
730
|
+
monetizationType
|
|
731
|
+
},
|
|
732
|
+
...data
|
|
733
|
+
};
|
|
734
|
+
delete subscriptionData.sourceId;
|
|
735
|
+
delete subscriptionData.sourceModel;
|
|
736
|
+
const sub = await SubscriptionModel.create(subscriptionData);
|
|
737
|
+
await this.plugins.executeHook("subscription.create.after", this.getPluginContext(idempotencyKey), {
|
|
738
|
+
subscriptionId: sub._id.toString(),
|
|
739
|
+
planKey,
|
|
740
|
+
customerId: data.customerId,
|
|
741
|
+
organizationId: data.organizationId,
|
|
742
|
+
entity
|
|
743
|
+
}, async () => ({
|
|
744
|
+
subscription: sub,
|
|
745
|
+
transaction
|
|
746
|
+
}));
|
|
747
|
+
return {
|
|
748
|
+
subscription: sub,
|
|
749
|
+
transaction
|
|
750
|
+
};
|
|
751
|
+
})).subscription;
|
|
752
|
+
}
|
|
753
|
+
this.events.emit("monetization.created", {
|
|
754
|
+
monetizationType,
|
|
755
|
+
subscription: subscription ?? void 0,
|
|
756
|
+
transaction: transaction ?? void 0,
|
|
757
|
+
paymentIntent: paymentIntent ?? void 0
|
|
758
|
+
});
|
|
759
|
+
if (monetizationType === MONETIZATION_TYPES.PURCHASE) {
|
|
760
|
+
if (transaction) this.events.emit("purchase.created", {
|
|
761
|
+
monetizationType,
|
|
762
|
+
subscription: subscription ?? void 0,
|
|
763
|
+
transaction,
|
|
764
|
+
paymentIntent: paymentIntent ?? void 0
|
|
765
|
+
});
|
|
766
|
+
} else if (monetizationType === MONETIZATION_TYPES.SUBSCRIPTION) {
|
|
767
|
+
if (subscription) this.events.emit("subscription.created", {
|
|
768
|
+
subscriptionId: subscription._id.toString(),
|
|
769
|
+
subscription,
|
|
770
|
+
transactionId: transaction?._id?.toString()
|
|
771
|
+
});
|
|
772
|
+
} else if (monetizationType === MONETIZATION_TYPES.FREE) this.events.emit("free.created", {
|
|
773
|
+
monetizationType,
|
|
774
|
+
subscription: subscription ?? void 0,
|
|
775
|
+
transaction: transaction ?? void 0,
|
|
776
|
+
paymentIntent: paymentIntent ?? void 0
|
|
777
|
+
});
|
|
778
|
+
const result = {
|
|
779
|
+
subscription,
|
|
780
|
+
transaction,
|
|
781
|
+
paymentIntent
|
|
782
|
+
};
|
|
783
|
+
return this.plugins.executeHook("monetization.create.after", this.getPluginContext(params.idempotencyKey), params, async () => result);
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Activate subscription after payment verification
|
|
788
|
+
*
|
|
789
|
+
* @param subscriptionId - Subscription ID or transaction ID
|
|
790
|
+
* @param options - Activation options
|
|
791
|
+
* @returns Updated subscription
|
|
792
|
+
*/
|
|
793
|
+
async activate(subscriptionId, options = {}) {
|
|
794
|
+
return this.plugins.executeHook("subscription.activate.before", this.getPluginContext(), {
|
|
795
|
+
subscriptionId,
|
|
796
|
+
...options
|
|
797
|
+
}, async () => {
|
|
798
|
+
const { timestamp = /* @__PURE__ */ new Date() } = options;
|
|
799
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
800
|
+
const subscription = await this.models.Subscription.findById(subscriptionId);
|
|
801
|
+
if (!subscription) throw new SubscriptionNotFoundError(subscriptionId);
|
|
802
|
+
if (subscription.isActive) {
|
|
803
|
+
this.logger.warn("Subscription already active", { subscriptionId });
|
|
804
|
+
return subscription;
|
|
805
|
+
}
|
|
806
|
+
const periodEnd = this._calculatePeriodEnd(subscription.planKey, timestamp);
|
|
807
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(subscription.status, "active", subscription._id.toString(), {
|
|
808
|
+
changedBy: "system",
|
|
809
|
+
reason: `Subscription activated for plan: ${subscription.planKey}`,
|
|
810
|
+
metadata: {
|
|
811
|
+
planKey: subscription.planKey,
|
|
812
|
+
startDate: timestamp,
|
|
813
|
+
endDate: periodEnd
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
subscription.isActive = true;
|
|
817
|
+
subscription.status = "active";
|
|
818
|
+
subscription.startDate = timestamp;
|
|
819
|
+
subscription.endDate = periodEnd;
|
|
820
|
+
subscription.activatedAt = timestamp;
|
|
821
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
822
|
+
await subscription.save();
|
|
823
|
+
this.events.emit("subscription.activated", {
|
|
824
|
+
subscription,
|
|
825
|
+
activatedAt: timestamp
|
|
826
|
+
});
|
|
827
|
+
return this.plugins.executeHook("subscription.activate.after", this.getPluginContext(), {
|
|
828
|
+
subscriptionId,
|
|
829
|
+
...options
|
|
830
|
+
}, async () => subscription);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Renew subscription
|
|
835
|
+
*
|
|
836
|
+
* @param subscriptionId - Subscription ID
|
|
837
|
+
* @param params - Renewal parameters
|
|
838
|
+
* @returns { subscription, transaction, paymentIntent }
|
|
839
|
+
*/
|
|
840
|
+
async renew(subscriptionId, params = {}) {
|
|
841
|
+
const { gateway = "manual", entity = null, paymentData, metadata = {}, idempotencyKey = null } = params;
|
|
842
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
843
|
+
const subscription = await this.models.Subscription.findById(subscriptionId);
|
|
844
|
+
if (!subscription) throw new SubscriptionNotFoundError(subscriptionId);
|
|
845
|
+
if (subscription.amount === 0) throw new InvalidAmountError(0, "Free subscriptions do not require renewal");
|
|
846
|
+
const provider = this.providers[gateway];
|
|
847
|
+
if (!provider) throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
848
|
+
let paymentIntent = null;
|
|
849
|
+
try {
|
|
850
|
+
paymentIntent = await provider.createIntent({
|
|
851
|
+
amount: subscription.amount,
|
|
852
|
+
currency: subscription.currency ?? "BDT",
|
|
853
|
+
metadata: {
|
|
854
|
+
...metadata,
|
|
855
|
+
type: "subscription_renewal",
|
|
856
|
+
subscriptionId: subscription._id.toString()
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
} catch (error) {
|
|
860
|
+
this.logger.error("Failed to create payment intent for renewal:", error);
|
|
861
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
862
|
+
}
|
|
863
|
+
const effectiveEntity = entity ?? subscription.metadata?.entity;
|
|
864
|
+
const effectiveMonetizationType = subscription.metadata?.monetizationType ?? MONETIZATION_TYPES.SUBSCRIPTION;
|
|
865
|
+
const category = resolveCategory(effectiveEntity, effectiveMonetizationType, this.config.categoryMappings);
|
|
866
|
+
const transactionFlow = this.config.transactionTypeMapping?.[category] ?? this.config.transactionTypeMapping?.subscription_renewal ?? this.config.transactionTypeMapping?.[effectiveMonetizationType] ?? TRANSACTION_FLOW.INFLOW;
|
|
867
|
+
const commissionRate = getCommissionRate(this.config, category);
|
|
868
|
+
const gatewayFeeRate = getGatewayFeeRate(this.config, gateway);
|
|
869
|
+
const commission = calculateCommission(subscription.amount, commissionRate, gatewayFeeRate);
|
|
870
|
+
const feeAmount = commission?.gatewayFeeAmount || 0;
|
|
871
|
+
const netAmount = subscription.amount - feeAmount;
|
|
872
|
+
const transaction = await this.models.Transaction.create({
|
|
873
|
+
organizationId: subscription.organizationId,
|
|
874
|
+
customerId: subscription.customerId,
|
|
875
|
+
type: category,
|
|
876
|
+
flow: transactionFlow,
|
|
877
|
+
tags: [
|
|
878
|
+
"recurring",
|
|
879
|
+
"subscription",
|
|
880
|
+
"renewal"
|
|
881
|
+
],
|
|
882
|
+
amount: subscription.amount,
|
|
883
|
+
currency: subscription.currency ?? "BDT",
|
|
884
|
+
fee: feeAmount,
|
|
885
|
+
tax: 0,
|
|
886
|
+
net: netAmount,
|
|
887
|
+
method: paymentData?.method ?? "manual",
|
|
888
|
+
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
889
|
+
gateway: {
|
|
890
|
+
provider: gateway,
|
|
891
|
+
sessionId: paymentIntent.sessionId,
|
|
892
|
+
paymentIntentId: paymentIntent.paymentIntentId,
|
|
893
|
+
chargeId: paymentIntent.id,
|
|
894
|
+
metadata: paymentIntent.metadata
|
|
895
|
+
},
|
|
896
|
+
paymentDetails: {
|
|
897
|
+
provider: gateway,
|
|
898
|
+
...paymentData
|
|
899
|
+
},
|
|
900
|
+
...commission && { commission },
|
|
901
|
+
sourceId: subscription._id,
|
|
902
|
+
sourceModel: "Subscription",
|
|
903
|
+
metadata: {
|
|
904
|
+
...metadata,
|
|
905
|
+
subscriptionId: subscription._id.toString(),
|
|
906
|
+
entity: effectiveEntity,
|
|
907
|
+
monetizationType: effectiveMonetizationType,
|
|
908
|
+
isRenewal: true,
|
|
909
|
+
paymentIntentId: paymentIntent.id
|
|
910
|
+
},
|
|
911
|
+
idempotencyKey: idempotencyKey ?? `renewal_${nanoid(16)}`
|
|
912
|
+
});
|
|
913
|
+
subscription.status = "pending_renewal";
|
|
914
|
+
subscription.renewalTransactionId = transaction._id;
|
|
915
|
+
subscription.renewalCount = (subscription.renewalCount ?? 0) + 1;
|
|
916
|
+
await subscription.save();
|
|
917
|
+
this.events.emit("subscription.renewed", {
|
|
918
|
+
subscription,
|
|
919
|
+
transaction,
|
|
920
|
+
paymentIntent: paymentIntent ?? void 0,
|
|
921
|
+
renewalCount: subscription.renewalCount
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
subscription,
|
|
925
|
+
transaction,
|
|
926
|
+
paymentIntent
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Cancel subscription
|
|
931
|
+
*
|
|
932
|
+
* @param subscriptionId - Subscription ID
|
|
933
|
+
* @param options - Cancellation options
|
|
934
|
+
* @returns Updated subscription
|
|
935
|
+
*/
|
|
936
|
+
async cancel(subscriptionId, options = {}) {
|
|
937
|
+
return this.plugins.executeHook("subscription.cancel.before", this.getPluginContext(), {
|
|
938
|
+
subscriptionId,
|
|
939
|
+
...options
|
|
940
|
+
}, async () => {
|
|
941
|
+
const { immediate = false, reason = null } = options;
|
|
942
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
943
|
+
const subscription = await this.models.Subscription.findById(subscriptionId);
|
|
944
|
+
if (!subscription) throw new SubscriptionNotFoundError(subscriptionId);
|
|
945
|
+
const now = /* @__PURE__ */ new Date();
|
|
946
|
+
if (immediate) {
|
|
947
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(subscription.status, "cancelled", subscription._id.toString(), {
|
|
948
|
+
changedBy: "system",
|
|
949
|
+
reason: `Subscription cancelled immediately${reason ? ": " + reason : ""}`,
|
|
950
|
+
metadata: {
|
|
951
|
+
cancellationReason: reason,
|
|
952
|
+
immediate: true
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
subscription.isActive = false;
|
|
956
|
+
subscription.status = "cancelled";
|
|
957
|
+
subscription.canceledAt = now;
|
|
958
|
+
subscription.cancellationReason = reason;
|
|
959
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
960
|
+
} else {
|
|
961
|
+
subscription.cancelAt = subscription.endDate ?? now;
|
|
962
|
+
subscription.cancellationReason = reason;
|
|
963
|
+
}
|
|
964
|
+
await subscription.save();
|
|
965
|
+
this.events.emit("subscription.cancelled", {
|
|
966
|
+
subscription,
|
|
967
|
+
immediate,
|
|
968
|
+
reason: reason ?? void 0,
|
|
969
|
+
canceledAt: immediate ? now : subscription.cancelAt
|
|
970
|
+
});
|
|
971
|
+
return this.plugins.executeHook("subscription.cancel.after", this.getPluginContext(), {
|
|
972
|
+
subscriptionId,
|
|
973
|
+
...options
|
|
974
|
+
}, async () => subscription);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Pause subscription
|
|
979
|
+
*
|
|
980
|
+
* @param subscriptionId - Subscription ID
|
|
981
|
+
* @param options - Pause options
|
|
982
|
+
* @returns Updated subscription
|
|
983
|
+
*/
|
|
984
|
+
async pause(subscriptionId, options = {}) {
|
|
985
|
+
return this.plugins.executeHook("subscription.pause.before", this.getPluginContext(), {
|
|
986
|
+
subscriptionId,
|
|
987
|
+
...options
|
|
988
|
+
}, async () => {
|
|
989
|
+
const { reason = null } = options;
|
|
990
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
991
|
+
const subscription = await this.models.Subscription.findById(subscriptionId);
|
|
992
|
+
if (!subscription) throw new SubscriptionNotFoundError(subscriptionId);
|
|
993
|
+
if (!subscription.isActive) throw new SubscriptionNotActiveError(subscriptionId, "Only active subscriptions can be paused");
|
|
994
|
+
const pausedAt = /* @__PURE__ */ new Date();
|
|
995
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(subscription.status, "paused", subscription._id.toString(), {
|
|
996
|
+
changedBy: "system",
|
|
997
|
+
reason: `Subscription paused${reason ? ": " + reason : ""}`,
|
|
998
|
+
metadata: {
|
|
999
|
+
pauseReason: reason,
|
|
1000
|
+
pausedAt
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
subscription.isActive = false;
|
|
1004
|
+
subscription.status = "paused";
|
|
1005
|
+
subscription.pausedAt = pausedAt;
|
|
1006
|
+
subscription.pauseReason = reason;
|
|
1007
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
1008
|
+
await subscription.save();
|
|
1009
|
+
this.events.emit("subscription.paused", {
|
|
1010
|
+
subscription,
|
|
1011
|
+
reason: reason ?? void 0,
|
|
1012
|
+
pausedAt
|
|
1013
|
+
});
|
|
1014
|
+
return this.plugins.executeHook("subscription.pause.after", this.getPluginContext(), {
|
|
1015
|
+
subscriptionId,
|
|
1016
|
+
...options
|
|
1017
|
+
}, async () => subscription);
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Resume subscription
|
|
1022
|
+
*
|
|
1023
|
+
* @param subscriptionId - Subscription ID
|
|
1024
|
+
* @param options - Resume options
|
|
1025
|
+
* @returns Updated subscription
|
|
1026
|
+
*/
|
|
1027
|
+
async resume(subscriptionId, options = {}) {
|
|
1028
|
+
return this.plugins.executeHook("subscription.resume.before", this.getPluginContext(), {
|
|
1029
|
+
subscriptionId,
|
|
1030
|
+
...options
|
|
1031
|
+
}, async () => {
|
|
1032
|
+
const { extendPeriod = false } = options;
|
|
1033
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
1034
|
+
const subscription = await this.models.Subscription.findById(subscriptionId);
|
|
1035
|
+
if (!subscription) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1036
|
+
if (!subscription.pausedAt) throw new InvalidStateTransitionError("resume", "paused", subscription.status, "Only paused subscriptions can be resumed");
|
|
1037
|
+
const now = /* @__PURE__ */ new Date();
|
|
1038
|
+
const pausedAt = new Date(subscription.pausedAt);
|
|
1039
|
+
const pauseDuration = now.getTime() - pausedAt.getTime();
|
|
1040
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(subscription.status, "active", subscription._id.toString(), {
|
|
1041
|
+
changedBy: "system",
|
|
1042
|
+
reason: "Subscription resumed from paused state",
|
|
1043
|
+
metadata: {
|
|
1044
|
+
pausedAt,
|
|
1045
|
+
pauseDuration,
|
|
1046
|
+
extendPeriod,
|
|
1047
|
+
newEndDate: extendPeriod && subscription.endDate ? new Date(new Date(subscription.endDate).getTime() + pauseDuration) : void 0
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
subscription.isActive = true;
|
|
1051
|
+
subscription.status = "active";
|
|
1052
|
+
subscription.pausedAt = null;
|
|
1053
|
+
subscription.pauseReason = null;
|
|
1054
|
+
if (extendPeriod && subscription.endDate) {
|
|
1055
|
+
const currentEnd = new Date(subscription.endDate);
|
|
1056
|
+
subscription.endDate = new Date(currentEnd.getTime() + pauseDuration);
|
|
1057
|
+
}
|
|
1058
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
1059
|
+
await subscription.save();
|
|
1060
|
+
this.events.emit("subscription.resumed", {
|
|
1061
|
+
subscription,
|
|
1062
|
+
extendPeriod,
|
|
1063
|
+
pauseDuration,
|
|
1064
|
+
resumedAt: now
|
|
1065
|
+
});
|
|
1066
|
+
return this.plugins.executeHook("subscription.resume.after", this.getPluginContext(), {
|
|
1067
|
+
subscriptionId,
|
|
1068
|
+
...options
|
|
1069
|
+
}, async () => subscription);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* List subscriptions with filters
|
|
1074
|
+
*
|
|
1075
|
+
* @param filters - Query filters
|
|
1076
|
+
* @param options - Query options (limit, skip, sort)
|
|
1077
|
+
* @returns Subscriptions
|
|
1078
|
+
*/
|
|
1079
|
+
async list(filters = {}, options = {}) {
|
|
1080
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
1081
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1082
|
+
const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
|
|
1083
|
+
return await SubscriptionModel.find(filters).limit(limit).skip(skip).sort(sort);
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Get subscription by ID
|
|
1087
|
+
*
|
|
1088
|
+
* @param subscriptionId - Subscription ID
|
|
1089
|
+
* @returns Subscription
|
|
1090
|
+
*/
|
|
1091
|
+
async get(subscriptionId) {
|
|
1092
|
+
if (!this.models.Subscription) throw new ModelNotRegisteredError("Subscription");
|
|
1093
|
+
const subscription = await this.models.Subscription.findById(subscriptionId);
|
|
1094
|
+
if (!subscription) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1095
|
+
return subscription;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Calculate period end date based on plan key
|
|
1099
|
+
* @private
|
|
1100
|
+
*/
|
|
1101
|
+
_calculatePeriodEnd(planKey, startDate = /* @__PURE__ */ new Date()) {
|
|
1102
|
+
const start = new Date(startDate);
|
|
1103
|
+
const end = new Date(start);
|
|
1104
|
+
switch (planKey) {
|
|
1105
|
+
case "monthly":
|
|
1106
|
+
end.setMonth(end.getMonth() + 1);
|
|
1107
|
+
break;
|
|
1108
|
+
case "quarterly":
|
|
1109
|
+
end.setMonth(end.getMonth() + 3);
|
|
1110
|
+
break;
|
|
1111
|
+
case "yearly":
|
|
1112
|
+
end.setFullYear(end.getFullYear() + 1);
|
|
1113
|
+
break;
|
|
1114
|
+
default: end.setDate(end.getDate() + 30);
|
|
1115
|
+
}
|
|
1116
|
+
return end;
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
//#endregion
|
|
1121
|
+
//#region src/application/services/payment.service.ts
|
|
1122
|
+
/**
|
|
1123
|
+
* Payment Service
|
|
1124
|
+
* @classytic/revenue
|
|
1125
|
+
*
|
|
1126
|
+
* Framework-agnostic payment verification and management service with DI
|
|
1127
|
+
* Handles payment verification, refunds, and status updates
|
|
1128
|
+
*/
|
|
1129
|
+
/**
|
|
1130
|
+
* Payment Service
|
|
1131
|
+
* Uses DI container for all dependencies
|
|
1132
|
+
*
|
|
1133
|
+
* Architecture:
|
|
1134
|
+
* - PluginManager: Wraps operations with lifecycle hooks (before/after)
|
|
1135
|
+
* - EventBus: Fire-and-forget notifications for completed operations
|
|
1136
|
+
*/
|
|
1137
|
+
var PaymentService = class {
|
|
1138
|
+
models;
|
|
1139
|
+
providers;
|
|
1140
|
+
config;
|
|
1141
|
+
plugins;
|
|
1142
|
+
logger;
|
|
1143
|
+
events;
|
|
1144
|
+
retryConfig;
|
|
1145
|
+
circuitBreaker;
|
|
1146
|
+
constructor(container) {
|
|
1147
|
+
this.models = container.get("models");
|
|
1148
|
+
this.providers = container.get("providers");
|
|
1149
|
+
this.config = container.get("config");
|
|
1150
|
+
this.plugins = container.get("plugins");
|
|
1151
|
+
this.logger = container.get("logger");
|
|
1152
|
+
this.events = container.get("events");
|
|
1153
|
+
this.retryConfig = container.get("retryConfig");
|
|
1154
|
+
this.circuitBreaker = container.get("circuitBreaker");
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Create plugin context for hook execution
|
|
1158
|
+
* @private
|
|
1159
|
+
*/
|
|
1160
|
+
getPluginContext(idempotencyKey) {
|
|
1161
|
+
return {
|
|
1162
|
+
events: this.events,
|
|
1163
|
+
logger: this.logger,
|
|
1164
|
+
storage: /* @__PURE__ */ new Map(),
|
|
1165
|
+
meta: {
|
|
1166
|
+
idempotencyKey,
|
|
1167
|
+
requestId: nanoid(),
|
|
1168
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Execute provider call with retry and circuit breaker protection
|
|
1174
|
+
* @private
|
|
1175
|
+
*/
|
|
1176
|
+
async executeProviderCall(operation, operationName) {
|
|
1177
|
+
const withCircuitBreaker = this.circuitBreaker ? () => this.circuitBreaker.execute(operation) : operation;
|
|
1178
|
+
if (this.retryConfig && Object.keys(this.retryConfig).length > 0) return retry(withCircuitBreaker, {
|
|
1179
|
+
...this.retryConfig,
|
|
1180
|
+
onRetry: (error, attempt, delay) => {
|
|
1181
|
+
this.logger.warn(`[${operationName}] Retry attempt ${attempt} after ${delay}ms:`, error);
|
|
1182
|
+
this.retryConfig.onRetry?.(error, attempt, delay);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
return withCircuitBreaker();
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Verify a payment
|
|
1189
|
+
*
|
|
1190
|
+
* @param paymentIntentId - Payment intent ID, session ID, or transaction ID
|
|
1191
|
+
* @param options - Verification options
|
|
1192
|
+
* @returns { transaction, status }
|
|
1193
|
+
*/
|
|
1194
|
+
async verify(paymentIntentId, options = {}) {
|
|
1195
|
+
return this.plugins.executeHook("payment.verify.before", this.getPluginContext(), {
|
|
1196
|
+
id: paymentIntentId,
|
|
1197
|
+
...options
|
|
1198
|
+
}, async () => {
|
|
1199
|
+
const { verifiedBy = null } = options;
|
|
1200
|
+
const TransactionModel = this.models.Transaction;
|
|
1201
|
+
const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
|
|
1202
|
+
if (!transaction) throw new TransactionNotFoundError(paymentIntentId);
|
|
1203
|
+
if (transaction.status === "verified" || transaction.status === "completed") throw new AlreadyVerifiedError(transaction._id.toString());
|
|
1204
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
1205
|
+
const provider = this.providers[gatewayType];
|
|
1206
|
+
if (!provider) throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
1207
|
+
let paymentResult = null;
|
|
1208
|
+
try {
|
|
1209
|
+
const actualIntentId = transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentIntentId;
|
|
1210
|
+
paymentResult = await this.executeProviderCall(() => provider.verifyPayment(actualIntentId), `${gatewayType}.verifyPayment`);
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
this.logger.error("Payment verification failed:", error);
|
|
1213
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(transaction.status, "failed", transaction._id.toString(), {
|
|
1214
|
+
changedBy: "system",
|
|
1215
|
+
reason: `Payment verification failed: ${error.message}`,
|
|
1216
|
+
metadata: { error: error.message }
|
|
1217
|
+
});
|
|
1218
|
+
transaction.status = "failed";
|
|
1219
|
+
transaction.failureReason = error.message;
|
|
1220
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1221
|
+
transaction.metadata = {
|
|
1222
|
+
...transaction.metadata,
|
|
1223
|
+
verificationError: error.message,
|
|
1224
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1225
|
+
};
|
|
1226
|
+
await transaction.save();
|
|
1227
|
+
this.events.emit("payment.failed", {
|
|
1228
|
+
transaction,
|
|
1229
|
+
error: error.message,
|
|
1230
|
+
provider: gatewayType,
|
|
1231
|
+
paymentIntentId
|
|
1232
|
+
});
|
|
1233
|
+
throw new PaymentVerificationError(paymentIntentId, error.message);
|
|
1234
|
+
}
|
|
1235
|
+
if (paymentResult.amount && paymentResult.amount !== transaction.amount) throw new ValidationError(`Amount mismatch: expected ${transaction.amount}, got ${paymentResult.amount}`, {
|
|
1236
|
+
expected: transaction.amount,
|
|
1237
|
+
actual: paymentResult.amount
|
|
1238
|
+
});
|
|
1239
|
+
if (paymentResult.currency && paymentResult.currency.toUpperCase() !== transaction.currency.toUpperCase()) throw new ValidationError(`Currency mismatch: expected ${transaction.currency}, got ${paymentResult.currency}`, {
|
|
1240
|
+
expected: transaction.currency,
|
|
1241
|
+
actual: paymentResult.currency
|
|
1242
|
+
});
|
|
1243
|
+
const newStatus = paymentResult.status === "succeeded" ? "verified" : paymentResult.status;
|
|
1244
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(transaction.status, newStatus, transaction._id.toString(), {
|
|
1245
|
+
changedBy: verifiedBy ?? "system",
|
|
1246
|
+
reason: `Payment verification ${paymentResult.status === "succeeded" ? "succeeded" : "resulted in status: " + newStatus}`,
|
|
1247
|
+
metadata: { paymentResult: paymentResult.metadata }
|
|
1248
|
+
});
|
|
1249
|
+
transaction.status = newStatus;
|
|
1250
|
+
transaction.verifiedAt = paymentResult.paidAt ?? /* @__PURE__ */ new Date();
|
|
1251
|
+
transaction.verifiedBy = verifiedBy;
|
|
1252
|
+
transaction.gateway = {
|
|
1253
|
+
...transaction.gateway,
|
|
1254
|
+
type: transaction.gateway?.type ?? "manual",
|
|
1255
|
+
verificationData: paymentResult.metadata
|
|
1256
|
+
};
|
|
1257
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1258
|
+
await transaction.save();
|
|
1259
|
+
if (newStatus === "verified") this.events.emit("payment.verified", {
|
|
1260
|
+
transaction,
|
|
1261
|
+
paymentResult,
|
|
1262
|
+
verifiedBy: verifiedBy || void 0
|
|
1263
|
+
});
|
|
1264
|
+
else if (newStatus === "failed") this.events.emit("payment.failed", {
|
|
1265
|
+
transaction,
|
|
1266
|
+
error: paymentResult.metadata?.errorMessage || "Payment verification failed",
|
|
1267
|
+
provider: gatewayType,
|
|
1268
|
+
paymentIntentId: transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentIntentId
|
|
1269
|
+
});
|
|
1270
|
+
else if (newStatus === "requires_action") this.events.emit("payment.requires_action", {
|
|
1271
|
+
transaction,
|
|
1272
|
+
paymentResult,
|
|
1273
|
+
action: paymentResult.metadata?.requiredAction
|
|
1274
|
+
});
|
|
1275
|
+
else if (newStatus === "processing") this.events.emit("payment.processing", {
|
|
1276
|
+
transaction,
|
|
1277
|
+
paymentResult
|
|
1278
|
+
});
|
|
1279
|
+
const result = {
|
|
1280
|
+
transaction,
|
|
1281
|
+
paymentResult,
|
|
1282
|
+
status: transaction.status
|
|
1283
|
+
};
|
|
1284
|
+
return this.plugins.executeHook("payment.verify.after", this.getPluginContext(), {
|
|
1285
|
+
id: paymentIntentId,
|
|
1286
|
+
...options
|
|
1287
|
+
}, async () => result);
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Get payment status
|
|
1292
|
+
*
|
|
1293
|
+
* @param paymentIntentId - Payment intent ID, session ID, or transaction ID
|
|
1294
|
+
* @returns { transaction, status }
|
|
1295
|
+
*/
|
|
1296
|
+
async getStatus(paymentIntentId) {
|
|
1297
|
+
const TransactionModel = this.models.Transaction;
|
|
1298
|
+
const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
|
|
1299
|
+
if (!transaction) throw new TransactionNotFoundError(paymentIntentId);
|
|
1300
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
1301
|
+
const provider = this.providers[gatewayType];
|
|
1302
|
+
if (!provider) throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
1303
|
+
let paymentResult = null;
|
|
1304
|
+
try {
|
|
1305
|
+
const actualIntentId = transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentIntentId;
|
|
1306
|
+
paymentResult = await this.executeProviderCall(() => provider.getStatus(actualIntentId), `${gatewayType}.getStatus`);
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
this.logger.warn("Failed to get payment status from provider:", error);
|
|
1309
|
+
return {
|
|
1310
|
+
transaction,
|
|
1311
|
+
status: transaction.status,
|
|
1312
|
+
provider: gatewayType
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
transaction,
|
|
1317
|
+
paymentResult,
|
|
1318
|
+
status: paymentResult.status,
|
|
1319
|
+
provider: gatewayType
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Refund a payment
|
|
1324
|
+
*
|
|
1325
|
+
* @param paymentId - Payment intent ID, session ID, or transaction ID
|
|
1326
|
+
* @param amount - Amount to refund (optional, full refund if not provided)
|
|
1327
|
+
* @param options - Refund options
|
|
1328
|
+
* @returns { transaction, refundResult }
|
|
1329
|
+
*/
|
|
1330
|
+
async refund(paymentId, amount = null, options = {}) {
|
|
1331
|
+
return this.plugins.executeHook("payment.refund.before", this.getPluginContext(), {
|
|
1332
|
+
transactionId: paymentId,
|
|
1333
|
+
amount,
|
|
1334
|
+
...options
|
|
1335
|
+
}, async () => {
|
|
1336
|
+
const { reason = null } = options;
|
|
1337
|
+
const TransactionModel = this.models.Transaction;
|
|
1338
|
+
const transaction = await this._findTransaction(TransactionModel, paymentId);
|
|
1339
|
+
if (!transaction) throw new TransactionNotFoundError(paymentId);
|
|
1340
|
+
if (transaction.status !== "verified" && transaction.status !== "completed" && transaction.status !== "partially_refunded") throw new InvalidStateTransitionError("transaction", transaction._id.toString(), transaction.status, "verified, completed, or partially_refunded");
|
|
1341
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
1342
|
+
const provider = this.providers[gatewayType];
|
|
1343
|
+
if (!provider) throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
1344
|
+
if (!provider.getCapabilities().supportsRefunds) throw new RefundNotSupportedError(gatewayType);
|
|
1345
|
+
const refundedSoFar = transaction.refundedAmount ?? 0;
|
|
1346
|
+
const refundableAmount = transaction.amount - refundedSoFar;
|
|
1347
|
+
const refundAmount = amount ?? refundableAmount;
|
|
1348
|
+
if (refundAmount <= 0) throw new ValidationError(`Refund amount must be positive, got ${refundAmount}`);
|
|
1349
|
+
if (refundAmount > refundableAmount) throw new ValidationError(`Refund amount (${refundAmount}) exceeds refundable balance (${refundableAmount})`, {
|
|
1350
|
+
refundAmount,
|
|
1351
|
+
refundableAmount,
|
|
1352
|
+
alreadyRefunded: refundedSoFar
|
|
1353
|
+
});
|
|
1354
|
+
let refundResult;
|
|
1355
|
+
try {
|
|
1356
|
+
const actualIntentId = transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentId;
|
|
1357
|
+
refundResult = await this.executeProviderCall(() => provider.refund(actualIntentId, refundAmount, { reason: reason ?? void 0 }), `${gatewayType}.refund`);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
this.logger.error("Refund failed:", error);
|
|
1360
|
+
throw new RefundError(paymentId, error.message);
|
|
1361
|
+
}
|
|
1362
|
+
const refundFlow = this.config.transactionTypeMapping?.refund ?? TRANSACTION_FLOW.OUTFLOW;
|
|
1363
|
+
const refundCommission = transaction.commission ? reverseCommission(transaction.commission, transaction.amount, refundAmount) : null;
|
|
1364
|
+
let refundTaxAmount = 0;
|
|
1365
|
+
if (transaction.tax && transaction.tax > 0 && transaction.amount > 0) {
|
|
1366
|
+
const ratio = refundAmount / transaction.amount;
|
|
1367
|
+
refundTaxAmount = Math.round(transaction.tax * ratio);
|
|
1368
|
+
}
|
|
1369
|
+
const refundFeeAmount = refundCommission?.gatewayFeeAmount || 0;
|
|
1370
|
+
const refundNetAmount = refundAmount - refundFeeAmount - refundTaxAmount;
|
|
1371
|
+
const refundTransaction = await TransactionModel.create({
|
|
1372
|
+
organizationId: transaction.organizationId,
|
|
1373
|
+
customerId: transaction.customerId,
|
|
1374
|
+
type: "refund",
|
|
1375
|
+
flow: refundFlow,
|
|
1376
|
+
tags: ["refund"],
|
|
1377
|
+
amount: refundAmount,
|
|
1378
|
+
currency: transaction.currency,
|
|
1379
|
+
fee: refundFeeAmount,
|
|
1380
|
+
tax: refundTaxAmount,
|
|
1381
|
+
net: refundNetAmount,
|
|
1382
|
+
...transaction.taxDetails && { taxDetails: transaction.taxDetails },
|
|
1383
|
+
method: transaction.method ?? "manual",
|
|
1384
|
+
status: "completed",
|
|
1385
|
+
gateway: {
|
|
1386
|
+
type: transaction.gateway?.type ?? gatewayType,
|
|
1387
|
+
provider: transaction.gateway?.provider ?? "manual",
|
|
1388
|
+
paymentIntentId: refundResult.id,
|
|
1389
|
+
chargeId: refundResult.id
|
|
1390
|
+
},
|
|
1391
|
+
paymentDetails: transaction.paymentDetails,
|
|
1392
|
+
...refundCommission && { commission: refundCommission },
|
|
1393
|
+
...transaction.sourceId && { sourceId: transaction.sourceId },
|
|
1394
|
+
...transaction.sourceModel && { sourceModel: transaction.sourceModel },
|
|
1395
|
+
relatedTransactionId: transaction._id,
|
|
1396
|
+
metadata: {
|
|
1397
|
+
...transaction.metadata,
|
|
1398
|
+
isRefund: true,
|
|
1399
|
+
originalTransactionId: transaction._id.toString(),
|
|
1400
|
+
refundReason: reason,
|
|
1401
|
+
refundResult: refundResult.metadata
|
|
1402
|
+
},
|
|
1403
|
+
idempotencyKey: `refund_${transaction._id}_${Date.now()}`
|
|
1404
|
+
});
|
|
1405
|
+
const isPartialRefund = refundAmount < refundableAmount;
|
|
1406
|
+
const refundStatus = isPartialRefund ? "partially_refunded" : "refunded";
|
|
1407
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(transaction.status, refundStatus, transaction._id.toString(), {
|
|
1408
|
+
changedBy: "system",
|
|
1409
|
+
reason: `Refund processed: ${isPartialRefund ? "partial" : "full"} refund of ${refundAmount}${reason ? " - " + reason : ""}`,
|
|
1410
|
+
metadata: {
|
|
1411
|
+
refundAmount,
|
|
1412
|
+
isPartialRefund,
|
|
1413
|
+
refundTransactionId: refundTransaction._id.toString()
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
transaction.status = refundStatus;
|
|
1417
|
+
transaction.refundedAmount = (transaction.refundedAmount ?? 0) + refundAmount;
|
|
1418
|
+
transaction.refundedAt = refundResult.refundedAt ?? /* @__PURE__ */ new Date();
|
|
1419
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1420
|
+
transaction.metadata = {
|
|
1421
|
+
...transaction.metadata,
|
|
1422
|
+
refundTransactionId: refundTransaction._id.toString(),
|
|
1423
|
+
refundReason: reason
|
|
1424
|
+
};
|
|
1425
|
+
await transaction.save();
|
|
1426
|
+
this.events.emit("payment.refunded", {
|
|
1427
|
+
transaction,
|
|
1428
|
+
refundTransaction,
|
|
1429
|
+
refundResult: {
|
|
1430
|
+
...refundResult,
|
|
1431
|
+
currency: refundResult.currency ?? "USD",
|
|
1432
|
+
metadata: refundResult.metadata ?? {}
|
|
1433
|
+
},
|
|
1434
|
+
refundAmount,
|
|
1435
|
+
reason: reason ?? void 0,
|
|
1436
|
+
isPartialRefund
|
|
1437
|
+
});
|
|
1438
|
+
const result = {
|
|
1439
|
+
transaction,
|
|
1440
|
+
refundTransaction,
|
|
1441
|
+
refundResult,
|
|
1442
|
+
status: transaction.status
|
|
1443
|
+
};
|
|
1444
|
+
return this.plugins.executeHook("payment.refund.after", this.getPluginContext(), {
|
|
1445
|
+
transactionId: paymentId,
|
|
1446
|
+
amount,
|
|
1447
|
+
...options
|
|
1448
|
+
}, async () => result);
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Handle webhook from payment provider
|
|
1453
|
+
*
|
|
1454
|
+
* @param provider - Provider name
|
|
1455
|
+
* @param payload - Webhook payload
|
|
1456
|
+
* @param headers - Request headers
|
|
1457
|
+
* @returns { event, transaction }
|
|
1458
|
+
*/
|
|
1459
|
+
async handleWebhook(providerName, payload, headers = {}) {
|
|
1460
|
+
const provider = this.providers[providerName];
|
|
1461
|
+
if (!provider) throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
|
|
1462
|
+
if (!provider.getCapabilities().supportsWebhooks) throw new ProviderCapabilityError(providerName, "webhooks");
|
|
1463
|
+
let webhookEvent;
|
|
1464
|
+
try {
|
|
1465
|
+
webhookEvent = await this.executeProviderCall(() => provider.handleWebhook(payload, headers), `${providerName}.handleWebhook`);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
this.logger.error("Webhook processing failed:", error);
|
|
1468
|
+
throw new ProviderError(`Webhook processing failed for ${providerName}: ${error.message}`, "WEBHOOK_PROCESSING_FAILED", { retryable: false });
|
|
1469
|
+
}
|
|
1470
|
+
if (!webhookEvent?.data?.sessionId && !webhookEvent?.data?.paymentIntentId) throw new ValidationError(`Invalid webhook event structure from ${providerName}: missing sessionId or paymentIntentId`, {
|
|
1471
|
+
provider: providerName,
|
|
1472
|
+
eventType: webhookEvent?.type
|
|
1473
|
+
});
|
|
1474
|
+
const TransactionModel = this.models.Transaction;
|
|
1475
|
+
let transaction = null;
|
|
1476
|
+
if (webhookEvent.data.sessionId) transaction = await TransactionModel.findOne({ "gateway.sessionId": webhookEvent.data.sessionId });
|
|
1477
|
+
if (!transaction && webhookEvent.data.paymentIntentId) transaction = await TransactionModel.findOne({ "gateway.paymentIntentId": webhookEvent.data.paymentIntentId });
|
|
1478
|
+
if (!transaction) {
|
|
1479
|
+
this.logger.warn("Transaction not found for webhook event", {
|
|
1480
|
+
provider: providerName,
|
|
1481
|
+
eventId: webhookEvent.id,
|
|
1482
|
+
sessionId: webhookEvent.data.sessionId,
|
|
1483
|
+
paymentIntentId: webhookEvent.data.paymentIntentId
|
|
1484
|
+
});
|
|
1485
|
+
throw new TransactionNotFoundError(webhookEvent.data.sessionId ?? webhookEvent.data.paymentIntentId ?? "unknown");
|
|
1486
|
+
}
|
|
1487
|
+
if (webhookEvent.data.sessionId && !transaction.gateway?.sessionId) transaction.gateway = {
|
|
1488
|
+
...transaction.gateway,
|
|
1489
|
+
type: transaction.gateway?.type ?? "manual",
|
|
1490
|
+
sessionId: webhookEvent.data.sessionId
|
|
1491
|
+
};
|
|
1492
|
+
if (webhookEvent.data.paymentIntentId && !transaction.gateway?.paymentIntentId) transaction.gateway = {
|
|
1493
|
+
...transaction.gateway,
|
|
1494
|
+
type: transaction.gateway?.type ?? "manual",
|
|
1495
|
+
paymentIntentId: webhookEvent.data.paymentIntentId
|
|
1496
|
+
};
|
|
1497
|
+
if (transaction.webhook?.eventId === webhookEvent.id && transaction.webhook?.processedAt) {
|
|
1498
|
+
this.logger.warn("Webhook already processed", {
|
|
1499
|
+
transactionId: transaction._id,
|
|
1500
|
+
eventId: webhookEvent.id
|
|
1501
|
+
});
|
|
1502
|
+
return {
|
|
1503
|
+
event: webhookEvent,
|
|
1504
|
+
transaction,
|
|
1505
|
+
status: "already_processed"
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
transaction.webhook = {
|
|
1509
|
+
eventId: webhookEvent.id,
|
|
1510
|
+
eventType: webhookEvent.type,
|
|
1511
|
+
receivedAt: /* @__PURE__ */ new Date(),
|
|
1512
|
+
processedAt: /* @__PURE__ */ new Date(),
|
|
1513
|
+
data: webhookEvent.data
|
|
1514
|
+
};
|
|
1515
|
+
let newStatus = transaction.status;
|
|
1516
|
+
if (webhookEvent.type === "payment.succeeded") newStatus = "verified";
|
|
1517
|
+
else if (webhookEvent.type === "payment.failed") newStatus = "failed";
|
|
1518
|
+
else if (webhookEvent.type === "refund.succeeded") newStatus = "refunded";
|
|
1519
|
+
if (newStatus !== transaction.status) {
|
|
1520
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(transaction.status, newStatus, transaction._id.toString(), {
|
|
1521
|
+
changedBy: "webhook",
|
|
1522
|
+
reason: `Webhook event: ${webhookEvent.type}`,
|
|
1523
|
+
metadata: {
|
|
1524
|
+
webhookId: webhookEvent.id,
|
|
1525
|
+
webhookType: webhookEvent.type,
|
|
1526
|
+
webhookData: webhookEvent.data
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
transaction.status = newStatus;
|
|
1530
|
+
if (newStatus === "verified") transaction.verifiedAt = webhookEvent.createdAt;
|
|
1531
|
+
else if (newStatus === "refunded") transaction.refundedAt = webhookEvent.createdAt;
|
|
1532
|
+
else if (newStatus === "failed") transaction.failedAt = webhookEvent.createdAt;
|
|
1533
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1534
|
+
}
|
|
1535
|
+
await transaction.save();
|
|
1536
|
+
this.events.emit("webhook.processed", {
|
|
1537
|
+
webhookType: webhookEvent.type,
|
|
1538
|
+
provider: webhookEvent.provider,
|
|
1539
|
+
event: webhookEvent,
|
|
1540
|
+
transaction,
|
|
1541
|
+
processedAt: /* @__PURE__ */ new Date()
|
|
1542
|
+
});
|
|
1543
|
+
return {
|
|
1544
|
+
event: webhookEvent,
|
|
1545
|
+
transaction,
|
|
1546
|
+
status: "processed"
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* List payments/transactions with filters
|
|
1551
|
+
*
|
|
1552
|
+
* @param filters - Query filters
|
|
1553
|
+
* @param options - Query options (limit, skip, sort)
|
|
1554
|
+
* @returns Transactions
|
|
1555
|
+
*/
|
|
1556
|
+
async list(filters = {}, options = {}) {
|
|
1557
|
+
const TransactionModel = this.models.Transaction;
|
|
1558
|
+
const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
|
|
1559
|
+
return await TransactionModel.find(filters).limit(limit).skip(skip).sort(sort);
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Get payment/transaction by ID
|
|
1563
|
+
*
|
|
1564
|
+
* @param transactionId - Transaction ID
|
|
1565
|
+
* @returns Transaction
|
|
1566
|
+
*/
|
|
1567
|
+
async get(transactionId) {
|
|
1568
|
+
const transaction = await this.models.Transaction.findById(transactionId);
|
|
1569
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1570
|
+
return transaction;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Get provider instance
|
|
1574
|
+
*
|
|
1575
|
+
* @param providerName - Provider name
|
|
1576
|
+
* @returns Provider instance
|
|
1577
|
+
*/
|
|
1578
|
+
getProvider(providerName) {
|
|
1579
|
+
const provider = this.providers[providerName];
|
|
1580
|
+
if (!provider) throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
|
|
1581
|
+
return provider;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Find transaction by sessionId, paymentIntentId, or transaction ID
|
|
1585
|
+
* @private
|
|
1586
|
+
*/
|
|
1587
|
+
async _findTransaction(TransactionModel, identifier) {
|
|
1588
|
+
let transaction = await TransactionModel.findOne({ "gateway.sessionId": identifier });
|
|
1589
|
+
if (!transaction) transaction = await TransactionModel.findOne({ "gateway.paymentIntentId": identifier });
|
|
1590
|
+
if (!transaction) transaction = await TransactionModel.findById(identifier);
|
|
1591
|
+
return transaction;
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
//#endregion
|
|
1596
|
+
//#region src/application/services/transaction.service.ts
|
|
1597
|
+
/**
|
|
1598
|
+
* Transaction Service
|
|
1599
|
+
* @classytic/revenue
|
|
1600
|
+
*
|
|
1601
|
+
* Thin, focused transaction service for core operations
|
|
1602
|
+
* Users handle their own analytics, exports, and complex queries
|
|
1603
|
+
*
|
|
1604
|
+
* Works with ANY model implementation:
|
|
1605
|
+
* - Plain Mongoose models
|
|
1606
|
+
* - @classytic/mongokit Repository instances
|
|
1607
|
+
* - Any other abstraction with compatible interface
|
|
1608
|
+
*/
|
|
1609
|
+
/**
|
|
1610
|
+
* Transaction Service
|
|
1611
|
+
* Focused on core transaction lifecycle operations
|
|
1612
|
+
*
|
|
1613
|
+
* Architecture:
|
|
1614
|
+
* - PluginManager: Wraps operations with lifecycle hooks (before/after)
|
|
1615
|
+
* - EventBus: Fire-and-forget notifications for completed operations
|
|
1616
|
+
*/
|
|
1617
|
+
var TransactionService = class {
|
|
1618
|
+
models;
|
|
1619
|
+
plugins;
|
|
1620
|
+
events;
|
|
1621
|
+
logger;
|
|
1622
|
+
constructor(container) {
|
|
1623
|
+
this.models = container.get("models");
|
|
1624
|
+
this.plugins = container.get("plugins");
|
|
1625
|
+
this.events = container.get("events");
|
|
1626
|
+
this.logger = container.get("logger");
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Create plugin context for hook execution
|
|
1630
|
+
* @private
|
|
1631
|
+
*/
|
|
1632
|
+
getPluginContext() {
|
|
1633
|
+
return {
|
|
1634
|
+
events: this.events,
|
|
1635
|
+
logger: this.logger,
|
|
1636
|
+
storage: /* @__PURE__ */ new Map(),
|
|
1637
|
+
meta: {
|
|
1638
|
+
requestId: nanoid(),
|
|
1639
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Get transaction by ID
|
|
1645
|
+
*
|
|
1646
|
+
* @param transactionId - Transaction ID
|
|
1647
|
+
* @returns Transaction
|
|
1648
|
+
*/
|
|
1649
|
+
async get(transactionId) {
|
|
1650
|
+
const transaction = await this.models.Transaction.findById(transactionId);
|
|
1651
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1652
|
+
return transaction;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* List transactions with filters
|
|
1656
|
+
*
|
|
1657
|
+
* @param filters - Query filters
|
|
1658
|
+
* @param options - Query options (limit, skip, sort, populate)
|
|
1659
|
+
* @returns { transactions, total, page, limit }
|
|
1660
|
+
*/
|
|
1661
|
+
async list(filters = {}, options = {}) {
|
|
1662
|
+
const TransactionModel = this.models.Transaction;
|
|
1663
|
+
const { limit = 50, skip = 0, page = null, sort = { createdAt: -1 }, populate = [] } = options;
|
|
1664
|
+
const actualSkip = page ? (page - 1) * limit : skip;
|
|
1665
|
+
let query = TransactionModel.find(filters).limit(limit).skip(actualSkip).sort(sort);
|
|
1666
|
+
if (populate.length > 0 && typeof query.populate === "function") populate.forEach((field) => {
|
|
1667
|
+
query = query.populate(field);
|
|
1668
|
+
});
|
|
1669
|
+
const transactions = await query;
|
|
1670
|
+
const model = TransactionModel;
|
|
1671
|
+
const total = await (model.countDocuments ? model.countDocuments(filters) : model.count?.(filters)) ?? 0;
|
|
1672
|
+
return {
|
|
1673
|
+
transactions,
|
|
1674
|
+
total,
|
|
1675
|
+
page: page ?? Math.floor(actualSkip / limit) + 1,
|
|
1676
|
+
limit,
|
|
1677
|
+
pages: Math.ceil(total / limit)
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Update transaction
|
|
1682
|
+
*
|
|
1683
|
+
* @param transactionId - Transaction ID
|
|
1684
|
+
* @param updates - Fields to update
|
|
1685
|
+
* @returns Updated transaction
|
|
1686
|
+
*/
|
|
1687
|
+
async update(transactionId, updates) {
|
|
1688
|
+
const hookInput = {
|
|
1689
|
+
transactionId,
|
|
1690
|
+
updates
|
|
1691
|
+
};
|
|
1692
|
+
return this.plugins.executeHook("transaction.update.before", this.getPluginContext(), hookInput, async () => {
|
|
1693
|
+
const TransactionModel = this.models.Transaction;
|
|
1694
|
+
const effectiveUpdates = hookInput.updates;
|
|
1695
|
+
const model = TransactionModel;
|
|
1696
|
+
let transaction;
|
|
1697
|
+
if (typeof model.update === "function") transaction = await model.update(transactionId, effectiveUpdates);
|
|
1698
|
+
else if (typeof model.findByIdAndUpdate === "function") transaction = await model.findByIdAndUpdate(transactionId, { $set: effectiveUpdates }, { new: true });
|
|
1699
|
+
else throw new Error("Transaction model does not support update operations");
|
|
1700
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1701
|
+
this.events.emit("transaction.updated", {
|
|
1702
|
+
transaction,
|
|
1703
|
+
updates: effectiveUpdates
|
|
1704
|
+
});
|
|
1705
|
+
return this.plugins.executeHook("transaction.update.after", this.getPluginContext(), {
|
|
1706
|
+
transactionId,
|
|
1707
|
+
updates: effectiveUpdates
|
|
1708
|
+
}, async () => transaction);
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
//#endregion
|
|
1714
|
+
//#region src/application/services/escrow.service.ts
|
|
1715
|
+
/**
|
|
1716
|
+
* Escrow Service
|
|
1717
|
+
* @classytic/revenue
|
|
1718
|
+
*
|
|
1719
|
+
* Platform-as-intermediary payment flow
|
|
1720
|
+
* Hold funds → Verify → Split/Deduct → Release to organization
|
|
1721
|
+
*/
|
|
1722
|
+
/**
|
|
1723
|
+
* Escrow Service
|
|
1724
|
+
* Uses DI container for all dependencies
|
|
1725
|
+
*
|
|
1726
|
+
* Architecture:
|
|
1727
|
+
* - PluginManager: Wraps operations with lifecycle hooks (before/after)
|
|
1728
|
+
* - EventBus: Fire-and-forget notifications for completed operations
|
|
1729
|
+
*/
|
|
1730
|
+
var EscrowService = class {
|
|
1731
|
+
models;
|
|
1732
|
+
plugins;
|
|
1733
|
+
logger;
|
|
1734
|
+
events;
|
|
1735
|
+
constructor(container) {
|
|
1736
|
+
this.models = container.get("models");
|
|
1737
|
+
this.plugins = container.get("plugins");
|
|
1738
|
+
this.logger = container.get("logger");
|
|
1739
|
+
this.events = container.get("events");
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Create plugin context for hook execution
|
|
1743
|
+
* @private
|
|
1744
|
+
*/
|
|
1745
|
+
getPluginContext() {
|
|
1746
|
+
return {
|
|
1747
|
+
events: this.events,
|
|
1748
|
+
logger: this.logger,
|
|
1749
|
+
storage: /* @__PURE__ */ new Map(),
|
|
1750
|
+
meta: {
|
|
1751
|
+
requestId: nanoid(),
|
|
1752
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Hold funds in escrow
|
|
1758
|
+
*
|
|
1759
|
+
* @param transactionId - Transaction to hold
|
|
1760
|
+
* @param options - Hold options
|
|
1761
|
+
* @returns Updated transaction
|
|
1762
|
+
*/
|
|
1763
|
+
async hold(transactionId, options = {}) {
|
|
1764
|
+
return this.plugins.executeHook("escrow.hold.before", this.getPluginContext(), {
|
|
1765
|
+
transactionId,
|
|
1766
|
+
...options
|
|
1767
|
+
}, async () => {
|
|
1768
|
+
const { reason = HOLD_REASON.PAYMENT_VERIFICATION, holdUntil = null, metadata = {} } = options;
|
|
1769
|
+
const transaction = await this.models.Transaction.findById(transactionId);
|
|
1770
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1771
|
+
if (transaction.status !== TRANSACTION_STATUS.VERIFIED) throw new InvalidStateTransitionError("transaction", transaction._id.toString(), transaction.status, TRANSACTION_STATUS.VERIFIED);
|
|
1772
|
+
const heldAmount = transaction.amount;
|
|
1773
|
+
transaction.hold = {
|
|
1774
|
+
status: HOLD_STATUS.HELD,
|
|
1775
|
+
heldAmount,
|
|
1776
|
+
releasedAmount: 0,
|
|
1777
|
+
reason,
|
|
1778
|
+
heldAt: /* @__PURE__ */ new Date(),
|
|
1779
|
+
...holdUntil && { holdUntil },
|
|
1780
|
+
releases: [],
|
|
1781
|
+
metadata
|
|
1782
|
+
};
|
|
1783
|
+
await transaction.save();
|
|
1784
|
+
this.events.emit("escrow.held", {
|
|
1785
|
+
transaction,
|
|
1786
|
+
heldAmount,
|
|
1787
|
+
reason
|
|
1788
|
+
});
|
|
1789
|
+
return this.plugins.executeHook("escrow.hold.after", this.getPluginContext(), {
|
|
1790
|
+
transactionId,
|
|
1791
|
+
...options
|
|
1792
|
+
}, async () => transaction);
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Release funds from escrow to recipient
|
|
1797
|
+
*
|
|
1798
|
+
* @param transactionId - Transaction to release
|
|
1799
|
+
* @param options - Release options
|
|
1800
|
+
* @returns { transaction, releaseTransaction }
|
|
1801
|
+
*/
|
|
1802
|
+
async release(transactionId, options) {
|
|
1803
|
+
return this.plugins.executeHook("escrow.release.before", this.getPluginContext(), {
|
|
1804
|
+
transactionId,
|
|
1805
|
+
...options
|
|
1806
|
+
}, async () => {
|
|
1807
|
+
const { amount = null, recipientId, recipientType = "organization", reason = RELEASE_REASON.PAYMENT_VERIFIED, releasedBy = null, createTransaction = true, metadata = {} } = options;
|
|
1808
|
+
const TransactionModel = this.models.Transaction;
|
|
1809
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
1810
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1811
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) throw new InvalidStateTransitionError("escrow_hold", transaction._id.toString(), transaction.hold?.status ?? "none", HOLD_STATUS.HELD);
|
|
1812
|
+
if (!recipientId) throw new ValidationError("recipientId is required for release", { transactionId });
|
|
1813
|
+
const releaseAmount = amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
1814
|
+
const availableAmount = transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
1815
|
+
if (releaseAmount > availableAmount) throw new ValidationError(`Release amount (${releaseAmount}) exceeds available held amount (${availableAmount})`, {
|
|
1816
|
+
releaseAmount,
|
|
1817
|
+
availableAmount,
|
|
1818
|
+
transactionId
|
|
1819
|
+
});
|
|
1820
|
+
const releaseRecord = {
|
|
1821
|
+
amount: releaseAmount,
|
|
1822
|
+
recipientId,
|
|
1823
|
+
recipientType,
|
|
1824
|
+
releasedAt: /* @__PURE__ */ new Date(),
|
|
1825
|
+
releasedBy,
|
|
1826
|
+
reason,
|
|
1827
|
+
metadata
|
|
1828
|
+
};
|
|
1829
|
+
transaction.hold.releases.push(releaseRecord);
|
|
1830
|
+
transaction.hold.releasedAmount += releaseAmount;
|
|
1831
|
+
const isFullRelease = transaction.hold.releasedAmount >= transaction.hold.heldAmount;
|
|
1832
|
+
const isPartialRelease = transaction.hold.releasedAmount > 0 && transaction.hold.releasedAmount < transaction.hold.heldAmount;
|
|
1833
|
+
if (isFullRelease) {
|
|
1834
|
+
const holdAuditEvent = HOLD_STATE_MACHINE.validateAndCreateAuditEvent(transaction.hold.status, HOLD_STATUS.RELEASED, transaction._id.toString(), {
|
|
1835
|
+
changedBy: releasedBy ?? "system",
|
|
1836
|
+
reason: `Escrow hold fully released: ${releaseAmount} to ${recipientId}${reason ? " - " + reason : ""}`,
|
|
1837
|
+
metadata: {
|
|
1838
|
+
releaseAmount,
|
|
1839
|
+
recipientId,
|
|
1840
|
+
releaseReason: reason
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
transaction.hold.status = HOLD_STATUS.RELEASED;
|
|
1844
|
+
transaction.hold.releasedAt = /* @__PURE__ */ new Date();
|
|
1845
|
+
const transactionAuditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(transaction.status, TRANSACTION_STATUS.COMPLETED, transaction._id.toString(), {
|
|
1846
|
+
changedBy: releasedBy ?? "system",
|
|
1847
|
+
reason: `Transaction completed after full escrow release`,
|
|
1848
|
+
metadata: {
|
|
1849
|
+
releaseAmount,
|
|
1850
|
+
recipientId
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
transaction.status = TRANSACTION_STATUS.COMPLETED;
|
|
1854
|
+
Object.assign(transaction, appendAuditEvent(transaction, holdAuditEvent));
|
|
1855
|
+
Object.assign(transaction, appendAuditEvent(transaction, transactionAuditEvent));
|
|
1856
|
+
} else if (isPartialRelease) {
|
|
1857
|
+
const auditEvent = HOLD_STATE_MACHINE.validateAndCreateAuditEvent(transaction.hold.status, HOLD_STATUS.PARTIALLY_RELEASED, transaction._id.toString(), {
|
|
1858
|
+
changedBy: releasedBy ?? "system",
|
|
1859
|
+
reason: `Partial escrow release: ${releaseAmount} of ${transaction.hold.heldAmount} to ${recipientId}${reason ? " - " + reason : ""}`,
|
|
1860
|
+
metadata: {
|
|
1861
|
+
releaseAmount,
|
|
1862
|
+
recipientId,
|
|
1863
|
+
releaseReason: reason,
|
|
1864
|
+
remainingHeld: transaction.hold.heldAmount - transaction.hold.releasedAmount
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
transaction.hold.status = HOLD_STATUS.PARTIALLY_RELEASED;
|
|
1868
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1869
|
+
}
|
|
1870
|
+
if ("markModified" in transaction) transaction.markModified("hold");
|
|
1871
|
+
await transaction.save();
|
|
1872
|
+
let releaseTaxAmount = 0;
|
|
1873
|
+
if (transaction.tax && transaction.tax > 0) if (releaseAmount === availableAmount && !amount) {
|
|
1874
|
+
const releasedTaxSoFar = transaction.hold.releasedTaxAmount ?? 0;
|
|
1875
|
+
releaseTaxAmount = transaction.tax - releasedTaxSoFar;
|
|
1876
|
+
} else {
|
|
1877
|
+
const totalAmount = transaction.amount + transaction.tax;
|
|
1878
|
+
if (totalAmount > 0) {
|
|
1879
|
+
const taxRatio = transaction.tax / totalAmount;
|
|
1880
|
+
releaseTaxAmount = Math.round(releaseAmount * taxRatio);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
const releaseNetAmount = releaseAmount - releaseTaxAmount;
|
|
1884
|
+
let releaseTransaction = null;
|
|
1885
|
+
if (createTransaction) releaseTransaction = await TransactionModel.create({
|
|
1886
|
+
organizationId: transaction.organizationId,
|
|
1887
|
+
customerId: recipientId,
|
|
1888
|
+
type: "escrow_release",
|
|
1889
|
+
flow: "inflow",
|
|
1890
|
+
tags: ["escrow", "release"],
|
|
1891
|
+
amount: releaseAmount,
|
|
1892
|
+
currency: transaction.currency,
|
|
1893
|
+
fee: 0,
|
|
1894
|
+
tax: releaseTaxAmount,
|
|
1895
|
+
net: releaseNetAmount,
|
|
1896
|
+
...transaction.taxDetails && { taxDetails: transaction.taxDetails },
|
|
1897
|
+
method: transaction.method,
|
|
1898
|
+
status: "completed",
|
|
1899
|
+
gateway: transaction.gateway,
|
|
1900
|
+
sourceId: transaction._id,
|
|
1901
|
+
sourceModel: "Transaction",
|
|
1902
|
+
relatedTransactionId: transaction._id,
|
|
1903
|
+
metadata: {
|
|
1904
|
+
...metadata,
|
|
1905
|
+
isRelease: true,
|
|
1906
|
+
heldTransactionId: transaction._id.toString(),
|
|
1907
|
+
releaseReason: reason,
|
|
1908
|
+
recipientType,
|
|
1909
|
+
originalCategory: transaction.category
|
|
1910
|
+
},
|
|
1911
|
+
idempotencyKey: `release_${transaction._id}_${Date.now()}`
|
|
1912
|
+
});
|
|
1913
|
+
this.events.emit("escrow.released", {
|
|
1914
|
+
transaction,
|
|
1915
|
+
releaseTransaction,
|
|
1916
|
+
releaseAmount,
|
|
1917
|
+
recipientId,
|
|
1918
|
+
recipientType,
|
|
1919
|
+
reason,
|
|
1920
|
+
isFullRelease,
|
|
1921
|
+
isPartialRelease
|
|
1922
|
+
});
|
|
1923
|
+
const result = {
|
|
1924
|
+
transaction,
|
|
1925
|
+
releaseTransaction,
|
|
1926
|
+
releaseAmount,
|
|
1927
|
+
isFullRelease,
|
|
1928
|
+
isPartialRelease
|
|
1929
|
+
};
|
|
1930
|
+
return this.plugins.executeHook("escrow.release.after", this.getPluginContext(), {
|
|
1931
|
+
transactionId,
|
|
1932
|
+
...options
|
|
1933
|
+
}, async () => result);
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Cancel hold and release back to customer
|
|
1938
|
+
*
|
|
1939
|
+
* @param transactionId - Transaction to cancel hold
|
|
1940
|
+
* @param options - Cancel options
|
|
1941
|
+
* @returns Updated transaction
|
|
1942
|
+
*/
|
|
1943
|
+
async cancel(transactionId, options = {}) {
|
|
1944
|
+
const { reason = "Hold cancelled", metadata = {} } = options;
|
|
1945
|
+
const transaction = await this.models.Transaction.findById(transactionId);
|
|
1946
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1947
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) throw new InvalidStateTransitionError("escrow_hold", transaction._id.toString(), transaction.hold?.status ?? "none", HOLD_STATUS.HELD);
|
|
1948
|
+
const holdAuditEvent = HOLD_STATE_MACHINE.validateAndCreateAuditEvent(transaction.hold.status, HOLD_STATUS.CANCELLED, transaction._id.toString(), {
|
|
1949
|
+
changedBy: "system",
|
|
1950
|
+
reason: `Escrow hold cancelled${reason ? ": " + reason : ""}`,
|
|
1951
|
+
metadata: {
|
|
1952
|
+
cancelReason: reason,
|
|
1953
|
+
...metadata
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
transaction.hold.status = HOLD_STATUS.CANCELLED;
|
|
1957
|
+
transaction.hold.cancelledAt = /* @__PURE__ */ new Date();
|
|
1958
|
+
transaction.hold.metadata = {
|
|
1959
|
+
...transaction.hold.metadata,
|
|
1960
|
+
...metadata,
|
|
1961
|
+
cancelReason: reason
|
|
1962
|
+
};
|
|
1963
|
+
const transactionAuditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(transaction.status, TRANSACTION_STATUS.CANCELLED, transaction._id.toString(), {
|
|
1964
|
+
changedBy: "system",
|
|
1965
|
+
reason: `Transaction cancelled due to escrow hold cancellation`,
|
|
1966
|
+
metadata: { cancelReason: reason }
|
|
1967
|
+
});
|
|
1968
|
+
transaction.status = TRANSACTION_STATUS.CANCELLED;
|
|
1969
|
+
Object.assign(transaction, appendAuditEvent(transaction, holdAuditEvent));
|
|
1970
|
+
Object.assign(transaction, appendAuditEvent(transaction, transactionAuditEvent));
|
|
1971
|
+
if ("markModified" in transaction) transaction.markModified("hold");
|
|
1972
|
+
await transaction.save();
|
|
1973
|
+
this.events.emit("escrow.cancelled", {
|
|
1974
|
+
transaction,
|
|
1975
|
+
reason
|
|
1976
|
+
});
|
|
1977
|
+
return transaction;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Split payment to multiple recipients
|
|
1981
|
+
* Deducts splits from held amount and releases remainder to organization
|
|
1982
|
+
*
|
|
1983
|
+
* @param transactionId - Transaction to split
|
|
1984
|
+
* @param splitRules - Split configuration
|
|
1985
|
+
* @returns { transaction, splitTransactions, organizationTransaction }
|
|
1986
|
+
*/
|
|
1987
|
+
async split(transactionId, splitRules = []) {
|
|
1988
|
+
const TransactionModel = this.models.Transaction;
|
|
1989
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
1990
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
1991
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) throw new InvalidStateTransitionError("escrow_hold", transaction._id.toString(), transaction.hold?.status ?? "none", HOLD_STATUS.HELD);
|
|
1992
|
+
if (!splitRules || splitRules.length === 0) throw new ValidationError("splitRules cannot be empty", { transactionId });
|
|
1993
|
+
const splits = calculateSplits(transaction.amount, splitRules, transaction.commission?.gatewayFeeRate ?? 0);
|
|
1994
|
+
transaction.splits = splits;
|
|
1995
|
+
await transaction.save();
|
|
1996
|
+
const splitTransactions = [];
|
|
1997
|
+
const totalTax = transaction.tax ?? 0;
|
|
1998
|
+
const totalBaseAmount = transaction.amount;
|
|
1999
|
+
let allocatedTaxAmount = 0;
|
|
2000
|
+
const splitTaxAmounts = splits.map((split) => {
|
|
2001
|
+
if (!totalTax || totalBaseAmount <= 0) return 0;
|
|
2002
|
+
const ratio = split.grossAmount / totalBaseAmount;
|
|
2003
|
+
const taxAmount = Math.round(totalTax * ratio);
|
|
2004
|
+
allocatedTaxAmount += taxAmount;
|
|
2005
|
+
return taxAmount;
|
|
2006
|
+
});
|
|
2007
|
+
for (const [index, split] of splits.entries()) {
|
|
2008
|
+
const splitTaxAmount = totalTax > 0 ? splitTaxAmounts[index] ?? 0 : 0;
|
|
2009
|
+
const splitNetAmount = split.grossAmount - split.gatewayFeeAmount - splitTaxAmount;
|
|
2010
|
+
const splitTransaction = await TransactionModel.create({
|
|
2011
|
+
organizationId: transaction.organizationId,
|
|
2012
|
+
customerId: split.recipientId,
|
|
2013
|
+
type: split.type,
|
|
2014
|
+
flow: "outflow",
|
|
2015
|
+
tags: ["split", "commission"],
|
|
2016
|
+
amount: split.grossAmount,
|
|
2017
|
+
currency: transaction.currency,
|
|
2018
|
+
fee: split.gatewayFeeAmount,
|
|
2019
|
+
tax: splitTaxAmount,
|
|
2020
|
+
net: splitNetAmount,
|
|
2021
|
+
...transaction.taxDetails && splitTaxAmount > 0 && { taxDetails: transaction.taxDetails },
|
|
2022
|
+
method: transaction.method,
|
|
2023
|
+
status: "completed",
|
|
2024
|
+
gateway: transaction.gateway,
|
|
2025
|
+
sourceId: transaction._id,
|
|
2026
|
+
sourceModel: "Transaction",
|
|
2027
|
+
relatedTransactionId: transaction._id,
|
|
2028
|
+
metadata: {
|
|
2029
|
+
isSplit: true,
|
|
2030
|
+
splitType: split.type,
|
|
2031
|
+
recipientType: split.recipientType,
|
|
2032
|
+
originalTransactionId: transaction._id.toString(),
|
|
2033
|
+
splitGrossAmount: split.grossAmount,
|
|
2034
|
+
splitNetAmount: split.netAmount,
|
|
2035
|
+
gatewayFeeAmount: split.gatewayFeeAmount
|
|
2036
|
+
},
|
|
2037
|
+
idempotencyKey: `split_${transaction._id}_${split.recipientId}_${Date.now()}`
|
|
2038
|
+
});
|
|
2039
|
+
split.payoutTransactionId = splitTransaction._id.toString();
|
|
2040
|
+
split.status = SPLIT_STATUS.PAID;
|
|
2041
|
+
split.paidDate = /* @__PURE__ */ new Date();
|
|
2042
|
+
splitTransactions.push(splitTransaction);
|
|
2043
|
+
}
|
|
2044
|
+
await transaction.save();
|
|
2045
|
+
const organizationPayout = calculateOrganizationPayout(transaction.amount, splits);
|
|
2046
|
+
const organizationTaxAmount = totalTax > 0 ? Math.max(0, totalTax - allocatedTaxAmount) : 0;
|
|
2047
|
+
const organizationPayoutTotal = totalTax > 0 ? organizationPayout + organizationTaxAmount : organizationPayout;
|
|
2048
|
+
const organizationTransaction = await this.release(transactionId, {
|
|
2049
|
+
amount: organizationPayoutTotal,
|
|
2050
|
+
recipientId: transaction.organizationId?.toString() ?? "",
|
|
2051
|
+
recipientType: "organization",
|
|
2052
|
+
reason: RELEASE_REASON.PAYMENT_VERIFIED,
|
|
2053
|
+
createTransaction: true,
|
|
2054
|
+
metadata: {
|
|
2055
|
+
afterSplits: true,
|
|
2056
|
+
totalSplits: splits.length,
|
|
2057
|
+
totalSplitAmount: transaction.amount - organizationPayout
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
this.events.emit("escrow.split", {
|
|
2061
|
+
transaction,
|
|
2062
|
+
splits,
|
|
2063
|
+
splitTransactions,
|
|
2064
|
+
organizationTransaction: organizationTransaction.releaseTransaction,
|
|
2065
|
+
organizationPayout
|
|
2066
|
+
});
|
|
2067
|
+
return {
|
|
2068
|
+
transaction,
|
|
2069
|
+
splits,
|
|
2070
|
+
splitTransactions,
|
|
2071
|
+
organizationTransaction: organizationTransaction.releaseTransaction,
|
|
2072
|
+
organizationPayout
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Get escrow status
|
|
2077
|
+
*
|
|
2078
|
+
* @param transactionId - Transaction ID
|
|
2079
|
+
* @returns Escrow status
|
|
2080
|
+
*/
|
|
2081
|
+
async getStatus(transactionId) {
|
|
2082
|
+
const transaction = await this.models.Transaction.findById(transactionId);
|
|
2083
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
2084
|
+
return {
|
|
2085
|
+
transaction,
|
|
2086
|
+
hold: transaction.hold ?? null,
|
|
2087
|
+
splits: transaction.splits ?? [],
|
|
2088
|
+
hasHold: !!transaction.hold,
|
|
2089
|
+
hasSplits: transaction.splits ? transaction.splits.length > 0 : false
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
//#endregion
|
|
2095
|
+
//#region src/application/services/settlement.service.ts
|
|
2096
|
+
/**
|
|
2097
|
+
* Settlement Service
|
|
2098
|
+
*
|
|
2099
|
+
* Handles payout tracking after splits are released from escrow:
|
|
2100
|
+
*
|
|
2101
|
+
* Flow:
|
|
2102
|
+
* 1. Transaction has splits → 2. Escrow released → 3. Create settlements → 4. Process payouts → 5. Mark complete
|
|
2103
|
+
*
|
|
2104
|
+
* @example
|
|
2105
|
+
* ```typescript
|
|
2106
|
+
* // Auto-create settlements from transaction splits
|
|
2107
|
+
* await revenue.settlement.createFromSplits(transactionId);
|
|
2108
|
+
*
|
|
2109
|
+
* // Schedule a manual payout
|
|
2110
|
+
* await revenue.settlement.schedule({
|
|
2111
|
+
* recipientId: vendorId,
|
|
2112
|
+
* amount: 8500,
|
|
2113
|
+
* payoutMethod: 'bank_transfer',
|
|
2114
|
+
* });
|
|
2115
|
+
*
|
|
2116
|
+
* // Process pending payouts
|
|
2117
|
+
* const result = await revenue.settlement.processPending({ limit: 100 });
|
|
2118
|
+
*
|
|
2119
|
+
* // Mark as completed after bank confirms
|
|
2120
|
+
* await revenue.settlement.complete(settlementId, { transferReference: 'TRF123' });
|
|
2121
|
+
* ```
|
|
2122
|
+
*/
|
|
2123
|
+
/**
|
|
2124
|
+
* Settlement Service
|
|
2125
|
+
* Uses DI container for all dependencies
|
|
2126
|
+
*
|
|
2127
|
+
* Architecture:
|
|
2128
|
+
* - PluginManager: Wraps operations with lifecycle hooks (before/after)
|
|
2129
|
+
* - EventBus: Fire-and-forget notifications for completed operations
|
|
2130
|
+
*/
|
|
2131
|
+
var SettlementService = class {
|
|
2132
|
+
models;
|
|
2133
|
+
plugins;
|
|
2134
|
+
logger;
|
|
2135
|
+
events;
|
|
2136
|
+
constructor(container) {
|
|
2137
|
+
this.models = container.get("models");
|
|
2138
|
+
this.plugins = container.get("plugins");
|
|
2139
|
+
this.logger = container.get("logger");
|
|
2140
|
+
this.events = container.get("events");
|
|
2141
|
+
this.plugins;
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Create settlements from transaction splits
|
|
2145
|
+
* Typically called after escrow is released
|
|
2146
|
+
*
|
|
2147
|
+
* @param transactionId - Transaction ID with splits
|
|
2148
|
+
* @param options - Creation options
|
|
2149
|
+
* @returns Array of created settlements
|
|
2150
|
+
*/
|
|
2151
|
+
async createFromSplits(transactionId, options = {}) {
|
|
2152
|
+
const { scheduledAt = /* @__PURE__ */ new Date(), payoutMethod = "bank_transfer", metadata = {} } = options;
|
|
2153
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2154
|
+
const transaction = await this.models.Transaction.findById(transactionId);
|
|
2155
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
2156
|
+
if (!transaction.splits || transaction.splits.length === 0) throw new ValidationError("Transaction has no splits to settle", { transactionId });
|
|
2157
|
+
const SettlementModel = this.models.Settlement;
|
|
2158
|
+
const settlements = [];
|
|
2159
|
+
for (const split of transaction.splits) {
|
|
2160
|
+
if (split.status === "paid") {
|
|
2161
|
+
this.logger.info("Split already paid, skipping", { splitId: split._id });
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
const settlement = await SettlementModel.create({
|
|
2165
|
+
organizationId: transaction.organizationId,
|
|
2166
|
+
recipientId: split.recipientId,
|
|
2167
|
+
recipientType: split.recipientType,
|
|
2168
|
+
type: SETTLEMENT_TYPE.SPLIT_PAYOUT,
|
|
2169
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
2170
|
+
payoutMethod,
|
|
2171
|
+
amount: split.netAmount,
|
|
2172
|
+
currency: transaction.currency,
|
|
2173
|
+
sourceTransactionIds: [transaction._id],
|
|
2174
|
+
sourceSplitIds: [split._id?.toString() || ""],
|
|
2175
|
+
scheduledAt,
|
|
2176
|
+
metadata: {
|
|
2177
|
+
...metadata,
|
|
2178
|
+
splitType: split.type,
|
|
2179
|
+
transactionCategory: transaction.category
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
settlements.push(settlement);
|
|
2183
|
+
}
|
|
2184
|
+
this.events.emit("settlement.created", {
|
|
2185
|
+
settlements,
|
|
2186
|
+
transactionId,
|
|
2187
|
+
count: settlements.length
|
|
2188
|
+
});
|
|
2189
|
+
this.logger.info("Created settlements from splits", {
|
|
2190
|
+
transactionId,
|
|
2191
|
+
count: settlements.length
|
|
2192
|
+
});
|
|
2193
|
+
return settlements;
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* Schedule a payout
|
|
2197
|
+
*
|
|
2198
|
+
* @param params - Settlement parameters
|
|
2199
|
+
* @returns Created settlement
|
|
2200
|
+
*/
|
|
2201
|
+
async schedule(params) {
|
|
2202
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2203
|
+
const { organizationId, recipientId, recipientType, type, amount, currency = "USD", payoutMethod, sourceTransactionIds = [], sourceSplitIds = [], scheduledAt = /* @__PURE__ */ new Date(), bankTransferDetails, mobileWalletDetails, cryptoDetails, notes, metadata = {} } = params;
|
|
2204
|
+
if (amount <= 0) throw new ValidationError("Settlement amount must be positive", { amount });
|
|
2205
|
+
const settlement = await this.models.Settlement.create({
|
|
2206
|
+
organizationId,
|
|
2207
|
+
recipientId,
|
|
2208
|
+
recipientType,
|
|
2209
|
+
type,
|
|
2210
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
2211
|
+
payoutMethod,
|
|
2212
|
+
amount,
|
|
2213
|
+
currency,
|
|
2214
|
+
sourceTransactionIds,
|
|
2215
|
+
sourceSplitIds,
|
|
2216
|
+
scheduledAt,
|
|
2217
|
+
bankTransferDetails,
|
|
2218
|
+
mobileWalletDetails,
|
|
2219
|
+
cryptoDetails,
|
|
2220
|
+
notes,
|
|
2221
|
+
metadata
|
|
2222
|
+
});
|
|
2223
|
+
this.events.emit("settlement.scheduled", {
|
|
2224
|
+
settlement,
|
|
2225
|
+
scheduledAt
|
|
2226
|
+
});
|
|
2227
|
+
this.logger.info("Settlement scheduled", {
|
|
2228
|
+
settlementId: settlement._id,
|
|
2229
|
+
recipientId,
|
|
2230
|
+
amount
|
|
2231
|
+
});
|
|
2232
|
+
return settlement;
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* Process pending settlements
|
|
2236
|
+
* Batch process settlements that are due
|
|
2237
|
+
*
|
|
2238
|
+
* @param options - Processing options
|
|
2239
|
+
* @returns Processing result
|
|
2240
|
+
*/
|
|
2241
|
+
async processPending(options = {}) {
|
|
2242
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2243
|
+
const { limit = 100, organizationId, payoutMethod, dryRun = false } = options;
|
|
2244
|
+
const SettlementModel = this.models.Settlement;
|
|
2245
|
+
const query = {
|
|
2246
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
2247
|
+
scheduledAt: { $lte: /* @__PURE__ */ new Date() }
|
|
2248
|
+
};
|
|
2249
|
+
if (organizationId) query.organizationId = organizationId;
|
|
2250
|
+
if (payoutMethod) query.payoutMethod = payoutMethod;
|
|
2251
|
+
const settlements = await SettlementModel.find(query).limit(limit).sort({ scheduledAt: 1 });
|
|
2252
|
+
const result = {
|
|
2253
|
+
processed: 0,
|
|
2254
|
+
succeeded: 0,
|
|
2255
|
+
failed: 0,
|
|
2256
|
+
settlements: [],
|
|
2257
|
+
errors: []
|
|
2258
|
+
};
|
|
2259
|
+
if (dryRun) {
|
|
2260
|
+
this.logger.info("Dry run: would process settlements", { count: settlements.length });
|
|
2261
|
+
result.settlements = settlements;
|
|
2262
|
+
return result;
|
|
2263
|
+
}
|
|
2264
|
+
for (const settlement of settlements) {
|
|
2265
|
+
result.processed++;
|
|
2266
|
+
try {
|
|
2267
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(settlement.status, SETTLEMENT_STATUS.PROCESSING, settlement._id.toString(), {
|
|
2268
|
+
changedBy: "system",
|
|
2269
|
+
reason: "Settlement processing started",
|
|
2270
|
+
metadata: {
|
|
2271
|
+
recipientId: settlement.recipientId,
|
|
2272
|
+
amount: settlement.amount
|
|
2273
|
+
}
|
|
2274
|
+
});
|
|
2275
|
+
settlement.status = SETTLEMENT_STATUS.PROCESSING;
|
|
2276
|
+
settlement.processedAt = /* @__PURE__ */ new Date();
|
|
2277
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
2278
|
+
await settlement.save();
|
|
2279
|
+
result.succeeded++;
|
|
2280
|
+
result.settlements.push(settlement);
|
|
2281
|
+
this.events.emit("settlement.processing", {
|
|
2282
|
+
settlement,
|
|
2283
|
+
processedAt: settlement.processedAt
|
|
2284
|
+
});
|
|
2285
|
+
} catch (error) {
|
|
2286
|
+
result.failed++;
|
|
2287
|
+
result.errors.push({
|
|
2288
|
+
settlementId: settlement._id.toString(),
|
|
2289
|
+
error: error.message
|
|
2290
|
+
});
|
|
2291
|
+
this.logger.error("Failed to process settlement", {
|
|
2292
|
+
settlementId: settlement._id,
|
|
2293
|
+
error
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
this.logger.info("Processed settlements", result);
|
|
2298
|
+
return result;
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Mark settlement as completed
|
|
2302
|
+
* Call this after bank confirms the transfer
|
|
2303
|
+
*
|
|
2304
|
+
* @param settlementId - Settlement ID
|
|
2305
|
+
* @param details - Completion details
|
|
2306
|
+
* @returns Updated settlement
|
|
2307
|
+
*/
|
|
2308
|
+
async complete(settlementId, details = {}) {
|
|
2309
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2310
|
+
const settlement = await this.models.Settlement.findById(settlementId);
|
|
2311
|
+
if (!settlement) throw new ValidationError("Settlement not found", { settlementId });
|
|
2312
|
+
if (settlement.status !== SETTLEMENT_STATUS.PROCESSING && settlement.status !== SETTLEMENT_STATUS.PENDING) throw new InvalidStateTransitionError("complete", SETTLEMENT_STATUS.PROCESSING, settlement.status, "Only processing or pending settlements can be completed");
|
|
2313
|
+
const { transferReference, transferredAt = /* @__PURE__ */ new Date(), transactionHash, notes, metadata = {} } = details;
|
|
2314
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(settlement.status, SETTLEMENT_STATUS.COMPLETED, settlement._id.toString(), {
|
|
2315
|
+
changedBy: "system",
|
|
2316
|
+
reason: "Settlement completed successfully",
|
|
2317
|
+
metadata: {
|
|
2318
|
+
transferReference,
|
|
2319
|
+
transferredAt,
|
|
2320
|
+
transactionHash,
|
|
2321
|
+
payoutMethod: settlement.payoutMethod,
|
|
2322
|
+
amount: settlement.amount
|
|
2323
|
+
}
|
|
2324
|
+
});
|
|
2325
|
+
settlement.status = SETTLEMENT_STATUS.COMPLETED;
|
|
2326
|
+
settlement.completedAt = /* @__PURE__ */ new Date();
|
|
2327
|
+
if (settlement.payoutMethod === "bank_transfer" && transferReference) settlement.bankTransferDetails = {
|
|
2328
|
+
...settlement.bankTransferDetails,
|
|
2329
|
+
transferReference,
|
|
2330
|
+
transferredAt
|
|
2331
|
+
};
|
|
2332
|
+
else if (settlement.payoutMethod === "crypto" && transactionHash) settlement.cryptoDetails = {
|
|
2333
|
+
...settlement.cryptoDetails,
|
|
2334
|
+
transactionHash,
|
|
2335
|
+
transferredAt
|
|
2336
|
+
};
|
|
2337
|
+
else if (settlement.payoutMethod === "mobile_wallet") settlement.mobileWalletDetails = {
|
|
2338
|
+
...settlement.mobileWalletDetails,
|
|
2339
|
+
transferredAt
|
|
2340
|
+
};
|
|
2341
|
+
else if (settlement.payoutMethod === "platform_balance") settlement.platformBalanceDetails = {
|
|
2342
|
+
...settlement.platformBalanceDetails,
|
|
2343
|
+
appliedAt: transferredAt
|
|
2344
|
+
};
|
|
2345
|
+
if (notes) settlement.notes = notes;
|
|
2346
|
+
settlement.metadata = {
|
|
2347
|
+
...settlement.metadata,
|
|
2348
|
+
...metadata
|
|
2349
|
+
};
|
|
2350
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
2351
|
+
await settlement.save();
|
|
2352
|
+
this.events.emit("settlement.completed", {
|
|
2353
|
+
settlement,
|
|
2354
|
+
completedAt: settlement.completedAt
|
|
2355
|
+
});
|
|
2356
|
+
this.logger.info("Settlement completed", {
|
|
2357
|
+
settlementId: settlement._id,
|
|
2358
|
+
recipientId: settlement.recipientId,
|
|
2359
|
+
amount: settlement.amount
|
|
2360
|
+
});
|
|
2361
|
+
return settlement;
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* Mark settlement as failed
|
|
2365
|
+
*
|
|
2366
|
+
* @param settlementId - Settlement ID
|
|
2367
|
+
* @param reason - Failure reason
|
|
2368
|
+
* @returns Updated settlement
|
|
2369
|
+
*/
|
|
2370
|
+
async fail(settlementId, reason, options = {}) {
|
|
2371
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2372
|
+
const settlement = await this.models.Settlement.findById(settlementId);
|
|
2373
|
+
if (!settlement) throw new ValidationError("Settlement not found", { settlementId });
|
|
2374
|
+
const { code, retry = false } = options;
|
|
2375
|
+
if (retry) {
|
|
2376
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(settlement.status, SETTLEMENT_STATUS.PENDING, settlement._id.toString(), {
|
|
2377
|
+
changedBy: "system",
|
|
2378
|
+
reason: `Settlement failed, retrying: ${reason}`,
|
|
2379
|
+
metadata: {
|
|
2380
|
+
failureReason: reason,
|
|
2381
|
+
failureCode: code,
|
|
2382
|
+
retryCount: (settlement.retryCount || 0) + 1,
|
|
2383
|
+
scheduledAt: new Date(Date.now() + 3600 * 1e3)
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
settlement.status = SETTLEMENT_STATUS.PENDING;
|
|
2387
|
+
settlement.retryCount = (settlement.retryCount || 0) + 1;
|
|
2388
|
+
settlement.scheduledAt = new Date(Date.now() + 3600 * 1e3);
|
|
2389
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
2390
|
+
} else {
|
|
2391
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(settlement.status, SETTLEMENT_STATUS.FAILED, settlement._id.toString(), {
|
|
2392
|
+
changedBy: "system",
|
|
2393
|
+
reason: `Settlement failed: ${reason}`,
|
|
2394
|
+
metadata: {
|
|
2395
|
+
failureReason: reason,
|
|
2396
|
+
failureCode: code
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
settlement.status = SETTLEMENT_STATUS.FAILED;
|
|
2400
|
+
settlement.failedAt = /* @__PURE__ */ new Date();
|
|
2401
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
2402
|
+
}
|
|
2403
|
+
settlement.failureReason = reason;
|
|
2404
|
+
if (code) settlement.failureCode = code;
|
|
2405
|
+
await settlement.save();
|
|
2406
|
+
this.events.emit("settlement.failed", {
|
|
2407
|
+
settlement,
|
|
2408
|
+
reason,
|
|
2409
|
+
code,
|
|
2410
|
+
retry
|
|
2411
|
+
});
|
|
2412
|
+
this.logger.warn("Settlement failed", {
|
|
2413
|
+
settlementId: settlement._id,
|
|
2414
|
+
reason,
|
|
2415
|
+
retry
|
|
2416
|
+
});
|
|
2417
|
+
return settlement;
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* List settlements with filters
|
|
2421
|
+
*
|
|
2422
|
+
* @param filters - Query filters
|
|
2423
|
+
* @returns Settlements
|
|
2424
|
+
*/
|
|
2425
|
+
async list(filters = {}) {
|
|
2426
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2427
|
+
const SettlementModel = this.models.Settlement;
|
|
2428
|
+
const { organizationId, recipientId, status, type, payoutMethod, scheduledAfter, scheduledBefore, limit = 50, skip = 0, sort = { createdAt: -1 } } = filters;
|
|
2429
|
+
const query = {};
|
|
2430
|
+
if (organizationId) query.organizationId = organizationId;
|
|
2431
|
+
if (recipientId) query.recipientId = recipientId;
|
|
2432
|
+
if (status) query.status = Array.isArray(status) ? { $in: status } : status;
|
|
2433
|
+
if (type) query.type = type;
|
|
2434
|
+
if (payoutMethod) query.payoutMethod = payoutMethod;
|
|
2435
|
+
if (scheduledAfter || scheduledBefore) {
|
|
2436
|
+
query.scheduledAt = {};
|
|
2437
|
+
if (scheduledAfter) query.scheduledAt.$gte = scheduledAfter;
|
|
2438
|
+
if (scheduledBefore) query.scheduledAt.$lte = scheduledBefore;
|
|
2439
|
+
}
|
|
2440
|
+
return await SettlementModel.find(query).limit(limit).skip(skip).sort(sort);
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Get payout summary for recipient
|
|
2444
|
+
*
|
|
2445
|
+
* @param recipientId - Recipient ID
|
|
2446
|
+
* @param options - Summary options
|
|
2447
|
+
* @returns Settlement summary
|
|
2448
|
+
*/
|
|
2449
|
+
async getSummary(recipientId, options = {}) {
|
|
2450
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2451
|
+
const { organizationId, startDate, endDate } = options;
|
|
2452
|
+
const SettlementModel = this.models.Settlement;
|
|
2453
|
+
const query = { recipientId };
|
|
2454
|
+
if (organizationId) query.organizationId = organizationId;
|
|
2455
|
+
if (startDate || endDate) {
|
|
2456
|
+
query.createdAt = {};
|
|
2457
|
+
if (startDate) query.createdAt.$gte = startDate;
|
|
2458
|
+
if (endDate) query.createdAt.$lte = endDate;
|
|
2459
|
+
}
|
|
2460
|
+
const settlements = await SettlementModel.find(query);
|
|
2461
|
+
const summary = {
|
|
2462
|
+
recipientId,
|
|
2463
|
+
totalPending: 0,
|
|
2464
|
+
totalProcessing: 0,
|
|
2465
|
+
totalCompleted: 0,
|
|
2466
|
+
totalFailed: 0,
|
|
2467
|
+
amountPending: 0,
|
|
2468
|
+
amountCompleted: 0,
|
|
2469
|
+
amountFailed: 0,
|
|
2470
|
+
currency: settlements[0]?.currency || "USD",
|
|
2471
|
+
settlements: {
|
|
2472
|
+
pending: 0,
|
|
2473
|
+
processing: 0,
|
|
2474
|
+
completed: 0,
|
|
2475
|
+
failed: 0,
|
|
2476
|
+
cancelled: 0
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
for (const settlement of settlements) {
|
|
2480
|
+
summary.settlements[settlement.status]++;
|
|
2481
|
+
if (settlement.status === SETTLEMENT_STATUS.PENDING) {
|
|
2482
|
+
summary.totalPending++;
|
|
2483
|
+
summary.amountPending += settlement.amount;
|
|
2484
|
+
} else if (settlement.status === SETTLEMENT_STATUS.PROCESSING) summary.totalProcessing++;
|
|
2485
|
+
else if (settlement.status === SETTLEMENT_STATUS.COMPLETED) {
|
|
2486
|
+
summary.totalCompleted++;
|
|
2487
|
+
summary.amountCompleted += settlement.amount;
|
|
2488
|
+
if (!summary.lastSettlementDate || settlement.completedAt > summary.lastSettlementDate) summary.lastSettlementDate = settlement.completedAt;
|
|
2489
|
+
} else if (settlement.status === SETTLEMENT_STATUS.FAILED) {
|
|
2490
|
+
summary.totalFailed++;
|
|
2491
|
+
summary.amountFailed += settlement.amount;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return summary;
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Get settlement by ID
|
|
2498
|
+
*
|
|
2499
|
+
* @param settlementId - Settlement ID
|
|
2500
|
+
* @returns Settlement
|
|
2501
|
+
*/
|
|
2502
|
+
async get(settlementId) {
|
|
2503
|
+
if (!this.models.Settlement) throw new ModelNotRegisteredError("Settlement");
|
|
2504
|
+
const settlement = await this.models.Settlement.findById(settlementId);
|
|
2505
|
+
if (!settlement) throw new ValidationError("Settlement not found", { settlementId });
|
|
2506
|
+
return settlement;
|
|
2507
|
+
}
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
//#endregion
|
|
2511
|
+
export { MonetizationService as a, getAuditTrail as c, SETTLEMENT_STATE_MACHINE as d, SPLIT_STATE_MACHINE as f, resolveConfig as g, StateMachine as h, PaymentService as i, getLastStateChange as l, TRANSACTION_STATE_MACHINE as m, EscrowService as n, appendAuditEvent as o, SUBSCRIPTION_STATE_MACHINE as p, TransactionService as r, filterAuditTrail as s, SettlementService as t, HOLD_STATE_MACHINE as u };
|