@classytic/revenue 1.0.2 → 1.1.2
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 +603 -486
- package/dist/application/services/index.d.ts +6 -0
- package/dist/application/services/index.js +3288 -0
- package/dist/application/services/index.js.map +1 -0
- package/dist/core/events.d.ts +455 -0
- package/dist/core/events.js +122 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/index.d.ts +12 -889
- package/dist/core/index.js +2361 -705
- package/dist/core/index.js.map +1 -1
- package/dist/enums/index.d.ts +54 -25
- package/dist/enums/index.js +143 -14
- package/dist/enums/index.js.map +1 -1
- package/dist/escrow.enums-CE0VQsfe.d.ts +76 -0
- package/dist/{index-BnJWVXuw.d.ts → index-DxIK0UmZ.d.ts} +281 -26
- package/dist/index-EnfKzDbs.d.ts +806 -0
- package/dist/{index-ChVD3P9k.d.ts → index-cLJBLUvx.d.ts} +55 -81
- package/dist/index.d.ts +16 -15
- package/dist/index.js +2583 -2066
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/plugins/index.d.ts +267 -0
- package/dist/infrastructure/plugins/index.js +292 -0
- package/dist/infrastructure/plugins/index.js.map +1 -0
- package/dist/money-widWVD7r.d.ts +111 -0
- package/dist/payment.enums-C1BiGlRa.d.ts +69 -0
- package/dist/plugin-Bb9HOE10.d.ts +336 -0
- package/dist/providers/index.d.ts +19 -6
- package/dist/providers/index.js +22 -3
- package/dist/providers/index.js.map +1 -1
- package/dist/reconciliation/index.d.ts +215 -0
- package/dist/reconciliation/index.js +140 -0
- package/dist/reconciliation/index.js.map +1 -0
- package/dist/{retry-80lBCmSe.d.ts → retry-D4hFUwVk.d.ts} +1 -41
- package/dist/schemas/index.d.ts +1927 -166
- package/dist/schemas/index.js +357 -40
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/validation.d.ts +87 -12
- package/dist/schemas/validation.js +71 -17
- package/dist/schemas/validation.js.map +1 -1
- package/dist/settlement.enums-ByC1x0ye.d.ts +130 -0
- package/dist/settlement.schema-CpamV7ZY.d.ts +343 -0
- package/dist/split.enums-DG3TxQf9.d.ts +42 -0
- package/dist/tax-CV8A0sxl.d.ts +60 -0
- package/dist/utils/index.d.ts +487 -13
- package/dist/utils/index.js +370 -235
- package/dist/utils/index.js.map +1 -1
- package/package.json +27 -13
- package/dist/actions-CwG-b7fR.d.ts +0 -519
- package/dist/services/index.d.ts +0 -3
- package/dist/services/index.js +0 -1632
- package/dist/services/index.js.map +0 -1
- package/dist/split.enums-Bh24jw8p.d.ts +0 -255
- package/dist/split.schema-DYVP7Wu2.d.ts +0 -958
package/dist/core/index.js
CHANGED
|
@@ -132,12 +132,10 @@ var EventBus = class {
|
|
|
132
132
|
this.handlers.get(event)?.delete(handler);
|
|
133
133
|
this.onceHandlers.get(event)?.delete(handler);
|
|
134
134
|
}
|
|
135
|
-
|
|
136
|
-
* Emit an event (fire and forget, non-blocking)
|
|
137
|
-
*/
|
|
138
|
-
emit(event, payload) {
|
|
135
|
+
emit(event, data) {
|
|
139
136
|
const fullPayload = {
|
|
140
|
-
...
|
|
137
|
+
...data,
|
|
138
|
+
type: event,
|
|
141
139
|
timestamp: /* @__PURE__ */ new Date()
|
|
142
140
|
};
|
|
143
141
|
const handlers = this.handlers.get(event);
|
|
@@ -174,6 +172,7 @@ var EventBus = class {
|
|
|
174
172
|
async emitAsync(event, payload) {
|
|
175
173
|
const fullPayload = {
|
|
176
174
|
...payload,
|
|
175
|
+
type: event,
|
|
177
176
|
timestamp: /* @__PURE__ */ new Date()
|
|
178
177
|
};
|
|
179
178
|
const promises = [];
|
|
@@ -324,19 +323,19 @@ function loggingPlugin(options = {}) {
|
|
|
324
323
|
version: "1.0.0",
|
|
325
324
|
description: "Logs all revenue operations",
|
|
326
325
|
hooks: {
|
|
327
|
-
"payment.create.
|
|
326
|
+
"payment.create.after": async (ctx, input, next) => {
|
|
328
327
|
ctx.logger[level]("Creating payment", { amount: input.amount, currency: input.currency });
|
|
329
328
|
const result = await next();
|
|
330
|
-
ctx.logger[level]("Payment created", {
|
|
329
|
+
ctx.logger[level]("Payment created", { paymentIntentId: result?.paymentIntentId });
|
|
331
330
|
return result;
|
|
332
331
|
},
|
|
333
|
-
"payment.verify.
|
|
332
|
+
"payment.verify.after": async (ctx, input, next) => {
|
|
334
333
|
ctx.logger[level]("Verifying payment", { id: input.id });
|
|
335
334
|
const result = await next();
|
|
336
335
|
ctx.logger[level]("Payment verified", { verified: result?.verified });
|
|
337
336
|
return result;
|
|
338
337
|
},
|
|
339
|
-
"payment.refund.
|
|
338
|
+
"payment.refund.after": async (ctx, input, next) => {
|
|
340
339
|
ctx.logger[level]("Processing refund", { transactionId: input.transactionId, amount: input.amount });
|
|
341
340
|
const result = await next();
|
|
342
341
|
ctx.logger[level]("Refund processed", { refundId: result?.refundId });
|
|
@@ -569,16 +568,29 @@ var IdempotencyManager = class {
|
|
|
569
568
|
}
|
|
570
569
|
/**
|
|
571
570
|
* Hash request parameters for validation
|
|
571
|
+
* Uses deterministic JSON serialization and simple hash function
|
|
572
572
|
*/
|
|
573
573
|
hashRequest(params) {
|
|
574
|
-
|
|
574
|
+
let normalized;
|
|
575
|
+
if (params === null || params === void 0) {
|
|
576
|
+
normalized = null;
|
|
577
|
+
} else if (typeof params === "object" && !Array.isArray(params)) {
|
|
578
|
+
const sortedKeys = Object.keys(params).sort();
|
|
579
|
+
normalized = sortedKeys.reduce((acc, key) => {
|
|
580
|
+
acc[key] = params[key];
|
|
581
|
+
return acc;
|
|
582
|
+
}, {});
|
|
583
|
+
} else {
|
|
584
|
+
normalized = params;
|
|
585
|
+
}
|
|
586
|
+
const json = JSON.stringify(normalized);
|
|
575
587
|
let hash = 0;
|
|
576
588
|
for (let i = 0; i < json.length; i++) {
|
|
577
589
|
const char = json.charCodeAt(i);
|
|
578
590
|
hash = (hash << 5) - hash + char;
|
|
579
591
|
hash = hash & hash;
|
|
580
592
|
}
|
|
581
|
-
return hash.toString(36);
|
|
593
|
+
return Math.abs(hash).toString(36);
|
|
582
594
|
}
|
|
583
595
|
/**
|
|
584
596
|
* Execute operation with idempotency protection
|
|
@@ -651,12 +663,21 @@ var IdempotencyManager = class {
|
|
|
651
663
|
const fullKey = key.startsWith(this.prefix) ? key : `${this.prefix}${key}`;
|
|
652
664
|
await this.store.delete(fullKey);
|
|
653
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* Destroy the idempotency manager and cleanup resources
|
|
668
|
+
* Call this when shutting down to prevent memory leaks
|
|
669
|
+
*/
|
|
670
|
+
destroy() {
|
|
671
|
+
if (this.store instanceof MemoryIdempotencyStore) {
|
|
672
|
+
this.store.destroy();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
654
675
|
};
|
|
655
676
|
function createIdempotencyManager(config) {
|
|
656
677
|
return new IdempotencyManager(config);
|
|
657
678
|
}
|
|
658
679
|
|
|
659
|
-
// src/utils/retry.ts
|
|
680
|
+
// src/shared/utils/resilience/retry.ts
|
|
660
681
|
var DEFAULT_CONFIG = {
|
|
661
682
|
maxAttempts: 3,
|
|
662
683
|
baseDelay: 1e3,
|
|
@@ -1059,43 +1080,44 @@ function isRevenueError(error) {
|
|
|
1059
1080
|
return error instanceof RevenueError;
|
|
1060
1081
|
}
|
|
1061
1082
|
|
|
1062
|
-
// src/utils/hooks.ts
|
|
1063
|
-
function triggerHook(hooks, event, data, logger) {
|
|
1064
|
-
const handlers = hooks[event] ?? [];
|
|
1065
|
-
if (handlers.length === 0) {
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
Promise.all(
|
|
1069
|
-
handlers.map(
|
|
1070
|
-
(handler) => Promise.resolve(handler(data)).catch((error) => {
|
|
1071
|
-
logger.error(`Hook "${event}" failed:`, {
|
|
1072
|
-
error: error.message,
|
|
1073
|
-
stack: error.stack,
|
|
1074
|
-
event,
|
|
1075
|
-
// Don't log full data (could be huge)
|
|
1076
|
-
dataKeys: Object.keys(data)
|
|
1077
|
-
});
|
|
1078
|
-
})
|
|
1079
|
-
)
|
|
1080
|
-
).catch(() => {
|
|
1081
|
-
});
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
1083
|
// src/enums/transaction.enums.ts
|
|
1085
|
-
var
|
|
1086
|
-
|
|
1087
|
-
|
|
1084
|
+
var TRANSACTION_FLOW = {
|
|
1085
|
+
INFLOW: "inflow",
|
|
1086
|
+
OUTFLOW: "outflow"
|
|
1088
1087
|
};
|
|
1088
|
+
var TRANSACTION_FLOW_VALUES = Object.values(
|
|
1089
|
+
TRANSACTION_FLOW
|
|
1090
|
+
);
|
|
1089
1091
|
var TRANSACTION_STATUS = {
|
|
1092
|
+
PENDING: "pending",
|
|
1093
|
+
PAYMENT_INITIATED: "payment_initiated",
|
|
1094
|
+
PROCESSING: "processing",
|
|
1095
|
+
REQUIRES_ACTION: "requires_action",
|
|
1090
1096
|
VERIFIED: "verified",
|
|
1091
1097
|
COMPLETED: "completed",
|
|
1092
|
-
|
|
1098
|
+
FAILED: "failed",
|
|
1099
|
+
CANCELLED: "cancelled",
|
|
1100
|
+
EXPIRED: "expired",
|
|
1101
|
+
REFUNDED: "refunded",
|
|
1102
|
+
PARTIALLY_REFUNDED: "partially_refunded"
|
|
1103
|
+
};
|
|
1104
|
+
var TRANSACTION_STATUS_VALUES = Object.values(
|
|
1105
|
+
TRANSACTION_STATUS
|
|
1106
|
+
);
|
|
1093
1107
|
var LIBRARY_CATEGORIES = {
|
|
1094
1108
|
SUBSCRIPTION: "subscription",
|
|
1095
1109
|
PURCHASE: "purchase"
|
|
1096
1110
|
};
|
|
1111
|
+
var LIBRARY_CATEGORY_VALUES = Object.values(
|
|
1112
|
+
LIBRARY_CATEGORIES
|
|
1113
|
+
);
|
|
1114
|
+
new Set(TRANSACTION_FLOW_VALUES);
|
|
1115
|
+
new Set(
|
|
1116
|
+
TRANSACTION_STATUS_VALUES
|
|
1117
|
+
);
|
|
1118
|
+
new Set(LIBRARY_CATEGORY_VALUES);
|
|
1097
1119
|
|
|
1098
|
-
// src/utils/category-resolver.ts
|
|
1120
|
+
// src/shared/utils/validators/category-resolver.ts
|
|
1099
1121
|
function resolveCategory(entity, monetizationType, categoryMappings = {}) {
|
|
1100
1122
|
if (entity && categoryMappings[entity]) {
|
|
1101
1123
|
return categoryMappings[entity];
|
|
@@ -1112,7 +1134,7 @@ function resolveCategory(entity, monetizationType, categoryMappings = {}) {
|
|
|
1112
1134
|
}
|
|
1113
1135
|
}
|
|
1114
1136
|
|
|
1115
|
-
// src/utils/commission.ts
|
|
1137
|
+
// src/shared/utils/calculators/commission.ts
|
|
1116
1138
|
function calculateCommission(amount, commissionRate, gatewayFeeRate = 0) {
|
|
1117
1139
|
if (!commissionRate || commissionRate <= 0) {
|
|
1118
1140
|
return null;
|
|
@@ -1126,9 +1148,9 @@ function calculateCommission(amount, commissionRate, gatewayFeeRate = 0) {
|
|
|
1126
1148
|
if (gatewayFeeRate < 0 || gatewayFeeRate > 1) {
|
|
1127
1149
|
throw new Error("Gateway fee rate must be between 0 and 1");
|
|
1128
1150
|
}
|
|
1129
|
-
const grossAmount = Math.round(amount * commissionRate
|
|
1130
|
-
const gatewayFeeAmount = Math.round(amount * gatewayFeeRate
|
|
1131
|
-
const netAmount = Math.max(0,
|
|
1151
|
+
const grossAmount = Math.round(amount * commissionRate);
|
|
1152
|
+
const gatewayFeeAmount = Math.round(amount * gatewayFeeRate);
|
|
1153
|
+
const netAmount = Math.max(0, grossAmount - gatewayFeeAmount);
|
|
1132
1154
|
return {
|
|
1133
1155
|
rate: commissionRate,
|
|
1134
1156
|
grossAmount,
|
|
@@ -1142,10 +1164,22 @@ function reverseCommission(originalCommission, originalAmount, refundAmount) {
|
|
|
1142
1164
|
if (!originalCommission?.netAmount) {
|
|
1143
1165
|
return null;
|
|
1144
1166
|
}
|
|
1167
|
+
if (!originalAmount || originalAmount <= 0) {
|
|
1168
|
+
throw new ValidationError("Original amount must be greater than 0", { originalAmount });
|
|
1169
|
+
}
|
|
1170
|
+
if (refundAmount < 0) {
|
|
1171
|
+
throw new ValidationError("Refund amount cannot be negative", { refundAmount });
|
|
1172
|
+
}
|
|
1173
|
+
if (refundAmount > originalAmount) {
|
|
1174
|
+
throw new ValidationError(
|
|
1175
|
+
`Refund amount (${refundAmount}) exceeds original amount (${originalAmount})`,
|
|
1176
|
+
{ refundAmount, originalAmount }
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1145
1179
|
const refundRatio = refundAmount / originalAmount;
|
|
1146
|
-
const reversedNetAmount = Math.round(originalCommission.netAmount * refundRatio
|
|
1147
|
-
const reversedGrossAmount = Math.round(originalCommission.grossAmount * refundRatio
|
|
1148
|
-
const reversedGatewayFee = Math.round(originalCommission.gatewayFeeAmount * refundRatio
|
|
1180
|
+
const reversedNetAmount = Math.round(originalCommission.netAmount * refundRatio);
|
|
1181
|
+
const reversedGrossAmount = Math.round(originalCommission.grossAmount * refundRatio);
|
|
1182
|
+
const reversedGatewayFee = Math.round(originalCommission.gatewayFeeAmount * refundRatio);
|
|
1149
1183
|
return {
|
|
1150
1184
|
rate: originalCommission.rate,
|
|
1151
1185
|
grossAmount: reversedGrossAmount,
|
|
@@ -1157,26 +1191,566 @@ function reverseCommission(originalCommission, originalAmount, refundAmount) {
|
|
|
1157
1191
|
};
|
|
1158
1192
|
}
|
|
1159
1193
|
|
|
1194
|
+
// src/infrastructure/config/resolver.ts
|
|
1195
|
+
function resolveConfig(options) {
|
|
1196
|
+
const config = {
|
|
1197
|
+
targetModels: [],
|
|
1198
|
+
categoryMappings: {}
|
|
1199
|
+
};
|
|
1200
|
+
if (options.commissionRate !== void 0) {
|
|
1201
|
+
config.commissionRates = {
|
|
1202
|
+
"*": options.commissionRate
|
|
1203
|
+
// Global default for all categories
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
if (options.gatewayFeeRate !== void 0) {
|
|
1207
|
+
config.gatewayFeeRates = {
|
|
1208
|
+
"*": options.gatewayFeeRate
|
|
1209
|
+
// Global default for all gateways
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
return config;
|
|
1213
|
+
}
|
|
1214
|
+
function getCommissionRate(config, category) {
|
|
1215
|
+
if (!config?.commissionRates) return 0;
|
|
1216
|
+
if (category in config.commissionRates) {
|
|
1217
|
+
return config.commissionRates[category];
|
|
1218
|
+
}
|
|
1219
|
+
return config.commissionRates["*"] ?? 0;
|
|
1220
|
+
}
|
|
1221
|
+
function getGatewayFeeRate(config, gateway) {
|
|
1222
|
+
if (!config?.gatewayFeeRates) return 0;
|
|
1223
|
+
if (gateway in config.gatewayFeeRates) {
|
|
1224
|
+
return config.gatewayFeeRates[gateway];
|
|
1225
|
+
}
|
|
1226
|
+
return config.gatewayFeeRates["*"] ?? 0;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1160
1229
|
// src/enums/monetization.enums.ts
|
|
1161
1230
|
var MONETIZATION_TYPES = {
|
|
1162
1231
|
FREE: "free",
|
|
1163
1232
|
PURCHASE: "purchase",
|
|
1164
1233
|
SUBSCRIPTION: "subscription"
|
|
1165
1234
|
};
|
|
1235
|
+
var MONETIZATION_TYPE_VALUES = Object.values(
|
|
1236
|
+
MONETIZATION_TYPES
|
|
1237
|
+
);
|
|
1238
|
+
new Set(MONETIZATION_TYPE_VALUES);
|
|
1239
|
+
|
|
1240
|
+
// src/core/state-machine/StateMachine.ts
|
|
1241
|
+
var StateMachine = class {
|
|
1242
|
+
/**
|
|
1243
|
+
* @param transitions - Map of state → allowed next states
|
|
1244
|
+
* @param resourceType - Type of resource (for error messages)
|
|
1245
|
+
*/
|
|
1246
|
+
constructor(transitions, resourceType) {
|
|
1247
|
+
this.transitions = transitions;
|
|
1248
|
+
this.resourceType = resourceType;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Validate state transition is allowed
|
|
1252
|
+
*
|
|
1253
|
+
* @param from - Current state
|
|
1254
|
+
* @param to - Target state
|
|
1255
|
+
* @param resourceId - ID of the resource being transitioned
|
|
1256
|
+
* @throws InvalidStateTransitionError if transition is invalid
|
|
1257
|
+
*
|
|
1258
|
+
* @example
|
|
1259
|
+
* ```typescript
|
|
1260
|
+
* try {
|
|
1261
|
+
* stateMachine.validate('pending', 'completed', 'tx_123');
|
|
1262
|
+
* } catch (error) {
|
|
1263
|
+
* if (error instanceof InvalidStateTransitionError) {
|
|
1264
|
+
* console.error('Invalid transition:', error.message);
|
|
1265
|
+
* }
|
|
1266
|
+
* }
|
|
1267
|
+
* ```
|
|
1268
|
+
*/
|
|
1269
|
+
validate(from, to, resourceId) {
|
|
1270
|
+
const allowedTransitions = this.transitions.get(from);
|
|
1271
|
+
if (!allowedTransitions?.has(to)) {
|
|
1272
|
+
throw new InvalidStateTransitionError(
|
|
1273
|
+
this.resourceType,
|
|
1274
|
+
resourceId,
|
|
1275
|
+
from,
|
|
1276
|
+
to
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Check if transition is valid (non-throwing)
|
|
1282
|
+
*
|
|
1283
|
+
* @param from - Current state
|
|
1284
|
+
* @param to - Target state
|
|
1285
|
+
* @returns true if transition is allowed
|
|
1286
|
+
*
|
|
1287
|
+
* @example
|
|
1288
|
+
* ```typescript
|
|
1289
|
+
* if (stateMachine.canTransition('pending', 'processing')) {
|
|
1290
|
+
* // Safe to proceed with transition
|
|
1291
|
+
* transaction.status = 'processing';
|
|
1292
|
+
* }
|
|
1293
|
+
* ```
|
|
1294
|
+
*/
|
|
1295
|
+
canTransition(from, to) {
|
|
1296
|
+
return this.transitions.get(from)?.has(to) ?? false;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Get all allowed next states from current state
|
|
1300
|
+
*
|
|
1301
|
+
* @param from - Current state
|
|
1302
|
+
* @returns Array of allowed next states
|
|
1303
|
+
*
|
|
1304
|
+
* @example
|
|
1305
|
+
* ```typescript
|
|
1306
|
+
* const nextStates = stateMachine.getAllowedTransitions('pending');
|
|
1307
|
+
* console.log(nextStates); // ['processing', 'failed']
|
|
1308
|
+
* ```
|
|
1309
|
+
*/
|
|
1310
|
+
getAllowedTransitions(from) {
|
|
1311
|
+
return Array.from(this.transitions.get(from) ?? []);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Check if state is terminal (no outgoing transitions)
|
|
1315
|
+
*
|
|
1316
|
+
* @param state - State to check
|
|
1317
|
+
* @returns true if state has no outgoing transitions
|
|
1318
|
+
*
|
|
1319
|
+
* @example
|
|
1320
|
+
* ```typescript
|
|
1321
|
+
* stateMachine.isTerminalState('completed'); // true
|
|
1322
|
+
* stateMachine.isTerminalState('pending'); // false
|
|
1323
|
+
* ```
|
|
1324
|
+
*/
|
|
1325
|
+
isTerminalState(state) {
|
|
1326
|
+
const transitions = this.transitions.get(state);
|
|
1327
|
+
return !transitions || transitions.size === 0;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Get the resource type this state machine manages
|
|
1331
|
+
*
|
|
1332
|
+
* @returns Resource type string
|
|
1333
|
+
*/
|
|
1334
|
+
getResourceType() {
|
|
1335
|
+
return this.resourceType;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Validate state transition and create audit event
|
|
1339
|
+
*
|
|
1340
|
+
* This is a convenience method that combines validation with audit event creation.
|
|
1341
|
+
* Use this when you want to both validate a transition and record it in the audit trail.
|
|
1342
|
+
*
|
|
1343
|
+
* @param from - Current state
|
|
1344
|
+
* @param to - Target state
|
|
1345
|
+
* @param resourceId - ID of the resource being transitioned
|
|
1346
|
+
* @param context - Optional audit context (who, why, metadata)
|
|
1347
|
+
* @returns StateChangeEvent ready to be appended to document metadata
|
|
1348
|
+
* @throws InvalidStateTransitionError if transition is invalid
|
|
1349
|
+
*
|
|
1350
|
+
* @example
|
|
1351
|
+
* ```typescript
|
|
1352
|
+
* import { appendAuditEvent } from '@classytic/revenue';
|
|
1353
|
+
*
|
|
1354
|
+
* // Validate and create audit event
|
|
1355
|
+
* const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
1356
|
+
* transaction.status,
|
|
1357
|
+
* 'verified',
|
|
1358
|
+
* transaction._id.toString(),
|
|
1359
|
+
* {
|
|
1360
|
+
* changedBy: 'admin_123',
|
|
1361
|
+
* reason: 'Payment verified by payment gateway',
|
|
1362
|
+
* metadata: { verificationId: 'ver_abc' }
|
|
1363
|
+
* }
|
|
1364
|
+
* );
|
|
1365
|
+
*
|
|
1366
|
+
* // Apply state change
|
|
1367
|
+
* transaction.status = 'verified';
|
|
1368
|
+
*
|
|
1369
|
+
* // Append audit event to metadata
|
|
1370
|
+
* Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1371
|
+
*
|
|
1372
|
+
* // Save
|
|
1373
|
+
* await transaction.save();
|
|
1374
|
+
* ```
|
|
1375
|
+
*/
|
|
1376
|
+
validateAndCreateAuditEvent(from, to, resourceId, context) {
|
|
1377
|
+
this.validate(from, to, resourceId);
|
|
1378
|
+
return {
|
|
1379
|
+
resourceType: this.resourceType,
|
|
1380
|
+
resourceId,
|
|
1381
|
+
fromState: from,
|
|
1382
|
+
toState: to,
|
|
1383
|
+
changedAt: /* @__PURE__ */ new Date(),
|
|
1384
|
+
changedBy: context?.changedBy,
|
|
1385
|
+
reason: context?.reason,
|
|
1386
|
+
metadata: context?.metadata
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
// src/enums/subscription.enums.ts
|
|
1392
|
+
var SUBSCRIPTION_STATUS = {
|
|
1393
|
+
ACTIVE: "active",
|
|
1394
|
+
PAUSED: "paused",
|
|
1395
|
+
CANCELLED: "cancelled",
|
|
1396
|
+
EXPIRED: "expired",
|
|
1397
|
+
PENDING: "pending",
|
|
1398
|
+
PENDING_RENEWAL: "pending_renewal",
|
|
1399
|
+
INACTIVE: "inactive"
|
|
1400
|
+
};
|
|
1401
|
+
var SUBSCRIPTION_STATUS_VALUES = Object.values(
|
|
1402
|
+
SUBSCRIPTION_STATUS
|
|
1403
|
+
);
|
|
1404
|
+
var PLAN_KEYS = {
|
|
1405
|
+
MONTHLY: "monthly",
|
|
1406
|
+
QUARTERLY: "quarterly",
|
|
1407
|
+
YEARLY: "yearly"
|
|
1408
|
+
};
|
|
1409
|
+
var PLAN_KEY_VALUES = Object.values(PLAN_KEYS);
|
|
1410
|
+
new Set(
|
|
1411
|
+
SUBSCRIPTION_STATUS_VALUES
|
|
1412
|
+
);
|
|
1413
|
+
new Set(PLAN_KEY_VALUES);
|
|
1414
|
+
|
|
1415
|
+
// src/enums/settlement.enums.ts
|
|
1416
|
+
var SETTLEMENT_STATUS = {
|
|
1417
|
+
PENDING: "pending",
|
|
1418
|
+
PROCESSING: "processing",
|
|
1419
|
+
COMPLETED: "completed",
|
|
1420
|
+
FAILED: "failed",
|
|
1421
|
+
CANCELLED: "cancelled"
|
|
1422
|
+
};
|
|
1423
|
+
var SETTLEMENT_TYPE = {
|
|
1424
|
+
SPLIT_PAYOUT: "split_payout"};
|
|
1425
|
+
|
|
1426
|
+
// src/enums/escrow.enums.ts
|
|
1427
|
+
var HOLD_STATUS = {
|
|
1428
|
+
PENDING: "pending",
|
|
1429
|
+
HELD: "held",
|
|
1430
|
+
RELEASED: "released",
|
|
1431
|
+
CANCELLED: "cancelled",
|
|
1432
|
+
EXPIRED: "expired",
|
|
1433
|
+
PARTIALLY_RELEASED: "partially_released"
|
|
1434
|
+
};
|
|
1435
|
+
var HOLD_STATUS_VALUES = Object.values(HOLD_STATUS);
|
|
1436
|
+
var RELEASE_REASON = {
|
|
1437
|
+
PAYMENT_VERIFIED: "payment_verified",
|
|
1438
|
+
MANUAL_RELEASE: "manual_release",
|
|
1439
|
+
AUTO_RELEASE: "auto_release",
|
|
1440
|
+
DISPUTE_RESOLVED: "dispute_resolved"
|
|
1441
|
+
};
|
|
1442
|
+
var RELEASE_REASON_VALUES = Object.values(
|
|
1443
|
+
RELEASE_REASON
|
|
1444
|
+
);
|
|
1445
|
+
var HOLD_REASON = {
|
|
1446
|
+
PAYMENT_VERIFICATION: "payment_verification",
|
|
1447
|
+
FRAUD_CHECK: "fraud_check",
|
|
1448
|
+
MANUAL_REVIEW: "manual_review",
|
|
1449
|
+
DISPUTE: "dispute",
|
|
1450
|
+
COMPLIANCE: "compliance"
|
|
1451
|
+
};
|
|
1452
|
+
var HOLD_REASON_VALUES = Object.values(HOLD_REASON);
|
|
1453
|
+
new Set(HOLD_STATUS_VALUES);
|
|
1454
|
+
new Set(RELEASE_REASON_VALUES);
|
|
1455
|
+
new Set(HOLD_REASON_VALUES);
|
|
1456
|
+
|
|
1457
|
+
// src/enums/split.enums.ts
|
|
1458
|
+
var SPLIT_TYPE = {
|
|
1459
|
+
PLATFORM_COMMISSION: "platform_commission",
|
|
1460
|
+
AFFILIATE_COMMISSION: "affiliate_commission",
|
|
1461
|
+
REFERRAL_COMMISSION: "referral_commission",
|
|
1462
|
+
PARTNER_COMMISSION: "partner_commission",
|
|
1463
|
+
CUSTOM: "custom"
|
|
1464
|
+
};
|
|
1465
|
+
var SPLIT_TYPE_VALUES = Object.values(SPLIT_TYPE);
|
|
1466
|
+
var SPLIT_STATUS = {
|
|
1467
|
+
PENDING: "pending",
|
|
1468
|
+
DUE: "due",
|
|
1469
|
+
PAID: "paid",
|
|
1470
|
+
WAIVED: "waived",
|
|
1471
|
+
CANCELLED: "cancelled"
|
|
1472
|
+
};
|
|
1473
|
+
var SPLIT_STATUS_VALUES = Object.values(
|
|
1474
|
+
SPLIT_STATUS
|
|
1475
|
+
);
|
|
1476
|
+
var PAYOUT_METHOD = {
|
|
1477
|
+
BANK_TRANSFER: "bank_transfer",
|
|
1478
|
+
MOBILE_WALLET: "mobile_wallet",
|
|
1479
|
+
PLATFORM_BALANCE: "platform_balance",
|
|
1480
|
+
CRYPTO: "crypto",
|
|
1481
|
+
CHECK: "check",
|
|
1482
|
+
MANUAL: "manual"
|
|
1483
|
+
};
|
|
1484
|
+
var PAYOUT_METHOD_VALUES = Object.values(PAYOUT_METHOD);
|
|
1485
|
+
new Set(SPLIT_TYPE_VALUES);
|
|
1486
|
+
new Set(SPLIT_STATUS_VALUES);
|
|
1487
|
+
new Set(PAYOUT_METHOD_VALUES);
|
|
1488
|
+
|
|
1489
|
+
// src/core/state-machine/definitions.ts
|
|
1490
|
+
var TRANSACTION_STATE_MACHINE = new StateMachine(
|
|
1491
|
+
/* @__PURE__ */ new Map([
|
|
1492
|
+
[
|
|
1493
|
+
TRANSACTION_STATUS.PENDING,
|
|
1494
|
+
/* @__PURE__ */ new Set([
|
|
1495
|
+
TRANSACTION_STATUS.PAYMENT_INITIATED,
|
|
1496
|
+
TRANSACTION_STATUS.PROCESSING,
|
|
1497
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
1498
|
+
// Allow direct verification (manual payments)
|
|
1499
|
+
TRANSACTION_STATUS.FAILED,
|
|
1500
|
+
TRANSACTION_STATUS.CANCELLED
|
|
1501
|
+
])
|
|
1502
|
+
],
|
|
1503
|
+
[
|
|
1504
|
+
TRANSACTION_STATUS.PAYMENT_INITIATED,
|
|
1505
|
+
/* @__PURE__ */ new Set([
|
|
1506
|
+
TRANSACTION_STATUS.PROCESSING,
|
|
1507
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
1508
|
+
// Allow direct verification (webhook/instant payments)
|
|
1509
|
+
TRANSACTION_STATUS.REQUIRES_ACTION,
|
|
1510
|
+
TRANSACTION_STATUS.FAILED,
|
|
1511
|
+
TRANSACTION_STATUS.CANCELLED
|
|
1512
|
+
])
|
|
1513
|
+
],
|
|
1514
|
+
[
|
|
1515
|
+
TRANSACTION_STATUS.PROCESSING,
|
|
1516
|
+
/* @__PURE__ */ new Set([
|
|
1517
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
1518
|
+
TRANSACTION_STATUS.REQUIRES_ACTION,
|
|
1519
|
+
TRANSACTION_STATUS.FAILED
|
|
1520
|
+
])
|
|
1521
|
+
],
|
|
1522
|
+
[
|
|
1523
|
+
TRANSACTION_STATUS.REQUIRES_ACTION,
|
|
1524
|
+
/* @__PURE__ */ new Set([
|
|
1525
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
1526
|
+
TRANSACTION_STATUS.FAILED,
|
|
1527
|
+
TRANSACTION_STATUS.CANCELLED,
|
|
1528
|
+
TRANSACTION_STATUS.EXPIRED
|
|
1529
|
+
])
|
|
1530
|
+
],
|
|
1531
|
+
[
|
|
1532
|
+
TRANSACTION_STATUS.VERIFIED,
|
|
1533
|
+
/* @__PURE__ */ new Set([
|
|
1534
|
+
TRANSACTION_STATUS.COMPLETED,
|
|
1535
|
+
TRANSACTION_STATUS.REFUNDED,
|
|
1536
|
+
TRANSACTION_STATUS.PARTIALLY_REFUNDED,
|
|
1537
|
+
TRANSACTION_STATUS.CANCELLED
|
|
1538
|
+
])
|
|
1539
|
+
],
|
|
1540
|
+
[
|
|
1541
|
+
TRANSACTION_STATUS.COMPLETED,
|
|
1542
|
+
/* @__PURE__ */ new Set([
|
|
1543
|
+
TRANSACTION_STATUS.REFUNDED,
|
|
1544
|
+
TRANSACTION_STATUS.PARTIALLY_REFUNDED
|
|
1545
|
+
])
|
|
1546
|
+
],
|
|
1547
|
+
[
|
|
1548
|
+
TRANSACTION_STATUS.PARTIALLY_REFUNDED,
|
|
1549
|
+
/* @__PURE__ */ new Set([TRANSACTION_STATUS.REFUNDED])
|
|
1550
|
+
],
|
|
1551
|
+
// Terminal states
|
|
1552
|
+
[TRANSACTION_STATUS.FAILED, /* @__PURE__ */ new Set([])],
|
|
1553
|
+
[TRANSACTION_STATUS.REFUNDED, /* @__PURE__ */ new Set([])],
|
|
1554
|
+
[TRANSACTION_STATUS.CANCELLED, /* @__PURE__ */ new Set([])],
|
|
1555
|
+
[TRANSACTION_STATUS.EXPIRED, /* @__PURE__ */ new Set([])]
|
|
1556
|
+
]),
|
|
1557
|
+
"transaction"
|
|
1558
|
+
);
|
|
1559
|
+
var SUBSCRIPTION_STATE_MACHINE = new StateMachine(
|
|
1560
|
+
/* @__PURE__ */ new Map([
|
|
1561
|
+
[
|
|
1562
|
+
SUBSCRIPTION_STATUS.PENDING,
|
|
1563
|
+
/* @__PURE__ */ new Set([
|
|
1564
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
1565
|
+
SUBSCRIPTION_STATUS.CANCELLED
|
|
1566
|
+
])
|
|
1567
|
+
],
|
|
1568
|
+
[
|
|
1569
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
1570
|
+
/* @__PURE__ */ new Set([
|
|
1571
|
+
SUBSCRIPTION_STATUS.PAUSED,
|
|
1572
|
+
SUBSCRIPTION_STATUS.PENDING_RENEWAL,
|
|
1573
|
+
SUBSCRIPTION_STATUS.CANCELLED,
|
|
1574
|
+
SUBSCRIPTION_STATUS.EXPIRED
|
|
1575
|
+
])
|
|
1576
|
+
],
|
|
1577
|
+
[
|
|
1578
|
+
SUBSCRIPTION_STATUS.PENDING_RENEWAL,
|
|
1579
|
+
/* @__PURE__ */ new Set([
|
|
1580
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
1581
|
+
SUBSCRIPTION_STATUS.CANCELLED,
|
|
1582
|
+
SUBSCRIPTION_STATUS.EXPIRED
|
|
1583
|
+
])
|
|
1584
|
+
],
|
|
1585
|
+
[
|
|
1586
|
+
SUBSCRIPTION_STATUS.PAUSED,
|
|
1587
|
+
/* @__PURE__ */ new Set([
|
|
1588
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
1589
|
+
SUBSCRIPTION_STATUS.CANCELLED
|
|
1590
|
+
])
|
|
1591
|
+
],
|
|
1592
|
+
[
|
|
1593
|
+
SUBSCRIPTION_STATUS.INACTIVE,
|
|
1594
|
+
/* @__PURE__ */ new Set([
|
|
1595
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
1596
|
+
SUBSCRIPTION_STATUS.CANCELLED
|
|
1597
|
+
])
|
|
1598
|
+
],
|
|
1599
|
+
// Terminal states
|
|
1600
|
+
[SUBSCRIPTION_STATUS.CANCELLED, /* @__PURE__ */ new Set([])],
|
|
1601
|
+
[SUBSCRIPTION_STATUS.EXPIRED, /* @__PURE__ */ new Set([])]
|
|
1602
|
+
]),
|
|
1603
|
+
"subscription"
|
|
1604
|
+
);
|
|
1605
|
+
var SETTLEMENT_STATE_MACHINE = new StateMachine(
|
|
1606
|
+
/* @__PURE__ */ new Map([
|
|
1607
|
+
[
|
|
1608
|
+
SETTLEMENT_STATUS.PENDING,
|
|
1609
|
+
/* @__PURE__ */ new Set([
|
|
1610
|
+
SETTLEMENT_STATUS.PROCESSING,
|
|
1611
|
+
SETTLEMENT_STATUS.CANCELLED
|
|
1612
|
+
])
|
|
1613
|
+
],
|
|
1614
|
+
[
|
|
1615
|
+
SETTLEMENT_STATUS.PROCESSING,
|
|
1616
|
+
/* @__PURE__ */ new Set([
|
|
1617
|
+
SETTLEMENT_STATUS.COMPLETED,
|
|
1618
|
+
SETTLEMENT_STATUS.FAILED
|
|
1619
|
+
])
|
|
1620
|
+
],
|
|
1621
|
+
[
|
|
1622
|
+
SETTLEMENT_STATUS.FAILED,
|
|
1623
|
+
/* @__PURE__ */ new Set([
|
|
1624
|
+
SETTLEMENT_STATUS.PENDING,
|
|
1625
|
+
// Allow retry
|
|
1626
|
+
SETTLEMENT_STATUS.CANCELLED
|
|
1627
|
+
])
|
|
1628
|
+
],
|
|
1629
|
+
// Terminal states
|
|
1630
|
+
[SETTLEMENT_STATUS.COMPLETED, /* @__PURE__ */ new Set([])],
|
|
1631
|
+
[SETTLEMENT_STATUS.CANCELLED, /* @__PURE__ */ new Set([])]
|
|
1632
|
+
]),
|
|
1633
|
+
"settlement"
|
|
1634
|
+
);
|
|
1635
|
+
var HOLD_STATE_MACHINE = new StateMachine(
|
|
1636
|
+
/* @__PURE__ */ new Map([
|
|
1637
|
+
[
|
|
1638
|
+
HOLD_STATUS.HELD,
|
|
1639
|
+
/* @__PURE__ */ new Set([
|
|
1640
|
+
HOLD_STATUS.RELEASED,
|
|
1641
|
+
HOLD_STATUS.PARTIALLY_RELEASED,
|
|
1642
|
+
HOLD_STATUS.CANCELLED,
|
|
1643
|
+
HOLD_STATUS.EXPIRED
|
|
1644
|
+
])
|
|
1645
|
+
],
|
|
1646
|
+
[
|
|
1647
|
+
HOLD_STATUS.PARTIALLY_RELEASED,
|
|
1648
|
+
/* @__PURE__ */ new Set([
|
|
1649
|
+
HOLD_STATUS.RELEASED,
|
|
1650
|
+
HOLD_STATUS.CANCELLED
|
|
1651
|
+
])
|
|
1652
|
+
],
|
|
1653
|
+
// Terminal states
|
|
1654
|
+
[HOLD_STATUS.RELEASED, /* @__PURE__ */ new Set([])],
|
|
1655
|
+
[HOLD_STATUS.CANCELLED, /* @__PURE__ */ new Set([])],
|
|
1656
|
+
[HOLD_STATUS.EXPIRED, /* @__PURE__ */ new Set([])]
|
|
1657
|
+
]),
|
|
1658
|
+
"escrow_hold"
|
|
1659
|
+
);
|
|
1660
|
+
var SPLIT_STATE_MACHINE = new StateMachine(
|
|
1661
|
+
/* @__PURE__ */ new Map([
|
|
1662
|
+
[
|
|
1663
|
+
SPLIT_STATUS.PENDING,
|
|
1664
|
+
/* @__PURE__ */ new Set([
|
|
1665
|
+
SPLIT_STATUS.DUE,
|
|
1666
|
+
SPLIT_STATUS.PAID,
|
|
1667
|
+
SPLIT_STATUS.WAIVED,
|
|
1668
|
+
SPLIT_STATUS.CANCELLED
|
|
1669
|
+
])
|
|
1670
|
+
],
|
|
1671
|
+
[
|
|
1672
|
+
SPLIT_STATUS.DUE,
|
|
1673
|
+
/* @__PURE__ */ new Set([
|
|
1674
|
+
SPLIT_STATUS.PAID,
|
|
1675
|
+
SPLIT_STATUS.WAIVED,
|
|
1676
|
+
SPLIT_STATUS.CANCELLED
|
|
1677
|
+
])
|
|
1678
|
+
],
|
|
1679
|
+
// Terminal states
|
|
1680
|
+
[SPLIT_STATUS.PAID, /* @__PURE__ */ new Set([])],
|
|
1681
|
+
[SPLIT_STATUS.WAIVED, /* @__PURE__ */ new Set([])],
|
|
1682
|
+
[SPLIT_STATUS.CANCELLED, /* @__PURE__ */ new Set([])]
|
|
1683
|
+
]),
|
|
1684
|
+
"split"
|
|
1685
|
+
);
|
|
1686
|
+
|
|
1687
|
+
// src/infrastructure/audit/types.ts
|
|
1688
|
+
function appendAuditEvent(document, event) {
|
|
1689
|
+
const stateHistory = document.metadata?.stateHistory ?? [];
|
|
1690
|
+
return {
|
|
1691
|
+
...document,
|
|
1692
|
+
metadata: {
|
|
1693
|
+
...document.metadata,
|
|
1694
|
+
stateHistory: [...stateHistory, event]
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1166
1698
|
|
|
1167
|
-
// src/services/monetization.service.ts
|
|
1699
|
+
// src/application/services/monetization.service.ts
|
|
1168
1700
|
var MonetizationService = class {
|
|
1169
1701
|
models;
|
|
1170
1702
|
providers;
|
|
1171
1703
|
config;
|
|
1172
|
-
|
|
1704
|
+
plugins;
|
|
1173
1705
|
logger;
|
|
1706
|
+
events;
|
|
1707
|
+
retryConfig;
|
|
1708
|
+
circuitBreaker;
|
|
1174
1709
|
constructor(container) {
|
|
1175
1710
|
this.models = container.get("models");
|
|
1176
1711
|
this.providers = container.get("providers");
|
|
1177
1712
|
this.config = container.get("config");
|
|
1178
|
-
this.
|
|
1713
|
+
this.plugins = container.get("plugins");
|
|
1179
1714
|
this.logger = container.get("logger");
|
|
1715
|
+
this.events = container.get("events");
|
|
1716
|
+
this.retryConfig = container.get("retryConfig");
|
|
1717
|
+
this.circuitBreaker = container.get("circuitBreaker");
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Create plugin context for hook execution
|
|
1721
|
+
* @private
|
|
1722
|
+
*/
|
|
1723
|
+
getPluginContext(idempotencyKey) {
|
|
1724
|
+
return {
|
|
1725
|
+
events: this.events,
|
|
1726
|
+
logger: this.logger,
|
|
1727
|
+
storage: /* @__PURE__ */ new Map(),
|
|
1728
|
+
meta: {
|
|
1729
|
+
idempotencyKey: idempotencyKey ?? void 0,
|
|
1730
|
+
requestId: nanoid(),
|
|
1731
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Execute provider call with retry and circuit breaker protection
|
|
1737
|
+
* @private
|
|
1738
|
+
*/
|
|
1739
|
+
async executeProviderCall(operation, operationName) {
|
|
1740
|
+
const withCircuitBreaker = this.circuitBreaker ? () => this.circuitBreaker.execute(operation) : operation;
|
|
1741
|
+
if (this.retryConfig && Object.keys(this.retryConfig).length > 0) {
|
|
1742
|
+
return retry(withCircuitBreaker, {
|
|
1743
|
+
...this.retryConfig,
|
|
1744
|
+
onRetry: (error, attempt, delay) => {
|
|
1745
|
+
this.logger.warn(
|
|
1746
|
+
`[${operationName}] Retry attempt ${attempt} after ${delay}ms:`,
|
|
1747
|
+
error
|
|
1748
|
+
);
|
|
1749
|
+
this.retryConfig.onRetry?.(error, attempt, delay);
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
return withCircuitBreaker();
|
|
1180
1754
|
}
|
|
1181
1755
|
/**
|
|
1182
1756
|
* Create a new monetization (purchase, subscription, or free item)
|
|
@@ -1189,8 +1763,8 @@ var MonetizationService = class {
|
|
|
1189
1763
|
* data: {
|
|
1190
1764
|
* organizationId: '...',
|
|
1191
1765
|
* customerId: '...',
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1766
|
+
* sourceId: order._id,
|
|
1767
|
+
* sourceModel: 'Order',
|
|
1194
1768
|
* },
|
|
1195
1769
|
* planKey: 'one_time',
|
|
1196
1770
|
* monetizationType: 'purchase',
|
|
@@ -1203,8 +1777,8 @@ var MonetizationService = class {
|
|
|
1203
1777
|
* data: {
|
|
1204
1778
|
* organizationId: '...',
|
|
1205
1779
|
* customerId: '...',
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1780
|
+
* sourceId: subscription._id,
|
|
1781
|
+
* sourceModel: 'Subscription',
|
|
1208
1782
|
* },
|
|
1209
1783
|
* planKey: 'monthly',
|
|
1210
1784
|
* monetizationType: 'subscription',
|
|
@@ -1215,132 +1789,217 @@ var MonetizationService = class {
|
|
|
1215
1789
|
* @returns Result with subscription, transaction, and paymentIntent
|
|
1216
1790
|
*/
|
|
1217
1791
|
async create(params) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
paymentData,
|
|
1227
|
-
metadata = {},
|
|
1228
|
-
idempotencyKey = null
|
|
1229
|
-
} = params;
|
|
1230
|
-
if (!planKey) {
|
|
1231
|
-
throw new MissingRequiredFieldError("planKey");
|
|
1232
|
-
}
|
|
1233
|
-
if (amount < 0) {
|
|
1234
|
-
throw new InvalidAmountError(amount);
|
|
1235
|
-
}
|
|
1236
|
-
const isFree = amount === 0;
|
|
1237
|
-
const provider = this.providers[gateway];
|
|
1238
|
-
if (!provider) {
|
|
1239
|
-
throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
1240
|
-
}
|
|
1241
|
-
let paymentIntent = null;
|
|
1242
|
-
let transaction = null;
|
|
1243
|
-
if (!isFree) {
|
|
1244
|
-
try {
|
|
1245
|
-
paymentIntent = await provider.createIntent({
|
|
1792
|
+
return this.plugins.executeHook(
|
|
1793
|
+
"monetization.create.before",
|
|
1794
|
+
this.getPluginContext(params.idempotencyKey),
|
|
1795
|
+
params,
|
|
1796
|
+
async () => {
|
|
1797
|
+
const {
|
|
1798
|
+
data,
|
|
1799
|
+
planKey,
|
|
1246
1800
|
amount,
|
|
1247
|
-
currency,
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1801
|
+
currency = "BDT",
|
|
1802
|
+
gateway = "manual",
|
|
1803
|
+
entity = null,
|
|
1804
|
+
monetizationType = MONETIZATION_TYPES.SUBSCRIPTION,
|
|
1805
|
+
paymentData,
|
|
1806
|
+
metadata = {},
|
|
1807
|
+
idempotencyKey = null
|
|
1808
|
+
} = params;
|
|
1809
|
+
if (!planKey) {
|
|
1810
|
+
throw new MissingRequiredFieldError("planKey");
|
|
1811
|
+
}
|
|
1812
|
+
if (amount < 0) {
|
|
1813
|
+
throw new InvalidAmountError(amount);
|
|
1814
|
+
}
|
|
1815
|
+
const isFree = amount === 0;
|
|
1816
|
+
const provider = this.providers[gateway];
|
|
1817
|
+
if (!provider) {
|
|
1818
|
+
throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
1819
|
+
}
|
|
1820
|
+
let paymentIntent = null;
|
|
1821
|
+
let transaction = null;
|
|
1822
|
+
if (!isFree) {
|
|
1823
|
+
try {
|
|
1824
|
+
paymentIntent = await this.executeProviderCall(
|
|
1825
|
+
() => provider.createIntent({
|
|
1826
|
+
amount,
|
|
1827
|
+
currency,
|
|
1828
|
+
metadata: {
|
|
1829
|
+
...metadata,
|
|
1830
|
+
type: "subscription",
|
|
1831
|
+
planKey
|
|
1832
|
+
}
|
|
1833
|
+
}),
|
|
1834
|
+
`${gateway}.createIntent`
|
|
1835
|
+
);
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
1252
1838
|
}
|
|
1839
|
+
const category = resolveCategory(entity, monetizationType, this.config.categoryMappings);
|
|
1840
|
+
const transactionFlow = this.config.transactionTypeMapping?.[category] ?? this.config.transactionTypeMapping?.[monetizationType] ?? TRANSACTION_FLOW.INFLOW;
|
|
1841
|
+
const commissionRate = getCommissionRate(this.config, category);
|
|
1842
|
+
const gatewayFeeRate = getGatewayFeeRate(this.config, gateway);
|
|
1843
|
+
const commission = calculateCommission(amount, commissionRate, gatewayFeeRate);
|
|
1844
|
+
const tax = params.tax;
|
|
1845
|
+
const TransactionModel = this.models.Transaction;
|
|
1846
|
+
const baseAmount = tax?.pricesIncludeTax ? tax.baseAmount : amount;
|
|
1847
|
+
const feeAmount = commission?.gatewayFeeAmount || 0;
|
|
1848
|
+
const taxAmount = tax?.taxAmount || 0;
|
|
1849
|
+
const netAmount = baseAmount - feeAmount - taxAmount;
|
|
1850
|
+
transaction = await TransactionModel.create({
|
|
1851
|
+
organizationId: data.organizationId,
|
|
1852
|
+
customerId: data.customerId ?? null,
|
|
1853
|
+
// ✅ UNIFIED: Use category as type directly
|
|
1854
|
+
type: category,
|
|
1855
|
+
// 'subscription', 'purchase', etc.
|
|
1856
|
+
flow: transactionFlow,
|
|
1857
|
+
// ✅ Use config-driven transaction type
|
|
1858
|
+
// Auto-tagging (middleware will handle, but we can set explicitly)
|
|
1859
|
+
tags: category === "subscription" ? ["recurring", "subscription"] : [],
|
|
1860
|
+
// ✅ UNIFIED: Amount structure
|
|
1861
|
+
// When prices include tax, use baseAmount (tax already extracted)
|
|
1862
|
+
amount: baseAmount,
|
|
1863
|
+
currency,
|
|
1864
|
+
fee: feeAmount,
|
|
1865
|
+
tax: taxAmount,
|
|
1866
|
+
net: netAmount,
|
|
1867
|
+
// ✅ UNIFIED: Tax details (if tax plugin used)
|
|
1868
|
+
...tax && {
|
|
1869
|
+
taxDetails: {
|
|
1870
|
+
type: tax.type === "collected" ? "sales_tax" : tax.type === "paid" ? "vat" : "none",
|
|
1871
|
+
rate: tax.rate || 0,
|
|
1872
|
+
isInclusive: tax.pricesIncludeTax || false
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
method: paymentData?.method ?? "manual",
|
|
1876
|
+
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
1877
|
+
// ✅ Map 'succeeded' to valid TransactionStatusValue
|
|
1878
|
+
gateway: {
|
|
1879
|
+
type: gateway,
|
|
1880
|
+
// Gateway/provider type (e.g., 'stripe', 'manual')
|
|
1881
|
+
provider: gateway,
|
|
1882
|
+
sessionId: paymentIntent.sessionId,
|
|
1883
|
+
paymentIntentId: paymentIntent.paymentIntentId,
|
|
1884
|
+
chargeId: paymentIntent.id,
|
|
1885
|
+
metadata: paymentIntent.metadata
|
|
1886
|
+
},
|
|
1887
|
+
paymentDetails: {
|
|
1888
|
+
...paymentData
|
|
1889
|
+
},
|
|
1890
|
+
// Commission (for marketplace/splits)
|
|
1891
|
+
...commission && { commission },
|
|
1892
|
+
// ✅ UNIFIED: Source reference (renamed from reference)
|
|
1893
|
+
...data.sourceId && { sourceId: data.sourceId },
|
|
1894
|
+
...data.sourceModel && { sourceModel: data.sourceModel },
|
|
1895
|
+
metadata: {
|
|
1896
|
+
...metadata,
|
|
1897
|
+
planKey,
|
|
1898
|
+
entity,
|
|
1899
|
+
monetizationType,
|
|
1900
|
+
paymentIntentId: paymentIntent.id
|
|
1901
|
+
},
|
|
1902
|
+
idempotencyKey: idempotencyKey ?? `sub_${nanoid(16)}`
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
let subscription = null;
|
|
1906
|
+
if (this.models.Subscription) {
|
|
1907
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1908
|
+
const result2 = await this.plugins.executeHook(
|
|
1909
|
+
"subscription.create.before",
|
|
1910
|
+
this.getPluginContext(idempotencyKey),
|
|
1911
|
+
{
|
|
1912
|
+
subscriptionId: void 0,
|
|
1913
|
+
// Not yet created - populated in after hook
|
|
1914
|
+
planKey,
|
|
1915
|
+
customerId: data.customerId,
|
|
1916
|
+
organizationId: data.organizationId,
|
|
1917
|
+
entity
|
|
1918
|
+
},
|
|
1919
|
+
async () => {
|
|
1920
|
+
const subscriptionData = {
|
|
1921
|
+
organizationId: data.organizationId,
|
|
1922
|
+
customerId: data.customerId ?? null,
|
|
1923
|
+
planKey,
|
|
1924
|
+
amount,
|
|
1925
|
+
currency,
|
|
1926
|
+
status: isFree ? "active" : "pending",
|
|
1927
|
+
isActive: isFree,
|
|
1928
|
+
gateway,
|
|
1929
|
+
transactionId: transaction?._id ?? null,
|
|
1930
|
+
paymentIntentId: paymentIntent?.id ?? null,
|
|
1931
|
+
metadata: {
|
|
1932
|
+
...metadata,
|
|
1933
|
+
isFree,
|
|
1934
|
+
entity,
|
|
1935
|
+
monetizationType
|
|
1936
|
+
},
|
|
1937
|
+
...data
|
|
1938
|
+
};
|
|
1939
|
+
delete subscriptionData.sourceId;
|
|
1940
|
+
delete subscriptionData.sourceModel;
|
|
1941
|
+
const sub = await SubscriptionModel.create(subscriptionData);
|
|
1942
|
+
await this.plugins.executeHook(
|
|
1943
|
+
"subscription.create.after",
|
|
1944
|
+
this.getPluginContext(idempotencyKey),
|
|
1945
|
+
{
|
|
1946
|
+
subscriptionId: sub._id.toString(),
|
|
1947
|
+
planKey,
|
|
1948
|
+
customerId: data.customerId,
|
|
1949
|
+
organizationId: data.organizationId,
|
|
1950
|
+
entity
|
|
1951
|
+
},
|
|
1952
|
+
async () => ({ subscription: sub, transaction })
|
|
1953
|
+
);
|
|
1954
|
+
return { subscription: sub, transaction };
|
|
1955
|
+
}
|
|
1956
|
+
);
|
|
1957
|
+
subscription = result2.subscription;
|
|
1958
|
+
}
|
|
1959
|
+
this.events.emit("monetization.created", {
|
|
1960
|
+
monetizationType,
|
|
1961
|
+
subscription: subscription ?? void 0,
|
|
1962
|
+
transaction: transaction ?? void 0,
|
|
1963
|
+
paymentIntent: paymentIntent ?? void 0
|
|
1253
1964
|
});
|
|
1254
|
-
|
|
1255
|
-
|
|
1965
|
+
if (monetizationType === MONETIZATION_TYPES.PURCHASE) {
|
|
1966
|
+
if (transaction) {
|
|
1967
|
+
this.events.emit("purchase.created", {
|
|
1968
|
+
monetizationType,
|
|
1969
|
+
subscription: subscription ?? void 0,
|
|
1970
|
+
transaction,
|
|
1971
|
+
paymentIntent: paymentIntent ?? void 0
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
} else if (monetizationType === MONETIZATION_TYPES.SUBSCRIPTION) {
|
|
1975
|
+
if (subscription) {
|
|
1976
|
+
this.events.emit("subscription.created", {
|
|
1977
|
+
subscriptionId: subscription._id.toString(),
|
|
1978
|
+
subscription,
|
|
1979
|
+
transactionId: transaction?._id?.toString()
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
} else if (monetizationType === MONETIZATION_TYPES.FREE) {
|
|
1983
|
+
this.events.emit("free.created", {
|
|
1984
|
+
monetizationType,
|
|
1985
|
+
subscription: subscription ?? void 0,
|
|
1986
|
+
transaction: transaction ?? void 0,
|
|
1987
|
+
paymentIntent: paymentIntent ?? void 0
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
const result = {
|
|
1991
|
+
subscription,
|
|
1992
|
+
transaction,
|
|
1993
|
+
paymentIntent
|
|
1994
|
+
};
|
|
1995
|
+
return this.plugins.executeHook(
|
|
1996
|
+
"monetization.create.after",
|
|
1997
|
+
this.getPluginContext(params.idempotencyKey),
|
|
1998
|
+
params,
|
|
1999
|
+
async () => result
|
|
2000
|
+
);
|
|
1256
2001
|
}
|
|
1257
|
-
|
|
1258
|
-
const transactionType = this.config.transactionTypeMapping?.subscription ?? this.config.transactionTypeMapping?.[monetizationType] ?? TRANSACTION_TYPE.INCOME;
|
|
1259
|
-
const commissionRate = this.config.commissionRates?.[category] ?? 0;
|
|
1260
|
-
const gatewayFeeRate = this.config.gatewayFeeRates?.[gateway] ?? 0;
|
|
1261
|
-
const commission = calculateCommission(amount, commissionRate, gatewayFeeRate);
|
|
1262
|
-
const TransactionModel = this.models.Transaction;
|
|
1263
|
-
transaction = await TransactionModel.create({
|
|
1264
|
-
organizationId: data.organizationId,
|
|
1265
|
-
customerId: data.customerId ?? null,
|
|
1266
|
-
amount,
|
|
1267
|
-
currency,
|
|
1268
|
-
category,
|
|
1269
|
-
type: transactionType,
|
|
1270
|
-
method: paymentData?.method ?? "manual",
|
|
1271
|
-
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
1272
|
-
gateway: {
|
|
1273
|
-
type: gateway,
|
|
1274
|
-
sessionId: paymentIntent.sessionId,
|
|
1275
|
-
paymentIntentId: paymentIntent.paymentIntentId,
|
|
1276
|
-
provider: paymentIntent.provider,
|
|
1277
|
-
metadata: paymentIntent.metadata
|
|
1278
|
-
},
|
|
1279
|
-
paymentDetails: {
|
|
1280
|
-
provider: gateway,
|
|
1281
|
-
...paymentData
|
|
1282
|
-
},
|
|
1283
|
-
...commission && { commission },
|
|
1284
|
-
// Only include if commission exists
|
|
1285
|
-
// Polymorphic reference (top-level, not metadata)
|
|
1286
|
-
...data.referenceId && { referenceId: data.referenceId },
|
|
1287
|
-
...data.referenceModel && { referenceModel: data.referenceModel },
|
|
1288
|
-
metadata: {
|
|
1289
|
-
...metadata,
|
|
1290
|
-
planKey,
|
|
1291
|
-
entity,
|
|
1292
|
-
monetizationType,
|
|
1293
|
-
paymentIntentId: paymentIntent.id
|
|
1294
|
-
},
|
|
1295
|
-
idempotencyKey: idempotencyKey ?? `sub_${nanoid(16)}`
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
let subscription = null;
|
|
1299
|
-
if (this.models.Subscription) {
|
|
1300
|
-
const SubscriptionModel = this.models.Subscription;
|
|
1301
|
-
const subscriptionData = {
|
|
1302
|
-
organizationId: data.organizationId,
|
|
1303
|
-
customerId: data.customerId ?? null,
|
|
1304
|
-
planKey,
|
|
1305
|
-
amount,
|
|
1306
|
-
currency,
|
|
1307
|
-
status: isFree ? "active" : "pending",
|
|
1308
|
-
isActive: isFree,
|
|
1309
|
-
gateway,
|
|
1310
|
-
transactionId: transaction?._id ?? null,
|
|
1311
|
-
paymentIntentId: paymentIntent?.id ?? null,
|
|
1312
|
-
metadata: {
|
|
1313
|
-
...metadata,
|
|
1314
|
-
isFree,
|
|
1315
|
-
entity,
|
|
1316
|
-
monetizationType
|
|
1317
|
-
},
|
|
1318
|
-
...data
|
|
1319
|
-
};
|
|
1320
|
-
delete subscriptionData.referenceId;
|
|
1321
|
-
delete subscriptionData.referenceModel;
|
|
1322
|
-
subscription = await SubscriptionModel.create(subscriptionData);
|
|
1323
|
-
}
|
|
1324
|
-
const eventData = {
|
|
1325
|
-
subscription,
|
|
1326
|
-
transaction,
|
|
1327
|
-
paymentIntent,
|
|
1328
|
-
isFree,
|
|
1329
|
-
monetizationType
|
|
1330
|
-
};
|
|
1331
|
-
if (monetizationType === MONETIZATION_TYPES.PURCHASE) {
|
|
1332
|
-
this._triggerHook("purchase.created", eventData);
|
|
1333
|
-
} else if (monetizationType === MONETIZATION_TYPES.SUBSCRIPTION) {
|
|
1334
|
-
this._triggerHook("subscription.created", eventData);
|
|
1335
|
-
} else if (monetizationType === MONETIZATION_TYPES.FREE) {
|
|
1336
|
-
this._triggerHook("free.created", eventData);
|
|
1337
|
-
}
|
|
1338
|
-
this._triggerHook("monetization.created", eventData);
|
|
1339
|
-
return {
|
|
1340
|
-
subscription,
|
|
1341
|
-
transaction,
|
|
1342
|
-
paymentIntent
|
|
1343
|
-
};
|
|
2002
|
+
);
|
|
1344
2003
|
}
|
|
1345
2004
|
/**
|
|
1346
2005
|
* Activate subscription after payment verification
|
|
@@ -1350,31 +2009,54 @@ var MonetizationService = class {
|
|
|
1350
2009
|
* @returns Updated subscription
|
|
1351
2010
|
*/
|
|
1352
2011
|
async activate(subscriptionId, options = {}) {
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2012
|
+
return this.plugins.executeHook(
|
|
2013
|
+
"subscription.activate.before",
|
|
2014
|
+
this.getPluginContext(),
|
|
2015
|
+
{ subscriptionId, ...options },
|
|
2016
|
+
async () => {
|
|
2017
|
+
const { timestamp = /* @__PURE__ */ new Date() } = options;
|
|
2018
|
+
if (!this.models.Subscription) {
|
|
2019
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
2020
|
+
}
|
|
2021
|
+
const SubscriptionModel = this.models.Subscription;
|
|
2022
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
2023
|
+
if (!subscription) {
|
|
2024
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
2025
|
+
}
|
|
2026
|
+
if (subscription.isActive) {
|
|
2027
|
+
this.logger.warn("Subscription already active", { subscriptionId });
|
|
2028
|
+
return subscription;
|
|
2029
|
+
}
|
|
2030
|
+
const periodEnd = this._calculatePeriodEnd(subscription.planKey, timestamp);
|
|
2031
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2032
|
+
subscription.status,
|
|
2033
|
+
"active",
|
|
2034
|
+
subscription._id.toString(),
|
|
2035
|
+
{
|
|
2036
|
+
changedBy: "system",
|
|
2037
|
+
reason: `Subscription activated for plan: ${subscription.planKey}`,
|
|
2038
|
+
metadata: { planKey: subscription.planKey, startDate: timestamp, endDate: periodEnd }
|
|
2039
|
+
}
|
|
2040
|
+
);
|
|
2041
|
+
subscription.isActive = true;
|
|
2042
|
+
subscription.status = "active";
|
|
2043
|
+
subscription.startDate = timestamp;
|
|
2044
|
+
subscription.endDate = periodEnd;
|
|
2045
|
+
subscription.activatedAt = timestamp;
|
|
2046
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
2047
|
+
await subscription.save();
|
|
2048
|
+
this.events.emit("subscription.activated", {
|
|
2049
|
+
subscription,
|
|
2050
|
+
activatedAt: timestamp
|
|
2051
|
+
});
|
|
2052
|
+
return this.plugins.executeHook(
|
|
2053
|
+
"subscription.activate.after",
|
|
2054
|
+
this.getPluginContext(),
|
|
2055
|
+
{ subscriptionId, ...options },
|
|
2056
|
+
async () => subscription
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
);
|
|
1378
2060
|
}
|
|
1379
2061
|
/**
|
|
1380
2062
|
* Renew subscription
|
|
@@ -1424,36 +2106,48 @@ var MonetizationService = class {
|
|
|
1424
2106
|
const effectiveEntity = entity ?? subscription.metadata?.entity;
|
|
1425
2107
|
const effectiveMonetizationType = subscription.metadata?.monetizationType ?? MONETIZATION_TYPES.SUBSCRIPTION;
|
|
1426
2108
|
const category = resolveCategory(effectiveEntity, effectiveMonetizationType, this.config.categoryMappings);
|
|
1427
|
-
const
|
|
1428
|
-
const commissionRate = this.config
|
|
1429
|
-
const gatewayFeeRate = this.config
|
|
2109
|
+
const transactionFlow = this.config.transactionTypeMapping?.[category] ?? this.config.transactionTypeMapping?.subscription_renewal ?? this.config.transactionTypeMapping?.[effectiveMonetizationType] ?? TRANSACTION_FLOW.INFLOW;
|
|
2110
|
+
const commissionRate = getCommissionRate(this.config, category);
|
|
2111
|
+
const gatewayFeeRate = getGatewayFeeRate(this.config, gateway);
|
|
1430
2112
|
const commission = calculateCommission(subscription.amount, commissionRate, gatewayFeeRate);
|
|
2113
|
+
const feeAmount = commission?.gatewayFeeAmount || 0;
|
|
2114
|
+
const netAmount = subscription.amount - feeAmount;
|
|
1431
2115
|
const TransactionModel = this.models.Transaction;
|
|
1432
2116
|
const transaction = await TransactionModel.create({
|
|
1433
2117
|
organizationId: subscription.organizationId,
|
|
1434
2118
|
customerId: subscription.customerId,
|
|
2119
|
+
// ✅ UNIFIED: Use category as type directly
|
|
2120
|
+
type: category,
|
|
2121
|
+
// 'subscription', etc.
|
|
2122
|
+
flow: transactionFlow,
|
|
2123
|
+
// ✅ Use config-driven transaction type
|
|
2124
|
+
tags: ["recurring", "subscription", "renewal"],
|
|
2125
|
+
// ✅ UNIFIED: Amount structure
|
|
1435
2126
|
amount: subscription.amount,
|
|
1436
2127
|
currency: subscription.currency ?? "BDT",
|
|
1437
|
-
|
|
1438
|
-
|
|
2128
|
+
fee: feeAmount,
|
|
2129
|
+
tax: 0,
|
|
2130
|
+
// Tax plugin would add this
|
|
2131
|
+
net: netAmount,
|
|
1439
2132
|
method: paymentData?.method ?? "manual",
|
|
1440
2133
|
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
2134
|
+
// ✅ Map 'succeeded' to valid TransactionStatusValue
|
|
1441
2135
|
gateway: {
|
|
1442
|
-
|
|
2136
|
+
provider: gateway,
|
|
1443
2137
|
sessionId: paymentIntent.sessionId,
|
|
1444
2138
|
paymentIntentId: paymentIntent.paymentIntentId,
|
|
1445
|
-
|
|
2139
|
+
chargeId: paymentIntent.id,
|
|
1446
2140
|
metadata: paymentIntent.metadata
|
|
1447
2141
|
},
|
|
1448
2142
|
paymentDetails: {
|
|
1449
2143
|
provider: gateway,
|
|
1450
2144
|
...paymentData
|
|
1451
2145
|
},
|
|
2146
|
+
// Commission (for marketplace/splits)
|
|
1452
2147
|
...commission && { commission },
|
|
1453
|
-
//
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
referenceModel: "Subscription",
|
|
2148
|
+
// ✅ UNIFIED: Source reference (renamed from reference)
|
|
2149
|
+
sourceId: subscription._id,
|
|
2150
|
+
sourceModel: "Subscription",
|
|
1457
2151
|
metadata: {
|
|
1458
2152
|
...metadata,
|
|
1459
2153
|
subscriptionId: subscription._id.toString(),
|
|
@@ -1469,10 +2163,10 @@ var MonetizationService = class {
|
|
|
1469
2163
|
subscription.renewalTransactionId = transaction._id;
|
|
1470
2164
|
subscription.renewalCount = (subscription.renewalCount ?? 0) + 1;
|
|
1471
2165
|
await subscription.save();
|
|
1472
|
-
this.
|
|
2166
|
+
this.events.emit("subscription.renewed", {
|
|
1473
2167
|
subscription,
|
|
1474
2168
|
transaction,
|
|
1475
|
-
paymentIntent,
|
|
2169
|
+
paymentIntent: paymentIntent ?? void 0,
|
|
1476
2170
|
renewalCount: subscription.renewalCount
|
|
1477
2171
|
});
|
|
1478
2172
|
return {
|
|
@@ -1489,33 +2183,56 @@ var MonetizationService = class {
|
|
|
1489
2183
|
* @returns Updated subscription
|
|
1490
2184
|
*/
|
|
1491
2185
|
async cancel(subscriptionId, options = {}) {
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
2186
|
+
return this.plugins.executeHook(
|
|
2187
|
+
"subscription.cancel.before",
|
|
2188
|
+
this.getPluginContext(),
|
|
2189
|
+
{ subscriptionId, ...options },
|
|
2190
|
+
async () => {
|
|
2191
|
+
const { immediate = false, reason = null } = options;
|
|
2192
|
+
if (!this.models.Subscription) {
|
|
2193
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
2194
|
+
}
|
|
2195
|
+
const SubscriptionModel = this.models.Subscription;
|
|
2196
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
2197
|
+
if (!subscription) {
|
|
2198
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
2199
|
+
}
|
|
2200
|
+
const now = /* @__PURE__ */ new Date();
|
|
2201
|
+
if (immediate) {
|
|
2202
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2203
|
+
subscription.status,
|
|
2204
|
+
"cancelled",
|
|
2205
|
+
subscription._id.toString(),
|
|
2206
|
+
{
|
|
2207
|
+
changedBy: "system",
|
|
2208
|
+
reason: `Subscription cancelled immediately${reason ? ": " + reason : ""}`,
|
|
2209
|
+
metadata: { cancellationReason: reason, immediate: true }
|
|
2210
|
+
}
|
|
2211
|
+
);
|
|
2212
|
+
subscription.isActive = false;
|
|
2213
|
+
subscription.status = "cancelled";
|
|
2214
|
+
subscription.canceledAt = now;
|
|
2215
|
+
subscription.cancellationReason = reason;
|
|
2216
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
2217
|
+
} else {
|
|
2218
|
+
subscription.cancelAt = subscription.endDate ?? now;
|
|
2219
|
+
subscription.cancellationReason = reason;
|
|
2220
|
+
}
|
|
2221
|
+
await subscription.save();
|
|
2222
|
+
this.events.emit("subscription.cancelled", {
|
|
2223
|
+
subscription,
|
|
2224
|
+
immediate,
|
|
2225
|
+
reason: reason ?? void 0,
|
|
2226
|
+
canceledAt: immediate ? now : subscription.cancelAt
|
|
2227
|
+
});
|
|
2228
|
+
return this.plugins.executeHook(
|
|
2229
|
+
"subscription.cancel.after",
|
|
2230
|
+
this.getPluginContext(),
|
|
2231
|
+
{ subscriptionId, ...options },
|
|
2232
|
+
async () => subscription
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
);
|
|
1519
2236
|
}
|
|
1520
2237
|
/**
|
|
1521
2238
|
* Pause subscription
|
|
@@ -1525,30 +2242,53 @@ var MonetizationService = class {
|
|
|
1525
2242
|
* @returns Updated subscription
|
|
1526
2243
|
*/
|
|
1527
2244
|
async pause(subscriptionId, options = {}) {
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
2245
|
+
return this.plugins.executeHook(
|
|
2246
|
+
"subscription.pause.before",
|
|
2247
|
+
this.getPluginContext(),
|
|
2248
|
+
{ subscriptionId, ...options },
|
|
2249
|
+
async () => {
|
|
2250
|
+
const { reason = null } = options;
|
|
2251
|
+
if (!this.models.Subscription) {
|
|
2252
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
2253
|
+
}
|
|
2254
|
+
const SubscriptionModel = this.models.Subscription;
|
|
2255
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
2256
|
+
if (!subscription) {
|
|
2257
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
2258
|
+
}
|
|
2259
|
+
if (!subscription.isActive) {
|
|
2260
|
+
throw new SubscriptionNotActiveError(subscriptionId, "Only active subscriptions can be paused");
|
|
2261
|
+
}
|
|
2262
|
+
const pausedAt = /* @__PURE__ */ new Date();
|
|
2263
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2264
|
+
subscription.status,
|
|
2265
|
+
"paused",
|
|
2266
|
+
subscription._id.toString(),
|
|
2267
|
+
{
|
|
2268
|
+
changedBy: "system",
|
|
2269
|
+
reason: `Subscription paused${reason ? ": " + reason : ""}`,
|
|
2270
|
+
metadata: { pauseReason: reason, pausedAt }
|
|
2271
|
+
}
|
|
2272
|
+
);
|
|
2273
|
+
subscription.isActive = false;
|
|
2274
|
+
subscription.status = "paused";
|
|
2275
|
+
subscription.pausedAt = pausedAt;
|
|
2276
|
+
subscription.pauseReason = reason;
|
|
2277
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
2278
|
+
await subscription.save();
|
|
2279
|
+
this.events.emit("subscription.paused", {
|
|
2280
|
+
subscription,
|
|
2281
|
+
reason: reason ?? void 0,
|
|
2282
|
+
pausedAt
|
|
2283
|
+
});
|
|
2284
|
+
return this.plugins.executeHook(
|
|
2285
|
+
"subscription.pause.after",
|
|
2286
|
+
this.getPluginContext(),
|
|
2287
|
+
{ subscriptionId, ...options },
|
|
2288
|
+
async () => subscription
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
);
|
|
1552
2292
|
}
|
|
1553
2293
|
/**
|
|
1554
2294
|
* Resume subscription
|
|
@@ -1558,42 +2298,70 @@ var MonetizationService = class {
|
|
|
1558
2298
|
* @returns Updated subscription
|
|
1559
2299
|
*/
|
|
1560
2300
|
async resume(subscriptionId, options = {}) {
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
2301
|
+
return this.plugins.executeHook(
|
|
2302
|
+
"subscription.resume.before",
|
|
2303
|
+
this.getPluginContext(),
|
|
2304
|
+
{ subscriptionId, ...options },
|
|
2305
|
+
async () => {
|
|
2306
|
+
const { extendPeriod = false } = options;
|
|
2307
|
+
if (!this.models.Subscription) {
|
|
2308
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
2309
|
+
}
|
|
2310
|
+
const SubscriptionModel = this.models.Subscription;
|
|
2311
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
2312
|
+
if (!subscription) {
|
|
2313
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
2314
|
+
}
|
|
2315
|
+
if (!subscription.pausedAt) {
|
|
2316
|
+
throw new InvalidStateTransitionError(
|
|
2317
|
+
"resume",
|
|
2318
|
+
"paused",
|
|
2319
|
+
subscription.status,
|
|
2320
|
+
"Only paused subscriptions can be resumed"
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
const now = /* @__PURE__ */ new Date();
|
|
2324
|
+
const pausedAt = new Date(subscription.pausedAt);
|
|
2325
|
+
const pauseDuration = now.getTime() - pausedAt.getTime();
|
|
2326
|
+
const auditEvent = SUBSCRIPTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2327
|
+
subscription.status,
|
|
2328
|
+
"active",
|
|
2329
|
+
subscription._id.toString(),
|
|
2330
|
+
{
|
|
2331
|
+
changedBy: "system",
|
|
2332
|
+
reason: "Subscription resumed from paused state",
|
|
2333
|
+
metadata: {
|
|
2334
|
+
pausedAt,
|
|
2335
|
+
pauseDuration,
|
|
2336
|
+
extendPeriod,
|
|
2337
|
+
newEndDate: extendPeriod && subscription.endDate ? new Date(new Date(subscription.endDate).getTime() + pauseDuration) : void 0
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
);
|
|
2341
|
+
subscription.isActive = true;
|
|
2342
|
+
subscription.status = "active";
|
|
2343
|
+
subscription.pausedAt = null;
|
|
2344
|
+
subscription.pauseReason = null;
|
|
2345
|
+
if (extendPeriod && subscription.endDate) {
|
|
2346
|
+
const currentEnd = new Date(subscription.endDate);
|
|
2347
|
+
subscription.endDate = new Date(currentEnd.getTime() + pauseDuration);
|
|
2348
|
+
}
|
|
2349
|
+
Object.assign(subscription, appendAuditEvent(subscription, auditEvent));
|
|
2350
|
+
await subscription.save();
|
|
2351
|
+
this.events.emit("subscription.resumed", {
|
|
2352
|
+
subscription,
|
|
2353
|
+
extendPeriod,
|
|
2354
|
+
pauseDuration,
|
|
2355
|
+
resumedAt: now
|
|
2356
|
+
});
|
|
2357
|
+
return this.plugins.executeHook(
|
|
2358
|
+
"subscription.resume.after",
|
|
2359
|
+
this.getPluginContext(),
|
|
2360
|
+
{ subscriptionId, ...options },
|
|
2361
|
+
async () => subscription
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
);
|
|
1597
2365
|
}
|
|
1598
2366
|
/**
|
|
1599
2367
|
* List subscriptions with filters
|
|
@@ -1650,28 +2418,61 @@ var MonetizationService = class {
|
|
|
1650
2418
|
}
|
|
1651
2419
|
return end;
|
|
1652
2420
|
}
|
|
1653
|
-
/**
|
|
1654
|
-
* Trigger event hook (fire-and-forget, non-blocking)
|
|
1655
|
-
* @private
|
|
1656
|
-
*/
|
|
1657
|
-
_triggerHook(event, data) {
|
|
1658
|
-
triggerHook(this.hooks, event, data, this.logger);
|
|
1659
|
-
}
|
|
1660
2421
|
};
|
|
1661
|
-
|
|
1662
|
-
// src/services/payment.service.ts
|
|
1663
2422
|
var PaymentService = class {
|
|
1664
2423
|
models;
|
|
1665
2424
|
providers;
|
|
1666
2425
|
config;
|
|
1667
|
-
|
|
2426
|
+
plugins;
|
|
1668
2427
|
logger;
|
|
2428
|
+
events;
|
|
2429
|
+
retryConfig;
|
|
2430
|
+
circuitBreaker;
|
|
1669
2431
|
constructor(container) {
|
|
1670
2432
|
this.models = container.get("models");
|
|
1671
2433
|
this.providers = container.get("providers");
|
|
1672
2434
|
this.config = container.get("config");
|
|
1673
|
-
this.
|
|
2435
|
+
this.plugins = container.get("plugins");
|
|
1674
2436
|
this.logger = container.get("logger");
|
|
2437
|
+
this.events = container.get("events");
|
|
2438
|
+
this.retryConfig = container.get("retryConfig");
|
|
2439
|
+
this.circuitBreaker = container.get("circuitBreaker");
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Create plugin context for hook execution
|
|
2443
|
+
* @private
|
|
2444
|
+
*/
|
|
2445
|
+
getPluginContext(idempotencyKey) {
|
|
2446
|
+
return {
|
|
2447
|
+
events: this.events,
|
|
2448
|
+
logger: this.logger,
|
|
2449
|
+
storage: /* @__PURE__ */ new Map(),
|
|
2450
|
+
meta: {
|
|
2451
|
+
idempotencyKey,
|
|
2452
|
+
requestId: nanoid(),
|
|
2453
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Execute provider call with retry and circuit breaker protection
|
|
2459
|
+
* @private
|
|
2460
|
+
*/
|
|
2461
|
+
async executeProviderCall(operation, operationName) {
|
|
2462
|
+
const withCircuitBreaker = this.circuitBreaker ? () => this.circuitBreaker.execute(operation) : operation;
|
|
2463
|
+
if (this.retryConfig && Object.keys(this.retryConfig).length > 0) {
|
|
2464
|
+
return retry(withCircuitBreaker, {
|
|
2465
|
+
...this.retryConfig,
|
|
2466
|
+
onRetry: (error, attempt, delay) => {
|
|
2467
|
+
this.logger.warn(
|
|
2468
|
+
`[${operationName}] Retry attempt ${attempt} after ${delay}ms:`,
|
|
2469
|
+
error
|
|
2470
|
+
);
|
|
2471
|
+
this.retryConfig.onRetry?.(error, attempt, delay);
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
return withCircuitBreaker();
|
|
1675
2476
|
}
|
|
1676
2477
|
/**
|
|
1677
2478
|
* Verify a payment
|
|
@@ -1681,72 +2482,132 @@ var PaymentService = class {
|
|
|
1681
2482
|
* @returns { transaction, status }
|
|
1682
2483
|
*/
|
|
1683
2484
|
async verify(paymentIntentId, options = {}) {
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
2485
|
+
return this.plugins.executeHook(
|
|
2486
|
+
"payment.verify.before",
|
|
2487
|
+
this.getPluginContext(),
|
|
2488
|
+
{ id: paymentIntentId, ...options },
|
|
2489
|
+
async () => {
|
|
2490
|
+
const { verifiedBy = null } = options;
|
|
2491
|
+
const TransactionModel = this.models.Transaction;
|
|
2492
|
+
const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
|
|
2493
|
+
if (!transaction) {
|
|
2494
|
+
throw new TransactionNotFoundError(paymentIntentId);
|
|
2495
|
+
}
|
|
2496
|
+
if (transaction.status === "verified" || transaction.status === "completed") {
|
|
2497
|
+
throw new AlreadyVerifiedError(transaction._id.toString());
|
|
2498
|
+
}
|
|
2499
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
2500
|
+
const provider = this.providers[gatewayType];
|
|
2501
|
+
if (!provider) {
|
|
2502
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
2503
|
+
}
|
|
2504
|
+
let paymentResult = null;
|
|
2505
|
+
try {
|
|
2506
|
+
const actualIntentId = transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentIntentId;
|
|
2507
|
+
paymentResult = await this.executeProviderCall(
|
|
2508
|
+
() => provider.verifyPayment(actualIntentId),
|
|
2509
|
+
`${gatewayType}.verifyPayment`
|
|
2510
|
+
);
|
|
2511
|
+
} catch (error) {
|
|
2512
|
+
this.logger.error("Payment verification failed:", error);
|
|
2513
|
+
const auditEvent2 = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2514
|
+
transaction.status,
|
|
2515
|
+
"failed",
|
|
2516
|
+
transaction._id.toString(),
|
|
2517
|
+
{
|
|
2518
|
+
changedBy: "system",
|
|
2519
|
+
reason: `Payment verification failed: ${error.message}`,
|
|
2520
|
+
metadata: { error: error.message }
|
|
2521
|
+
}
|
|
2522
|
+
);
|
|
2523
|
+
transaction.status = "failed";
|
|
2524
|
+
transaction.failureReason = error.message;
|
|
2525
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent2));
|
|
2526
|
+
transaction.metadata = {
|
|
2527
|
+
...transaction.metadata,
|
|
2528
|
+
verificationError: error.message,
|
|
2529
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2530
|
+
};
|
|
2531
|
+
await transaction.save();
|
|
2532
|
+
this.events.emit("payment.failed", {
|
|
2533
|
+
transaction,
|
|
2534
|
+
error: error.message,
|
|
2535
|
+
provider: gatewayType,
|
|
2536
|
+
paymentIntentId
|
|
2537
|
+
});
|
|
2538
|
+
throw new PaymentVerificationError(paymentIntentId, error.message);
|
|
2539
|
+
}
|
|
2540
|
+
if (paymentResult.amount && paymentResult.amount !== transaction.amount) {
|
|
2541
|
+
throw new ValidationError(
|
|
2542
|
+
`Amount mismatch: expected ${transaction.amount}, got ${paymentResult.amount}`,
|
|
2543
|
+
{ expected: transaction.amount, actual: paymentResult.amount }
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
if (paymentResult.currency && paymentResult.currency.toUpperCase() !== transaction.currency.toUpperCase()) {
|
|
2547
|
+
throw new ValidationError(
|
|
2548
|
+
`Currency mismatch: expected ${transaction.currency}, got ${paymentResult.currency}`,
|
|
2549
|
+
{ expected: transaction.currency, actual: paymentResult.currency }
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
const newStatus = paymentResult.status === "succeeded" ? "verified" : paymentResult.status;
|
|
2553
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2554
|
+
transaction.status,
|
|
2555
|
+
newStatus,
|
|
2556
|
+
transaction._id.toString(),
|
|
2557
|
+
{
|
|
2558
|
+
changedBy: verifiedBy ?? "system",
|
|
2559
|
+
reason: `Payment verification ${paymentResult.status === "succeeded" ? "succeeded" : "resulted in status: " + newStatus}`,
|
|
2560
|
+
metadata: { paymentResult: paymentResult.metadata }
|
|
2561
|
+
}
|
|
2562
|
+
);
|
|
2563
|
+
transaction.status = newStatus;
|
|
2564
|
+
transaction.verifiedAt = paymentResult.paidAt ?? /* @__PURE__ */ new Date();
|
|
2565
|
+
transaction.verifiedBy = verifiedBy;
|
|
2566
|
+
transaction.gateway = {
|
|
2567
|
+
...transaction.gateway,
|
|
2568
|
+
type: transaction.gateway?.type ?? "manual",
|
|
2569
|
+
verificationData: paymentResult.metadata
|
|
2570
|
+
};
|
|
2571
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
2572
|
+
await transaction.save();
|
|
2573
|
+
if (newStatus === "verified") {
|
|
2574
|
+
this.events.emit("payment.verified", {
|
|
2575
|
+
transaction,
|
|
2576
|
+
paymentResult,
|
|
2577
|
+
verifiedBy: verifiedBy || void 0
|
|
2578
|
+
});
|
|
2579
|
+
} else if (newStatus === "failed") {
|
|
2580
|
+
this.events.emit("payment.failed", {
|
|
2581
|
+
transaction,
|
|
2582
|
+
error: paymentResult.metadata?.errorMessage || "Payment verification failed",
|
|
2583
|
+
provider: gatewayType,
|
|
2584
|
+
paymentIntentId: transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentIntentId
|
|
2585
|
+
});
|
|
2586
|
+
} else if (newStatus === "requires_action") {
|
|
2587
|
+
this.events.emit("payment.requires_action", {
|
|
2588
|
+
transaction,
|
|
2589
|
+
paymentResult,
|
|
2590
|
+
action: paymentResult.metadata?.requiredAction
|
|
2591
|
+
});
|
|
2592
|
+
} else if (newStatus === "processing") {
|
|
2593
|
+
this.events.emit("payment.processing", {
|
|
2594
|
+
transaction,
|
|
2595
|
+
paymentResult
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
const result = {
|
|
2599
|
+
transaction,
|
|
2600
|
+
paymentResult,
|
|
2601
|
+
status: transaction.status
|
|
2602
|
+
};
|
|
2603
|
+
return this.plugins.executeHook(
|
|
2604
|
+
"payment.verify.after",
|
|
2605
|
+
this.getPluginContext(),
|
|
2606
|
+
{ id: paymentIntentId, ...options },
|
|
2607
|
+
async () => result
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
);
|
|
1750
2611
|
}
|
|
1751
2612
|
/**
|
|
1752
2613
|
* Get payment status
|
|
@@ -1767,7 +2628,11 @@ var PaymentService = class {
|
|
|
1767
2628
|
}
|
|
1768
2629
|
let paymentResult = null;
|
|
1769
2630
|
try {
|
|
1770
|
-
|
|
2631
|
+
const actualIntentId = transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentIntentId;
|
|
2632
|
+
paymentResult = await this.executeProviderCall(
|
|
2633
|
+
() => provider.getStatus(actualIntentId),
|
|
2634
|
+
`${gatewayType}.getStatus`
|
|
2635
|
+
);
|
|
1771
2636
|
} catch (error) {
|
|
1772
2637
|
this.logger.warn("Failed to get payment status from provider:", error);
|
|
1773
2638
|
return {
|
|
@@ -1792,99 +2657,163 @@ var PaymentService = class {
|
|
|
1792
2657
|
* @returns { transaction, refundResult }
|
|
1793
2658
|
*/
|
|
1794
2659
|
async refund(paymentId, amount = null, options = {}) {
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2660
|
+
return this.plugins.executeHook(
|
|
2661
|
+
"payment.refund.before",
|
|
2662
|
+
this.getPluginContext(),
|
|
2663
|
+
{ transactionId: paymentId, amount, ...options },
|
|
2664
|
+
async () => {
|
|
2665
|
+
const { reason = null } = options;
|
|
2666
|
+
const TransactionModel = this.models.Transaction;
|
|
2667
|
+
const transaction = await this._findTransaction(TransactionModel, paymentId);
|
|
2668
|
+
if (!transaction) {
|
|
2669
|
+
throw new TransactionNotFoundError(paymentId);
|
|
2670
|
+
}
|
|
2671
|
+
if (transaction.status !== "verified" && transaction.status !== "completed" && transaction.status !== "partially_refunded") {
|
|
2672
|
+
throw new InvalidStateTransitionError(
|
|
2673
|
+
"transaction",
|
|
2674
|
+
transaction._id.toString(),
|
|
2675
|
+
transaction.status,
|
|
2676
|
+
"verified, completed, or partially_refunded"
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
2680
|
+
const provider = this.providers[gatewayType];
|
|
2681
|
+
if (!provider) {
|
|
2682
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
2683
|
+
}
|
|
2684
|
+
const capabilities = provider.getCapabilities();
|
|
2685
|
+
if (!capabilities.supportsRefunds) {
|
|
2686
|
+
throw new RefundNotSupportedError(gatewayType);
|
|
2687
|
+
}
|
|
2688
|
+
const refundedSoFar = transaction.refundedAmount ?? 0;
|
|
2689
|
+
const refundableAmount = transaction.amount - refundedSoFar;
|
|
2690
|
+
const refundAmount = amount ?? refundableAmount;
|
|
2691
|
+
if (refundAmount <= 0) {
|
|
2692
|
+
throw new ValidationError(`Refund amount must be positive, got ${refundAmount}`);
|
|
2693
|
+
}
|
|
2694
|
+
if (refundAmount > refundableAmount) {
|
|
2695
|
+
throw new ValidationError(
|
|
2696
|
+
`Refund amount (${refundAmount}) exceeds refundable balance (${refundableAmount})`,
|
|
2697
|
+
{ refundAmount, refundableAmount, alreadyRefunded: refundedSoFar }
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
let refundResult;
|
|
2701
|
+
try {
|
|
2702
|
+
const actualIntentId = transaction.gateway?.paymentIntentId || transaction.gateway?.sessionId || paymentId;
|
|
2703
|
+
refundResult = await this.executeProviderCall(
|
|
2704
|
+
() => provider.refund(actualIntentId, refundAmount, { reason: reason ?? void 0 }),
|
|
2705
|
+
`${gatewayType}.refund`
|
|
2706
|
+
);
|
|
2707
|
+
} catch (error) {
|
|
2708
|
+
this.logger.error("Refund failed:", error);
|
|
2709
|
+
throw new RefundError(paymentId, error.message);
|
|
2710
|
+
}
|
|
2711
|
+
const refundFlow = this.config.transactionTypeMapping?.refund ?? TRANSACTION_FLOW.OUTFLOW;
|
|
2712
|
+
const refundCommission = transaction.commission ? reverseCommission(transaction.commission, transaction.amount, refundAmount) : null;
|
|
2713
|
+
let refundTaxAmount = 0;
|
|
2714
|
+
if (transaction.tax && transaction.tax > 0) {
|
|
2715
|
+
if (transaction.amount > 0) {
|
|
2716
|
+
const ratio = refundAmount / transaction.amount;
|
|
2717
|
+
refundTaxAmount = Math.round(transaction.tax * ratio);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
const refundFeeAmount = refundCommission?.gatewayFeeAmount || 0;
|
|
2721
|
+
const refundNetAmount = refundAmount - refundFeeAmount - refundTaxAmount;
|
|
2722
|
+
const refundTransaction = await TransactionModel.create({
|
|
2723
|
+
organizationId: transaction.organizationId,
|
|
2724
|
+
customerId: transaction.customerId,
|
|
2725
|
+
// ✅ UNIFIED: Type = category (semantic), Flow = direction (from config)
|
|
2726
|
+
type: "refund",
|
|
2727
|
+
// Category: this is a refund transaction
|
|
2728
|
+
flow: refundFlow,
|
|
2729
|
+
// Direction: income or expense (from config)
|
|
2730
|
+
tags: ["refund"],
|
|
2731
|
+
// ✅ UNIFIED: Amount structure
|
|
2732
|
+
amount: refundAmount,
|
|
2733
|
+
currency: transaction.currency,
|
|
2734
|
+
fee: refundFeeAmount,
|
|
2735
|
+
tax: refundTaxAmount,
|
|
2736
|
+
net: refundNetAmount,
|
|
2737
|
+
// Tax details (if tax existed on original)
|
|
2738
|
+
...transaction.taxDetails && {
|
|
2739
|
+
taxDetails: transaction.taxDetails
|
|
2740
|
+
},
|
|
2741
|
+
method: transaction.method ?? "manual",
|
|
2742
|
+
status: "completed",
|
|
2743
|
+
gateway: {
|
|
2744
|
+
provider: transaction.gateway?.provider ?? "manual",
|
|
2745
|
+
paymentIntentId: refundResult.id,
|
|
2746
|
+
chargeId: refundResult.id
|
|
2747
|
+
},
|
|
2748
|
+
paymentDetails: transaction.paymentDetails,
|
|
2749
|
+
// Reversed commission
|
|
2750
|
+
...refundCommission && { commission: refundCommission },
|
|
2751
|
+
// ✅ UNIFIED: Source reference + related transaction
|
|
2752
|
+
...transaction.sourceId && { sourceId: transaction.sourceId },
|
|
2753
|
+
...transaction.sourceModel && { sourceModel: transaction.sourceModel },
|
|
2754
|
+
relatedTransactionId: transaction._id,
|
|
2755
|
+
// Link to original transaction
|
|
2756
|
+
metadata: {
|
|
2757
|
+
...transaction.metadata,
|
|
2758
|
+
isRefund: true,
|
|
2759
|
+
originalTransactionId: transaction._id.toString(),
|
|
2760
|
+
refundReason: reason,
|
|
2761
|
+
refundResult: refundResult.metadata
|
|
2762
|
+
},
|
|
2763
|
+
idempotencyKey: `refund_${transaction._id}_${Date.now()}`
|
|
2764
|
+
});
|
|
2765
|
+
const isPartialRefund = refundAmount < refundableAmount;
|
|
2766
|
+
const refundStatus = isPartialRefund ? "partially_refunded" : "refunded";
|
|
2767
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2768
|
+
transaction.status,
|
|
2769
|
+
refundStatus,
|
|
2770
|
+
transaction._id.toString(),
|
|
2771
|
+
{
|
|
2772
|
+
changedBy: "system",
|
|
2773
|
+
reason: `Refund processed: ${isPartialRefund ? "partial" : "full"} refund of ${refundAmount}${reason ? " - " + reason : ""}`,
|
|
2774
|
+
metadata: {
|
|
2775
|
+
refundAmount,
|
|
2776
|
+
isPartialRefund,
|
|
2777
|
+
refundTransactionId: refundTransaction._id.toString()
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
);
|
|
2781
|
+
transaction.status = refundStatus;
|
|
2782
|
+
transaction.refundedAmount = (transaction.refundedAmount ?? 0) + refundAmount;
|
|
2783
|
+
transaction.refundedAt = refundResult.refundedAt ?? /* @__PURE__ */ new Date();
|
|
2784
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
2785
|
+
transaction.metadata = {
|
|
2786
|
+
...transaction.metadata,
|
|
2787
|
+
refundTransactionId: refundTransaction._id.toString(),
|
|
2788
|
+
refundReason: reason
|
|
2789
|
+
};
|
|
2790
|
+
await transaction.save();
|
|
2791
|
+
this.events.emit("payment.refunded", {
|
|
2792
|
+
transaction,
|
|
2793
|
+
refundTransaction,
|
|
2794
|
+
refundResult: {
|
|
2795
|
+
...refundResult,
|
|
2796
|
+
currency: refundResult.currency ?? "USD",
|
|
2797
|
+
metadata: refundResult.metadata ?? {}
|
|
2798
|
+
},
|
|
2799
|
+
refundAmount,
|
|
2800
|
+
reason: reason ?? void 0,
|
|
2801
|
+
isPartialRefund
|
|
2802
|
+
});
|
|
2803
|
+
const result = {
|
|
2804
|
+
transaction,
|
|
2805
|
+
refundTransaction,
|
|
2806
|
+
refundResult,
|
|
2807
|
+
status: transaction.status
|
|
2808
|
+
};
|
|
2809
|
+
return this.plugins.executeHook(
|
|
2810
|
+
"payment.refund.after",
|
|
2811
|
+
this.getPluginContext(),
|
|
2812
|
+
{ transactionId: paymentId, amount, ...options },
|
|
2813
|
+
async () => result
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
);
|
|
1888
2817
|
}
|
|
1889
2818
|
/**
|
|
1890
2819
|
* Handle webhook from payment provider
|
|
@@ -1905,7 +2834,10 @@ var PaymentService = class {
|
|
|
1905
2834
|
}
|
|
1906
2835
|
let webhookEvent;
|
|
1907
2836
|
try {
|
|
1908
|
-
webhookEvent = await
|
|
2837
|
+
webhookEvent = await this.executeProviderCall(
|
|
2838
|
+
() => provider.handleWebhook(payload, headers),
|
|
2839
|
+
`${providerName}.handleWebhook`
|
|
2840
|
+
);
|
|
1909
2841
|
} catch (error) {
|
|
1910
2842
|
this.logger.error("Webhook processing failed:", error);
|
|
1911
2843
|
throw new ProviderError(
|
|
@@ -1975,19 +2907,46 @@ var PaymentService = class {
|
|
|
1975
2907
|
processedAt: /* @__PURE__ */ new Date(),
|
|
1976
2908
|
data: webhookEvent.data
|
|
1977
2909
|
};
|
|
2910
|
+
let newStatus = transaction.status;
|
|
1978
2911
|
if (webhookEvent.type === "payment.succeeded") {
|
|
1979
|
-
|
|
1980
|
-
transaction.verifiedAt = webhookEvent.createdAt;
|
|
2912
|
+
newStatus = "verified";
|
|
1981
2913
|
} else if (webhookEvent.type === "payment.failed") {
|
|
1982
|
-
|
|
2914
|
+
newStatus = "failed";
|
|
1983
2915
|
} else if (webhookEvent.type === "refund.succeeded") {
|
|
1984
|
-
|
|
1985
|
-
|
|
2916
|
+
newStatus = "refunded";
|
|
2917
|
+
}
|
|
2918
|
+
if (newStatus !== transaction.status) {
|
|
2919
|
+
const auditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
2920
|
+
transaction.status,
|
|
2921
|
+
newStatus,
|
|
2922
|
+
transaction._id.toString(),
|
|
2923
|
+
{
|
|
2924
|
+
changedBy: "webhook",
|
|
2925
|
+
reason: `Webhook event: ${webhookEvent.type}`,
|
|
2926
|
+
metadata: {
|
|
2927
|
+
webhookId: webhookEvent.id,
|
|
2928
|
+
webhookType: webhookEvent.type,
|
|
2929
|
+
webhookData: webhookEvent.data
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
);
|
|
2933
|
+
transaction.status = newStatus;
|
|
2934
|
+
if (newStatus === "verified") {
|
|
2935
|
+
transaction.verifiedAt = webhookEvent.createdAt;
|
|
2936
|
+
} else if (newStatus === "refunded") {
|
|
2937
|
+
transaction.refundedAt = webhookEvent.createdAt;
|
|
2938
|
+
} else if (newStatus === "failed") {
|
|
2939
|
+
transaction.failedAt = webhookEvent.createdAt;
|
|
2940
|
+
}
|
|
2941
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
1986
2942
|
}
|
|
1987
2943
|
await transaction.save();
|
|
1988
|
-
this.
|
|
2944
|
+
this.events.emit("webhook.processed", {
|
|
2945
|
+
webhookType: webhookEvent.type,
|
|
2946
|
+
provider: webhookEvent.provider,
|
|
1989
2947
|
event: webhookEvent,
|
|
1990
|
-
transaction
|
|
2948
|
+
transaction,
|
|
2949
|
+
processedAt: /* @__PURE__ */ new Date()
|
|
1991
2950
|
});
|
|
1992
2951
|
return {
|
|
1993
2952
|
event: webhookEvent,
|
|
@@ -2035,13 +2994,6 @@ var PaymentService = class {
|
|
|
2035
2994
|
}
|
|
2036
2995
|
return provider;
|
|
2037
2996
|
}
|
|
2038
|
-
/**
|
|
2039
|
-
* Trigger event hook (fire-and-forget, non-blocking)
|
|
2040
|
-
* @private
|
|
2041
|
-
*/
|
|
2042
|
-
_triggerHook(event, data) {
|
|
2043
|
-
triggerHook(this.hooks, event, data, this.logger);
|
|
2044
|
-
}
|
|
2045
2997
|
/**
|
|
2046
2998
|
* Find transaction by sessionId, paymentIntentId, or transaction ID
|
|
2047
2999
|
* @private
|
|
@@ -2061,17 +3013,32 @@ var PaymentService = class {
|
|
|
2061
3013
|
return transaction;
|
|
2062
3014
|
}
|
|
2063
3015
|
};
|
|
2064
|
-
|
|
2065
|
-
// src/services/transaction.service.ts
|
|
2066
3016
|
var TransactionService = class {
|
|
2067
3017
|
models;
|
|
2068
|
-
|
|
3018
|
+
plugins;
|
|
3019
|
+
events;
|
|
2069
3020
|
logger;
|
|
2070
3021
|
constructor(container) {
|
|
2071
3022
|
this.models = container.get("models");
|
|
2072
|
-
this.
|
|
3023
|
+
this.plugins = container.get("plugins");
|
|
3024
|
+
this.events = container.get("events");
|
|
2073
3025
|
this.logger = container.get("logger");
|
|
2074
3026
|
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Create plugin context for hook execution
|
|
3029
|
+
* @private
|
|
3030
|
+
*/
|
|
3031
|
+
getPluginContext() {
|
|
3032
|
+
return {
|
|
3033
|
+
events: this.events,
|
|
3034
|
+
logger: this.logger,
|
|
3035
|
+
storage: /* @__PURE__ */ new Map(),
|
|
3036
|
+
meta: {
|
|
3037
|
+
requestId: nanoid(),
|
|
3038
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
3039
|
+
}
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
2075
3042
|
/**
|
|
2076
3043
|
* Get transaction by ID
|
|
2077
3044
|
*
|
|
@@ -2128,59 +3095,46 @@ var TransactionService = class {
|
|
|
2128
3095
|
* @returns Updated transaction
|
|
2129
3096
|
*/
|
|
2130
3097
|
async update(transactionId, updates) {
|
|
2131
|
-
const
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
3098
|
+
const hookInput = { transactionId, updates };
|
|
3099
|
+
return this.plugins.executeHook(
|
|
3100
|
+
"transaction.update.before",
|
|
3101
|
+
this.getPluginContext(),
|
|
3102
|
+
hookInput,
|
|
3103
|
+
async () => {
|
|
3104
|
+
const TransactionModel = this.models.Transaction;
|
|
3105
|
+
const effectiveUpdates = hookInput.updates;
|
|
3106
|
+
const model = TransactionModel;
|
|
3107
|
+
let transaction;
|
|
3108
|
+
if (typeof model.update === "function") {
|
|
3109
|
+
transaction = await model.update(transactionId, effectiveUpdates);
|
|
3110
|
+
} else if (typeof model.findByIdAndUpdate === "function") {
|
|
3111
|
+
transaction = await model.findByIdAndUpdate(
|
|
3112
|
+
transactionId,
|
|
3113
|
+
{ $set: effectiveUpdates },
|
|
3114
|
+
{ new: true }
|
|
3115
|
+
);
|
|
3116
|
+
} else {
|
|
3117
|
+
throw new Error("Transaction model does not support update operations");
|
|
3118
|
+
}
|
|
3119
|
+
if (!transaction) {
|
|
3120
|
+
throw new TransactionNotFoundError(transactionId);
|
|
3121
|
+
}
|
|
3122
|
+
this.events.emit("transaction.updated", {
|
|
3123
|
+
transaction,
|
|
3124
|
+
updates: effectiveUpdates
|
|
3125
|
+
});
|
|
3126
|
+
return this.plugins.executeHook(
|
|
3127
|
+
"transaction.update.after",
|
|
3128
|
+
this.getPluginContext(),
|
|
3129
|
+
{ transactionId, updates: effectiveUpdates },
|
|
3130
|
+
async () => transaction
|
|
3131
|
+
);
|
|
3132
|
+
}
|
|
3133
|
+
);
|
|
2160
3134
|
}
|
|
2161
3135
|
};
|
|
2162
3136
|
|
|
2163
|
-
// src/
|
|
2164
|
-
var HOLD_STATUS = {
|
|
2165
|
-
HELD: "held",
|
|
2166
|
-
RELEASED: "released",
|
|
2167
|
-
CANCELLED: "cancelled",
|
|
2168
|
-
PARTIALLY_RELEASED: "partially_released"
|
|
2169
|
-
};
|
|
2170
|
-
var RELEASE_REASON = {
|
|
2171
|
-
PAYMENT_VERIFIED: "payment_verified"};
|
|
2172
|
-
var HOLD_REASON = {
|
|
2173
|
-
PAYMENT_VERIFICATION: "payment_verification"};
|
|
2174
|
-
|
|
2175
|
-
// src/enums/split.enums.ts
|
|
2176
|
-
var SPLIT_TYPE = {
|
|
2177
|
-
CUSTOM: "custom"
|
|
2178
|
-
};
|
|
2179
|
-
var SPLIT_STATUS = {
|
|
2180
|
-
PENDING: "pending",
|
|
2181
|
-
PAID: "paid"};
|
|
2182
|
-
|
|
2183
|
-
// src/utils/commission-split.ts
|
|
3137
|
+
// src/shared/utils/calculators/commission-split.ts
|
|
2184
3138
|
function calculateSplits(amount, splitRules = [], gatewayFeeRate = 0) {
|
|
2185
3139
|
if (!splitRules || splitRules.length === 0) {
|
|
2186
3140
|
return [];
|
|
@@ -2199,9 +3153,9 @@ function calculateSplits(amount, splitRules = [], gatewayFeeRate = 0) {
|
|
|
2199
3153
|
if (rule.rate < 0 || rule.rate > 1) {
|
|
2200
3154
|
throw new Error(`Split rate must be between 0 and 1 for split ${index}`);
|
|
2201
3155
|
}
|
|
2202
|
-
const grossAmount = Math.round(amount * rule.rate
|
|
2203
|
-
const gatewayFeeAmount = index === 0 && gatewayFeeRate > 0 ? Math.round(amount * gatewayFeeRate
|
|
2204
|
-
const netAmount = Math.max(0,
|
|
3156
|
+
const grossAmount = Math.round(amount * rule.rate);
|
|
3157
|
+
const gatewayFeeAmount = index === 0 && gatewayFeeRate > 0 ? Math.round(amount * gatewayFeeRate) : 0;
|
|
3158
|
+
const netAmount = Math.max(0, grossAmount - gatewayFeeAmount);
|
|
2205
3159
|
return {
|
|
2206
3160
|
type: rule.type ?? SPLIT_TYPE.CUSTOM,
|
|
2207
3161
|
recipientId: rule.recipientId,
|
|
@@ -2219,18 +3173,35 @@ function calculateSplits(amount, splitRules = [], gatewayFeeRate = 0) {
|
|
|
2219
3173
|
}
|
|
2220
3174
|
function calculateOrganizationPayout(amount, splits = []) {
|
|
2221
3175
|
const totalSplitAmount = splits.reduce((sum, split) => sum + split.grossAmount, 0);
|
|
2222
|
-
return Math.max(0,
|
|
3176
|
+
return Math.max(0, amount - totalSplitAmount);
|
|
2223
3177
|
}
|
|
2224
3178
|
|
|
2225
|
-
// src/services/escrow.service.ts
|
|
3179
|
+
// src/application/services/escrow.service.ts
|
|
2226
3180
|
var EscrowService = class {
|
|
2227
3181
|
models;
|
|
2228
|
-
|
|
3182
|
+
plugins;
|
|
2229
3183
|
logger;
|
|
3184
|
+
events;
|
|
2230
3185
|
constructor(container) {
|
|
2231
3186
|
this.models = container.get("models");
|
|
2232
|
-
this.
|
|
3187
|
+
this.plugins = container.get("plugins");
|
|
2233
3188
|
this.logger = container.get("logger");
|
|
3189
|
+
this.events = container.get("events");
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* Create plugin context for hook execution
|
|
3193
|
+
* @private
|
|
3194
|
+
*/
|
|
3195
|
+
getPluginContext() {
|
|
3196
|
+
return {
|
|
3197
|
+
events: this.events,
|
|
3198
|
+
logger: this.logger,
|
|
3199
|
+
storage: /* @__PURE__ */ new Map(),
|
|
3200
|
+
meta: {
|
|
3201
|
+
requestId: nanoid(),
|
|
3202
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
3203
|
+
}
|
|
3204
|
+
};
|
|
2234
3205
|
}
|
|
2235
3206
|
/**
|
|
2236
3207
|
* Hold funds in escrow
|
|
@@ -2240,36 +3211,54 @@ var EscrowService = class {
|
|
|
2240
3211
|
* @returns Updated transaction
|
|
2241
3212
|
*/
|
|
2242
3213
|
async hold(transactionId, options = {}) {
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
3214
|
+
return this.plugins.executeHook(
|
|
3215
|
+
"escrow.hold.before",
|
|
3216
|
+
this.getPluginContext(),
|
|
3217
|
+
{ transactionId, ...options },
|
|
3218
|
+
async () => {
|
|
3219
|
+
const {
|
|
3220
|
+
reason = HOLD_REASON.PAYMENT_VERIFICATION,
|
|
3221
|
+
holdUntil = null,
|
|
3222
|
+
metadata = {}
|
|
3223
|
+
} = options;
|
|
3224
|
+
const TransactionModel = this.models.Transaction;
|
|
3225
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
3226
|
+
if (!transaction) {
|
|
3227
|
+
throw new TransactionNotFoundError(transactionId);
|
|
3228
|
+
}
|
|
3229
|
+
if (transaction.status !== TRANSACTION_STATUS.VERIFIED) {
|
|
3230
|
+
throw new InvalidStateTransitionError(
|
|
3231
|
+
"transaction",
|
|
3232
|
+
transaction._id.toString(),
|
|
3233
|
+
transaction.status,
|
|
3234
|
+
TRANSACTION_STATUS.VERIFIED
|
|
3235
|
+
);
|
|
3236
|
+
}
|
|
3237
|
+
const heldAmount = transaction.amount;
|
|
3238
|
+
transaction.hold = {
|
|
3239
|
+
status: HOLD_STATUS.HELD,
|
|
3240
|
+
heldAmount,
|
|
3241
|
+
releasedAmount: 0,
|
|
3242
|
+
reason,
|
|
3243
|
+
heldAt: /* @__PURE__ */ new Date(),
|
|
3244
|
+
...holdUntil && { holdUntil },
|
|
3245
|
+
releases: [],
|
|
3246
|
+
metadata
|
|
3247
|
+
};
|
|
3248
|
+
await transaction.save();
|
|
3249
|
+
this.events.emit("escrow.held", {
|
|
3250
|
+
transaction,
|
|
3251
|
+
heldAmount,
|
|
3252
|
+
reason
|
|
3253
|
+
});
|
|
3254
|
+
return this.plugins.executeHook(
|
|
3255
|
+
"escrow.hold.after",
|
|
3256
|
+
this.getPluginContext(),
|
|
3257
|
+
{ transactionId, ...options },
|
|
3258
|
+
async () => transaction
|
|
3259
|
+
);
|
|
3260
|
+
}
|
|
3261
|
+
);
|
|
2273
3262
|
}
|
|
2274
3263
|
/**
|
|
2275
3264
|
* Release funds from escrow to recipient
|
|
@@ -2279,93 +3268,185 @@ var EscrowService = class {
|
|
|
2279
3268
|
* @returns { transaction, releaseTransaction }
|
|
2280
3269
|
*/
|
|
2281
3270
|
async release(transactionId, options) {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
3271
|
+
return this.plugins.executeHook(
|
|
3272
|
+
"escrow.release.before",
|
|
3273
|
+
this.getPluginContext(),
|
|
3274
|
+
{ transactionId, ...options },
|
|
3275
|
+
async () => {
|
|
3276
|
+
const {
|
|
3277
|
+
amount = null,
|
|
3278
|
+
recipientId,
|
|
3279
|
+
recipientType = "organization",
|
|
3280
|
+
reason = RELEASE_REASON.PAYMENT_VERIFIED,
|
|
3281
|
+
releasedBy = null,
|
|
3282
|
+
createTransaction = true,
|
|
3283
|
+
metadata = {}
|
|
3284
|
+
} = options;
|
|
3285
|
+
const TransactionModel = this.models.Transaction;
|
|
3286
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
3287
|
+
if (!transaction) {
|
|
3288
|
+
throw new TransactionNotFoundError(transactionId);
|
|
3289
|
+
}
|
|
3290
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
|
|
3291
|
+
throw new InvalidStateTransitionError(
|
|
3292
|
+
"escrow_hold",
|
|
3293
|
+
transaction._id.toString(),
|
|
3294
|
+
transaction.hold?.status ?? "none",
|
|
3295
|
+
HOLD_STATUS.HELD
|
|
3296
|
+
);
|
|
3297
|
+
}
|
|
3298
|
+
if (!recipientId) {
|
|
3299
|
+
throw new ValidationError("recipientId is required for release", { transactionId });
|
|
3300
|
+
}
|
|
3301
|
+
const releaseAmount = amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
3302
|
+
const availableAmount = transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
3303
|
+
if (releaseAmount > availableAmount) {
|
|
3304
|
+
throw new ValidationError(
|
|
3305
|
+
`Release amount (${releaseAmount}) exceeds available held amount (${availableAmount})`,
|
|
3306
|
+
{ releaseAmount, availableAmount, transactionId }
|
|
3307
|
+
);
|
|
3308
|
+
}
|
|
3309
|
+
const releaseRecord = {
|
|
3310
|
+
amount: releaseAmount,
|
|
3311
|
+
recipientId,
|
|
3312
|
+
recipientType,
|
|
3313
|
+
releasedAt: /* @__PURE__ */ new Date(),
|
|
3314
|
+
releasedBy,
|
|
3315
|
+
reason,
|
|
3316
|
+
metadata
|
|
3317
|
+
};
|
|
3318
|
+
transaction.hold.releases.push(releaseRecord);
|
|
3319
|
+
transaction.hold.releasedAmount += releaseAmount;
|
|
3320
|
+
const isFullRelease = transaction.hold.releasedAmount >= transaction.hold.heldAmount;
|
|
3321
|
+
const isPartialRelease = transaction.hold.releasedAmount > 0 && transaction.hold.releasedAmount < transaction.hold.heldAmount;
|
|
3322
|
+
if (isFullRelease) {
|
|
3323
|
+
const holdAuditEvent = HOLD_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3324
|
+
transaction.hold.status,
|
|
3325
|
+
HOLD_STATUS.RELEASED,
|
|
3326
|
+
transaction._id.toString(),
|
|
3327
|
+
{
|
|
3328
|
+
changedBy: releasedBy ?? "system",
|
|
3329
|
+
reason: `Escrow hold fully released: ${releaseAmount} to ${recipientId}${reason ? " - " + reason : ""}`,
|
|
3330
|
+
metadata: { releaseAmount, recipientId, releaseReason: reason }
|
|
3331
|
+
}
|
|
3332
|
+
);
|
|
3333
|
+
transaction.hold.status = HOLD_STATUS.RELEASED;
|
|
3334
|
+
transaction.hold.releasedAt = /* @__PURE__ */ new Date();
|
|
3335
|
+
const transactionAuditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3336
|
+
transaction.status,
|
|
3337
|
+
TRANSACTION_STATUS.COMPLETED,
|
|
3338
|
+
transaction._id.toString(),
|
|
3339
|
+
{
|
|
3340
|
+
changedBy: releasedBy ?? "system",
|
|
3341
|
+
reason: `Transaction completed after full escrow release`,
|
|
3342
|
+
metadata: { releaseAmount, recipientId }
|
|
3343
|
+
}
|
|
3344
|
+
);
|
|
3345
|
+
transaction.status = TRANSACTION_STATUS.COMPLETED;
|
|
3346
|
+
Object.assign(transaction, appendAuditEvent(transaction, holdAuditEvent));
|
|
3347
|
+
Object.assign(transaction, appendAuditEvent(transaction, transactionAuditEvent));
|
|
3348
|
+
} else if (isPartialRelease) {
|
|
3349
|
+
const auditEvent = HOLD_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3350
|
+
transaction.hold.status,
|
|
3351
|
+
HOLD_STATUS.PARTIALLY_RELEASED,
|
|
3352
|
+
transaction._id.toString(),
|
|
3353
|
+
{
|
|
3354
|
+
changedBy: releasedBy ?? "system",
|
|
3355
|
+
reason: `Partial escrow release: ${releaseAmount} of ${transaction.hold.heldAmount} to ${recipientId}${reason ? " - " + reason : ""}`,
|
|
3356
|
+
metadata: {
|
|
3357
|
+
releaseAmount,
|
|
3358
|
+
recipientId,
|
|
3359
|
+
releaseReason: reason,
|
|
3360
|
+
remainingHeld: transaction.hold.heldAmount - transaction.hold.releasedAmount
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
);
|
|
3364
|
+
transaction.hold.status = HOLD_STATUS.PARTIALLY_RELEASED;
|
|
3365
|
+
Object.assign(transaction, appendAuditEvent(transaction, auditEvent));
|
|
3366
|
+
}
|
|
3367
|
+
if ("markModified" in transaction) {
|
|
3368
|
+
transaction.markModified("hold");
|
|
3369
|
+
}
|
|
3370
|
+
await transaction.save();
|
|
3371
|
+
let releaseTaxAmount = 0;
|
|
3372
|
+
if (transaction.tax && transaction.tax > 0) {
|
|
3373
|
+
if (releaseAmount === availableAmount && !amount) {
|
|
3374
|
+
const releasedTaxSoFar = transaction.hold.releasedTaxAmount ?? 0;
|
|
3375
|
+
releaseTaxAmount = transaction.tax - releasedTaxSoFar;
|
|
3376
|
+
} else {
|
|
3377
|
+
const totalAmount = transaction.amount + transaction.tax;
|
|
3378
|
+
if (totalAmount > 0) {
|
|
3379
|
+
const taxRatio = transaction.tax / totalAmount;
|
|
3380
|
+
releaseTaxAmount = Math.round(releaseAmount * taxRatio);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
const releaseNetAmount = releaseAmount - releaseTaxAmount;
|
|
3385
|
+
let releaseTransaction = null;
|
|
3386
|
+
if (createTransaction) {
|
|
3387
|
+
releaseTransaction = await TransactionModel.create({
|
|
3388
|
+
organizationId: transaction.organizationId,
|
|
3389
|
+
customerId: recipientId,
|
|
3390
|
+
// ✅ UNIFIED: Use 'escrow_release' as type, inflow
|
|
3391
|
+
type: "escrow_release",
|
|
3392
|
+
flow: "inflow",
|
|
3393
|
+
tags: ["escrow", "release"],
|
|
3394
|
+
// ✅ UNIFIED: Amount structure
|
|
3395
|
+
amount: releaseAmount,
|
|
3396
|
+
currency: transaction.currency,
|
|
3397
|
+
fee: 0,
|
|
3398
|
+
// No processing fees on releases
|
|
3399
|
+
tax: releaseTaxAmount,
|
|
3400
|
+
// ✅ Top-level number
|
|
3401
|
+
net: releaseNetAmount,
|
|
3402
|
+
// Copy tax details from original transaction
|
|
3403
|
+
...transaction.taxDetails && {
|
|
3404
|
+
taxDetails: transaction.taxDetails
|
|
3405
|
+
},
|
|
3406
|
+
method: transaction.method,
|
|
3407
|
+
status: "completed",
|
|
3408
|
+
gateway: transaction.gateway,
|
|
3409
|
+
// ✅ UNIFIED: Source reference (link to held transaction)
|
|
3410
|
+
sourceId: transaction._id,
|
|
3411
|
+
sourceModel: "Transaction",
|
|
3412
|
+
relatedTransactionId: transaction._id,
|
|
3413
|
+
metadata: {
|
|
3414
|
+
...metadata,
|
|
3415
|
+
isRelease: true,
|
|
3416
|
+
heldTransactionId: transaction._id.toString(),
|
|
3417
|
+
releaseReason: reason,
|
|
3418
|
+
recipientType,
|
|
3419
|
+
// Store original category for reference
|
|
3420
|
+
originalCategory: transaction.category
|
|
3421
|
+
},
|
|
3422
|
+
idempotencyKey: `release_${transaction._id}_${Date.now()}`
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
this.events.emit("escrow.released", {
|
|
3426
|
+
transaction,
|
|
3427
|
+
releaseTransaction,
|
|
3428
|
+
releaseAmount,
|
|
3429
|
+
recipientId,
|
|
3430
|
+
recipientType,
|
|
3431
|
+
reason,
|
|
3432
|
+
isFullRelease,
|
|
3433
|
+
isPartialRelease
|
|
3434
|
+
});
|
|
3435
|
+
const result = {
|
|
3436
|
+
transaction,
|
|
3437
|
+
releaseTransaction,
|
|
3438
|
+
releaseAmount,
|
|
3439
|
+
isFullRelease,
|
|
3440
|
+
isPartialRelease
|
|
3441
|
+
};
|
|
3442
|
+
return this.plugins.executeHook(
|
|
3443
|
+
"escrow.release.after",
|
|
3444
|
+
this.getPluginContext(),
|
|
3445
|
+
{ transactionId, ...options },
|
|
3446
|
+
async () => result
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
);
|
|
2369
3450
|
}
|
|
2370
3451
|
/**
|
|
2371
3452
|
* Cancel hold and release back to customer
|
|
@@ -2382,8 +3463,23 @@ var EscrowService = class {
|
|
|
2382
3463
|
throw new TransactionNotFoundError(transactionId);
|
|
2383
3464
|
}
|
|
2384
3465
|
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
|
|
2385
|
-
throw new
|
|
3466
|
+
throw new InvalidStateTransitionError(
|
|
3467
|
+
"escrow_hold",
|
|
3468
|
+
transaction._id.toString(),
|
|
3469
|
+
transaction.hold?.status ?? "none",
|
|
3470
|
+
HOLD_STATUS.HELD
|
|
3471
|
+
);
|
|
2386
3472
|
}
|
|
3473
|
+
const holdAuditEvent = HOLD_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3474
|
+
transaction.hold.status,
|
|
3475
|
+
HOLD_STATUS.CANCELLED,
|
|
3476
|
+
transaction._id.toString(),
|
|
3477
|
+
{
|
|
3478
|
+
changedBy: "system",
|
|
3479
|
+
reason: `Escrow hold cancelled${reason ? ": " + reason : ""}`,
|
|
3480
|
+
metadata: { cancelReason: reason, ...metadata }
|
|
3481
|
+
}
|
|
3482
|
+
);
|
|
2387
3483
|
transaction.hold.status = HOLD_STATUS.CANCELLED;
|
|
2388
3484
|
transaction.hold.cancelledAt = /* @__PURE__ */ new Date();
|
|
2389
3485
|
transaction.hold.metadata = {
|
|
@@ -2391,9 +3487,24 @@ var EscrowService = class {
|
|
|
2391
3487
|
...metadata,
|
|
2392
3488
|
cancelReason: reason
|
|
2393
3489
|
};
|
|
3490
|
+
const transactionAuditEvent = TRANSACTION_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3491
|
+
transaction.status,
|
|
3492
|
+
TRANSACTION_STATUS.CANCELLED,
|
|
3493
|
+
transaction._id.toString(),
|
|
3494
|
+
{
|
|
3495
|
+
changedBy: "system",
|
|
3496
|
+
reason: `Transaction cancelled due to escrow hold cancellation`,
|
|
3497
|
+
metadata: { cancelReason: reason }
|
|
3498
|
+
}
|
|
3499
|
+
);
|
|
2394
3500
|
transaction.status = TRANSACTION_STATUS.CANCELLED;
|
|
3501
|
+
Object.assign(transaction, appendAuditEvent(transaction, holdAuditEvent));
|
|
3502
|
+
Object.assign(transaction, appendAuditEvent(transaction, transactionAuditEvent));
|
|
3503
|
+
if ("markModified" in transaction) {
|
|
3504
|
+
transaction.markModified("hold");
|
|
3505
|
+
}
|
|
2395
3506
|
await transaction.save();
|
|
2396
|
-
this.
|
|
3507
|
+
this.events.emit("escrow.cancelled", {
|
|
2397
3508
|
transaction,
|
|
2398
3509
|
reason
|
|
2399
3510
|
});
|
|
@@ -2414,10 +3525,15 @@ var EscrowService = class {
|
|
|
2414
3525
|
throw new TransactionNotFoundError(transactionId);
|
|
2415
3526
|
}
|
|
2416
3527
|
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
|
|
2417
|
-
throw new
|
|
3528
|
+
throw new InvalidStateTransitionError(
|
|
3529
|
+
"escrow_hold",
|
|
3530
|
+
transaction._id.toString(),
|
|
3531
|
+
transaction.hold?.status ?? "none",
|
|
3532
|
+
HOLD_STATUS.HELD
|
|
3533
|
+
);
|
|
2418
3534
|
}
|
|
2419
3535
|
if (!splitRules || splitRules.length === 0) {
|
|
2420
|
-
throw new
|
|
3536
|
+
throw new ValidationError("splitRules cannot be empty", { transactionId });
|
|
2421
3537
|
}
|
|
2422
3538
|
const splits = calculateSplits(
|
|
2423
3539
|
transaction.amount,
|
|
@@ -2427,25 +3543,58 @@ var EscrowService = class {
|
|
|
2427
3543
|
transaction.splits = splits;
|
|
2428
3544
|
await transaction.save();
|
|
2429
3545
|
const splitTransactions = [];
|
|
2430
|
-
|
|
3546
|
+
const totalTax = transaction.tax ?? 0;
|
|
3547
|
+
const totalBaseAmount = transaction.amount;
|
|
3548
|
+
let allocatedTaxAmount = 0;
|
|
3549
|
+
const splitTaxAmounts = splits.map((split) => {
|
|
3550
|
+
if (!totalTax || totalBaseAmount <= 0) {
|
|
3551
|
+
return 0;
|
|
3552
|
+
}
|
|
3553
|
+
const ratio = split.grossAmount / totalBaseAmount;
|
|
3554
|
+
const taxAmount = Math.round(totalTax * ratio);
|
|
3555
|
+
allocatedTaxAmount += taxAmount;
|
|
3556
|
+
return taxAmount;
|
|
3557
|
+
});
|
|
3558
|
+
for (const [index, split] of splits.entries()) {
|
|
3559
|
+
const splitTaxAmount = totalTax > 0 ? splitTaxAmounts[index] ?? 0 : 0;
|
|
3560
|
+
const splitNetAmount = split.grossAmount - split.gatewayFeeAmount - splitTaxAmount;
|
|
2431
3561
|
const splitTransaction = await TransactionModel.create({
|
|
2432
3562
|
organizationId: transaction.organizationId,
|
|
2433
3563
|
customerId: split.recipientId,
|
|
2434
|
-
|
|
3564
|
+
// ✅ UNIFIED: Use split type directly (commission, platform_fee, etc.)
|
|
3565
|
+
type: split.type,
|
|
3566
|
+
flow: "outflow",
|
|
3567
|
+
// Splits are money going out
|
|
3568
|
+
tags: ["split", "commission"],
|
|
3569
|
+
// ✅ UNIFIED: Amount structure (gross, fee, tax, net)
|
|
3570
|
+
amount: split.grossAmount,
|
|
3571
|
+
// ✅ Gross amount (before deductions)
|
|
2435
3572
|
currency: transaction.currency,
|
|
2436
|
-
|
|
2437
|
-
|
|
3573
|
+
fee: split.gatewayFeeAmount,
|
|
3574
|
+
tax: splitTaxAmount,
|
|
3575
|
+
// ✅ Top-level number
|
|
3576
|
+
net: splitNetAmount,
|
|
3577
|
+
// ✅ Net = gross - fee - tax
|
|
3578
|
+
// Copy tax details from original transaction (if applicable)
|
|
3579
|
+
...transaction.taxDetails && splitTaxAmount > 0 && {
|
|
3580
|
+
taxDetails: transaction.taxDetails
|
|
3581
|
+
},
|
|
2438
3582
|
method: transaction.method,
|
|
2439
|
-
status:
|
|
3583
|
+
status: "completed",
|
|
2440
3584
|
gateway: transaction.gateway,
|
|
2441
|
-
|
|
2442
|
-
|
|
3585
|
+
// ✅ UNIFIED: Source reference (link to original transaction)
|
|
3586
|
+
sourceId: transaction._id,
|
|
3587
|
+
sourceModel: "Transaction",
|
|
3588
|
+
relatedTransactionId: transaction._id,
|
|
2443
3589
|
metadata: {
|
|
2444
3590
|
isSplit: true,
|
|
2445
3591
|
splitType: split.type,
|
|
2446
3592
|
recipientType: split.recipientType,
|
|
2447
3593
|
originalTransactionId: transaction._id.toString(),
|
|
2448
|
-
|
|
3594
|
+
// Store split details for reference
|
|
3595
|
+
splitGrossAmount: split.grossAmount,
|
|
3596
|
+
splitNetAmount: split.netAmount,
|
|
3597
|
+
// Original calculation
|
|
2449
3598
|
gatewayFeeAmount: split.gatewayFeeAmount
|
|
2450
3599
|
},
|
|
2451
3600
|
idempotencyKey: `split_${transaction._id}_${split.recipientId}_${Date.now()}`
|
|
@@ -2457,8 +3606,10 @@ var EscrowService = class {
|
|
|
2457
3606
|
}
|
|
2458
3607
|
await transaction.save();
|
|
2459
3608
|
const organizationPayout = calculateOrganizationPayout(transaction.amount, splits);
|
|
3609
|
+
const organizationTaxAmount = totalTax > 0 ? Math.max(0, totalTax - allocatedTaxAmount) : 0;
|
|
3610
|
+
const organizationPayoutTotal = totalTax > 0 ? organizationPayout + organizationTaxAmount : organizationPayout;
|
|
2460
3611
|
const organizationTransaction = await this.release(transactionId, {
|
|
2461
|
-
amount:
|
|
3612
|
+
amount: organizationPayoutTotal,
|
|
2462
3613
|
recipientId: transaction.organizationId?.toString() ?? "",
|
|
2463
3614
|
recipientType: "organization",
|
|
2464
3615
|
reason: RELEASE_REASON.PAYMENT_VERIFIED,
|
|
@@ -2469,7 +3620,7 @@ var EscrowService = class {
|
|
|
2469
3620
|
totalSplitAmount: transaction.amount - organizationPayout
|
|
2470
3621
|
}
|
|
2471
3622
|
});
|
|
2472
|
-
this.
|
|
3623
|
+
this.events.emit("escrow.split", {
|
|
2473
3624
|
transaction,
|
|
2474
3625
|
splits,
|
|
2475
3626
|
splitTransactions,
|
|
@@ -2504,8 +3655,490 @@ var EscrowService = class {
|
|
|
2504
3655
|
hasSplits: transaction.splits ? transaction.splits.length > 0 : false
|
|
2505
3656
|
};
|
|
2506
3657
|
}
|
|
2507
|
-
|
|
2508
|
-
|
|
3658
|
+
};
|
|
3659
|
+
|
|
3660
|
+
// src/application/services/settlement.service.ts
|
|
3661
|
+
var SettlementService = class {
|
|
3662
|
+
models;
|
|
3663
|
+
plugins;
|
|
3664
|
+
logger;
|
|
3665
|
+
events;
|
|
3666
|
+
constructor(container) {
|
|
3667
|
+
this.models = container.get("models");
|
|
3668
|
+
this.plugins = container.get("plugins");
|
|
3669
|
+
this.logger = container.get("logger");
|
|
3670
|
+
this.events = container.get("events");
|
|
3671
|
+
void this.plugins;
|
|
3672
|
+
}
|
|
3673
|
+
/**
|
|
3674
|
+
* Create settlements from transaction splits
|
|
3675
|
+
* Typically called after escrow is released
|
|
3676
|
+
*
|
|
3677
|
+
* @param transactionId - Transaction ID with splits
|
|
3678
|
+
* @param options - Creation options
|
|
3679
|
+
* @returns Array of created settlements
|
|
3680
|
+
*/
|
|
3681
|
+
async createFromSplits(transactionId, options = {}) {
|
|
3682
|
+
const {
|
|
3683
|
+
scheduledAt = /* @__PURE__ */ new Date(),
|
|
3684
|
+
payoutMethod = "bank_transfer",
|
|
3685
|
+
metadata = {}
|
|
3686
|
+
} = options;
|
|
3687
|
+
if (!this.models.Settlement) {
|
|
3688
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
3689
|
+
}
|
|
3690
|
+
const TransactionModel = this.models.Transaction;
|
|
3691
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
3692
|
+
if (!transaction) {
|
|
3693
|
+
throw new TransactionNotFoundError(transactionId);
|
|
3694
|
+
}
|
|
3695
|
+
if (!transaction.splits || transaction.splits.length === 0) {
|
|
3696
|
+
throw new ValidationError("Transaction has no splits to settle", { transactionId });
|
|
3697
|
+
}
|
|
3698
|
+
const SettlementModel = this.models.Settlement;
|
|
3699
|
+
const settlements = [];
|
|
3700
|
+
for (const split of transaction.splits) {
|
|
3701
|
+
if (split.status === "paid") {
|
|
3702
|
+
this.logger.info("Split already paid, skipping", { splitId: split._id });
|
|
3703
|
+
continue;
|
|
3704
|
+
}
|
|
3705
|
+
const settlement = await SettlementModel.create({
|
|
3706
|
+
organizationId: transaction.organizationId,
|
|
3707
|
+
recipientId: split.recipientId,
|
|
3708
|
+
recipientType: split.recipientType,
|
|
3709
|
+
type: SETTLEMENT_TYPE.SPLIT_PAYOUT,
|
|
3710
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
3711
|
+
payoutMethod,
|
|
3712
|
+
amount: split.netAmount,
|
|
3713
|
+
currency: transaction.currency,
|
|
3714
|
+
sourceTransactionIds: [transaction._id],
|
|
3715
|
+
sourceSplitIds: [split._id?.toString() || ""],
|
|
3716
|
+
scheduledAt,
|
|
3717
|
+
metadata: {
|
|
3718
|
+
...metadata,
|
|
3719
|
+
splitType: split.type,
|
|
3720
|
+
transactionCategory: transaction.category
|
|
3721
|
+
}
|
|
3722
|
+
});
|
|
3723
|
+
settlements.push(settlement);
|
|
3724
|
+
}
|
|
3725
|
+
this.events.emit("settlement.created", {
|
|
3726
|
+
settlements,
|
|
3727
|
+
transactionId,
|
|
3728
|
+
count: settlements.length
|
|
3729
|
+
});
|
|
3730
|
+
this.logger.info("Created settlements from splits", {
|
|
3731
|
+
transactionId,
|
|
3732
|
+
count: settlements.length
|
|
3733
|
+
});
|
|
3734
|
+
return settlements;
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* Schedule a payout
|
|
3738
|
+
*
|
|
3739
|
+
* @param params - Settlement parameters
|
|
3740
|
+
* @returns Created settlement
|
|
3741
|
+
*/
|
|
3742
|
+
async schedule(params) {
|
|
3743
|
+
if (!this.models.Settlement) {
|
|
3744
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
3745
|
+
}
|
|
3746
|
+
const {
|
|
3747
|
+
organizationId,
|
|
3748
|
+
recipientId,
|
|
3749
|
+
recipientType,
|
|
3750
|
+
type,
|
|
3751
|
+
amount,
|
|
3752
|
+
currency = "USD",
|
|
3753
|
+
payoutMethod,
|
|
3754
|
+
sourceTransactionIds = [],
|
|
3755
|
+
sourceSplitIds = [],
|
|
3756
|
+
scheduledAt = /* @__PURE__ */ new Date(),
|
|
3757
|
+
bankTransferDetails,
|
|
3758
|
+
mobileWalletDetails,
|
|
3759
|
+
cryptoDetails,
|
|
3760
|
+
notes,
|
|
3761
|
+
metadata = {}
|
|
3762
|
+
} = params;
|
|
3763
|
+
if (amount <= 0) {
|
|
3764
|
+
throw new ValidationError("Settlement amount must be positive", { amount });
|
|
3765
|
+
}
|
|
3766
|
+
const SettlementModel = this.models.Settlement;
|
|
3767
|
+
const settlement = await SettlementModel.create({
|
|
3768
|
+
organizationId,
|
|
3769
|
+
recipientId,
|
|
3770
|
+
recipientType,
|
|
3771
|
+
type,
|
|
3772
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
3773
|
+
payoutMethod,
|
|
3774
|
+
amount,
|
|
3775
|
+
currency,
|
|
3776
|
+
sourceTransactionIds,
|
|
3777
|
+
sourceSplitIds,
|
|
3778
|
+
scheduledAt,
|
|
3779
|
+
bankTransferDetails,
|
|
3780
|
+
mobileWalletDetails,
|
|
3781
|
+
cryptoDetails,
|
|
3782
|
+
notes,
|
|
3783
|
+
metadata
|
|
3784
|
+
});
|
|
3785
|
+
this.events.emit("settlement.scheduled", {
|
|
3786
|
+
settlement,
|
|
3787
|
+
scheduledAt
|
|
3788
|
+
});
|
|
3789
|
+
this.logger.info("Settlement scheduled", {
|
|
3790
|
+
settlementId: settlement._id,
|
|
3791
|
+
recipientId,
|
|
3792
|
+
amount
|
|
3793
|
+
});
|
|
3794
|
+
return settlement;
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Process pending settlements
|
|
3798
|
+
* Batch process settlements that are due
|
|
3799
|
+
*
|
|
3800
|
+
* @param options - Processing options
|
|
3801
|
+
* @returns Processing result
|
|
3802
|
+
*/
|
|
3803
|
+
async processPending(options = {}) {
|
|
3804
|
+
if (!this.models.Settlement) {
|
|
3805
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
3806
|
+
}
|
|
3807
|
+
const {
|
|
3808
|
+
limit = 100,
|
|
3809
|
+
organizationId,
|
|
3810
|
+
payoutMethod,
|
|
3811
|
+
dryRun = false
|
|
3812
|
+
} = options;
|
|
3813
|
+
const SettlementModel = this.models.Settlement;
|
|
3814
|
+
const query = {
|
|
3815
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
3816
|
+
scheduledAt: { $lte: /* @__PURE__ */ new Date() }
|
|
3817
|
+
};
|
|
3818
|
+
if (organizationId) query.organizationId = organizationId;
|
|
3819
|
+
if (payoutMethod) query.payoutMethod = payoutMethod;
|
|
3820
|
+
const settlements = await SettlementModel.find(query).limit(limit).sort({ scheduledAt: 1 });
|
|
3821
|
+
const result = {
|
|
3822
|
+
processed: 0,
|
|
3823
|
+
succeeded: 0,
|
|
3824
|
+
failed: 0,
|
|
3825
|
+
settlements: [],
|
|
3826
|
+
errors: []
|
|
3827
|
+
};
|
|
3828
|
+
if (dryRun) {
|
|
3829
|
+
this.logger.info("Dry run: would process settlements", { count: settlements.length });
|
|
3830
|
+
result.settlements = settlements;
|
|
3831
|
+
return result;
|
|
3832
|
+
}
|
|
3833
|
+
for (const settlement of settlements) {
|
|
3834
|
+
result.processed++;
|
|
3835
|
+
try {
|
|
3836
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3837
|
+
settlement.status,
|
|
3838
|
+
SETTLEMENT_STATUS.PROCESSING,
|
|
3839
|
+
settlement._id.toString(),
|
|
3840
|
+
{
|
|
3841
|
+
changedBy: "system",
|
|
3842
|
+
reason: "Settlement processing started",
|
|
3843
|
+
metadata: { recipientId: settlement.recipientId, amount: settlement.amount }
|
|
3844
|
+
}
|
|
3845
|
+
);
|
|
3846
|
+
settlement.status = SETTLEMENT_STATUS.PROCESSING;
|
|
3847
|
+
settlement.processedAt = /* @__PURE__ */ new Date();
|
|
3848
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
3849
|
+
await settlement.save();
|
|
3850
|
+
result.succeeded++;
|
|
3851
|
+
result.settlements.push(settlement);
|
|
3852
|
+
this.events.emit("settlement.processing", {
|
|
3853
|
+
settlement,
|
|
3854
|
+
processedAt: settlement.processedAt
|
|
3855
|
+
});
|
|
3856
|
+
} catch (error) {
|
|
3857
|
+
result.failed++;
|
|
3858
|
+
result.errors.push({
|
|
3859
|
+
settlementId: settlement._id.toString(),
|
|
3860
|
+
error: error.message
|
|
3861
|
+
});
|
|
3862
|
+
this.logger.error("Failed to process settlement", {
|
|
3863
|
+
settlementId: settlement._id,
|
|
3864
|
+
error
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
this.logger.info("Processed settlements", result);
|
|
3869
|
+
return result;
|
|
3870
|
+
}
|
|
3871
|
+
/**
|
|
3872
|
+
* Mark settlement as completed
|
|
3873
|
+
* Call this after bank confirms the transfer
|
|
3874
|
+
*
|
|
3875
|
+
* @param settlementId - Settlement ID
|
|
3876
|
+
* @param details - Completion details
|
|
3877
|
+
* @returns Updated settlement
|
|
3878
|
+
*/
|
|
3879
|
+
async complete(settlementId, details = {}) {
|
|
3880
|
+
if (!this.models.Settlement) {
|
|
3881
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
3882
|
+
}
|
|
3883
|
+
const SettlementModel = this.models.Settlement;
|
|
3884
|
+
const settlement = await SettlementModel.findById(settlementId);
|
|
3885
|
+
if (!settlement) {
|
|
3886
|
+
throw new ValidationError("Settlement not found", { settlementId });
|
|
3887
|
+
}
|
|
3888
|
+
if (settlement.status !== SETTLEMENT_STATUS.PROCESSING && settlement.status !== SETTLEMENT_STATUS.PENDING) {
|
|
3889
|
+
throw new InvalidStateTransitionError(
|
|
3890
|
+
"complete",
|
|
3891
|
+
SETTLEMENT_STATUS.PROCESSING,
|
|
3892
|
+
settlement.status,
|
|
3893
|
+
"Only processing or pending settlements can be completed"
|
|
3894
|
+
);
|
|
3895
|
+
}
|
|
3896
|
+
const {
|
|
3897
|
+
transferReference,
|
|
3898
|
+
transferredAt = /* @__PURE__ */ new Date(),
|
|
3899
|
+
transactionHash,
|
|
3900
|
+
notes,
|
|
3901
|
+
metadata = {}
|
|
3902
|
+
} = details;
|
|
3903
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3904
|
+
settlement.status,
|
|
3905
|
+
SETTLEMENT_STATUS.COMPLETED,
|
|
3906
|
+
settlement._id.toString(),
|
|
3907
|
+
{
|
|
3908
|
+
changedBy: "system",
|
|
3909
|
+
reason: "Settlement completed successfully",
|
|
3910
|
+
metadata: {
|
|
3911
|
+
transferReference,
|
|
3912
|
+
transferredAt,
|
|
3913
|
+
transactionHash,
|
|
3914
|
+
payoutMethod: settlement.payoutMethod,
|
|
3915
|
+
amount: settlement.amount
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
);
|
|
3919
|
+
settlement.status = SETTLEMENT_STATUS.COMPLETED;
|
|
3920
|
+
settlement.completedAt = /* @__PURE__ */ new Date();
|
|
3921
|
+
if (settlement.payoutMethod === "bank_transfer" && transferReference) {
|
|
3922
|
+
settlement.bankTransferDetails = {
|
|
3923
|
+
...settlement.bankTransferDetails,
|
|
3924
|
+
transferReference,
|
|
3925
|
+
transferredAt
|
|
3926
|
+
};
|
|
3927
|
+
} else if (settlement.payoutMethod === "crypto" && transactionHash) {
|
|
3928
|
+
settlement.cryptoDetails = {
|
|
3929
|
+
...settlement.cryptoDetails,
|
|
3930
|
+
transactionHash,
|
|
3931
|
+
transferredAt
|
|
3932
|
+
};
|
|
3933
|
+
} else if (settlement.payoutMethod === "mobile_wallet") {
|
|
3934
|
+
settlement.mobileWalletDetails = {
|
|
3935
|
+
...settlement.mobileWalletDetails,
|
|
3936
|
+
transferredAt
|
|
3937
|
+
};
|
|
3938
|
+
} else if (settlement.payoutMethod === "platform_balance") {
|
|
3939
|
+
settlement.platformBalanceDetails = {
|
|
3940
|
+
...settlement.platformBalanceDetails,
|
|
3941
|
+
appliedAt: transferredAt
|
|
3942
|
+
};
|
|
3943
|
+
}
|
|
3944
|
+
if (notes) settlement.notes = notes;
|
|
3945
|
+
settlement.metadata = { ...settlement.metadata, ...metadata };
|
|
3946
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
3947
|
+
await settlement.save();
|
|
3948
|
+
this.events.emit("settlement.completed", {
|
|
3949
|
+
settlement,
|
|
3950
|
+
completedAt: settlement.completedAt
|
|
3951
|
+
});
|
|
3952
|
+
this.logger.info("Settlement completed", {
|
|
3953
|
+
settlementId: settlement._id,
|
|
3954
|
+
recipientId: settlement.recipientId,
|
|
3955
|
+
amount: settlement.amount
|
|
3956
|
+
});
|
|
3957
|
+
return settlement;
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Mark settlement as failed
|
|
3961
|
+
*
|
|
3962
|
+
* @param settlementId - Settlement ID
|
|
3963
|
+
* @param reason - Failure reason
|
|
3964
|
+
* @returns Updated settlement
|
|
3965
|
+
*/
|
|
3966
|
+
async fail(settlementId, reason, options = {}) {
|
|
3967
|
+
if (!this.models.Settlement) {
|
|
3968
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
3969
|
+
}
|
|
3970
|
+
const SettlementModel = this.models.Settlement;
|
|
3971
|
+
const settlement = await SettlementModel.findById(settlementId);
|
|
3972
|
+
if (!settlement) {
|
|
3973
|
+
throw new ValidationError("Settlement not found", { settlementId });
|
|
3974
|
+
}
|
|
3975
|
+
const { code, retry: retry2 = false } = options;
|
|
3976
|
+
if (retry2) {
|
|
3977
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3978
|
+
settlement.status,
|
|
3979
|
+
SETTLEMENT_STATUS.PENDING,
|
|
3980
|
+
settlement._id.toString(),
|
|
3981
|
+
{
|
|
3982
|
+
changedBy: "system",
|
|
3983
|
+
reason: `Settlement failed, retrying: ${reason}`,
|
|
3984
|
+
metadata: {
|
|
3985
|
+
failureReason: reason,
|
|
3986
|
+
failureCode: code,
|
|
3987
|
+
retryCount: (settlement.retryCount || 0) + 1,
|
|
3988
|
+
scheduledAt: new Date(Date.now() + 60 * 60 * 1e3)
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
);
|
|
3992
|
+
settlement.status = SETTLEMENT_STATUS.PENDING;
|
|
3993
|
+
settlement.retryCount = (settlement.retryCount || 0) + 1;
|
|
3994
|
+
settlement.scheduledAt = new Date(Date.now() + 60 * 60 * 1e3);
|
|
3995
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
3996
|
+
} else {
|
|
3997
|
+
const auditEvent = SETTLEMENT_STATE_MACHINE.validateAndCreateAuditEvent(
|
|
3998
|
+
settlement.status,
|
|
3999
|
+
SETTLEMENT_STATUS.FAILED,
|
|
4000
|
+
settlement._id.toString(),
|
|
4001
|
+
{
|
|
4002
|
+
changedBy: "system",
|
|
4003
|
+
reason: `Settlement failed: ${reason}`,
|
|
4004
|
+
metadata: {
|
|
4005
|
+
failureReason: reason,
|
|
4006
|
+
failureCode: code
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
);
|
|
4010
|
+
settlement.status = SETTLEMENT_STATUS.FAILED;
|
|
4011
|
+
settlement.failedAt = /* @__PURE__ */ new Date();
|
|
4012
|
+
Object.assign(settlement, appendAuditEvent(settlement, auditEvent));
|
|
4013
|
+
}
|
|
4014
|
+
settlement.failureReason = reason;
|
|
4015
|
+
if (code) settlement.failureCode = code;
|
|
4016
|
+
await settlement.save();
|
|
4017
|
+
this.events.emit("settlement.failed", {
|
|
4018
|
+
settlement,
|
|
4019
|
+
reason,
|
|
4020
|
+
code,
|
|
4021
|
+
retry: retry2
|
|
4022
|
+
});
|
|
4023
|
+
this.logger.warn("Settlement failed", {
|
|
4024
|
+
settlementId: settlement._id,
|
|
4025
|
+
reason,
|
|
4026
|
+
retry: retry2
|
|
4027
|
+
});
|
|
4028
|
+
return settlement;
|
|
4029
|
+
}
|
|
4030
|
+
/**
|
|
4031
|
+
* List settlements with filters
|
|
4032
|
+
*
|
|
4033
|
+
* @param filters - Query filters
|
|
4034
|
+
* @returns Settlements
|
|
4035
|
+
*/
|
|
4036
|
+
async list(filters = {}) {
|
|
4037
|
+
if (!this.models.Settlement) {
|
|
4038
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
4039
|
+
}
|
|
4040
|
+
const SettlementModel = this.models.Settlement;
|
|
4041
|
+
const {
|
|
4042
|
+
organizationId,
|
|
4043
|
+
recipientId,
|
|
4044
|
+
status,
|
|
4045
|
+
type,
|
|
4046
|
+
payoutMethod,
|
|
4047
|
+
scheduledAfter,
|
|
4048
|
+
scheduledBefore,
|
|
4049
|
+
limit = 50,
|
|
4050
|
+
skip = 0,
|
|
4051
|
+
sort = { createdAt: -1 }
|
|
4052
|
+
} = filters;
|
|
4053
|
+
const query = {};
|
|
4054
|
+
if (organizationId) query.organizationId = organizationId;
|
|
4055
|
+
if (recipientId) query.recipientId = recipientId;
|
|
4056
|
+
if (status) query.status = Array.isArray(status) ? { $in: status } : status;
|
|
4057
|
+
if (type) query.type = type;
|
|
4058
|
+
if (payoutMethod) query.payoutMethod = payoutMethod;
|
|
4059
|
+
if (scheduledAfter || scheduledBefore) {
|
|
4060
|
+
query.scheduledAt = {};
|
|
4061
|
+
if (scheduledAfter) query.scheduledAt.$gte = scheduledAfter;
|
|
4062
|
+
if (scheduledBefore) query.scheduledAt.$lte = scheduledBefore;
|
|
4063
|
+
}
|
|
4064
|
+
const settlements = await SettlementModel.find(query).limit(limit).skip(skip).sort(sort);
|
|
4065
|
+
return settlements;
|
|
4066
|
+
}
|
|
4067
|
+
/**
|
|
4068
|
+
* Get payout summary for recipient
|
|
4069
|
+
*
|
|
4070
|
+
* @param recipientId - Recipient ID
|
|
4071
|
+
* @param options - Summary options
|
|
4072
|
+
* @returns Settlement summary
|
|
4073
|
+
*/
|
|
4074
|
+
async getSummary(recipientId, options = {}) {
|
|
4075
|
+
if (!this.models.Settlement) {
|
|
4076
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
4077
|
+
}
|
|
4078
|
+
const { organizationId, startDate, endDate } = options;
|
|
4079
|
+
const SettlementModel = this.models.Settlement;
|
|
4080
|
+
const query = { recipientId };
|
|
4081
|
+
if (organizationId) query.organizationId = organizationId;
|
|
4082
|
+
if (startDate || endDate) {
|
|
4083
|
+
query.createdAt = {};
|
|
4084
|
+
if (startDate) query.createdAt.$gte = startDate;
|
|
4085
|
+
if (endDate) query.createdAt.$lte = endDate;
|
|
4086
|
+
}
|
|
4087
|
+
const settlements = await SettlementModel.find(query);
|
|
4088
|
+
const summary = {
|
|
4089
|
+
recipientId,
|
|
4090
|
+
totalPending: 0,
|
|
4091
|
+
totalProcessing: 0,
|
|
4092
|
+
totalCompleted: 0,
|
|
4093
|
+
totalFailed: 0,
|
|
4094
|
+
amountPending: 0,
|
|
4095
|
+
amountCompleted: 0,
|
|
4096
|
+
amountFailed: 0,
|
|
4097
|
+
currency: settlements[0]?.currency || "USD",
|
|
4098
|
+
settlements: {
|
|
4099
|
+
pending: 0,
|
|
4100
|
+
processing: 0,
|
|
4101
|
+
completed: 0,
|
|
4102
|
+
failed: 0,
|
|
4103
|
+
cancelled: 0
|
|
4104
|
+
}
|
|
4105
|
+
};
|
|
4106
|
+
for (const settlement of settlements) {
|
|
4107
|
+
summary.settlements[settlement.status]++;
|
|
4108
|
+
if (settlement.status === SETTLEMENT_STATUS.PENDING) {
|
|
4109
|
+
summary.totalPending++;
|
|
4110
|
+
summary.amountPending += settlement.amount;
|
|
4111
|
+
} else if (settlement.status === SETTLEMENT_STATUS.PROCESSING) {
|
|
4112
|
+
summary.totalProcessing++;
|
|
4113
|
+
} else if (settlement.status === SETTLEMENT_STATUS.COMPLETED) {
|
|
4114
|
+
summary.totalCompleted++;
|
|
4115
|
+
summary.amountCompleted += settlement.amount;
|
|
4116
|
+
if (!summary.lastSettlementDate || settlement.completedAt > summary.lastSettlementDate) {
|
|
4117
|
+
summary.lastSettlementDate = settlement.completedAt;
|
|
4118
|
+
}
|
|
4119
|
+
} else if (settlement.status === SETTLEMENT_STATUS.FAILED) {
|
|
4120
|
+
summary.totalFailed++;
|
|
4121
|
+
summary.amountFailed += settlement.amount;
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
return summary;
|
|
4125
|
+
}
|
|
4126
|
+
/**
|
|
4127
|
+
* Get settlement by ID
|
|
4128
|
+
*
|
|
4129
|
+
* @param settlementId - Settlement ID
|
|
4130
|
+
* @returns Settlement
|
|
4131
|
+
*/
|
|
4132
|
+
async get(settlementId) {
|
|
4133
|
+
if (!this.models.Settlement) {
|
|
4134
|
+
throw new ModelNotRegisteredError("Settlement");
|
|
4135
|
+
}
|
|
4136
|
+
const SettlementModel = this.models.Settlement;
|
|
4137
|
+
const settlement = await SettlementModel.findById(settlementId);
|
|
4138
|
+
if (!settlement) {
|
|
4139
|
+
throw new ValidationError("Settlement not found", { settlementId });
|
|
4140
|
+
}
|
|
4141
|
+
return settlement;
|
|
2509
4142
|
}
|
|
2510
4143
|
};
|
|
2511
4144
|
|
|
@@ -2530,6 +4163,8 @@ var Revenue = class {
|
|
|
2530
4163
|
transactions;
|
|
2531
4164
|
/** Escrow service - hold, release, splits */
|
|
2532
4165
|
escrow;
|
|
4166
|
+
/** Settlement service - payout tracking */
|
|
4167
|
+
settlement;
|
|
2533
4168
|
constructor(container, events, plugins, options, providers, config) {
|
|
2534
4169
|
this._container = container;
|
|
2535
4170
|
this._events = events;
|
|
@@ -2542,16 +4177,20 @@ var Revenue = class {
|
|
|
2542
4177
|
ttl: options.idempotencyTtl
|
|
2543
4178
|
});
|
|
2544
4179
|
if (options.circuitBreaker) {
|
|
2545
|
-
|
|
4180
|
+
const cbConfig = typeof options.circuitBreaker === "boolean" ? {} : options.circuitBreaker;
|
|
4181
|
+
this._circuitBreaker = createCircuitBreaker(cbConfig);
|
|
2546
4182
|
}
|
|
2547
4183
|
container.singleton("events", events);
|
|
2548
4184
|
container.singleton("plugins", plugins);
|
|
2549
4185
|
container.singleton("idempotency", this._idempotency);
|
|
2550
4186
|
container.singleton("logger", this._logger);
|
|
4187
|
+
container.singleton("retryConfig", options.retry || {});
|
|
4188
|
+
container.singleton("circuitBreaker", this._circuitBreaker || null);
|
|
2551
4189
|
this.monetization = new MonetizationService(container);
|
|
2552
4190
|
this.payments = new PaymentService(container);
|
|
2553
4191
|
this.transactions = new TransactionService(container);
|
|
2554
4192
|
this.escrow = new EscrowService(container);
|
|
4193
|
+
this.settlement = new SettlementService(container);
|
|
2555
4194
|
Object.freeze(this._providers);
|
|
2556
4195
|
Object.freeze(this._config);
|
|
2557
4196
|
}
|
|
@@ -2627,8 +4266,8 @@ var Revenue = class {
|
|
|
2627
4266
|
*
|
|
2628
4267
|
* @example
|
|
2629
4268
|
* ```typescript
|
|
2630
|
-
* revenue.on('payment.
|
|
2631
|
-
* console.log('Payment:', event.
|
|
4269
|
+
* revenue.on('payment.verified', (event) => {
|
|
4270
|
+
* console.log('Payment:', event.transaction._id);
|
|
2632
4271
|
* });
|
|
2633
4272
|
* ```
|
|
2634
4273
|
*/
|
|
@@ -2685,7 +4324,6 @@ var Revenue = class {
|
|
|
2685
4324
|
return {
|
|
2686
4325
|
events: this._events,
|
|
2687
4326
|
logger: this._logger,
|
|
2688
|
-
get: (key) => this._container.get(key),
|
|
2689
4327
|
storage: /* @__PURE__ */ new Map(),
|
|
2690
4328
|
meta: {
|
|
2691
4329
|
...meta,
|
|
@@ -2700,6 +4338,7 @@ var Revenue = class {
|
|
|
2700
4338
|
async destroy() {
|
|
2701
4339
|
await this._plugins.destroy();
|
|
2702
4340
|
this._events.clear();
|
|
4341
|
+
this._idempotency.destroy();
|
|
2703
4342
|
}
|
|
2704
4343
|
};
|
|
2705
4344
|
var RevenueBuilder = class {
|
|
@@ -2707,8 +4346,8 @@ var RevenueBuilder = class {
|
|
|
2707
4346
|
models = null;
|
|
2708
4347
|
providers = {};
|
|
2709
4348
|
plugins = [];
|
|
2710
|
-
hooks = {};
|
|
2711
4349
|
categoryMappings = {};
|
|
4350
|
+
transactionTypeMapping = {};
|
|
2712
4351
|
constructor(options = {}) {
|
|
2713
4352
|
this.options = options;
|
|
2714
4353
|
}
|
|
@@ -2732,7 +4371,7 @@ var RevenueBuilder = class {
|
|
|
2732
4371
|
*/
|
|
2733
4372
|
withModel(name, model) {
|
|
2734
4373
|
if (!this.models) {
|
|
2735
|
-
this.models = {
|
|
4374
|
+
this.models = {};
|
|
2736
4375
|
}
|
|
2737
4376
|
this.models[name] = model;
|
|
2738
4377
|
return this;
|
|
@@ -2777,15 +4416,6 @@ var RevenueBuilder = class {
|
|
|
2777
4416
|
this.plugins.push(...plugins);
|
|
2778
4417
|
return this;
|
|
2779
4418
|
}
|
|
2780
|
-
withHooks(hooks) {
|
|
2781
|
-
const normalized = {};
|
|
2782
|
-
for (const [event, handlerOrHandlers] of Object.entries(hooks)) {
|
|
2783
|
-
if (!handlerOrHandlers) continue;
|
|
2784
|
-
normalized[event] = Array.isArray(handlerOrHandlers) ? handlerOrHandlers : [handlerOrHandlers];
|
|
2785
|
-
}
|
|
2786
|
-
this.hooks = { ...this.hooks, ...normalized };
|
|
2787
|
-
return this;
|
|
2788
|
-
}
|
|
2789
4419
|
/**
|
|
2790
4420
|
* Set retry configuration
|
|
2791
4421
|
*
|
|
@@ -2827,7 +4457,15 @@ var RevenueBuilder = class {
|
|
|
2827
4457
|
return this;
|
|
2828
4458
|
}
|
|
2829
4459
|
/**
|
|
2830
|
-
* Set commission rate (0-
|
|
4460
|
+
* Set commission rate as decimal (0-1, e.g., 0.10 for 10%)
|
|
4461
|
+
*
|
|
4462
|
+
* @param rate - Commission rate (0-1 decimal format)
|
|
4463
|
+
* @param gatewayFeeRate - Gateway fee rate (0-1 decimal format, e.g., 0.029 for 2.9%)
|
|
4464
|
+
*
|
|
4465
|
+
* @example
|
|
4466
|
+
* ```typescript
|
|
4467
|
+
* .withCommission(0.10, 0.029) // 10% commission + 2.9% gateway fee
|
|
4468
|
+
* ```
|
|
2831
4469
|
*/
|
|
2832
4470
|
withCommission(rate, gatewayFeeRate = 0) {
|
|
2833
4471
|
this.options.commissionRate = rate;
|
|
@@ -2850,6 +4488,22 @@ var RevenueBuilder = class {
|
|
|
2850
4488
|
this.categoryMappings = { ...this.categoryMappings, ...mappings };
|
|
2851
4489
|
return this;
|
|
2852
4490
|
}
|
|
4491
|
+
/**
|
|
4492
|
+
* Set transaction flow mapping by category or monetization type
|
|
4493
|
+
*
|
|
4494
|
+
* @example
|
|
4495
|
+
* ```typescript
|
|
4496
|
+
* .withTransactionTypeMapping({
|
|
4497
|
+
* platform_subscription: 'inflow',
|
|
4498
|
+
* subscription: 'inflow',
|
|
4499
|
+
* refund: 'outflow',
|
|
4500
|
+
* })
|
|
4501
|
+
* ```
|
|
4502
|
+
*/
|
|
4503
|
+
withTransactionTypeMapping(mapping) {
|
|
4504
|
+
this.transactionTypeMapping = { ...this.transactionTypeMapping, ...mapping };
|
|
4505
|
+
return this;
|
|
4506
|
+
}
|
|
2853
4507
|
/**
|
|
2854
4508
|
* Build the Revenue instance
|
|
2855
4509
|
*/
|
|
@@ -2888,14 +4542,19 @@ var RevenueBuilder = class {
|
|
|
2888
4542
|
gatewayFeeRate: this.options.gatewayFeeRate ?? 0
|
|
2889
4543
|
};
|
|
2890
4544
|
const config = {
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
categoryMappings: this.categoryMappings
|
|
4545
|
+
...resolveConfig(resolvedOptions),
|
|
4546
|
+
// Converts singular → plural with '*' global default
|
|
4547
|
+
targetModels: [],
|
|
4548
|
+
categoryMappings: this.categoryMappings,
|
|
4549
|
+
transactionTypeMapping: this.transactionTypeMapping
|
|
2895
4550
|
};
|
|
4551
|
+
for (const provider of Object.values(this.providers)) {
|
|
4552
|
+
if (typeof provider.setDefaultCurrency === "function") {
|
|
4553
|
+
provider.setDefaultCurrency(resolvedOptions.defaultCurrency);
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
2896
4556
|
container.singleton("models", this.models);
|
|
2897
4557
|
container.singleton("providers", this.providers);
|
|
2898
|
-
container.singleton("hooks", this.hooks);
|
|
2899
4558
|
container.singleton("config", config);
|
|
2900
4559
|
const events = createEventBus();
|
|
2901
4560
|
const pluginManager = new PluginManager();
|
|
@@ -2924,12 +4583,9 @@ function createRevenue(config) {
|
|
|
2924
4583
|
if (config.plugins) {
|
|
2925
4584
|
builder = builder.withPlugins(config.plugins);
|
|
2926
4585
|
}
|
|
2927
|
-
if (config.hooks) {
|
|
2928
|
-
builder = builder.withHooks(config.hooks);
|
|
2929
|
-
}
|
|
2930
4586
|
return builder.build();
|
|
2931
4587
|
}
|
|
2932
4588
|
|
|
2933
|
-
export { AlreadyVerifiedError, ConfigurationError, Container, ERROR_CODES, EventBus, InvalidAmountError, InvalidStateTransitionError, MissingRequiredFieldError, ModelNotRegisteredError, NotFoundError, OperationError, PaymentIntentCreationError, PaymentVerificationError, PluginManager, ProviderCapabilityError, ProviderError, ProviderNotFoundError, RefundError, RefundNotSupportedError, Result, Revenue, RevenueBuilder, RevenueError, StateError, SubscriptionNotActiveError, SubscriptionNotFoundError, TransactionNotFoundError, ValidationError, all, auditPlugin, createEventBus, createRevenue, definePlugin, err, flatMap, isErr, isOk, isRetryable, isRevenueError, loggingPlugin, map, mapErr, match, metricsPlugin, ok, tryCatch, tryCatchSync, unwrap, unwrapOr };
|
|
4589
|
+
export { AlreadyVerifiedError, ConfigurationError, Container, ERROR_CODES, EventBus, HOLD_STATE_MACHINE, InvalidAmountError, InvalidStateTransitionError, MissingRequiredFieldError, ModelNotRegisteredError, NotFoundError, OperationError, PaymentIntentCreationError, PaymentVerificationError, PluginManager, ProviderCapabilityError, ProviderError, ProviderNotFoundError, RefundError, RefundNotSupportedError, Result, Revenue, RevenueBuilder, RevenueError, SETTLEMENT_STATE_MACHINE, SPLIT_STATE_MACHINE, SUBSCRIPTION_STATE_MACHINE, StateError, StateMachine, SubscriptionNotActiveError, SubscriptionNotFoundError, TRANSACTION_STATE_MACHINE, TransactionNotFoundError, ValidationError, all, auditPlugin, createEventBus, createRevenue, definePlugin, err, flatMap, isErr, isOk, isRetryable, isRevenueError, loggingPlugin, map, mapErr, match, metricsPlugin, ok, tryCatch, tryCatchSync, unwrap, unwrapOr };
|
|
2934
4590
|
//# sourceMappingURL=index.js.map
|
|
2935
4591
|
//# sourceMappingURL=index.js.map
|