@classytic/revenue 0.2.4 → 1.0.1
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 +498 -501
- package/dist/actions-CwG-b7fR.d.ts +519 -0
- package/dist/core/index.d.ts +884 -0
- package/dist/core/index.js +2941 -0
- package/dist/core/index.js.map +1 -0
- package/dist/enums/index.d.ts +130 -0
- package/dist/enums/index.js +167 -0
- package/dist/enums/index.js.map +1 -0
- package/dist/index-BnJWVXuw.d.ts +378 -0
- package/dist/index-ChVD3P9k.d.ts +504 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +4362 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/index.d.ts +132 -0
- package/dist/providers/index.js +122 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/retry-80lBCmSe.d.ts +234 -0
- package/dist/schemas/index.d.ts +906 -0
- package/dist/schemas/index.js +533 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/validation.d.ts +309 -0
- package/dist/schemas/validation.js +249 -0
- package/dist/schemas/validation.js.map +1 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +1632 -0
- package/dist/services/index.js.map +1 -0
- package/dist/split.enums-DHdM1YAV.d.ts +255 -0
- package/dist/split.schema-CETjPq10.d.ts +976 -0
- package/dist/utils/index.d.ts +24 -0
- package/dist/utils/index.js +1067 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +48 -32
- package/core/builder.js +0 -219
- package/core/container.js +0 -119
- package/core/errors.js +0 -262
- package/dist/types/core/builder.d.ts +0 -97
- package/dist/types/core/container.d.ts +0 -57
- package/dist/types/core/errors.d.ts +0 -122
- package/dist/types/enums/escrow.enums.d.ts +0 -24
- package/dist/types/enums/index.d.ts +0 -69
- package/dist/types/enums/monetization.enums.d.ts +0 -6
- package/dist/types/enums/payment.enums.d.ts +0 -16
- package/dist/types/enums/split.enums.d.ts +0 -25
- package/dist/types/enums/subscription.enums.d.ts +0 -15
- package/dist/types/enums/transaction.enums.d.ts +0 -24
- package/dist/types/index.d.ts +0 -22
- package/dist/types/providers/base.d.ts +0 -128
- package/dist/types/schemas/escrow/hold.schema.d.ts +0 -54
- package/dist/types/schemas/escrow/index.d.ts +0 -6
- package/dist/types/schemas/index.d.ts +0 -506
- package/dist/types/schemas/split/index.d.ts +0 -8
- package/dist/types/schemas/split/split.schema.d.ts +0 -142
- package/dist/types/schemas/subscription/index.d.ts +0 -152
- package/dist/types/schemas/subscription/info.schema.d.ts +0 -128
- package/dist/types/schemas/subscription/plan.schema.d.ts +0 -39
- package/dist/types/schemas/transaction/common.schema.d.ts +0 -12
- package/dist/types/schemas/transaction/gateway.schema.d.ts +0 -86
- package/dist/types/schemas/transaction/index.d.ts +0 -202
- package/dist/types/schemas/transaction/payment.schema.d.ts +0 -145
- package/dist/types/services/escrow.service.d.ts +0 -51
- package/dist/types/services/monetization.service.d.ts +0 -193
- package/dist/types/services/payment.service.d.ts +0 -117
- package/dist/types/services/transaction.service.d.ts +0 -40
- package/dist/types/utils/category-resolver.d.ts +0 -46
- package/dist/types/utils/commission-split.d.ts +0 -56
- package/dist/types/utils/commission.d.ts +0 -29
- package/dist/types/utils/hooks.d.ts +0 -17
- package/dist/types/utils/index.d.ts +0 -6
- package/dist/types/utils/logger.d.ts +0 -12
- package/dist/types/utils/subscription/actions.d.ts +0 -28
- package/dist/types/utils/subscription/index.d.ts +0 -2
- package/dist/types/utils/subscription/period.d.ts +0 -47
- package/dist/types/utils/transaction-type.d.ts +0 -102
- package/enums/escrow.enums.js +0 -36
- package/enums/index.d.ts +0 -116
- package/enums/index.js +0 -110
- package/enums/monetization.enums.js +0 -15
- package/enums/payment.enums.js +0 -64
- package/enums/split.enums.js +0 -37
- package/enums/subscription.enums.js +0 -33
- package/enums/transaction.enums.js +0 -69
- package/index.js +0 -76
- package/providers/base.js +0 -162
- package/schemas/escrow/hold.schema.js +0 -62
- package/schemas/escrow/index.js +0 -15
- package/schemas/index.d.ts +0 -33
- package/schemas/index.js +0 -27
- package/schemas/split/index.js +0 -16
- package/schemas/split/split.schema.js +0 -86
- package/schemas/subscription/index.js +0 -17
- package/schemas/subscription/info.schema.js +0 -115
- package/schemas/subscription/plan.schema.js +0 -48
- package/schemas/transaction/common.schema.js +0 -22
- package/schemas/transaction/gateway.schema.js +0 -69
- package/schemas/transaction/index.js +0 -20
- package/schemas/transaction/payment.schema.js +0 -110
- package/services/escrow.service.js +0 -353
- package/services/monetization.service.js +0 -675
- package/services/payment.service.js +0 -535
- package/services/transaction.service.js +0 -142
- package/utils/category-resolver.js +0 -74
- package/utils/commission-split.js +0 -180
- package/utils/commission.js +0 -83
- package/utils/hooks.js +0 -44
- package/utils/index.d.ts +0 -164
- package/utils/index.js +0 -16
- package/utils/logger.js +0 -36
- package/utils/subscription/actions.js +0 -68
- package/utils/subscription/index.js +0 -20
- package/utils/subscription/period.js +0 -123
- package/utils/transaction-type.js +0 -254
package/dist/index.js
ADDED
|
@@ -0,0 +1,4362 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
export { z };
|
|
4
|
+
import { Schema } from 'mongoose';
|
|
5
|
+
|
|
6
|
+
// @classytic/revenue - Enterprise Revenue Management System
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// src/core/container.ts
|
|
10
|
+
var Container = class _Container {
|
|
11
|
+
_services;
|
|
12
|
+
_singletons;
|
|
13
|
+
constructor() {
|
|
14
|
+
this._services = /* @__PURE__ */ new Map();
|
|
15
|
+
this._singletons = /* @__PURE__ */ new Map();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Register a service
|
|
19
|
+
* @param name - Service name
|
|
20
|
+
* @param implementation - Service implementation or factory
|
|
21
|
+
* @param options - Registration options
|
|
22
|
+
*/
|
|
23
|
+
register(name, implementation, options = {}) {
|
|
24
|
+
this._services.set(name, {
|
|
25
|
+
implementation,
|
|
26
|
+
singleton: options.singleton !== false,
|
|
27
|
+
// Default to singleton
|
|
28
|
+
factory: options.factory ?? false
|
|
29
|
+
});
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Register a singleton service
|
|
34
|
+
* @param name - Service name
|
|
35
|
+
* @param implementation - Service implementation
|
|
36
|
+
*/
|
|
37
|
+
singleton(name, implementation) {
|
|
38
|
+
return this.register(name, implementation, { singleton: true });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Register a transient service (new instance each time)
|
|
42
|
+
* @param name - Service name
|
|
43
|
+
* @param factory - Factory function
|
|
44
|
+
*/
|
|
45
|
+
transient(name, factory) {
|
|
46
|
+
return this.register(name, factory, { singleton: false, factory: true });
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get a service from the container
|
|
50
|
+
* @param name - Service name
|
|
51
|
+
* @returns Service instance
|
|
52
|
+
*/
|
|
53
|
+
get(name) {
|
|
54
|
+
if (this._singletons.has(name)) {
|
|
55
|
+
return this._singletons.get(name);
|
|
56
|
+
}
|
|
57
|
+
const service = this._services.get(name);
|
|
58
|
+
if (!service) {
|
|
59
|
+
throw new Error(`Service "${name}" not registered in container`);
|
|
60
|
+
}
|
|
61
|
+
if (service.factory) {
|
|
62
|
+
const factory = service.implementation;
|
|
63
|
+
const instance2 = factory(this);
|
|
64
|
+
if (service.singleton) {
|
|
65
|
+
this._singletons.set(name, instance2);
|
|
66
|
+
}
|
|
67
|
+
return instance2;
|
|
68
|
+
}
|
|
69
|
+
const instance = service.implementation;
|
|
70
|
+
if (service.singleton) {
|
|
71
|
+
this._singletons.set(name, instance);
|
|
72
|
+
}
|
|
73
|
+
return instance;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Check if service is registered
|
|
77
|
+
* @param name - Service name
|
|
78
|
+
*/
|
|
79
|
+
has(name) {
|
|
80
|
+
return this._services.has(name);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get all registered service names
|
|
84
|
+
*/
|
|
85
|
+
keys() {
|
|
86
|
+
return Array.from(this._services.keys());
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clear all services (useful for testing)
|
|
90
|
+
*/
|
|
91
|
+
clear() {
|
|
92
|
+
this._services.clear();
|
|
93
|
+
this._singletons.clear();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Create a child container (for scoped dependencies)
|
|
97
|
+
*/
|
|
98
|
+
createScope() {
|
|
99
|
+
const scope = new _Container();
|
|
100
|
+
this._services.forEach((value, key) => {
|
|
101
|
+
scope._services.set(key, value);
|
|
102
|
+
});
|
|
103
|
+
return scope;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// src/core/events.ts
|
|
108
|
+
var EventBus = class {
|
|
109
|
+
handlers = /* @__PURE__ */ new Map();
|
|
110
|
+
onceHandlers = /* @__PURE__ */ new Map();
|
|
111
|
+
/**
|
|
112
|
+
* Subscribe to an event
|
|
113
|
+
*/
|
|
114
|
+
on(event, handler) {
|
|
115
|
+
if (!this.handlers.has(event)) {
|
|
116
|
+
this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
117
|
+
}
|
|
118
|
+
this.handlers.get(event).add(handler);
|
|
119
|
+
return () => this.off(event, handler);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Subscribe to an event once
|
|
123
|
+
*/
|
|
124
|
+
once(event, handler) {
|
|
125
|
+
if (!this.onceHandlers.has(event)) {
|
|
126
|
+
this.onceHandlers.set(event, /* @__PURE__ */ new Set());
|
|
127
|
+
}
|
|
128
|
+
this.onceHandlers.get(event).add(handler);
|
|
129
|
+
return () => this.onceHandlers.get(event)?.delete(handler);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Unsubscribe from an event
|
|
133
|
+
*/
|
|
134
|
+
off(event, handler) {
|
|
135
|
+
this.handlers.get(event)?.delete(handler);
|
|
136
|
+
this.onceHandlers.get(event)?.delete(handler);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Emit an event (fire and forget, non-blocking)
|
|
140
|
+
*/
|
|
141
|
+
emit(event, payload) {
|
|
142
|
+
const fullPayload = {
|
|
143
|
+
...payload,
|
|
144
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
145
|
+
};
|
|
146
|
+
const handlers = this.handlers.get(event);
|
|
147
|
+
if (handlers) {
|
|
148
|
+
for (const handler of handlers) {
|
|
149
|
+
Promise.resolve(handler(fullPayload)).catch((err2) => {
|
|
150
|
+
console.error(`[Revenue] Event handler error for "${event}":`, err2);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const onceHandlers = this.onceHandlers.get(event);
|
|
155
|
+
if (onceHandlers) {
|
|
156
|
+
for (const handler of onceHandlers) {
|
|
157
|
+
Promise.resolve(handler(fullPayload)).catch((err2) => {
|
|
158
|
+
console.error(`[Revenue] Once handler error for "${event}":`, err2);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
this.onceHandlers.delete(event);
|
|
162
|
+
}
|
|
163
|
+
if (event !== "*") {
|
|
164
|
+
const wildcardHandlers = this.handlers.get("*");
|
|
165
|
+
if (wildcardHandlers) {
|
|
166
|
+
for (const handler of wildcardHandlers) {
|
|
167
|
+
Promise.resolve(handler(fullPayload)).catch((err2) => {
|
|
168
|
+
console.error(`[Revenue] Wildcard handler error:`, err2);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Emit and wait for all handlers to complete
|
|
176
|
+
*/
|
|
177
|
+
async emitAsync(event, payload) {
|
|
178
|
+
const fullPayload = {
|
|
179
|
+
...payload,
|
|
180
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
181
|
+
};
|
|
182
|
+
const promises = [];
|
|
183
|
+
const handlers = this.handlers.get(event);
|
|
184
|
+
if (handlers) {
|
|
185
|
+
for (const handler of handlers) {
|
|
186
|
+
promises.push(Promise.resolve(handler(fullPayload)));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const onceHandlers = this.onceHandlers.get(event);
|
|
190
|
+
if (onceHandlers) {
|
|
191
|
+
for (const handler of onceHandlers) {
|
|
192
|
+
promises.push(Promise.resolve(handler(fullPayload)));
|
|
193
|
+
}
|
|
194
|
+
this.onceHandlers.delete(event);
|
|
195
|
+
}
|
|
196
|
+
if (event !== "*") {
|
|
197
|
+
const wildcardHandlers = this.handlers.get("*");
|
|
198
|
+
if (wildcardHandlers) {
|
|
199
|
+
for (const handler of wildcardHandlers) {
|
|
200
|
+
promises.push(Promise.resolve(handler(fullPayload)));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await Promise.all(promises);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Remove all handlers
|
|
208
|
+
*/
|
|
209
|
+
clear() {
|
|
210
|
+
this.handlers.clear();
|
|
211
|
+
this.onceHandlers.clear();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Get handler count for an event
|
|
215
|
+
*/
|
|
216
|
+
listenerCount(event) {
|
|
217
|
+
return (this.handlers.get(event)?.size ?? 0) + (this.onceHandlers.get(event)?.size ?? 0);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
function createEventBus() {
|
|
221
|
+
return new EventBus();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/core/plugin.ts
|
|
225
|
+
var PluginManager = class {
|
|
226
|
+
plugins = /* @__PURE__ */ new Map();
|
|
227
|
+
hooks = /* @__PURE__ */ new Map();
|
|
228
|
+
initialized = false;
|
|
229
|
+
/**
|
|
230
|
+
* Register a plugin
|
|
231
|
+
*/
|
|
232
|
+
register(plugin) {
|
|
233
|
+
if (this.plugins.has(plugin.name)) {
|
|
234
|
+
throw new Error(`Plugin "${plugin.name}" is already registered`);
|
|
235
|
+
}
|
|
236
|
+
if (plugin.dependencies) {
|
|
237
|
+
for (const dep of plugin.dependencies) {
|
|
238
|
+
if (!this.plugins.has(dep)) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Plugin "${plugin.name}" requires "${dep}" to be registered first`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
this.plugins.set(plugin.name, plugin);
|
|
246
|
+
if (plugin.hooks) {
|
|
247
|
+
for (const [hookName, hookFn] of Object.entries(plugin.hooks)) {
|
|
248
|
+
if (!this.hooks.has(hookName)) {
|
|
249
|
+
this.hooks.set(hookName, []);
|
|
250
|
+
}
|
|
251
|
+
this.hooks.get(hookName).push(hookFn);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Initialize all plugins
|
|
258
|
+
*/
|
|
259
|
+
async init(ctx) {
|
|
260
|
+
if (this.initialized) return;
|
|
261
|
+
for (const plugin of this.plugins.values()) {
|
|
262
|
+
if (plugin.init) {
|
|
263
|
+
await plugin.init(ctx);
|
|
264
|
+
}
|
|
265
|
+
if (plugin.events) {
|
|
266
|
+
for (const [event, handler] of Object.entries(plugin.events)) {
|
|
267
|
+
ctx.events.on(event, handler);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.initialized = true;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Execute a hook chain
|
|
275
|
+
*/
|
|
276
|
+
async executeHook(hookName, ctx, input, execute) {
|
|
277
|
+
const hooks = this.hooks.get(hookName) ?? [];
|
|
278
|
+
if (hooks.length === 0) {
|
|
279
|
+
return execute();
|
|
280
|
+
}
|
|
281
|
+
let index = 0;
|
|
282
|
+
const next = async () => {
|
|
283
|
+
if (index >= hooks.length) {
|
|
284
|
+
return execute();
|
|
285
|
+
}
|
|
286
|
+
const hook = hooks[index++];
|
|
287
|
+
return hook(ctx, input, next);
|
|
288
|
+
};
|
|
289
|
+
return next();
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Check if plugin is registered
|
|
293
|
+
*/
|
|
294
|
+
has(name) {
|
|
295
|
+
return this.plugins.has(name);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get a plugin by name
|
|
299
|
+
*/
|
|
300
|
+
get(name) {
|
|
301
|
+
return this.plugins.get(name);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Get all registered plugins
|
|
305
|
+
*/
|
|
306
|
+
list() {
|
|
307
|
+
return Array.from(this.plugins.values());
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Destroy all plugins
|
|
311
|
+
*/
|
|
312
|
+
async destroy() {
|
|
313
|
+
for (const plugin of this.plugins.values()) {
|
|
314
|
+
if (plugin.destroy) {
|
|
315
|
+
await plugin.destroy();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
this.plugins.clear();
|
|
319
|
+
this.hooks.clear();
|
|
320
|
+
this.initialized = false;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
function loggingPlugin(options = {}) {
|
|
324
|
+
const level = options.level ?? "info";
|
|
325
|
+
return {
|
|
326
|
+
name: "logging",
|
|
327
|
+
version: "1.0.0",
|
|
328
|
+
description: "Logs all revenue operations",
|
|
329
|
+
hooks: {
|
|
330
|
+
"payment.create.before": async (ctx, input, next) => {
|
|
331
|
+
ctx.logger[level]("Creating payment", { amount: input.amount, currency: input.currency });
|
|
332
|
+
const result = await next();
|
|
333
|
+
ctx.logger[level]("Payment created", { transactionId: result?.transactionId });
|
|
334
|
+
return result;
|
|
335
|
+
},
|
|
336
|
+
"payment.verify.before": async (ctx, input, next) => {
|
|
337
|
+
ctx.logger[level]("Verifying payment", { id: input.id });
|
|
338
|
+
const result = await next();
|
|
339
|
+
ctx.logger[level]("Payment verified", { verified: result?.verified });
|
|
340
|
+
return result;
|
|
341
|
+
},
|
|
342
|
+
"payment.refund.before": async (ctx, input, next) => {
|
|
343
|
+
ctx.logger[level]("Processing refund", { transactionId: input.transactionId, amount: input.amount });
|
|
344
|
+
const result = await next();
|
|
345
|
+
ctx.logger[level]("Refund processed", { refundId: result?.refundId });
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function auditPlugin(options = {}) {
|
|
352
|
+
const store = options.store ?? (async (entry) => {
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
name: "audit",
|
|
356
|
+
version: "1.0.0",
|
|
357
|
+
description: "Audit trail for all operations",
|
|
358
|
+
hooks: {
|
|
359
|
+
"payment.create.after": async (ctx, input, next) => {
|
|
360
|
+
const result = await next();
|
|
361
|
+
await store({
|
|
362
|
+
action: "payment.create",
|
|
363
|
+
requestId: ctx.meta.requestId,
|
|
364
|
+
timestamp: ctx.meta.timestamp,
|
|
365
|
+
input: sanitizeInput(input),
|
|
366
|
+
output: sanitizeOutput(result),
|
|
367
|
+
idempotencyKey: ctx.meta.idempotencyKey
|
|
368
|
+
});
|
|
369
|
+
return result;
|
|
370
|
+
},
|
|
371
|
+
"payment.refund.after": async (ctx, input, next) => {
|
|
372
|
+
const result = await next();
|
|
373
|
+
await store({
|
|
374
|
+
action: "payment.refund",
|
|
375
|
+
requestId: ctx.meta.requestId,
|
|
376
|
+
timestamp: ctx.meta.timestamp,
|
|
377
|
+
input: sanitizeInput(input),
|
|
378
|
+
output: sanitizeOutput(result),
|
|
379
|
+
idempotencyKey: ctx.meta.idempotencyKey
|
|
380
|
+
});
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function sanitizeInput(input) {
|
|
387
|
+
if (typeof input !== "object" || !input) return {};
|
|
388
|
+
const sanitized = { ...input };
|
|
389
|
+
delete sanitized.apiKey;
|
|
390
|
+
delete sanitized.secretKey;
|
|
391
|
+
delete sanitized.password;
|
|
392
|
+
return sanitized;
|
|
393
|
+
}
|
|
394
|
+
function sanitizeOutput(output) {
|
|
395
|
+
if (typeof output !== "object" || !output) return {};
|
|
396
|
+
return { ...output };
|
|
397
|
+
}
|
|
398
|
+
function metricsPlugin(options = {}) {
|
|
399
|
+
const record2 = options.onMetric ?? ((metric) => {
|
|
400
|
+
});
|
|
401
|
+
return {
|
|
402
|
+
name: "metrics",
|
|
403
|
+
version: "1.0.0",
|
|
404
|
+
description: "Collects operation metrics",
|
|
405
|
+
hooks: {
|
|
406
|
+
"payment.create.before": async (_ctx, input, next) => {
|
|
407
|
+
const start = Date.now();
|
|
408
|
+
try {
|
|
409
|
+
const result = await next();
|
|
410
|
+
record2({
|
|
411
|
+
name: "payment.create",
|
|
412
|
+
duration: Date.now() - start,
|
|
413
|
+
success: true,
|
|
414
|
+
amount: input.amount,
|
|
415
|
+
currency: input.currency
|
|
416
|
+
});
|
|
417
|
+
return result;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
record2({
|
|
420
|
+
name: "payment.create",
|
|
421
|
+
duration: Date.now() - start,
|
|
422
|
+
success: false,
|
|
423
|
+
error: error.message
|
|
424
|
+
});
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function definePlugin(plugin) {
|
|
432
|
+
return plugin;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/core/result.ts
|
|
436
|
+
function ok(value) {
|
|
437
|
+
return { ok: true, value };
|
|
438
|
+
}
|
|
439
|
+
function err(error) {
|
|
440
|
+
return { ok: false, error };
|
|
441
|
+
}
|
|
442
|
+
function isOk(result) {
|
|
443
|
+
return result.ok === true;
|
|
444
|
+
}
|
|
445
|
+
function isErr(result) {
|
|
446
|
+
return result.ok === false;
|
|
447
|
+
}
|
|
448
|
+
function unwrap(result) {
|
|
449
|
+
if (result.ok) return result.value;
|
|
450
|
+
throw result.error;
|
|
451
|
+
}
|
|
452
|
+
function unwrapOr(result, defaultValue) {
|
|
453
|
+
return result.ok ? result.value : defaultValue;
|
|
454
|
+
}
|
|
455
|
+
function map(result, fn) {
|
|
456
|
+
return result.ok ? ok(fn(result.value)) : result;
|
|
457
|
+
}
|
|
458
|
+
function mapErr(result, fn) {
|
|
459
|
+
return result.ok ? result : err(fn(result.error));
|
|
460
|
+
}
|
|
461
|
+
function flatMap(result, fn) {
|
|
462
|
+
return result.ok ? fn(result.value) : result;
|
|
463
|
+
}
|
|
464
|
+
async function tryCatch(fn, mapError) {
|
|
465
|
+
try {
|
|
466
|
+
const value = await fn();
|
|
467
|
+
return ok(value);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
const error = mapError ? mapError(e) : e;
|
|
470
|
+
return err(error);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function tryCatchSync(fn, mapError) {
|
|
474
|
+
try {
|
|
475
|
+
const value = fn();
|
|
476
|
+
return ok(value);
|
|
477
|
+
} catch (e) {
|
|
478
|
+
const error = mapError ? mapError(e) : e;
|
|
479
|
+
return err(error);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function all(results) {
|
|
483
|
+
const values = [];
|
|
484
|
+
for (const result of results) {
|
|
485
|
+
if (!result.ok) return result;
|
|
486
|
+
values.push(result.value);
|
|
487
|
+
}
|
|
488
|
+
return ok(values);
|
|
489
|
+
}
|
|
490
|
+
function match(result, handlers) {
|
|
491
|
+
return result.ok ? handlers.ok(result.value) : handlers.err(result.error);
|
|
492
|
+
}
|
|
493
|
+
var Result = {
|
|
494
|
+
ok,
|
|
495
|
+
err,
|
|
496
|
+
isOk,
|
|
497
|
+
isErr,
|
|
498
|
+
unwrap,
|
|
499
|
+
unwrapOr,
|
|
500
|
+
map,
|
|
501
|
+
mapErr,
|
|
502
|
+
flatMap,
|
|
503
|
+
tryCatch,
|
|
504
|
+
tryCatchSync,
|
|
505
|
+
all,
|
|
506
|
+
match
|
|
507
|
+
};
|
|
508
|
+
var MemoryIdempotencyStore = class {
|
|
509
|
+
records = /* @__PURE__ */ new Map();
|
|
510
|
+
cleanupInterval = null;
|
|
511
|
+
constructor(cleanupIntervalMs = 6e4) {
|
|
512
|
+
this.cleanupInterval = setInterval(() => {
|
|
513
|
+
this.cleanup();
|
|
514
|
+
}, cleanupIntervalMs);
|
|
515
|
+
}
|
|
516
|
+
async get(key) {
|
|
517
|
+
const record2 = this.records.get(key);
|
|
518
|
+
if (!record2) return null;
|
|
519
|
+
if (record2.expiresAt < /* @__PURE__ */ new Date()) {
|
|
520
|
+
this.records.delete(key);
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
return record2;
|
|
524
|
+
}
|
|
525
|
+
async set(key, record2) {
|
|
526
|
+
this.records.set(key, record2);
|
|
527
|
+
}
|
|
528
|
+
async delete(key) {
|
|
529
|
+
this.records.delete(key);
|
|
530
|
+
}
|
|
531
|
+
async exists(key) {
|
|
532
|
+
const record2 = await this.get(key);
|
|
533
|
+
return record2 !== null;
|
|
534
|
+
}
|
|
535
|
+
cleanup() {
|
|
536
|
+
const now = /* @__PURE__ */ new Date();
|
|
537
|
+
for (const [key, record2] of this.records) {
|
|
538
|
+
if (record2.expiresAt < now) {
|
|
539
|
+
this.records.delete(key);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
destroy() {
|
|
544
|
+
if (this.cleanupInterval) {
|
|
545
|
+
clearInterval(this.cleanupInterval);
|
|
546
|
+
this.cleanupInterval = null;
|
|
547
|
+
}
|
|
548
|
+
this.records.clear();
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
var IdempotencyError = class extends Error {
|
|
552
|
+
constructor(message, code) {
|
|
553
|
+
super(message);
|
|
554
|
+
this.code = code;
|
|
555
|
+
this.name = "IdempotencyError";
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
var IdempotencyManager = class {
|
|
559
|
+
store;
|
|
560
|
+
ttl;
|
|
561
|
+
prefix;
|
|
562
|
+
constructor(config = {}) {
|
|
563
|
+
this.store = config.store ?? new MemoryIdempotencyStore();
|
|
564
|
+
this.ttl = config.ttl ?? 24 * 60 * 60 * 1e3;
|
|
565
|
+
this.prefix = config.prefix ?? "idem:";
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Generate a unique idempotency key
|
|
569
|
+
*/
|
|
570
|
+
generateKey() {
|
|
571
|
+
return `${this.prefix}${nanoid(21)}`;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Hash request parameters for validation
|
|
575
|
+
*/
|
|
576
|
+
hashRequest(params) {
|
|
577
|
+
const json = JSON.stringify(params, Object.keys(params).sort());
|
|
578
|
+
let hash = 0;
|
|
579
|
+
for (let i = 0; i < json.length; i++) {
|
|
580
|
+
const char = json.charCodeAt(i);
|
|
581
|
+
hash = (hash << 5) - hash + char;
|
|
582
|
+
hash = hash & hash;
|
|
583
|
+
}
|
|
584
|
+
return hash.toString(36);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Execute operation with idempotency protection
|
|
588
|
+
*/
|
|
589
|
+
async execute(key, params, operation) {
|
|
590
|
+
const fullKey = key.startsWith(this.prefix) ? key : `${this.prefix}${key}`;
|
|
591
|
+
const requestHash = this.hashRequest(params);
|
|
592
|
+
const existing = await this.store.get(fullKey);
|
|
593
|
+
if (existing) {
|
|
594
|
+
if (existing.requestHash !== requestHash) {
|
|
595
|
+
return err(new IdempotencyError(
|
|
596
|
+
"Idempotency key used with different request parameters",
|
|
597
|
+
"REQUEST_MISMATCH"
|
|
598
|
+
));
|
|
599
|
+
}
|
|
600
|
+
if (existing.status === "completed" && existing.result !== void 0) {
|
|
601
|
+
return ok(existing.result);
|
|
602
|
+
}
|
|
603
|
+
if (existing.status === "pending") {
|
|
604
|
+
return err(new IdempotencyError(
|
|
605
|
+
"Request with this idempotency key is already in progress",
|
|
606
|
+
"REQUEST_IN_PROGRESS"
|
|
607
|
+
));
|
|
608
|
+
}
|
|
609
|
+
if (existing.status === "failed") {
|
|
610
|
+
await this.store.delete(fullKey);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const record2 = {
|
|
614
|
+
key: fullKey,
|
|
615
|
+
status: "pending",
|
|
616
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
617
|
+
requestHash,
|
|
618
|
+
expiresAt: new Date(Date.now() + this.ttl)
|
|
619
|
+
};
|
|
620
|
+
await this.store.set(fullKey, record2);
|
|
621
|
+
try {
|
|
622
|
+
const result = await operation();
|
|
623
|
+
record2.status = "completed";
|
|
624
|
+
record2.result = result;
|
|
625
|
+
record2.completedAt = /* @__PURE__ */ new Date();
|
|
626
|
+
await this.store.set(fullKey, record2);
|
|
627
|
+
return ok(result);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
record2.status = "failed";
|
|
630
|
+
await this.store.set(fullKey, record2);
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Check if operation with key was already completed
|
|
636
|
+
*/
|
|
637
|
+
async wasCompleted(key) {
|
|
638
|
+
const fullKey = key.startsWith(this.prefix) ? key : `${this.prefix}${key}`;
|
|
639
|
+
const record2 = await this.store.get(fullKey);
|
|
640
|
+
return record2?.status === "completed";
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Get cached result for key
|
|
644
|
+
*/
|
|
645
|
+
async getCached(key) {
|
|
646
|
+
const fullKey = key.startsWith(this.prefix) ? key : `${this.prefix}${key}`;
|
|
647
|
+
const record2 = await this.store.get(fullKey);
|
|
648
|
+
return record2?.status === "completed" ? record2.result ?? null : null;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Invalidate a key (force re-execution on next call)
|
|
652
|
+
*/
|
|
653
|
+
async invalidate(key) {
|
|
654
|
+
const fullKey = key.startsWith(this.prefix) ? key : `${this.prefix}${key}`;
|
|
655
|
+
await this.store.delete(fullKey);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
function createIdempotencyManager(config) {
|
|
659
|
+
return new IdempotencyManager(config);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/utils/retry.ts
|
|
663
|
+
var DEFAULT_CONFIG = {
|
|
664
|
+
maxAttempts: 3,
|
|
665
|
+
baseDelay: 1e3,
|
|
666
|
+
maxDelay: 3e4,
|
|
667
|
+
backoffMultiplier: 2,
|
|
668
|
+
jitter: 0.1,
|
|
669
|
+
retryIf: isRetryableError
|
|
670
|
+
};
|
|
671
|
+
function calculateDelay(attempt, config) {
|
|
672
|
+
const exponentialDelay = config.baseDelay * Math.pow(config.backoffMultiplier, attempt);
|
|
673
|
+
const cappedDelay = Math.min(exponentialDelay, config.maxDelay);
|
|
674
|
+
const jitterRange = cappedDelay * config.jitter;
|
|
675
|
+
const jitter = Math.random() * jitterRange * 2 - jitterRange;
|
|
676
|
+
return Math.round(Math.max(0, cappedDelay + jitter));
|
|
677
|
+
}
|
|
678
|
+
function sleep(ms) {
|
|
679
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
680
|
+
}
|
|
681
|
+
function isRetryableError(error) {
|
|
682
|
+
if (!(error instanceof Error)) return false;
|
|
683
|
+
if (error.message.includes("ECONNREFUSED")) return true;
|
|
684
|
+
if (error.message.includes("ETIMEDOUT")) return true;
|
|
685
|
+
if (error.message.includes("ENOTFOUND")) return true;
|
|
686
|
+
if (error.message.includes("network")) return true;
|
|
687
|
+
if (error.message.includes("timeout")) return true;
|
|
688
|
+
if (error.message.includes("429")) return true;
|
|
689
|
+
if (error.message.includes("rate limit")) return true;
|
|
690
|
+
if (error.message.includes("500")) return true;
|
|
691
|
+
if (error.message.includes("502")) return true;
|
|
692
|
+
if (error.message.includes("503")) return true;
|
|
693
|
+
if (error.message.includes("504")) return true;
|
|
694
|
+
if ("retryable" in error && error.retryable === true) return true;
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
async function retry(operation, config = {}) {
|
|
698
|
+
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
|
699
|
+
const state = {
|
|
700
|
+
attempt: 0,
|
|
701
|
+
totalDelay: 0,
|
|
702
|
+
errors: []
|
|
703
|
+
};
|
|
704
|
+
while (state.attempt < fullConfig.maxAttempts) {
|
|
705
|
+
try {
|
|
706
|
+
return await operation();
|
|
707
|
+
} catch (error) {
|
|
708
|
+
state.errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
709
|
+
state.attempt++;
|
|
710
|
+
const shouldRetry = fullConfig.retryIf?.(error) ?? isRetryableError(error);
|
|
711
|
+
if (!shouldRetry || state.attempt >= fullConfig.maxAttempts) {
|
|
712
|
+
throw new RetryExhaustedError(
|
|
713
|
+
`Operation failed after ${state.attempt} attempts`,
|
|
714
|
+
state.errors
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
const delay = calculateDelay(state.attempt - 1, fullConfig);
|
|
718
|
+
state.totalDelay += delay;
|
|
719
|
+
fullConfig.onRetry?.(error, state.attempt, delay);
|
|
720
|
+
await sleep(delay);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
throw new RetryExhaustedError(
|
|
724
|
+
`Operation failed after ${state.attempt} attempts`,
|
|
725
|
+
state.errors
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
async function retryWithResult(operation, config = {}) {
|
|
729
|
+
try {
|
|
730
|
+
const result = await retry(operation, config);
|
|
731
|
+
return ok(result);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (error instanceof RetryExhaustedError) {
|
|
734
|
+
return err(error);
|
|
735
|
+
}
|
|
736
|
+
return err(new RetryExhaustedError("Operation failed", [
|
|
737
|
+
error instanceof Error ? error : new Error(String(error))
|
|
738
|
+
]));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
var RetryExhaustedError = class extends Error {
|
|
742
|
+
attempts;
|
|
743
|
+
errors;
|
|
744
|
+
constructor(message, errors) {
|
|
745
|
+
super(message);
|
|
746
|
+
this.name = "RetryExhaustedError";
|
|
747
|
+
this.attempts = errors.length;
|
|
748
|
+
this.errors = errors;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Get the last error
|
|
752
|
+
*/
|
|
753
|
+
get lastError() {
|
|
754
|
+
return this.errors[this.errors.length - 1];
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Get the first error
|
|
758
|
+
*/
|
|
759
|
+
get firstError() {
|
|
760
|
+
return this.errors[0];
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
var DEFAULT_CIRCUIT_CONFIG = {
|
|
764
|
+
failureThreshold: 5,
|
|
765
|
+
resetTimeout: 3e4,
|
|
766
|
+
successThreshold: 3,
|
|
767
|
+
monitorWindow: 6e4
|
|
768
|
+
};
|
|
769
|
+
var CircuitBreaker = class {
|
|
770
|
+
state = "closed";
|
|
771
|
+
failures = [];
|
|
772
|
+
successes = 0;
|
|
773
|
+
lastFailure;
|
|
774
|
+
config;
|
|
775
|
+
constructor(config = {}) {
|
|
776
|
+
this.config = { ...DEFAULT_CIRCUIT_CONFIG, ...config };
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Execute operation through circuit breaker
|
|
780
|
+
*/
|
|
781
|
+
async execute(operation) {
|
|
782
|
+
if (this.state === "open") {
|
|
783
|
+
if (this.shouldAttemptReset()) {
|
|
784
|
+
this.state = "half-open";
|
|
785
|
+
} else {
|
|
786
|
+
throw new CircuitOpenError("Circuit is open, request rejected");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const result = await operation();
|
|
791
|
+
this.onSuccess();
|
|
792
|
+
return result;
|
|
793
|
+
} catch (error) {
|
|
794
|
+
this.onFailure();
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Execute with Result type
|
|
800
|
+
*/
|
|
801
|
+
async executeWithResult(operation) {
|
|
802
|
+
try {
|
|
803
|
+
const result = await this.execute(operation);
|
|
804
|
+
return ok(result);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
return err(error);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
onSuccess() {
|
|
810
|
+
if (this.state === "half-open") {
|
|
811
|
+
this.successes++;
|
|
812
|
+
if (this.successes >= this.config.successThreshold) {
|
|
813
|
+
this.reset();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
this.cleanOldFailures();
|
|
817
|
+
}
|
|
818
|
+
onFailure() {
|
|
819
|
+
this.failures.push(/* @__PURE__ */ new Date());
|
|
820
|
+
this.lastFailure = /* @__PURE__ */ new Date();
|
|
821
|
+
this.successes = 0;
|
|
822
|
+
this.cleanOldFailures();
|
|
823
|
+
if (this.failures.length >= this.config.failureThreshold) {
|
|
824
|
+
this.state = "open";
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
shouldAttemptReset() {
|
|
828
|
+
if (!this.lastFailure) return true;
|
|
829
|
+
return Date.now() - this.lastFailure.getTime() >= this.config.resetTimeout;
|
|
830
|
+
}
|
|
831
|
+
cleanOldFailures() {
|
|
832
|
+
const cutoff = Date.now() - this.config.monitorWindow;
|
|
833
|
+
this.failures = this.failures.filter((f) => f.getTime() > cutoff);
|
|
834
|
+
}
|
|
835
|
+
reset() {
|
|
836
|
+
this.state = "closed";
|
|
837
|
+
this.failures = [];
|
|
838
|
+
this.successes = 0;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Get current circuit state
|
|
842
|
+
*/
|
|
843
|
+
getState() {
|
|
844
|
+
return this.state;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Manually reset circuit
|
|
848
|
+
*/
|
|
849
|
+
forceReset() {
|
|
850
|
+
this.reset();
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Get circuit statistics
|
|
854
|
+
*/
|
|
855
|
+
getStats() {
|
|
856
|
+
return {
|
|
857
|
+
state: this.state,
|
|
858
|
+
failures: this.failures.length,
|
|
859
|
+
successes: this.successes,
|
|
860
|
+
lastFailure: this.lastFailure
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
var CircuitOpenError = class extends Error {
|
|
865
|
+
constructor(message) {
|
|
866
|
+
super(message);
|
|
867
|
+
this.name = "CircuitOpenError";
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
function createCircuitBreaker(config) {
|
|
871
|
+
return new CircuitBreaker(config);
|
|
872
|
+
}
|
|
873
|
+
async function resilientExecute(operation, options = {}) {
|
|
874
|
+
const { retry: retryConfig, circuitBreaker } = options;
|
|
875
|
+
const wrappedOperation = async () => {
|
|
876
|
+
if (circuitBreaker) {
|
|
877
|
+
return circuitBreaker.execute(operation);
|
|
878
|
+
}
|
|
879
|
+
return operation();
|
|
880
|
+
};
|
|
881
|
+
if (retryConfig) {
|
|
882
|
+
return retry(wrappedOperation, retryConfig);
|
|
883
|
+
}
|
|
884
|
+
return wrappedOperation();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/core/errors.ts
|
|
888
|
+
var RevenueError = class extends Error {
|
|
889
|
+
code;
|
|
890
|
+
retryable;
|
|
891
|
+
metadata;
|
|
892
|
+
constructor(message, code, options = {}) {
|
|
893
|
+
super(message);
|
|
894
|
+
this.name = this.constructor.name;
|
|
895
|
+
this.code = code;
|
|
896
|
+
this.retryable = options.retryable ?? false;
|
|
897
|
+
this.metadata = options.metadata ?? {};
|
|
898
|
+
Error.captureStackTrace(this, this.constructor);
|
|
899
|
+
}
|
|
900
|
+
toJSON() {
|
|
901
|
+
return {
|
|
902
|
+
name: this.name,
|
|
903
|
+
message: this.message,
|
|
904
|
+
code: this.code,
|
|
905
|
+
retryable: this.retryable,
|
|
906
|
+
metadata: this.metadata
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
var ConfigurationError = class extends RevenueError {
|
|
911
|
+
constructor(message, metadata = {}) {
|
|
912
|
+
super(message, "CONFIGURATION_ERROR", { retryable: false, metadata });
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
var ModelNotRegisteredError = class extends ConfigurationError {
|
|
916
|
+
constructor(modelName) {
|
|
917
|
+
super(
|
|
918
|
+
`Model "${modelName}" is not registered. Register it via createRevenue({ models: { ${modelName}: ... } })`,
|
|
919
|
+
{ modelName }
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
var ProviderError = class extends RevenueError {
|
|
924
|
+
constructor(message, code, options = {}) {
|
|
925
|
+
super(message, code, options);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
var ProviderNotFoundError = class extends ProviderError {
|
|
929
|
+
constructor(providerName, availableProviders = []) {
|
|
930
|
+
super(
|
|
931
|
+
`Payment provider "${providerName}" not found. Available: ${availableProviders.join(", ")}`,
|
|
932
|
+
"PROVIDER_NOT_FOUND",
|
|
933
|
+
{ retryable: false, metadata: { providerName, availableProviders } }
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
var ProviderCapabilityError = class extends ProviderError {
|
|
938
|
+
constructor(providerName, capability) {
|
|
939
|
+
super(
|
|
940
|
+
`Provider "${providerName}" does not support ${capability}`,
|
|
941
|
+
"PROVIDER_CAPABILITY_NOT_SUPPORTED",
|
|
942
|
+
{ retryable: false, metadata: { providerName, capability } }
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
var PaymentIntentCreationError = class extends ProviderError {
|
|
947
|
+
constructor(providerName, originalError) {
|
|
948
|
+
super(
|
|
949
|
+
`Failed to create payment intent with provider "${providerName}": ${originalError.message}`,
|
|
950
|
+
"PAYMENT_INTENT_CREATION_FAILED",
|
|
951
|
+
{ retryable: true, metadata: { providerName, originalError: originalError.message } }
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
var PaymentVerificationError = class extends ProviderError {
|
|
956
|
+
constructor(paymentIntentId, reason) {
|
|
957
|
+
super(
|
|
958
|
+
`Payment verification failed for intent "${paymentIntentId}": ${reason}`,
|
|
959
|
+
"PAYMENT_VERIFICATION_FAILED",
|
|
960
|
+
{ retryable: true, metadata: { paymentIntentId, reason } }
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
var NotFoundError = class extends RevenueError {
|
|
965
|
+
constructor(message, code, metadata = {}) {
|
|
966
|
+
super(message, code, { retryable: false, metadata });
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
var SubscriptionNotFoundError = class extends NotFoundError {
|
|
970
|
+
constructor(subscriptionId) {
|
|
971
|
+
super(
|
|
972
|
+
`Subscription not found: ${subscriptionId}`,
|
|
973
|
+
"SUBSCRIPTION_NOT_FOUND",
|
|
974
|
+
{ subscriptionId }
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
var TransactionNotFoundError = class extends NotFoundError {
|
|
979
|
+
constructor(transactionId) {
|
|
980
|
+
super(
|
|
981
|
+
`Transaction not found: ${transactionId}`,
|
|
982
|
+
"TRANSACTION_NOT_FOUND",
|
|
983
|
+
{ transactionId }
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
var ValidationError = class extends RevenueError {
|
|
988
|
+
constructor(message, metadata = {}) {
|
|
989
|
+
super(message, "VALIDATION_ERROR", { retryable: false, metadata });
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
var InvalidAmountError = class extends ValidationError {
|
|
993
|
+
constructor(amount, message) {
|
|
994
|
+
super(
|
|
995
|
+
message ?? `Invalid amount: ${amount}. Amount must be non-negative`,
|
|
996
|
+
{ amount }
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
var MissingRequiredFieldError = class extends ValidationError {
|
|
1001
|
+
constructor(fieldName) {
|
|
1002
|
+
super(`Missing required field: ${fieldName}`, { fieldName });
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
var StateError = class extends RevenueError {
|
|
1006
|
+
constructor(message, code, metadata = {}) {
|
|
1007
|
+
super(message, code, { retryable: false, metadata });
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
var AlreadyVerifiedError = class extends StateError {
|
|
1011
|
+
constructor(transactionId) {
|
|
1012
|
+
super(
|
|
1013
|
+
`Transaction ${transactionId} is already verified`,
|
|
1014
|
+
"ALREADY_VERIFIED",
|
|
1015
|
+
{ transactionId }
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
var InvalidStateTransitionError = class extends StateError {
|
|
1020
|
+
constructor(resourceType, resourceId, fromState, toState) {
|
|
1021
|
+
super(
|
|
1022
|
+
`Invalid state transition for ${resourceType} ${resourceId}: ${fromState} \u2192 ${toState}`,
|
|
1023
|
+
"INVALID_STATE_TRANSITION",
|
|
1024
|
+
{ resourceType, resourceId, fromState, toState }
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
var SubscriptionNotActiveError = class extends StateError {
|
|
1029
|
+
constructor(subscriptionId, message) {
|
|
1030
|
+
super(
|
|
1031
|
+
message ?? `Subscription ${subscriptionId} is not active`,
|
|
1032
|
+
"SUBSCRIPTION_NOT_ACTIVE",
|
|
1033
|
+
{ subscriptionId }
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
var OperationError = class extends RevenueError {
|
|
1038
|
+
constructor(message, code, options = {}) {
|
|
1039
|
+
super(message, code, options);
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
var RefundNotSupportedError = class extends OperationError {
|
|
1043
|
+
constructor(providerName) {
|
|
1044
|
+
super(
|
|
1045
|
+
`Refunds are not supported by provider "${providerName}"`,
|
|
1046
|
+
"REFUND_NOT_SUPPORTED",
|
|
1047
|
+
{ retryable: false, metadata: { providerName } }
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
var RefundError = class extends OperationError {
|
|
1052
|
+
constructor(transactionId, reason) {
|
|
1053
|
+
super(
|
|
1054
|
+
`Refund failed for transaction ${transactionId}: ${reason}`,
|
|
1055
|
+
"REFUND_FAILED",
|
|
1056
|
+
{ retryable: true, metadata: { transactionId, reason } }
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
var ERROR_CODES = {
|
|
1061
|
+
// Configuration
|
|
1062
|
+
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
|
|
1063
|
+
MODEL_NOT_REGISTERED: "MODEL_NOT_REGISTERED",
|
|
1064
|
+
// Provider
|
|
1065
|
+
PROVIDER_NOT_FOUND: "PROVIDER_NOT_FOUND",
|
|
1066
|
+
PROVIDER_CAPABILITY_NOT_SUPPORTED: "PROVIDER_CAPABILITY_NOT_SUPPORTED",
|
|
1067
|
+
PAYMENT_INTENT_CREATION_FAILED: "PAYMENT_INTENT_CREATION_FAILED",
|
|
1068
|
+
PAYMENT_VERIFICATION_FAILED: "PAYMENT_VERIFICATION_FAILED",
|
|
1069
|
+
// Not Found
|
|
1070
|
+
SUBSCRIPTION_NOT_FOUND: "SUBSCRIPTION_NOT_FOUND",
|
|
1071
|
+
TRANSACTION_NOT_FOUND: "TRANSACTION_NOT_FOUND",
|
|
1072
|
+
// Validation
|
|
1073
|
+
VALIDATION_ERROR: "VALIDATION_ERROR",
|
|
1074
|
+
INVALID_AMOUNT: "INVALID_AMOUNT",
|
|
1075
|
+
MISSING_REQUIRED_FIELD: "MISSING_REQUIRED_FIELD",
|
|
1076
|
+
// State
|
|
1077
|
+
ALREADY_VERIFIED: "ALREADY_VERIFIED",
|
|
1078
|
+
INVALID_STATE_TRANSITION: "INVALID_STATE_TRANSITION",
|
|
1079
|
+
SUBSCRIPTION_NOT_ACTIVE: "SUBSCRIPTION_NOT_ACTIVE",
|
|
1080
|
+
// Operations
|
|
1081
|
+
REFUND_NOT_SUPPORTED: "REFUND_NOT_SUPPORTED",
|
|
1082
|
+
REFUND_FAILED: "REFUND_FAILED"
|
|
1083
|
+
};
|
|
1084
|
+
function isRetryable(error) {
|
|
1085
|
+
return error instanceof RevenueError && error.retryable;
|
|
1086
|
+
}
|
|
1087
|
+
function isRevenueError(error) {
|
|
1088
|
+
return error instanceof RevenueError;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// src/utils/hooks.ts
|
|
1092
|
+
function triggerHook(hooks, event, data, logger2) {
|
|
1093
|
+
const handlers = hooks[event] ?? [];
|
|
1094
|
+
if (handlers.length === 0) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
Promise.all(
|
|
1098
|
+
handlers.map(
|
|
1099
|
+
(handler) => Promise.resolve(handler(data)).catch((error) => {
|
|
1100
|
+
logger2.error(`Hook "${event}" failed:`, {
|
|
1101
|
+
error: error.message,
|
|
1102
|
+
stack: error.stack,
|
|
1103
|
+
event,
|
|
1104
|
+
// Don't log full data (could be huge)
|
|
1105
|
+
dataKeys: Object.keys(data)
|
|
1106
|
+
});
|
|
1107
|
+
})
|
|
1108
|
+
)
|
|
1109
|
+
).catch(() => {
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// src/enums/transaction.enums.ts
|
|
1114
|
+
var TRANSACTION_TYPE = {
|
|
1115
|
+
INCOME: "income",
|
|
1116
|
+
EXPENSE: "expense"
|
|
1117
|
+
};
|
|
1118
|
+
var TRANSACTION_TYPE_VALUES = Object.values(TRANSACTION_TYPE);
|
|
1119
|
+
var TRANSACTION_STATUS = {
|
|
1120
|
+
PENDING: "pending",
|
|
1121
|
+
PAYMENT_INITIATED: "payment_initiated",
|
|
1122
|
+
PROCESSING: "processing",
|
|
1123
|
+
REQUIRES_ACTION: "requires_action",
|
|
1124
|
+
VERIFIED: "verified",
|
|
1125
|
+
COMPLETED: "completed",
|
|
1126
|
+
FAILED: "failed",
|
|
1127
|
+
CANCELLED: "cancelled",
|
|
1128
|
+
EXPIRED: "expired",
|
|
1129
|
+
REFUNDED: "refunded",
|
|
1130
|
+
PARTIALLY_REFUNDED: "partially_refunded"
|
|
1131
|
+
};
|
|
1132
|
+
var TRANSACTION_STATUS_VALUES = Object.values(TRANSACTION_STATUS);
|
|
1133
|
+
var LIBRARY_CATEGORIES = {
|
|
1134
|
+
SUBSCRIPTION: "subscription",
|
|
1135
|
+
PURCHASE: "purchase"
|
|
1136
|
+
};
|
|
1137
|
+
var LIBRARY_CATEGORY_VALUES = Object.values(LIBRARY_CATEGORIES);
|
|
1138
|
+
|
|
1139
|
+
// src/utils/category-resolver.ts
|
|
1140
|
+
function resolveCategory(entity, monetizationType, categoryMappings = {}) {
|
|
1141
|
+
if (entity && categoryMappings[entity]) {
|
|
1142
|
+
return categoryMappings[entity];
|
|
1143
|
+
}
|
|
1144
|
+
switch (monetizationType) {
|
|
1145
|
+
case "subscription":
|
|
1146
|
+
return LIBRARY_CATEGORIES.SUBSCRIPTION;
|
|
1147
|
+
// 'subscription'
|
|
1148
|
+
case "purchase":
|
|
1149
|
+
return LIBRARY_CATEGORIES.PURCHASE;
|
|
1150
|
+
// 'purchase'
|
|
1151
|
+
default:
|
|
1152
|
+
return LIBRARY_CATEGORIES.SUBSCRIPTION;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function isCategoryValid(category, allowedCategories = []) {
|
|
1156
|
+
return allowedCategories.includes(category);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/utils/commission.ts
|
|
1160
|
+
function calculateCommission(amount, commissionRate, gatewayFeeRate = 0) {
|
|
1161
|
+
if (!commissionRate || commissionRate <= 0) {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
if (amount < 0) {
|
|
1165
|
+
throw new Error("Transaction amount cannot be negative");
|
|
1166
|
+
}
|
|
1167
|
+
if (commissionRate < 0 || commissionRate > 1) {
|
|
1168
|
+
throw new Error("Commission rate must be between 0 and 1");
|
|
1169
|
+
}
|
|
1170
|
+
if (gatewayFeeRate < 0 || gatewayFeeRate > 1) {
|
|
1171
|
+
throw new Error("Gateway fee rate must be between 0 and 1");
|
|
1172
|
+
}
|
|
1173
|
+
const grossAmount = Math.round(amount * commissionRate * 100) / 100;
|
|
1174
|
+
const gatewayFeeAmount = Math.round(amount * gatewayFeeRate * 100) / 100;
|
|
1175
|
+
const netAmount = Math.max(0, Math.round((grossAmount - gatewayFeeAmount) * 100) / 100);
|
|
1176
|
+
return {
|
|
1177
|
+
rate: commissionRate,
|
|
1178
|
+
grossAmount,
|
|
1179
|
+
gatewayFeeRate,
|
|
1180
|
+
gatewayFeeAmount,
|
|
1181
|
+
netAmount,
|
|
1182
|
+
status: "pending"
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
function reverseCommission(originalCommission, originalAmount, refundAmount) {
|
|
1186
|
+
if (!originalCommission?.netAmount) {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
const refundRatio = refundAmount / originalAmount;
|
|
1190
|
+
const reversedNetAmount = Math.round(originalCommission.netAmount * refundRatio * 100) / 100;
|
|
1191
|
+
const reversedGrossAmount = Math.round(originalCommission.grossAmount * refundRatio * 100) / 100;
|
|
1192
|
+
const reversedGatewayFee = Math.round(originalCommission.gatewayFeeAmount * refundRatio * 100) / 100;
|
|
1193
|
+
return {
|
|
1194
|
+
rate: originalCommission.rate,
|
|
1195
|
+
grossAmount: reversedGrossAmount,
|
|
1196
|
+
gatewayFeeRate: originalCommission.gatewayFeeRate,
|
|
1197
|
+
gatewayFeeAmount: reversedGatewayFee,
|
|
1198
|
+
netAmount: reversedNetAmount,
|
|
1199
|
+
status: "waived"
|
|
1200
|
+
// Commission waived due to refund
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/enums/monetization.enums.ts
|
|
1205
|
+
var MONETIZATION_TYPES = {
|
|
1206
|
+
FREE: "free",
|
|
1207
|
+
PURCHASE: "purchase",
|
|
1208
|
+
SUBSCRIPTION: "subscription"
|
|
1209
|
+
};
|
|
1210
|
+
var MONETIZATION_TYPE_VALUES = Object.values(MONETIZATION_TYPES);
|
|
1211
|
+
|
|
1212
|
+
// src/services/monetization.service.ts
|
|
1213
|
+
var MonetizationService = class {
|
|
1214
|
+
models;
|
|
1215
|
+
providers;
|
|
1216
|
+
config;
|
|
1217
|
+
hooks;
|
|
1218
|
+
logger;
|
|
1219
|
+
constructor(container) {
|
|
1220
|
+
this.models = container.get("models");
|
|
1221
|
+
this.providers = container.get("providers");
|
|
1222
|
+
this.config = container.get("config");
|
|
1223
|
+
this.hooks = container.get("hooks");
|
|
1224
|
+
this.logger = container.get("logger");
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Create a new monetization (purchase, subscription, or free item)
|
|
1228
|
+
*
|
|
1229
|
+
* @param params - Monetization parameters
|
|
1230
|
+
*
|
|
1231
|
+
* @example
|
|
1232
|
+
* // One-time purchase
|
|
1233
|
+
* await revenue.monetization.create({
|
|
1234
|
+
* data: {
|
|
1235
|
+
* organizationId: '...',
|
|
1236
|
+
* customerId: '...',
|
|
1237
|
+
* referenceId: order._id,
|
|
1238
|
+
* referenceModel: 'Order',
|
|
1239
|
+
* },
|
|
1240
|
+
* planKey: 'one_time',
|
|
1241
|
+
* monetizationType: 'purchase',
|
|
1242
|
+
* gateway: 'bkash',
|
|
1243
|
+
* amount: 1500,
|
|
1244
|
+
* });
|
|
1245
|
+
*
|
|
1246
|
+
* // Recurring subscription
|
|
1247
|
+
* await revenue.monetization.create({
|
|
1248
|
+
* data: {
|
|
1249
|
+
* organizationId: '...',
|
|
1250
|
+
* customerId: '...',
|
|
1251
|
+
* referenceId: subscription._id,
|
|
1252
|
+
* referenceModel: 'Subscription',
|
|
1253
|
+
* },
|
|
1254
|
+
* planKey: 'monthly',
|
|
1255
|
+
* monetizationType: 'subscription',
|
|
1256
|
+
* gateway: 'stripe',
|
|
1257
|
+
* amount: 2000,
|
|
1258
|
+
* });
|
|
1259
|
+
*
|
|
1260
|
+
* @returns Result with subscription, transaction, and paymentIntent
|
|
1261
|
+
*/
|
|
1262
|
+
async create(params) {
|
|
1263
|
+
const {
|
|
1264
|
+
data,
|
|
1265
|
+
planKey,
|
|
1266
|
+
amount,
|
|
1267
|
+
currency = "BDT",
|
|
1268
|
+
gateway = "manual",
|
|
1269
|
+
entity = null,
|
|
1270
|
+
monetizationType = MONETIZATION_TYPES.SUBSCRIPTION,
|
|
1271
|
+
paymentData,
|
|
1272
|
+
metadata = {},
|
|
1273
|
+
idempotencyKey = null
|
|
1274
|
+
} = params;
|
|
1275
|
+
if (!planKey) {
|
|
1276
|
+
throw new MissingRequiredFieldError("planKey");
|
|
1277
|
+
}
|
|
1278
|
+
if (amount < 0) {
|
|
1279
|
+
throw new InvalidAmountError(amount);
|
|
1280
|
+
}
|
|
1281
|
+
const isFree = amount === 0;
|
|
1282
|
+
const provider = this.providers[gateway];
|
|
1283
|
+
if (!provider) {
|
|
1284
|
+
throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
1285
|
+
}
|
|
1286
|
+
let paymentIntent = null;
|
|
1287
|
+
let transaction = null;
|
|
1288
|
+
if (!isFree) {
|
|
1289
|
+
try {
|
|
1290
|
+
paymentIntent = await provider.createIntent({
|
|
1291
|
+
amount,
|
|
1292
|
+
currency,
|
|
1293
|
+
metadata: {
|
|
1294
|
+
...metadata,
|
|
1295
|
+
type: "subscription",
|
|
1296
|
+
planKey
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
1301
|
+
}
|
|
1302
|
+
const category = resolveCategory(entity, monetizationType, this.config.categoryMappings);
|
|
1303
|
+
const transactionType = this.config.transactionTypeMapping?.subscription ?? this.config.transactionTypeMapping?.[monetizationType] ?? TRANSACTION_TYPE.INCOME;
|
|
1304
|
+
const commissionRate = this.config.commissionRates?.[category] ?? 0;
|
|
1305
|
+
const gatewayFeeRate = this.config.gatewayFeeRates?.[gateway] ?? 0;
|
|
1306
|
+
const commission = calculateCommission(amount, commissionRate, gatewayFeeRate);
|
|
1307
|
+
const TransactionModel = this.models.Transaction;
|
|
1308
|
+
transaction = await TransactionModel.create({
|
|
1309
|
+
organizationId: data.organizationId,
|
|
1310
|
+
customerId: data.customerId ?? null,
|
|
1311
|
+
amount,
|
|
1312
|
+
currency,
|
|
1313
|
+
category,
|
|
1314
|
+
type: transactionType,
|
|
1315
|
+
method: paymentData?.method ?? "manual",
|
|
1316
|
+
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
1317
|
+
gateway: {
|
|
1318
|
+
type: gateway,
|
|
1319
|
+
sessionId: paymentIntent.sessionId,
|
|
1320
|
+
paymentIntentId: paymentIntent.paymentIntentId,
|
|
1321
|
+
provider: paymentIntent.provider,
|
|
1322
|
+
metadata: paymentIntent.metadata
|
|
1323
|
+
},
|
|
1324
|
+
paymentDetails: {
|
|
1325
|
+
provider: gateway,
|
|
1326
|
+
...paymentData
|
|
1327
|
+
},
|
|
1328
|
+
...commission && { commission },
|
|
1329
|
+
// Only include if commission exists
|
|
1330
|
+
// Polymorphic reference (top-level, not metadata)
|
|
1331
|
+
...data.referenceId && { referenceId: data.referenceId },
|
|
1332
|
+
...data.referenceModel && { referenceModel: data.referenceModel },
|
|
1333
|
+
metadata: {
|
|
1334
|
+
...metadata,
|
|
1335
|
+
planKey,
|
|
1336
|
+
entity,
|
|
1337
|
+
monetizationType,
|
|
1338
|
+
paymentIntentId: paymentIntent.id
|
|
1339
|
+
},
|
|
1340
|
+
idempotencyKey: idempotencyKey ?? `sub_${nanoid(16)}`
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
let subscription = null;
|
|
1344
|
+
if (this.models.Subscription) {
|
|
1345
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1346
|
+
const subscriptionData = {
|
|
1347
|
+
organizationId: data.organizationId,
|
|
1348
|
+
customerId: data.customerId ?? null,
|
|
1349
|
+
planKey,
|
|
1350
|
+
amount,
|
|
1351
|
+
currency,
|
|
1352
|
+
status: isFree ? "active" : "pending",
|
|
1353
|
+
isActive: isFree,
|
|
1354
|
+
gateway,
|
|
1355
|
+
transactionId: transaction?._id ?? null,
|
|
1356
|
+
paymentIntentId: paymentIntent?.id ?? null,
|
|
1357
|
+
metadata: {
|
|
1358
|
+
...metadata,
|
|
1359
|
+
isFree,
|
|
1360
|
+
entity,
|
|
1361
|
+
monetizationType
|
|
1362
|
+
},
|
|
1363
|
+
...data
|
|
1364
|
+
};
|
|
1365
|
+
delete subscriptionData.referenceId;
|
|
1366
|
+
delete subscriptionData.referenceModel;
|
|
1367
|
+
subscription = await SubscriptionModel.create(subscriptionData);
|
|
1368
|
+
}
|
|
1369
|
+
const eventData = {
|
|
1370
|
+
subscription,
|
|
1371
|
+
transaction,
|
|
1372
|
+
paymentIntent,
|
|
1373
|
+
isFree,
|
|
1374
|
+
monetizationType
|
|
1375
|
+
};
|
|
1376
|
+
if (monetizationType === MONETIZATION_TYPES.PURCHASE) {
|
|
1377
|
+
this._triggerHook("purchase.created", eventData);
|
|
1378
|
+
} else if (monetizationType === MONETIZATION_TYPES.SUBSCRIPTION) {
|
|
1379
|
+
this._triggerHook("subscription.created", eventData);
|
|
1380
|
+
} else if (monetizationType === MONETIZATION_TYPES.FREE) {
|
|
1381
|
+
this._triggerHook("free.created", eventData);
|
|
1382
|
+
}
|
|
1383
|
+
this._triggerHook("monetization.created", eventData);
|
|
1384
|
+
return {
|
|
1385
|
+
subscription,
|
|
1386
|
+
transaction,
|
|
1387
|
+
paymentIntent
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Activate subscription after payment verification
|
|
1392
|
+
*
|
|
1393
|
+
* @param subscriptionId - Subscription ID or transaction ID
|
|
1394
|
+
* @param options - Activation options
|
|
1395
|
+
* @returns Updated subscription
|
|
1396
|
+
*/
|
|
1397
|
+
async activate(subscriptionId, options = {}) {
|
|
1398
|
+
const { timestamp = /* @__PURE__ */ new Date() } = options;
|
|
1399
|
+
if (!this.models.Subscription) {
|
|
1400
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1401
|
+
}
|
|
1402
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1403
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
1404
|
+
if (!subscription) {
|
|
1405
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
1406
|
+
}
|
|
1407
|
+
if (subscription.isActive) {
|
|
1408
|
+
this.logger.warn("Subscription already active", { subscriptionId });
|
|
1409
|
+
return subscription;
|
|
1410
|
+
}
|
|
1411
|
+
const periodEnd = this._calculatePeriodEnd(subscription.planKey, timestamp);
|
|
1412
|
+
subscription.isActive = true;
|
|
1413
|
+
subscription.status = "active";
|
|
1414
|
+
subscription.startDate = timestamp;
|
|
1415
|
+
subscription.endDate = periodEnd;
|
|
1416
|
+
subscription.activatedAt = timestamp;
|
|
1417
|
+
await subscription.save();
|
|
1418
|
+
this._triggerHook("subscription.activated", {
|
|
1419
|
+
subscription,
|
|
1420
|
+
activatedAt: timestamp
|
|
1421
|
+
});
|
|
1422
|
+
return subscription;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Renew subscription
|
|
1426
|
+
*
|
|
1427
|
+
* @param subscriptionId - Subscription ID
|
|
1428
|
+
* @param params - Renewal parameters
|
|
1429
|
+
* @returns { subscription, transaction, paymentIntent }
|
|
1430
|
+
*/
|
|
1431
|
+
async renew(subscriptionId, params = {}) {
|
|
1432
|
+
const {
|
|
1433
|
+
gateway = "manual",
|
|
1434
|
+
entity = null,
|
|
1435
|
+
paymentData,
|
|
1436
|
+
metadata = {},
|
|
1437
|
+
idempotencyKey = null
|
|
1438
|
+
} = params;
|
|
1439
|
+
if (!this.models.Subscription) {
|
|
1440
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1441
|
+
}
|
|
1442
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1443
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
1444
|
+
if (!subscription) {
|
|
1445
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
1446
|
+
}
|
|
1447
|
+
if (subscription.amount === 0) {
|
|
1448
|
+
throw new InvalidAmountError(0, "Free subscriptions do not require renewal");
|
|
1449
|
+
}
|
|
1450
|
+
const provider = this.providers[gateway];
|
|
1451
|
+
if (!provider) {
|
|
1452
|
+
throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
1453
|
+
}
|
|
1454
|
+
let paymentIntent = null;
|
|
1455
|
+
try {
|
|
1456
|
+
paymentIntent = await provider.createIntent({
|
|
1457
|
+
amount: subscription.amount,
|
|
1458
|
+
currency: subscription.currency ?? "BDT",
|
|
1459
|
+
metadata: {
|
|
1460
|
+
...metadata,
|
|
1461
|
+
type: "subscription_renewal",
|
|
1462
|
+
subscriptionId: subscription._id.toString()
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
this.logger.error("Failed to create payment intent for renewal:", error);
|
|
1467
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
1468
|
+
}
|
|
1469
|
+
const effectiveEntity = entity ?? subscription.metadata?.entity;
|
|
1470
|
+
const effectiveMonetizationType = subscription.metadata?.monetizationType ?? MONETIZATION_TYPES.SUBSCRIPTION;
|
|
1471
|
+
const category = resolveCategory(effectiveEntity, effectiveMonetizationType, this.config.categoryMappings);
|
|
1472
|
+
const transactionType = this.config.transactionTypeMapping?.subscription_renewal ?? this.config.transactionTypeMapping?.subscription ?? this.config.transactionTypeMapping?.[effectiveMonetizationType] ?? TRANSACTION_TYPE.INCOME;
|
|
1473
|
+
const commissionRate = this.config.commissionRates?.[category] ?? 0;
|
|
1474
|
+
const gatewayFeeRate = this.config.gatewayFeeRates?.[gateway] ?? 0;
|
|
1475
|
+
const commission = calculateCommission(subscription.amount, commissionRate, gatewayFeeRate);
|
|
1476
|
+
const TransactionModel = this.models.Transaction;
|
|
1477
|
+
const transaction = await TransactionModel.create({
|
|
1478
|
+
organizationId: subscription.organizationId,
|
|
1479
|
+
customerId: subscription.customerId,
|
|
1480
|
+
amount: subscription.amount,
|
|
1481
|
+
currency: subscription.currency ?? "BDT",
|
|
1482
|
+
category,
|
|
1483
|
+
type: transactionType,
|
|
1484
|
+
method: paymentData?.method ?? "manual",
|
|
1485
|
+
status: paymentIntent.status === "succeeded" ? "verified" : "pending",
|
|
1486
|
+
gateway: {
|
|
1487
|
+
type: gateway,
|
|
1488
|
+
sessionId: paymentIntent.sessionId,
|
|
1489
|
+
paymentIntentId: paymentIntent.paymentIntentId,
|
|
1490
|
+
provider: paymentIntent.provider,
|
|
1491
|
+
metadata: paymentIntent.metadata
|
|
1492
|
+
},
|
|
1493
|
+
paymentDetails: {
|
|
1494
|
+
provider: gateway,
|
|
1495
|
+
...paymentData
|
|
1496
|
+
},
|
|
1497
|
+
...commission && { commission },
|
|
1498
|
+
// Only include if commission exists
|
|
1499
|
+
// Polymorphic reference to subscription
|
|
1500
|
+
referenceId: subscription._id,
|
|
1501
|
+
referenceModel: "Subscription",
|
|
1502
|
+
metadata: {
|
|
1503
|
+
...metadata,
|
|
1504
|
+
subscriptionId: subscription._id.toString(),
|
|
1505
|
+
// Keep for backward compat
|
|
1506
|
+
entity: effectiveEntity,
|
|
1507
|
+
monetizationType: effectiveMonetizationType,
|
|
1508
|
+
isRenewal: true,
|
|
1509
|
+
paymentIntentId: paymentIntent.id
|
|
1510
|
+
},
|
|
1511
|
+
idempotencyKey: idempotencyKey ?? `renewal_${nanoid(16)}`
|
|
1512
|
+
});
|
|
1513
|
+
subscription.status = "pending_renewal";
|
|
1514
|
+
subscription.renewalTransactionId = transaction._id;
|
|
1515
|
+
subscription.renewalCount = (subscription.renewalCount ?? 0) + 1;
|
|
1516
|
+
await subscription.save();
|
|
1517
|
+
this._triggerHook("subscription.renewed", {
|
|
1518
|
+
subscription,
|
|
1519
|
+
transaction,
|
|
1520
|
+
paymentIntent,
|
|
1521
|
+
renewalCount: subscription.renewalCount
|
|
1522
|
+
});
|
|
1523
|
+
return {
|
|
1524
|
+
subscription,
|
|
1525
|
+
transaction,
|
|
1526
|
+
paymentIntent
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Cancel subscription
|
|
1531
|
+
*
|
|
1532
|
+
* @param subscriptionId - Subscription ID
|
|
1533
|
+
* @param options - Cancellation options
|
|
1534
|
+
* @returns Updated subscription
|
|
1535
|
+
*/
|
|
1536
|
+
async cancel(subscriptionId, options = {}) {
|
|
1537
|
+
const { immediate = false, reason = null } = options;
|
|
1538
|
+
if (!this.models.Subscription) {
|
|
1539
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1540
|
+
}
|
|
1541
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1542
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
1543
|
+
if (!subscription) {
|
|
1544
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
1545
|
+
}
|
|
1546
|
+
const now = /* @__PURE__ */ new Date();
|
|
1547
|
+
if (immediate) {
|
|
1548
|
+
subscription.isActive = false;
|
|
1549
|
+
subscription.status = "cancelled";
|
|
1550
|
+
subscription.canceledAt = now;
|
|
1551
|
+
subscription.cancellationReason = reason;
|
|
1552
|
+
} else {
|
|
1553
|
+
subscription.cancelAt = subscription.endDate ?? now;
|
|
1554
|
+
subscription.cancellationReason = reason;
|
|
1555
|
+
}
|
|
1556
|
+
await subscription.save();
|
|
1557
|
+
this._triggerHook("subscription.cancelled", {
|
|
1558
|
+
subscription,
|
|
1559
|
+
immediate,
|
|
1560
|
+
reason,
|
|
1561
|
+
canceledAt: immediate ? now : subscription.cancelAt
|
|
1562
|
+
});
|
|
1563
|
+
return subscription;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Pause subscription
|
|
1567
|
+
*
|
|
1568
|
+
* @param subscriptionId - Subscription ID
|
|
1569
|
+
* @param options - Pause options
|
|
1570
|
+
* @returns Updated subscription
|
|
1571
|
+
*/
|
|
1572
|
+
async pause(subscriptionId, options = {}) {
|
|
1573
|
+
const { reason = null } = options;
|
|
1574
|
+
if (!this.models.Subscription) {
|
|
1575
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1576
|
+
}
|
|
1577
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1578
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
1579
|
+
if (!subscription) {
|
|
1580
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
1581
|
+
}
|
|
1582
|
+
if (!subscription.isActive) {
|
|
1583
|
+
throw new SubscriptionNotActiveError(subscriptionId, "Only active subscriptions can be paused");
|
|
1584
|
+
}
|
|
1585
|
+
const pausedAt = /* @__PURE__ */ new Date();
|
|
1586
|
+
subscription.isActive = false;
|
|
1587
|
+
subscription.status = "paused";
|
|
1588
|
+
subscription.pausedAt = pausedAt;
|
|
1589
|
+
subscription.pauseReason = reason;
|
|
1590
|
+
await subscription.save();
|
|
1591
|
+
this._triggerHook("subscription.paused", {
|
|
1592
|
+
subscription,
|
|
1593
|
+
reason,
|
|
1594
|
+
pausedAt
|
|
1595
|
+
});
|
|
1596
|
+
return subscription;
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Resume subscription
|
|
1600
|
+
*
|
|
1601
|
+
* @param subscriptionId - Subscription ID
|
|
1602
|
+
* @param options - Resume options
|
|
1603
|
+
* @returns Updated subscription
|
|
1604
|
+
*/
|
|
1605
|
+
async resume(subscriptionId, options = {}) {
|
|
1606
|
+
const { extendPeriod = false } = options;
|
|
1607
|
+
if (!this.models.Subscription) {
|
|
1608
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1609
|
+
}
|
|
1610
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1611
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
1612
|
+
if (!subscription) {
|
|
1613
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
1614
|
+
}
|
|
1615
|
+
if (!subscription.pausedAt) {
|
|
1616
|
+
throw new InvalidStateTransitionError(
|
|
1617
|
+
"resume",
|
|
1618
|
+
"paused",
|
|
1619
|
+
subscription.status,
|
|
1620
|
+
"Only paused subscriptions can be resumed"
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
const now = /* @__PURE__ */ new Date();
|
|
1624
|
+
const pausedAt = new Date(subscription.pausedAt);
|
|
1625
|
+
const pauseDuration = now.getTime() - pausedAt.getTime();
|
|
1626
|
+
subscription.isActive = true;
|
|
1627
|
+
subscription.status = "active";
|
|
1628
|
+
subscription.pausedAt = null;
|
|
1629
|
+
subscription.pauseReason = null;
|
|
1630
|
+
if (extendPeriod && subscription.endDate) {
|
|
1631
|
+
const currentEnd = new Date(subscription.endDate);
|
|
1632
|
+
subscription.endDate = new Date(currentEnd.getTime() + pauseDuration);
|
|
1633
|
+
}
|
|
1634
|
+
await subscription.save();
|
|
1635
|
+
this._triggerHook("subscription.resumed", {
|
|
1636
|
+
subscription,
|
|
1637
|
+
extendPeriod,
|
|
1638
|
+
pauseDuration,
|
|
1639
|
+
resumedAt: now
|
|
1640
|
+
});
|
|
1641
|
+
return subscription;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* List subscriptions with filters
|
|
1645
|
+
*
|
|
1646
|
+
* @param filters - Query filters
|
|
1647
|
+
* @param options - Query options (limit, skip, sort)
|
|
1648
|
+
* @returns Subscriptions
|
|
1649
|
+
*/
|
|
1650
|
+
async list(filters = {}, options = {}) {
|
|
1651
|
+
if (!this.models.Subscription) {
|
|
1652
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1653
|
+
}
|
|
1654
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1655
|
+
const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
|
|
1656
|
+
const subscriptions = await SubscriptionModel.find(filters).limit(limit).skip(skip).sort(sort);
|
|
1657
|
+
return subscriptions;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Get subscription by ID
|
|
1661
|
+
*
|
|
1662
|
+
* @param subscriptionId - Subscription ID
|
|
1663
|
+
* @returns Subscription
|
|
1664
|
+
*/
|
|
1665
|
+
async get(subscriptionId) {
|
|
1666
|
+
if (!this.models.Subscription) {
|
|
1667
|
+
throw new ModelNotRegisteredError("Subscription");
|
|
1668
|
+
}
|
|
1669
|
+
const SubscriptionModel = this.models.Subscription;
|
|
1670
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
1671
|
+
if (!subscription) {
|
|
1672
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
1673
|
+
}
|
|
1674
|
+
return subscription;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Calculate period end date based on plan key
|
|
1678
|
+
* @private
|
|
1679
|
+
*/
|
|
1680
|
+
_calculatePeriodEnd(planKey, startDate = /* @__PURE__ */ new Date()) {
|
|
1681
|
+
const start = new Date(startDate);
|
|
1682
|
+
const end = new Date(start);
|
|
1683
|
+
switch (planKey) {
|
|
1684
|
+
case "monthly":
|
|
1685
|
+
end.setMonth(end.getMonth() + 1);
|
|
1686
|
+
break;
|
|
1687
|
+
case "quarterly":
|
|
1688
|
+
end.setMonth(end.getMonth() + 3);
|
|
1689
|
+
break;
|
|
1690
|
+
case "yearly":
|
|
1691
|
+
end.setFullYear(end.getFullYear() + 1);
|
|
1692
|
+
break;
|
|
1693
|
+
default:
|
|
1694
|
+
end.setDate(end.getDate() + 30);
|
|
1695
|
+
}
|
|
1696
|
+
return end;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Trigger event hook (fire-and-forget, non-blocking)
|
|
1700
|
+
* @private
|
|
1701
|
+
*/
|
|
1702
|
+
_triggerHook(event, data) {
|
|
1703
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
// src/services/payment.service.ts
|
|
1708
|
+
var PaymentService = class {
|
|
1709
|
+
models;
|
|
1710
|
+
providers;
|
|
1711
|
+
config;
|
|
1712
|
+
hooks;
|
|
1713
|
+
logger;
|
|
1714
|
+
constructor(container) {
|
|
1715
|
+
this.models = container.get("models");
|
|
1716
|
+
this.providers = container.get("providers");
|
|
1717
|
+
this.config = container.get("config");
|
|
1718
|
+
this.hooks = container.get("hooks");
|
|
1719
|
+
this.logger = container.get("logger");
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Verify a payment
|
|
1723
|
+
*
|
|
1724
|
+
* @param paymentIntentId - Payment intent ID, session ID, or transaction ID
|
|
1725
|
+
* @param options - Verification options
|
|
1726
|
+
* @returns { transaction, status }
|
|
1727
|
+
*/
|
|
1728
|
+
async verify(paymentIntentId, options = {}) {
|
|
1729
|
+
const { verifiedBy = null } = options;
|
|
1730
|
+
const TransactionModel = this.models.Transaction;
|
|
1731
|
+
const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
|
|
1732
|
+
if (!transaction) {
|
|
1733
|
+
throw new TransactionNotFoundError(paymentIntentId);
|
|
1734
|
+
}
|
|
1735
|
+
if (transaction.status === "verified" || transaction.status === "completed") {
|
|
1736
|
+
throw new AlreadyVerifiedError(transaction._id.toString());
|
|
1737
|
+
}
|
|
1738
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
1739
|
+
const provider = this.providers[gatewayType];
|
|
1740
|
+
if (!provider) {
|
|
1741
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
1742
|
+
}
|
|
1743
|
+
let paymentResult = null;
|
|
1744
|
+
try {
|
|
1745
|
+
paymentResult = await provider.verifyPayment(paymentIntentId);
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
this.logger.error("Payment verification failed:", error);
|
|
1748
|
+
transaction.status = "failed";
|
|
1749
|
+
transaction.failureReason = error.message;
|
|
1750
|
+
transaction.metadata = {
|
|
1751
|
+
...transaction.metadata,
|
|
1752
|
+
verificationError: error.message,
|
|
1753
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1754
|
+
};
|
|
1755
|
+
await transaction.save();
|
|
1756
|
+
this._triggerHook("payment.failed", {
|
|
1757
|
+
transaction,
|
|
1758
|
+
error: error.message,
|
|
1759
|
+
provider: gatewayType,
|
|
1760
|
+
paymentIntentId
|
|
1761
|
+
});
|
|
1762
|
+
throw new PaymentVerificationError(paymentIntentId, error.message);
|
|
1763
|
+
}
|
|
1764
|
+
if (paymentResult.amount && paymentResult.amount !== transaction.amount) {
|
|
1765
|
+
throw new ValidationError(
|
|
1766
|
+
`Amount mismatch: expected ${transaction.amount}, got ${paymentResult.amount}`,
|
|
1767
|
+
{ expected: transaction.amount, actual: paymentResult.amount }
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
if (paymentResult.currency && paymentResult.currency.toUpperCase() !== transaction.currency.toUpperCase()) {
|
|
1771
|
+
throw new ValidationError(
|
|
1772
|
+
`Currency mismatch: expected ${transaction.currency}, got ${paymentResult.currency}`,
|
|
1773
|
+
{ expected: transaction.currency, actual: paymentResult.currency }
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
transaction.status = paymentResult.status === "succeeded" ? "verified" : paymentResult.status;
|
|
1777
|
+
transaction.verifiedAt = paymentResult.paidAt ?? /* @__PURE__ */ new Date();
|
|
1778
|
+
transaction.verifiedBy = verifiedBy;
|
|
1779
|
+
transaction.gateway = {
|
|
1780
|
+
...transaction.gateway,
|
|
1781
|
+
type: transaction.gateway?.type ?? "manual",
|
|
1782
|
+
verificationData: paymentResult.metadata
|
|
1783
|
+
};
|
|
1784
|
+
await transaction.save();
|
|
1785
|
+
this._triggerHook("payment.verified", {
|
|
1786
|
+
transaction,
|
|
1787
|
+
paymentResult,
|
|
1788
|
+
verifiedBy
|
|
1789
|
+
});
|
|
1790
|
+
return {
|
|
1791
|
+
transaction,
|
|
1792
|
+
paymentResult,
|
|
1793
|
+
status: transaction.status
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Get payment status
|
|
1798
|
+
*
|
|
1799
|
+
* @param paymentIntentId - Payment intent ID, session ID, or transaction ID
|
|
1800
|
+
* @returns { transaction, status }
|
|
1801
|
+
*/
|
|
1802
|
+
async getStatus(paymentIntentId) {
|
|
1803
|
+
const TransactionModel = this.models.Transaction;
|
|
1804
|
+
const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
|
|
1805
|
+
if (!transaction) {
|
|
1806
|
+
throw new TransactionNotFoundError(paymentIntentId);
|
|
1807
|
+
}
|
|
1808
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
1809
|
+
const provider = this.providers[gatewayType];
|
|
1810
|
+
if (!provider) {
|
|
1811
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
1812
|
+
}
|
|
1813
|
+
let paymentResult = null;
|
|
1814
|
+
try {
|
|
1815
|
+
paymentResult = await provider.getStatus(paymentIntentId);
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
this.logger.warn("Failed to get payment status from provider:", error);
|
|
1818
|
+
return {
|
|
1819
|
+
transaction,
|
|
1820
|
+
status: transaction.status,
|
|
1821
|
+
provider: gatewayType
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
return {
|
|
1825
|
+
transaction,
|
|
1826
|
+
paymentResult,
|
|
1827
|
+
status: paymentResult.status,
|
|
1828
|
+
provider: gatewayType
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Refund a payment
|
|
1833
|
+
*
|
|
1834
|
+
* @param paymentId - Payment intent ID, session ID, or transaction ID
|
|
1835
|
+
* @param amount - Amount to refund (optional, full refund if not provided)
|
|
1836
|
+
* @param options - Refund options
|
|
1837
|
+
* @returns { transaction, refundResult }
|
|
1838
|
+
*/
|
|
1839
|
+
async refund(paymentId, amount = null, options = {}) {
|
|
1840
|
+
const { reason = null } = options;
|
|
1841
|
+
const TransactionModel = this.models.Transaction;
|
|
1842
|
+
const transaction = await this._findTransaction(TransactionModel, paymentId);
|
|
1843
|
+
if (!transaction) {
|
|
1844
|
+
throw new TransactionNotFoundError(paymentId);
|
|
1845
|
+
}
|
|
1846
|
+
if (transaction.status !== "verified" && transaction.status !== "completed") {
|
|
1847
|
+
throw new RefundError(transaction._id.toString(), "Only verified/completed transactions can be refunded");
|
|
1848
|
+
}
|
|
1849
|
+
const gatewayType = transaction.gateway?.type ?? "manual";
|
|
1850
|
+
const provider = this.providers[gatewayType];
|
|
1851
|
+
if (!provider) {
|
|
1852
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
1853
|
+
}
|
|
1854
|
+
const capabilities = provider.getCapabilities();
|
|
1855
|
+
if (!capabilities.supportsRefunds) {
|
|
1856
|
+
throw new RefundNotSupportedError(gatewayType);
|
|
1857
|
+
}
|
|
1858
|
+
const refundedSoFar = transaction.refundedAmount ?? 0;
|
|
1859
|
+
const refundableAmount = transaction.amount - refundedSoFar;
|
|
1860
|
+
const refundAmount = amount ?? refundableAmount;
|
|
1861
|
+
if (refundAmount <= 0) {
|
|
1862
|
+
throw new ValidationError(`Refund amount must be positive, got ${refundAmount}`);
|
|
1863
|
+
}
|
|
1864
|
+
if (refundAmount > refundableAmount) {
|
|
1865
|
+
throw new ValidationError(
|
|
1866
|
+
`Refund amount (${refundAmount}) exceeds refundable balance (${refundableAmount})`,
|
|
1867
|
+
{ refundAmount, refundableAmount, alreadyRefunded: refundedSoFar }
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
let refundResult;
|
|
1871
|
+
try {
|
|
1872
|
+
refundResult = await provider.refund(paymentId, refundAmount, { reason: reason ?? void 0 });
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
this.logger.error("Refund failed:", error);
|
|
1875
|
+
throw new RefundError(paymentId, error.message);
|
|
1876
|
+
}
|
|
1877
|
+
const refundTransactionType = this.config.transactionTypeMapping?.refund ?? TRANSACTION_TYPE.EXPENSE;
|
|
1878
|
+
const refundCommission = transaction.commission ? reverseCommission(transaction.commission, transaction.amount, refundAmount) : null;
|
|
1879
|
+
const refundTransaction = await TransactionModel.create({
|
|
1880
|
+
organizationId: transaction.organizationId,
|
|
1881
|
+
customerId: transaction.customerId,
|
|
1882
|
+
amount: refundAmount,
|
|
1883
|
+
currency: transaction.currency,
|
|
1884
|
+
category: transaction.category,
|
|
1885
|
+
type: refundTransactionType,
|
|
1886
|
+
// EXPENSE - money going out
|
|
1887
|
+
method: transaction.method ?? "manual",
|
|
1888
|
+
status: "completed",
|
|
1889
|
+
gateway: {
|
|
1890
|
+
type: transaction.gateway?.type ?? "manual",
|
|
1891
|
+
paymentIntentId: refundResult.id,
|
|
1892
|
+
provider: refundResult.provider
|
|
1893
|
+
},
|
|
1894
|
+
paymentDetails: transaction.paymentDetails,
|
|
1895
|
+
...refundCommission && { commission: refundCommission },
|
|
1896
|
+
// Reversed commission
|
|
1897
|
+
// Polymorphic reference (copy from original transaction)
|
|
1898
|
+
...transaction.referenceId && { referenceId: transaction.referenceId },
|
|
1899
|
+
...transaction.referenceModel && { referenceModel: transaction.referenceModel },
|
|
1900
|
+
metadata: {
|
|
1901
|
+
...transaction.metadata,
|
|
1902
|
+
isRefund: true,
|
|
1903
|
+
originalTransactionId: transaction._id.toString(),
|
|
1904
|
+
refundReason: reason,
|
|
1905
|
+
refundResult: refundResult.metadata
|
|
1906
|
+
},
|
|
1907
|
+
idempotencyKey: `refund_${transaction._id}_${Date.now()}`
|
|
1908
|
+
});
|
|
1909
|
+
const isPartialRefund = refundAmount < transaction.amount;
|
|
1910
|
+
transaction.status = isPartialRefund ? "partially_refunded" : "refunded";
|
|
1911
|
+
transaction.refundedAmount = (transaction.refundedAmount ?? 0) + refundAmount;
|
|
1912
|
+
transaction.refundedAt = refundResult.refundedAt ?? /* @__PURE__ */ new Date();
|
|
1913
|
+
transaction.metadata = {
|
|
1914
|
+
...transaction.metadata,
|
|
1915
|
+
refundTransactionId: refundTransaction._id.toString(),
|
|
1916
|
+
refundReason: reason
|
|
1917
|
+
};
|
|
1918
|
+
await transaction.save();
|
|
1919
|
+
this._triggerHook("payment.refunded", {
|
|
1920
|
+
transaction,
|
|
1921
|
+
refundTransaction,
|
|
1922
|
+
refundResult,
|
|
1923
|
+
refundAmount,
|
|
1924
|
+
reason,
|
|
1925
|
+
isPartialRefund
|
|
1926
|
+
});
|
|
1927
|
+
return {
|
|
1928
|
+
transaction,
|
|
1929
|
+
refundTransaction,
|
|
1930
|
+
refundResult,
|
|
1931
|
+
status: transaction.status
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Handle webhook from payment provider
|
|
1936
|
+
*
|
|
1937
|
+
* @param provider - Provider name
|
|
1938
|
+
* @param payload - Webhook payload
|
|
1939
|
+
* @param headers - Request headers
|
|
1940
|
+
* @returns { event, transaction }
|
|
1941
|
+
*/
|
|
1942
|
+
async handleWebhook(providerName, payload, headers = {}) {
|
|
1943
|
+
const provider = this.providers[providerName];
|
|
1944
|
+
if (!provider) {
|
|
1945
|
+
throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
|
|
1946
|
+
}
|
|
1947
|
+
const capabilities = provider.getCapabilities();
|
|
1948
|
+
if (!capabilities.supportsWebhooks) {
|
|
1949
|
+
throw new ProviderCapabilityError(providerName, "webhooks");
|
|
1950
|
+
}
|
|
1951
|
+
let webhookEvent;
|
|
1952
|
+
try {
|
|
1953
|
+
webhookEvent = await provider.handleWebhook(payload, headers);
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
this.logger.error("Webhook processing failed:", error);
|
|
1956
|
+
throw new ProviderError(
|
|
1957
|
+
`Webhook processing failed for ${providerName}: ${error.message}`,
|
|
1958
|
+
"WEBHOOK_PROCESSING_FAILED",
|
|
1959
|
+
{ retryable: false }
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
if (!webhookEvent?.data?.sessionId && !webhookEvent?.data?.paymentIntentId) {
|
|
1963
|
+
throw new ValidationError(
|
|
1964
|
+
`Invalid webhook event structure from ${providerName}: missing sessionId or paymentIntentId`,
|
|
1965
|
+
{ provider: providerName, eventType: webhookEvent?.type }
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
const TransactionModel = this.models.Transaction;
|
|
1969
|
+
let transaction = null;
|
|
1970
|
+
if (webhookEvent.data.sessionId) {
|
|
1971
|
+
transaction = await TransactionModel.findOne({
|
|
1972
|
+
"gateway.sessionId": webhookEvent.data.sessionId
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
if (!transaction && webhookEvent.data.paymentIntentId) {
|
|
1976
|
+
transaction = await TransactionModel.findOne({
|
|
1977
|
+
"gateway.paymentIntentId": webhookEvent.data.paymentIntentId
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
if (!transaction) {
|
|
1981
|
+
this.logger.warn("Transaction not found for webhook event", {
|
|
1982
|
+
provider: providerName,
|
|
1983
|
+
eventId: webhookEvent.id,
|
|
1984
|
+
sessionId: webhookEvent.data.sessionId,
|
|
1985
|
+
paymentIntentId: webhookEvent.data.paymentIntentId
|
|
1986
|
+
});
|
|
1987
|
+
throw new TransactionNotFoundError(
|
|
1988
|
+
webhookEvent.data.sessionId ?? webhookEvent.data.paymentIntentId ?? "unknown"
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
if (webhookEvent.data.sessionId && !transaction.gateway?.sessionId) {
|
|
1992
|
+
transaction.gateway = {
|
|
1993
|
+
...transaction.gateway,
|
|
1994
|
+
type: transaction.gateway?.type ?? "manual",
|
|
1995
|
+
sessionId: webhookEvent.data.sessionId
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
if (webhookEvent.data.paymentIntentId && !transaction.gateway?.paymentIntentId) {
|
|
1999
|
+
transaction.gateway = {
|
|
2000
|
+
...transaction.gateway,
|
|
2001
|
+
type: transaction.gateway?.type ?? "manual",
|
|
2002
|
+
paymentIntentId: webhookEvent.data.paymentIntentId
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
if (transaction.webhook?.eventId === webhookEvent.id && transaction.webhook?.processedAt) {
|
|
2006
|
+
this.logger.warn("Webhook already processed", {
|
|
2007
|
+
transactionId: transaction._id,
|
|
2008
|
+
eventId: webhookEvent.id
|
|
2009
|
+
});
|
|
2010
|
+
return {
|
|
2011
|
+
event: webhookEvent,
|
|
2012
|
+
transaction,
|
|
2013
|
+
status: "already_processed"
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
transaction.webhook = {
|
|
2017
|
+
eventId: webhookEvent.id,
|
|
2018
|
+
eventType: webhookEvent.type,
|
|
2019
|
+
receivedAt: /* @__PURE__ */ new Date(),
|
|
2020
|
+
processedAt: /* @__PURE__ */ new Date(),
|
|
2021
|
+
data: webhookEvent.data
|
|
2022
|
+
};
|
|
2023
|
+
if (webhookEvent.type === "payment.succeeded") {
|
|
2024
|
+
transaction.status = "verified";
|
|
2025
|
+
transaction.verifiedAt = webhookEvent.createdAt;
|
|
2026
|
+
} else if (webhookEvent.type === "payment.failed") {
|
|
2027
|
+
transaction.status = "failed";
|
|
2028
|
+
} else if (webhookEvent.type === "refund.succeeded") {
|
|
2029
|
+
transaction.status = "refunded";
|
|
2030
|
+
transaction.refundedAt = webhookEvent.createdAt;
|
|
2031
|
+
}
|
|
2032
|
+
await transaction.save();
|
|
2033
|
+
this._triggerHook(`payment.webhook.${webhookEvent.type}`, {
|
|
2034
|
+
event: webhookEvent,
|
|
2035
|
+
transaction
|
|
2036
|
+
});
|
|
2037
|
+
return {
|
|
2038
|
+
event: webhookEvent,
|
|
2039
|
+
transaction,
|
|
2040
|
+
status: "processed"
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* List payments/transactions with filters
|
|
2045
|
+
*
|
|
2046
|
+
* @param filters - Query filters
|
|
2047
|
+
* @param options - Query options (limit, skip, sort)
|
|
2048
|
+
* @returns Transactions
|
|
2049
|
+
*/
|
|
2050
|
+
async list(filters = {}, options = {}) {
|
|
2051
|
+
const TransactionModel = this.models.Transaction;
|
|
2052
|
+
const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
|
|
2053
|
+
const transactions = await TransactionModel.find(filters).limit(limit).skip(skip).sort(sort);
|
|
2054
|
+
return transactions;
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Get payment/transaction by ID
|
|
2058
|
+
*
|
|
2059
|
+
* @param transactionId - Transaction ID
|
|
2060
|
+
* @returns Transaction
|
|
2061
|
+
*/
|
|
2062
|
+
async get(transactionId) {
|
|
2063
|
+
const TransactionModel = this.models.Transaction;
|
|
2064
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2065
|
+
if (!transaction) {
|
|
2066
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2067
|
+
}
|
|
2068
|
+
return transaction;
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Get provider instance
|
|
2072
|
+
*
|
|
2073
|
+
* @param providerName - Provider name
|
|
2074
|
+
* @returns Provider instance
|
|
2075
|
+
*/
|
|
2076
|
+
getProvider(providerName) {
|
|
2077
|
+
const provider = this.providers[providerName];
|
|
2078
|
+
if (!provider) {
|
|
2079
|
+
throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
|
|
2080
|
+
}
|
|
2081
|
+
return provider;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Trigger event hook (fire-and-forget, non-blocking)
|
|
2085
|
+
* @private
|
|
2086
|
+
*/
|
|
2087
|
+
_triggerHook(event, data) {
|
|
2088
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Find transaction by sessionId, paymentIntentId, or transaction ID
|
|
2092
|
+
* @private
|
|
2093
|
+
*/
|
|
2094
|
+
async _findTransaction(TransactionModel, identifier) {
|
|
2095
|
+
let transaction = await TransactionModel.findOne({
|
|
2096
|
+
"gateway.sessionId": identifier
|
|
2097
|
+
});
|
|
2098
|
+
if (!transaction) {
|
|
2099
|
+
transaction = await TransactionModel.findOne({
|
|
2100
|
+
"gateway.paymentIntentId": identifier
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
if (!transaction) {
|
|
2104
|
+
transaction = await TransactionModel.findById(identifier);
|
|
2105
|
+
}
|
|
2106
|
+
return transaction;
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
|
|
2110
|
+
// src/services/transaction.service.ts
|
|
2111
|
+
var TransactionService = class {
|
|
2112
|
+
models;
|
|
2113
|
+
hooks;
|
|
2114
|
+
logger;
|
|
2115
|
+
constructor(container) {
|
|
2116
|
+
this.models = container.get("models");
|
|
2117
|
+
this.hooks = container.get("hooks");
|
|
2118
|
+
this.logger = container.get("logger");
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Get transaction by ID
|
|
2122
|
+
*
|
|
2123
|
+
* @param transactionId - Transaction ID
|
|
2124
|
+
* @returns Transaction
|
|
2125
|
+
*/
|
|
2126
|
+
async get(transactionId) {
|
|
2127
|
+
const TransactionModel = this.models.Transaction;
|
|
2128
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2129
|
+
if (!transaction) {
|
|
2130
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2131
|
+
}
|
|
2132
|
+
return transaction;
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* List transactions with filters
|
|
2136
|
+
*
|
|
2137
|
+
* @param filters - Query filters
|
|
2138
|
+
* @param options - Query options (limit, skip, sort, populate)
|
|
2139
|
+
* @returns { transactions, total, page, limit }
|
|
2140
|
+
*/
|
|
2141
|
+
async list(filters = {}, options = {}) {
|
|
2142
|
+
const TransactionModel = this.models.Transaction;
|
|
2143
|
+
const {
|
|
2144
|
+
limit = 50,
|
|
2145
|
+
skip = 0,
|
|
2146
|
+
page = null,
|
|
2147
|
+
sort = { createdAt: -1 },
|
|
2148
|
+
populate = []
|
|
2149
|
+
} = options;
|
|
2150
|
+
const actualSkip = page ? (page - 1) * limit : skip;
|
|
2151
|
+
let query = TransactionModel.find(filters).limit(limit).skip(actualSkip).sort(sort);
|
|
2152
|
+
if (populate.length > 0 && typeof query.populate === "function") {
|
|
2153
|
+
populate.forEach((field) => {
|
|
2154
|
+
query = query.populate(field);
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
const transactions = await query;
|
|
2158
|
+
const model = TransactionModel;
|
|
2159
|
+
const total = await (model.countDocuments ? model.countDocuments(filters) : model.count?.(filters)) ?? 0;
|
|
2160
|
+
return {
|
|
2161
|
+
transactions,
|
|
2162
|
+
total,
|
|
2163
|
+
page: page ?? Math.floor(actualSkip / limit) + 1,
|
|
2164
|
+
limit,
|
|
2165
|
+
pages: Math.ceil(total / limit)
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Update transaction
|
|
2170
|
+
*
|
|
2171
|
+
* @param transactionId - Transaction ID
|
|
2172
|
+
* @param updates - Fields to update
|
|
2173
|
+
* @returns Updated transaction
|
|
2174
|
+
*/
|
|
2175
|
+
async update(transactionId, updates) {
|
|
2176
|
+
const TransactionModel = this.models.Transaction;
|
|
2177
|
+
const model = TransactionModel;
|
|
2178
|
+
let transaction;
|
|
2179
|
+
if (typeof model.update === "function") {
|
|
2180
|
+
transaction = await model.update(transactionId, updates);
|
|
2181
|
+
} else if (typeof model.findByIdAndUpdate === "function") {
|
|
2182
|
+
transaction = await model.findByIdAndUpdate(
|
|
2183
|
+
transactionId,
|
|
2184
|
+
{ $set: updates },
|
|
2185
|
+
{ new: true }
|
|
2186
|
+
);
|
|
2187
|
+
} else {
|
|
2188
|
+
throw new Error("Transaction model does not support update operations");
|
|
2189
|
+
}
|
|
2190
|
+
if (!transaction) {
|
|
2191
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2192
|
+
}
|
|
2193
|
+
this._triggerHook("transaction.updated", {
|
|
2194
|
+
transaction,
|
|
2195
|
+
updates
|
|
2196
|
+
});
|
|
2197
|
+
return transaction;
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Trigger event hook (fire-and-forget, non-blocking)
|
|
2201
|
+
* @private
|
|
2202
|
+
*/
|
|
2203
|
+
_triggerHook(event, data) {
|
|
2204
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
// src/enums/escrow.enums.ts
|
|
2209
|
+
var HOLD_STATUS = {
|
|
2210
|
+
PENDING: "pending",
|
|
2211
|
+
HELD: "held",
|
|
2212
|
+
RELEASED: "released",
|
|
2213
|
+
CANCELLED: "cancelled",
|
|
2214
|
+
EXPIRED: "expired",
|
|
2215
|
+
PARTIALLY_RELEASED: "partially_released"
|
|
2216
|
+
};
|
|
2217
|
+
var HOLD_STATUS_VALUES = Object.values(HOLD_STATUS);
|
|
2218
|
+
var RELEASE_REASON = {
|
|
2219
|
+
PAYMENT_VERIFIED: "payment_verified",
|
|
2220
|
+
MANUAL_RELEASE: "manual_release",
|
|
2221
|
+
AUTO_RELEASE: "auto_release",
|
|
2222
|
+
DISPUTE_RESOLVED: "dispute_resolved"
|
|
2223
|
+
};
|
|
2224
|
+
var RELEASE_REASON_VALUES = Object.values(RELEASE_REASON);
|
|
2225
|
+
var HOLD_REASON = {
|
|
2226
|
+
PAYMENT_VERIFICATION: "payment_verification",
|
|
2227
|
+
FRAUD_CHECK: "fraud_check",
|
|
2228
|
+
MANUAL_REVIEW: "manual_review",
|
|
2229
|
+
DISPUTE: "dispute",
|
|
2230
|
+
COMPLIANCE: "compliance"
|
|
2231
|
+
};
|
|
2232
|
+
var HOLD_REASON_VALUES = Object.values(HOLD_REASON);
|
|
2233
|
+
|
|
2234
|
+
// src/enums/split.enums.ts
|
|
2235
|
+
var SPLIT_TYPE = {
|
|
2236
|
+
PLATFORM_COMMISSION: "platform_commission",
|
|
2237
|
+
AFFILIATE_COMMISSION: "affiliate_commission",
|
|
2238
|
+
REFERRAL_COMMISSION: "referral_commission",
|
|
2239
|
+
PARTNER_COMMISSION: "partner_commission",
|
|
2240
|
+
CUSTOM: "custom"
|
|
2241
|
+
};
|
|
2242
|
+
var SPLIT_TYPE_VALUES = Object.values(SPLIT_TYPE);
|
|
2243
|
+
var SPLIT_STATUS = {
|
|
2244
|
+
PENDING: "pending",
|
|
2245
|
+
DUE: "due",
|
|
2246
|
+
PAID: "paid",
|
|
2247
|
+
WAIVED: "waived",
|
|
2248
|
+
CANCELLED: "cancelled"
|
|
2249
|
+
};
|
|
2250
|
+
var SPLIT_STATUS_VALUES = Object.values(SPLIT_STATUS);
|
|
2251
|
+
var PAYOUT_METHOD = {
|
|
2252
|
+
BANK_TRANSFER: "bank_transfer",
|
|
2253
|
+
MOBILE_WALLET: "mobile_wallet",
|
|
2254
|
+
PLATFORM_BALANCE: "platform_balance",
|
|
2255
|
+
CRYPTO: "crypto",
|
|
2256
|
+
CHECK: "check",
|
|
2257
|
+
MANUAL: "manual"
|
|
2258
|
+
};
|
|
2259
|
+
var PAYOUT_METHOD_VALUES = Object.values(PAYOUT_METHOD);
|
|
2260
|
+
|
|
2261
|
+
// src/utils/commission-split.ts
|
|
2262
|
+
function calculateSplits(amount, splitRules = [], gatewayFeeRate = 0) {
|
|
2263
|
+
if (!splitRules || splitRules.length === 0) {
|
|
2264
|
+
return [];
|
|
2265
|
+
}
|
|
2266
|
+
if (amount < 0) {
|
|
2267
|
+
throw new Error("Transaction amount cannot be negative");
|
|
2268
|
+
}
|
|
2269
|
+
if (gatewayFeeRate < 0 || gatewayFeeRate > 1) {
|
|
2270
|
+
throw new Error("Gateway fee rate must be between 0 and 1");
|
|
2271
|
+
}
|
|
2272
|
+
const totalRate = splitRules.reduce((sum, rule) => sum + rule.rate, 0);
|
|
2273
|
+
if (totalRate > 1) {
|
|
2274
|
+
throw new Error(`Total split rate (${totalRate}) cannot exceed 1.0`);
|
|
2275
|
+
}
|
|
2276
|
+
return splitRules.map((rule, index) => {
|
|
2277
|
+
if (rule.rate < 0 || rule.rate > 1) {
|
|
2278
|
+
throw new Error(`Split rate must be between 0 and 1 for split ${index}`);
|
|
2279
|
+
}
|
|
2280
|
+
const grossAmount = Math.round(amount * rule.rate * 100) / 100;
|
|
2281
|
+
const gatewayFeeAmount = index === 0 && gatewayFeeRate > 0 ? Math.round(amount * gatewayFeeRate * 100) / 100 : 0;
|
|
2282
|
+
const netAmount = Math.max(0, Math.round((grossAmount - gatewayFeeAmount) * 100) / 100);
|
|
2283
|
+
return {
|
|
2284
|
+
type: rule.type ?? SPLIT_TYPE.CUSTOM,
|
|
2285
|
+
recipientId: rule.recipientId,
|
|
2286
|
+
recipientType: rule.recipientType,
|
|
2287
|
+
rate: rule.rate,
|
|
2288
|
+
grossAmount,
|
|
2289
|
+
gatewayFeeRate: gatewayFeeAmount > 0 ? gatewayFeeRate : 0,
|
|
2290
|
+
gatewayFeeAmount,
|
|
2291
|
+
netAmount,
|
|
2292
|
+
status: SPLIT_STATUS.PENDING,
|
|
2293
|
+
dueDate: rule.dueDate ?? null,
|
|
2294
|
+
metadata: rule.metadata ?? {}
|
|
2295
|
+
};
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
function calculateOrganizationPayout(amount, splits = []) {
|
|
2299
|
+
const totalSplitAmount = splits.reduce((sum, split) => sum + split.grossAmount, 0);
|
|
2300
|
+
return Math.max(0, Math.round((amount - totalSplitAmount) * 100) / 100);
|
|
2301
|
+
}
|
|
2302
|
+
function reverseSplits(originalSplits, originalAmount, refundAmount) {
|
|
2303
|
+
if (!originalSplits || originalSplits.length === 0) {
|
|
2304
|
+
return [];
|
|
2305
|
+
}
|
|
2306
|
+
const refundRatio = refundAmount / originalAmount;
|
|
2307
|
+
return originalSplits.map((split) => ({
|
|
2308
|
+
...split,
|
|
2309
|
+
grossAmount: Math.round(split.grossAmount * refundRatio * 100) / 100,
|
|
2310
|
+
gatewayFeeAmount: Math.round(split.gatewayFeeAmount * refundRatio * 100) / 100,
|
|
2311
|
+
netAmount: Math.round(split.netAmount * refundRatio * 100) / 100,
|
|
2312
|
+
status: SPLIT_STATUS.WAIVED
|
|
2313
|
+
}));
|
|
2314
|
+
}
|
|
2315
|
+
function calculateCommissionWithSplits(amount, commissionRate, gatewayFeeRate = 0, options = {}) {
|
|
2316
|
+
const { affiliateRate = 0, affiliateId = null, affiliateType = "user" } = options;
|
|
2317
|
+
if (commissionRate <= 0 && affiliateRate <= 0) {
|
|
2318
|
+
return null;
|
|
2319
|
+
}
|
|
2320
|
+
const splitRules = [];
|
|
2321
|
+
if (commissionRate > 0) {
|
|
2322
|
+
splitRules.push({
|
|
2323
|
+
type: SPLIT_TYPE.PLATFORM_COMMISSION,
|
|
2324
|
+
recipientId: "platform",
|
|
2325
|
+
recipientType: "platform",
|
|
2326
|
+
rate: commissionRate
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
if (affiliateRate > 0 && affiliateId) {
|
|
2330
|
+
splitRules.push({
|
|
2331
|
+
type: SPLIT_TYPE.AFFILIATE_COMMISSION,
|
|
2332
|
+
recipientId: affiliateId,
|
|
2333
|
+
recipientType: affiliateType,
|
|
2334
|
+
rate: affiliateRate
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
const splits = calculateSplits(amount, splitRules, gatewayFeeRate);
|
|
2338
|
+
const platformSplit = splits.find((s) => s.type === SPLIT_TYPE.PLATFORM_COMMISSION);
|
|
2339
|
+
const affiliateSplit = splits.find((s) => s.type === SPLIT_TYPE.AFFILIATE_COMMISSION);
|
|
2340
|
+
return {
|
|
2341
|
+
rate: commissionRate,
|
|
2342
|
+
grossAmount: platformSplit?.grossAmount ?? 0,
|
|
2343
|
+
gatewayFeeRate: platformSplit?.gatewayFeeRate ?? 0,
|
|
2344
|
+
gatewayFeeAmount: platformSplit?.gatewayFeeAmount ?? 0,
|
|
2345
|
+
netAmount: platformSplit?.netAmount ?? 0,
|
|
2346
|
+
status: "pending",
|
|
2347
|
+
...splits.length > 0 && { splits },
|
|
2348
|
+
...affiliateSplit && {
|
|
2349
|
+
affiliate: {
|
|
2350
|
+
recipientId: affiliateSplit.recipientId,
|
|
2351
|
+
recipientType: affiliateSplit.recipientType,
|
|
2352
|
+
rate: affiliateSplit.rate,
|
|
2353
|
+
grossAmount: affiliateSplit.grossAmount,
|
|
2354
|
+
netAmount: affiliateSplit.netAmount
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// src/services/escrow.service.ts
|
|
2361
|
+
var EscrowService = class {
|
|
2362
|
+
models;
|
|
2363
|
+
hooks;
|
|
2364
|
+
logger;
|
|
2365
|
+
constructor(container) {
|
|
2366
|
+
this.models = container.get("models");
|
|
2367
|
+
this.hooks = container.get("hooks");
|
|
2368
|
+
this.logger = container.get("logger");
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Hold funds in escrow
|
|
2372
|
+
*
|
|
2373
|
+
* @param transactionId - Transaction to hold
|
|
2374
|
+
* @param options - Hold options
|
|
2375
|
+
* @returns Updated transaction
|
|
2376
|
+
*/
|
|
2377
|
+
async hold(transactionId, options = {}) {
|
|
2378
|
+
const {
|
|
2379
|
+
reason = HOLD_REASON.PAYMENT_VERIFICATION,
|
|
2380
|
+
holdUntil = null,
|
|
2381
|
+
metadata = {}
|
|
2382
|
+
} = options;
|
|
2383
|
+
const TransactionModel = this.models.Transaction;
|
|
2384
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2385
|
+
if (!transaction) {
|
|
2386
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2387
|
+
}
|
|
2388
|
+
if (transaction.status !== TRANSACTION_STATUS.VERIFIED) {
|
|
2389
|
+
throw new Error(`Cannot hold transaction with status: ${transaction.status}. Must be verified.`);
|
|
2390
|
+
}
|
|
2391
|
+
transaction.hold = {
|
|
2392
|
+
status: HOLD_STATUS.HELD,
|
|
2393
|
+
heldAmount: transaction.amount,
|
|
2394
|
+
releasedAmount: 0,
|
|
2395
|
+
reason,
|
|
2396
|
+
heldAt: /* @__PURE__ */ new Date(),
|
|
2397
|
+
...holdUntil && { holdUntil },
|
|
2398
|
+
releases: [],
|
|
2399
|
+
metadata
|
|
2400
|
+
};
|
|
2401
|
+
await transaction.save();
|
|
2402
|
+
this._triggerHook("escrow.held", {
|
|
2403
|
+
transaction,
|
|
2404
|
+
heldAmount: transaction.amount,
|
|
2405
|
+
reason
|
|
2406
|
+
});
|
|
2407
|
+
return transaction;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* Release funds from escrow to recipient
|
|
2411
|
+
*
|
|
2412
|
+
* @param transactionId - Transaction to release
|
|
2413
|
+
* @param options - Release options
|
|
2414
|
+
* @returns { transaction, releaseTransaction }
|
|
2415
|
+
*/
|
|
2416
|
+
async release(transactionId, options) {
|
|
2417
|
+
const {
|
|
2418
|
+
amount = null,
|
|
2419
|
+
recipientId,
|
|
2420
|
+
recipientType = "organization",
|
|
2421
|
+
reason = RELEASE_REASON.PAYMENT_VERIFIED,
|
|
2422
|
+
releasedBy = null,
|
|
2423
|
+
createTransaction = true,
|
|
2424
|
+
metadata = {}
|
|
2425
|
+
} = options;
|
|
2426
|
+
const TransactionModel = this.models.Transaction;
|
|
2427
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2428
|
+
if (!transaction) {
|
|
2429
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2430
|
+
}
|
|
2431
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
|
|
2432
|
+
throw new Error(`Transaction is not in held status. Current: ${transaction.hold?.status ?? "none"}`);
|
|
2433
|
+
}
|
|
2434
|
+
if (!recipientId) {
|
|
2435
|
+
throw new Error("recipientId is required for release");
|
|
2436
|
+
}
|
|
2437
|
+
const releaseAmount = amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
2438
|
+
const availableAmount = transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
2439
|
+
if (releaseAmount > availableAmount) {
|
|
2440
|
+
throw new Error(`Release amount (${releaseAmount}) exceeds available held amount (${availableAmount})`);
|
|
2441
|
+
}
|
|
2442
|
+
const releaseRecord = {
|
|
2443
|
+
amount: releaseAmount,
|
|
2444
|
+
recipientId,
|
|
2445
|
+
recipientType,
|
|
2446
|
+
releasedAt: /* @__PURE__ */ new Date(),
|
|
2447
|
+
releasedBy,
|
|
2448
|
+
reason,
|
|
2449
|
+
metadata
|
|
2450
|
+
};
|
|
2451
|
+
transaction.hold.releases.push(releaseRecord);
|
|
2452
|
+
transaction.hold.releasedAmount += releaseAmount;
|
|
2453
|
+
const isFullRelease = transaction.hold.releasedAmount >= transaction.hold.heldAmount;
|
|
2454
|
+
const isPartialRelease = transaction.hold.releasedAmount > 0 && transaction.hold.releasedAmount < transaction.hold.heldAmount;
|
|
2455
|
+
if (isFullRelease) {
|
|
2456
|
+
transaction.hold.status = HOLD_STATUS.RELEASED;
|
|
2457
|
+
transaction.hold.releasedAt = /* @__PURE__ */ new Date();
|
|
2458
|
+
transaction.status = TRANSACTION_STATUS.COMPLETED;
|
|
2459
|
+
} else if (isPartialRelease) {
|
|
2460
|
+
transaction.hold.status = HOLD_STATUS.PARTIALLY_RELEASED;
|
|
2461
|
+
}
|
|
2462
|
+
await transaction.save();
|
|
2463
|
+
let releaseTransaction = null;
|
|
2464
|
+
if (createTransaction) {
|
|
2465
|
+
releaseTransaction = await TransactionModel.create({
|
|
2466
|
+
organizationId: transaction.organizationId,
|
|
2467
|
+
customerId: recipientId,
|
|
2468
|
+
amount: releaseAmount,
|
|
2469
|
+
currency: transaction.currency,
|
|
2470
|
+
category: transaction.category,
|
|
2471
|
+
type: TRANSACTION_TYPE.INCOME,
|
|
2472
|
+
method: transaction.method,
|
|
2473
|
+
status: TRANSACTION_STATUS.COMPLETED,
|
|
2474
|
+
gateway: transaction.gateway,
|
|
2475
|
+
referenceId: transaction.referenceId,
|
|
2476
|
+
referenceModel: transaction.referenceModel,
|
|
2477
|
+
metadata: {
|
|
2478
|
+
...metadata,
|
|
2479
|
+
isRelease: true,
|
|
2480
|
+
heldTransactionId: transaction._id.toString(),
|
|
2481
|
+
releaseReason: reason,
|
|
2482
|
+
recipientType
|
|
2483
|
+
},
|
|
2484
|
+
idempotencyKey: `release_${transaction._id}_${Date.now()}`
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
this._triggerHook("escrow.released", {
|
|
2488
|
+
transaction,
|
|
2489
|
+
releaseTransaction,
|
|
2490
|
+
releaseAmount,
|
|
2491
|
+
recipientId,
|
|
2492
|
+
recipientType,
|
|
2493
|
+
reason,
|
|
2494
|
+
isFullRelease,
|
|
2495
|
+
isPartialRelease
|
|
2496
|
+
});
|
|
2497
|
+
return {
|
|
2498
|
+
transaction,
|
|
2499
|
+
releaseTransaction,
|
|
2500
|
+
releaseAmount,
|
|
2501
|
+
isFullRelease,
|
|
2502
|
+
isPartialRelease
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
/**
|
|
2506
|
+
* Cancel hold and release back to customer
|
|
2507
|
+
*
|
|
2508
|
+
* @param transactionId - Transaction to cancel hold
|
|
2509
|
+
* @param options - Cancel options
|
|
2510
|
+
* @returns Updated transaction
|
|
2511
|
+
*/
|
|
2512
|
+
async cancel(transactionId, options = {}) {
|
|
2513
|
+
const { reason = "Hold cancelled", metadata = {} } = options;
|
|
2514
|
+
const TransactionModel = this.models.Transaction;
|
|
2515
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2516
|
+
if (!transaction) {
|
|
2517
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2518
|
+
}
|
|
2519
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
|
|
2520
|
+
throw new Error(`Transaction is not in held status. Current: ${transaction.hold?.status ?? "none"}`);
|
|
2521
|
+
}
|
|
2522
|
+
transaction.hold.status = HOLD_STATUS.CANCELLED;
|
|
2523
|
+
transaction.hold.cancelledAt = /* @__PURE__ */ new Date();
|
|
2524
|
+
transaction.hold.metadata = {
|
|
2525
|
+
...transaction.hold.metadata,
|
|
2526
|
+
...metadata,
|
|
2527
|
+
cancelReason: reason
|
|
2528
|
+
};
|
|
2529
|
+
transaction.status = TRANSACTION_STATUS.CANCELLED;
|
|
2530
|
+
await transaction.save();
|
|
2531
|
+
this._triggerHook("escrow.cancelled", {
|
|
2532
|
+
transaction,
|
|
2533
|
+
reason
|
|
2534
|
+
});
|
|
2535
|
+
return transaction;
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Split payment to multiple recipients
|
|
2539
|
+
* Deducts splits from held amount and releases remainder to organization
|
|
2540
|
+
*
|
|
2541
|
+
* @param transactionId - Transaction to split
|
|
2542
|
+
* @param splitRules - Split configuration
|
|
2543
|
+
* @returns { transaction, splitTransactions, organizationTransaction }
|
|
2544
|
+
*/
|
|
2545
|
+
async split(transactionId, splitRules = []) {
|
|
2546
|
+
const TransactionModel = this.models.Transaction;
|
|
2547
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2548
|
+
if (!transaction) {
|
|
2549
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2550
|
+
}
|
|
2551
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
|
|
2552
|
+
throw new Error(`Transaction must be held before splitting. Current: ${transaction.hold?.status ?? "none"}`);
|
|
2553
|
+
}
|
|
2554
|
+
if (!splitRules || splitRules.length === 0) {
|
|
2555
|
+
throw new Error("splitRules cannot be empty");
|
|
2556
|
+
}
|
|
2557
|
+
const splits = calculateSplits(
|
|
2558
|
+
transaction.amount,
|
|
2559
|
+
splitRules,
|
|
2560
|
+
transaction.commission?.gatewayFeeRate ?? 0
|
|
2561
|
+
);
|
|
2562
|
+
transaction.splits = splits;
|
|
2563
|
+
await transaction.save();
|
|
2564
|
+
const splitTransactions = [];
|
|
2565
|
+
for (const split of splits) {
|
|
2566
|
+
const splitTransaction = await TransactionModel.create({
|
|
2567
|
+
organizationId: transaction.organizationId,
|
|
2568
|
+
customerId: split.recipientId,
|
|
2569
|
+
amount: split.netAmount,
|
|
2570
|
+
currency: transaction.currency,
|
|
2571
|
+
category: split.type,
|
|
2572
|
+
type: TRANSACTION_TYPE.EXPENSE,
|
|
2573
|
+
method: transaction.method,
|
|
2574
|
+
status: TRANSACTION_STATUS.COMPLETED,
|
|
2575
|
+
gateway: transaction.gateway,
|
|
2576
|
+
referenceId: transaction.referenceId,
|
|
2577
|
+
referenceModel: transaction.referenceModel,
|
|
2578
|
+
metadata: {
|
|
2579
|
+
isSplit: true,
|
|
2580
|
+
splitType: split.type,
|
|
2581
|
+
recipientType: split.recipientType,
|
|
2582
|
+
originalTransactionId: transaction._id.toString(),
|
|
2583
|
+
grossAmount: split.grossAmount,
|
|
2584
|
+
gatewayFeeAmount: split.gatewayFeeAmount
|
|
2585
|
+
},
|
|
2586
|
+
idempotencyKey: `split_${transaction._id}_${split.recipientId}_${Date.now()}`
|
|
2587
|
+
});
|
|
2588
|
+
split.payoutTransactionId = splitTransaction._id.toString();
|
|
2589
|
+
split.status = SPLIT_STATUS.PAID;
|
|
2590
|
+
split.paidDate = /* @__PURE__ */ new Date();
|
|
2591
|
+
splitTransactions.push(splitTransaction);
|
|
2592
|
+
}
|
|
2593
|
+
await transaction.save();
|
|
2594
|
+
const organizationPayout = calculateOrganizationPayout(transaction.amount, splits);
|
|
2595
|
+
const organizationTransaction = await this.release(transactionId, {
|
|
2596
|
+
amount: organizationPayout,
|
|
2597
|
+
recipientId: transaction.organizationId?.toString() ?? "",
|
|
2598
|
+
recipientType: "organization",
|
|
2599
|
+
reason: RELEASE_REASON.PAYMENT_VERIFIED,
|
|
2600
|
+
createTransaction: true,
|
|
2601
|
+
metadata: {
|
|
2602
|
+
afterSplits: true,
|
|
2603
|
+
totalSplits: splits.length,
|
|
2604
|
+
totalSplitAmount: transaction.amount - organizationPayout
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
this._triggerHook("escrow.split", {
|
|
2608
|
+
transaction,
|
|
2609
|
+
splits,
|
|
2610
|
+
splitTransactions,
|
|
2611
|
+
organizationTransaction: organizationTransaction.releaseTransaction,
|
|
2612
|
+
organizationPayout
|
|
2613
|
+
});
|
|
2614
|
+
return {
|
|
2615
|
+
transaction,
|
|
2616
|
+
splits,
|
|
2617
|
+
splitTransactions,
|
|
2618
|
+
organizationTransaction: organizationTransaction.releaseTransaction,
|
|
2619
|
+
organizationPayout
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* Get escrow status
|
|
2624
|
+
*
|
|
2625
|
+
* @param transactionId - Transaction ID
|
|
2626
|
+
* @returns Escrow status
|
|
2627
|
+
*/
|
|
2628
|
+
async getStatus(transactionId) {
|
|
2629
|
+
const TransactionModel = this.models.Transaction;
|
|
2630
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
2631
|
+
if (!transaction) {
|
|
2632
|
+
throw new TransactionNotFoundError(transactionId);
|
|
2633
|
+
}
|
|
2634
|
+
return {
|
|
2635
|
+
transaction,
|
|
2636
|
+
hold: transaction.hold ?? null,
|
|
2637
|
+
splits: transaction.splits ?? [],
|
|
2638
|
+
hasHold: !!transaction.hold,
|
|
2639
|
+
hasSplits: transaction.splits ? transaction.splits.length > 0 : false
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
_triggerHook(event, data) {
|
|
2643
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
// src/providers/base.ts
|
|
2648
|
+
var PaymentIntent = class {
|
|
2649
|
+
id;
|
|
2650
|
+
sessionId;
|
|
2651
|
+
paymentIntentId;
|
|
2652
|
+
provider;
|
|
2653
|
+
status;
|
|
2654
|
+
amount;
|
|
2655
|
+
currency;
|
|
2656
|
+
metadata;
|
|
2657
|
+
clientSecret;
|
|
2658
|
+
paymentUrl;
|
|
2659
|
+
instructions;
|
|
2660
|
+
raw;
|
|
2661
|
+
constructor(data) {
|
|
2662
|
+
this.id = data.id;
|
|
2663
|
+
this.sessionId = data.sessionId ?? null;
|
|
2664
|
+
this.paymentIntentId = data.paymentIntentId ?? null;
|
|
2665
|
+
this.provider = data.provider;
|
|
2666
|
+
this.status = data.status;
|
|
2667
|
+
this.amount = data.amount;
|
|
2668
|
+
this.currency = data.currency ?? "BDT";
|
|
2669
|
+
this.metadata = data.metadata ?? {};
|
|
2670
|
+
this.clientSecret = data.clientSecret;
|
|
2671
|
+
this.paymentUrl = data.paymentUrl;
|
|
2672
|
+
this.instructions = data.instructions;
|
|
2673
|
+
this.raw = data.raw;
|
|
2674
|
+
}
|
|
2675
|
+
};
|
|
2676
|
+
var PaymentResult = class {
|
|
2677
|
+
id;
|
|
2678
|
+
provider;
|
|
2679
|
+
status;
|
|
2680
|
+
amount;
|
|
2681
|
+
currency;
|
|
2682
|
+
paidAt;
|
|
2683
|
+
metadata;
|
|
2684
|
+
raw;
|
|
2685
|
+
constructor(data) {
|
|
2686
|
+
this.id = data.id;
|
|
2687
|
+
this.provider = data.provider;
|
|
2688
|
+
this.status = data.status;
|
|
2689
|
+
this.amount = data.amount;
|
|
2690
|
+
this.currency = data.currency ?? "BDT";
|
|
2691
|
+
this.paidAt = data.paidAt;
|
|
2692
|
+
this.metadata = data.metadata ?? {};
|
|
2693
|
+
this.raw = data.raw;
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
var RefundResult = class {
|
|
2697
|
+
id;
|
|
2698
|
+
provider;
|
|
2699
|
+
status;
|
|
2700
|
+
amount;
|
|
2701
|
+
currency;
|
|
2702
|
+
refundedAt;
|
|
2703
|
+
reason;
|
|
2704
|
+
metadata;
|
|
2705
|
+
raw;
|
|
2706
|
+
constructor(data) {
|
|
2707
|
+
this.id = data.id;
|
|
2708
|
+
this.provider = data.provider;
|
|
2709
|
+
this.status = data.status;
|
|
2710
|
+
this.amount = data.amount;
|
|
2711
|
+
this.currency = data.currency ?? "BDT";
|
|
2712
|
+
this.refundedAt = data.refundedAt;
|
|
2713
|
+
this.reason = data.reason;
|
|
2714
|
+
this.metadata = data.metadata ?? {};
|
|
2715
|
+
this.raw = data.raw;
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
var WebhookEvent = class {
|
|
2719
|
+
id;
|
|
2720
|
+
provider;
|
|
2721
|
+
type;
|
|
2722
|
+
data;
|
|
2723
|
+
createdAt;
|
|
2724
|
+
raw;
|
|
2725
|
+
constructor(data) {
|
|
2726
|
+
this.id = data.id;
|
|
2727
|
+
this.provider = data.provider;
|
|
2728
|
+
this.type = data.type;
|
|
2729
|
+
this.data = data.data;
|
|
2730
|
+
this.createdAt = data.createdAt;
|
|
2731
|
+
this.raw = data.raw;
|
|
2732
|
+
}
|
|
2733
|
+
};
|
|
2734
|
+
var PaymentProvider = class {
|
|
2735
|
+
config;
|
|
2736
|
+
name;
|
|
2737
|
+
constructor(config = {}) {
|
|
2738
|
+
this.config = config;
|
|
2739
|
+
this.name = "base";
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Verify webhook signature (optional)
|
|
2743
|
+
* @param payload - Webhook payload
|
|
2744
|
+
* @param signature - Webhook signature
|
|
2745
|
+
* @returns boolean
|
|
2746
|
+
*/
|
|
2747
|
+
verifyWebhookSignature(_payload, _signature) {
|
|
2748
|
+
return true;
|
|
2749
|
+
}
|
|
2750
|
+
/**
|
|
2751
|
+
* Get provider capabilities
|
|
2752
|
+
* @returns ProviderCapabilities
|
|
2753
|
+
*/
|
|
2754
|
+
getCapabilities() {
|
|
2755
|
+
return {
|
|
2756
|
+
supportsWebhooks: false,
|
|
2757
|
+
supportsRefunds: false,
|
|
2758
|
+
supportsPartialRefunds: false,
|
|
2759
|
+
requiresManualVerification: true
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
// src/core/revenue.ts
|
|
2765
|
+
var Revenue = class {
|
|
2766
|
+
// ============ CORE ============
|
|
2767
|
+
_container;
|
|
2768
|
+
_events;
|
|
2769
|
+
_plugins;
|
|
2770
|
+
_idempotency;
|
|
2771
|
+
_circuitBreaker;
|
|
2772
|
+
_options;
|
|
2773
|
+
_logger;
|
|
2774
|
+
_providers;
|
|
2775
|
+
_config;
|
|
2776
|
+
// ============ SERVICES ============
|
|
2777
|
+
/** Monetization service - purchases, subscriptions, free items */
|
|
2778
|
+
monetization;
|
|
2779
|
+
/** Payment service - verify, refund, webhooks */
|
|
2780
|
+
payments;
|
|
2781
|
+
/** Transaction service - query, update transactions */
|
|
2782
|
+
transactions;
|
|
2783
|
+
/** Escrow service - hold, release, splits */
|
|
2784
|
+
escrow;
|
|
2785
|
+
constructor(container, events, plugins, options, providers, config) {
|
|
2786
|
+
this._container = container;
|
|
2787
|
+
this._events = events;
|
|
2788
|
+
this._plugins = plugins;
|
|
2789
|
+
this._options = options;
|
|
2790
|
+
this._logger = options.logger;
|
|
2791
|
+
this._providers = providers;
|
|
2792
|
+
this._config = config;
|
|
2793
|
+
this._idempotency = createIdempotencyManager({
|
|
2794
|
+
ttl: options.idempotencyTtl
|
|
2795
|
+
});
|
|
2796
|
+
if (options.circuitBreaker) {
|
|
2797
|
+
this._circuitBreaker = createCircuitBreaker();
|
|
2798
|
+
}
|
|
2799
|
+
container.singleton("events", events);
|
|
2800
|
+
container.singleton("plugins", plugins);
|
|
2801
|
+
container.singleton("idempotency", this._idempotency);
|
|
2802
|
+
container.singleton("logger", this._logger);
|
|
2803
|
+
this.monetization = new MonetizationService(container);
|
|
2804
|
+
this.payments = new PaymentService(container);
|
|
2805
|
+
this.transactions = new TransactionService(container);
|
|
2806
|
+
this.escrow = new EscrowService(container);
|
|
2807
|
+
Object.freeze(this._providers);
|
|
2808
|
+
Object.freeze(this._config);
|
|
2809
|
+
}
|
|
2810
|
+
// ============ STATIC FACTORY ============
|
|
2811
|
+
/**
|
|
2812
|
+
* Create a new Revenue builder
|
|
2813
|
+
*
|
|
2814
|
+
* @example
|
|
2815
|
+
* ```typescript
|
|
2816
|
+
* const revenue = Revenue
|
|
2817
|
+
* .create({ defaultCurrency: 'BDT' })
|
|
2818
|
+
* .withModels({ Transaction, Subscription })
|
|
2819
|
+
* .withProvider('manual', new ManualProvider())
|
|
2820
|
+
* .build();
|
|
2821
|
+
* ```
|
|
2822
|
+
*/
|
|
2823
|
+
static create(options = {}) {
|
|
2824
|
+
return new RevenueBuilder(options);
|
|
2825
|
+
}
|
|
2826
|
+
// ============ ACCESSORS ============
|
|
2827
|
+
/** DI container (for advanced usage) */
|
|
2828
|
+
get container() {
|
|
2829
|
+
return this._container;
|
|
2830
|
+
}
|
|
2831
|
+
/** Event bus */
|
|
2832
|
+
get events() {
|
|
2833
|
+
return this._events;
|
|
2834
|
+
}
|
|
2835
|
+
/** Registered providers (frozen) */
|
|
2836
|
+
get providers() {
|
|
2837
|
+
return this._providers;
|
|
2838
|
+
}
|
|
2839
|
+
/** Configuration (frozen) */
|
|
2840
|
+
get config() {
|
|
2841
|
+
return this._config;
|
|
2842
|
+
}
|
|
2843
|
+
/** Default currency */
|
|
2844
|
+
get defaultCurrency() {
|
|
2845
|
+
return this._options.defaultCurrency;
|
|
2846
|
+
}
|
|
2847
|
+
/** Current environment */
|
|
2848
|
+
get environment() {
|
|
2849
|
+
return this._options.environment;
|
|
2850
|
+
}
|
|
2851
|
+
// ============ PROVIDER METHODS ============
|
|
2852
|
+
/**
|
|
2853
|
+
* Get a provider by name
|
|
2854
|
+
*/
|
|
2855
|
+
getProvider(name) {
|
|
2856
|
+
const provider = this._providers[name];
|
|
2857
|
+
if (!provider) {
|
|
2858
|
+
throw new ConfigurationError(
|
|
2859
|
+
`Provider "${name}" not found. Available: ${Object.keys(this._providers).join(", ")}`
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2862
|
+
return provider;
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Get all provider names
|
|
2866
|
+
*/
|
|
2867
|
+
getProviderNames() {
|
|
2868
|
+
return Object.keys(this._providers);
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Check if provider exists
|
|
2872
|
+
*/
|
|
2873
|
+
hasProvider(name) {
|
|
2874
|
+
return name in this._providers;
|
|
2875
|
+
}
|
|
2876
|
+
// ============ EVENT SYSTEM ============
|
|
2877
|
+
/**
|
|
2878
|
+
* Subscribe to events
|
|
2879
|
+
*
|
|
2880
|
+
* @example
|
|
2881
|
+
* ```typescript
|
|
2882
|
+
* revenue.on('payment.succeeded', (event) => {
|
|
2883
|
+
* console.log('Payment:', event.transactionId);
|
|
2884
|
+
* });
|
|
2885
|
+
* ```
|
|
2886
|
+
*/
|
|
2887
|
+
on = (event, handler) => {
|
|
2888
|
+
return this._events.on(event, handler);
|
|
2889
|
+
};
|
|
2890
|
+
/**
|
|
2891
|
+
* Subscribe once
|
|
2892
|
+
*/
|
|
2893
|
+
once = (event, handler) => {
|
|
2894
|
+
return this._events.once(event, handler);
|
|
2895
|
+
};
|
|
2896
|
+
/**
|
|
2897
|
+
* Unsubscribe
|
|
2898
|
+
*/
|
|
2899
|
+
off = (event, handler) => {
|
|
2900
|
+
this._events.off(event, handler);
|
|
2901
|
+
};
|
|
2902
|
+
/**
|
|
2903
|
+
* Emit an event
|
|
2904
|
+
*/
|
|
2905
|
+
emit = (event, payload) => {
|
|
2906
|
+
this._events.emit(event, payload);
|
|
2907
|
+
};
|
|
2908
|
+
// ============ RESILIENCE ============
|
|
2909
|
+
/**
|
|
2910
|
+
* Execute operation with retry and idempotency
|
|
2911
|
+
*/
|
|
2912
|
+
async execute(operation, options = {}) {
|
|
2913
|
+
const { idempotencyKey, params, useRetry = true, useCircuitBreaker = true } = options;
|
|
2914
|
+
const idempotentOp = async () => {
|
|
2915
|
+
if (idempotencyKey) {
|
|
2916
|
+
const result = await this._idempotency.execute(idempotencyKey, params, operation);
|
|
2917
|
+
if (!result.ok) throw result.error;
|
|
2918
|
+
return result.value;
|
|
2919
|
+
}
|
|
2920
|
+
return operation();
|
|
2921
|
+
};
|
|
2922
|
+
const resilientOp = async () => {
|
|
2923
|
+
if (useCircuitBreaker && this._circuitBreaker) {
|
|
2924
|
+
return this._circuitBreaker.execute(idempotentOp);
|
|
2925
|
+
}
|
|
2926
|
+
return idempotentOp();
|
|
2927
|
+
};
|
|
2928
|
+
if (useRetry && this._options.retry) {
|
|
2929
|
+
return tryCatch(() => retry(resilientOp, this._options.retry));
|
|
2930
|
+
}
|
|
2931
|
+
return tryCatch(resilientOp);
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* Create plugin context (for advanced usage)
|
|
2935
|
+
*/
|
|
2936
|
+
createContext(meta = {}) {
|
|
2937
|
+
return {
|
|
2938
|
+
events: this._events,
|
|
2939
|
+
logger: this._logger,
|
|
2940
|
+
get: (key) => this._container.get(key),
|
|
2941
|
+
storage: /* @__PURE__ */ new Map(),
|
|
2942
|
+
meta: {
|
|
2943
|
+
...meta,
|
|
2944
|
+
requestId: nanoid(12),
|
|
2945
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* Destroy instance and cleanup
|
|
2951
|
+
*/
|
|
2952
|
+
async destroy() {
|
|
2953
|
+
await this._plugins.destroy();
|
|
2954
|
+
this._events.clear();
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
var RevenueBuilder = class {
|
|
2958
|
+
options;
|
|
2959
|
+
models = null;
|
|
2960
|
+
providers = {};
|
|
2961
|
+
plugins = [];
|
|
2962
|
+
hooks = {};
|
|
2963
|
+
categoryMappings = {};
|
|
2964
|
+
constructor(options = {}) {
|
|
2965
|
+
this.options = options;
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* Register models (required)
|
|
2969
|
+
*
|
|
2970
|
+
* @example
|
|
2971
|
+
* ```typescript
|
|
2972
|
+
* .withModels({
|
|
2973
|
+
* Transaction: TransactionModel,
|
|
2974
|
+
* Subscription: SubscriptionModel,
|
|
2975
|
+
* })
|
|
2976
|
+
* ```
|
|
2977
|
+
*/
|
|
2978
|
+
withModels(models) {
|
|
2979
|
+
this.models = models;
|
|
2980
|
+
return this;
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Register a single model
|
|
2984
|
+
*/
|
|
2985
|
+
withModel(name, model) {
|
|
2986
|
+
if (!this.models) {
|
|
2987
|
+
this.models = { Transaction: model };
|
|
2988
|
+
}
|
|
2989
|
+
this.models[name] = model;
|
|
2990
|
+
return this;
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Register a payment provider
|
|
2994
|
+
*
|
|
2995
|
+
* @example
|
|
2996
|
+
* ```typescript
|
|
2997
|
+
* .withProvider('stripe', new StripeProvider({ apiKey: '...' }))
|
|
2998
|
+
* .withProvider('manual', new ManualProvider())
|
|
2999
|
+
* ```
|
|
3000
|
+
*/
|
|
3001
|
+
withProvider(name, provider) {
|
|
3002
|
+
this.providers[name] = provider;
|
|
3003
|
+
return this;
|
|
3004
|
+
}
|
|
3005
|
+
/**
|
|
3006
|
+
* Register multiple providers at once
|
|
3007
|
+
*/
|
|
3008
|
+
withProviders(providers) {
|
|
3009
|
+
this.providers = { ...this.providers, ...providers };
|
|
3010
|
+
return this;
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* Register a plugin
|
|
3014
|
+
*
|
|
3015
|
+
* @example
|
|
3016
|
+
* ```typescript
|
|
3017
|
+
* .withPlugin(loggingPlugin())
|
|
3018
|
+
* .withPlugin(auditPlugin({ store: saveToDb }))
|
|
3019
|
+
* ```
|
|
3020
|
+
*/
|
|
3021
|
+
withPlugin(plugin) {
|
|
3022
|
+
this.plugins.push(plugin);
|
|
3023
|
+
return this;
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Register multiple plugins
|
|
3027
|
+
*/
|
|
3028
|
+
withPlugins(plugins) {
|
|
3029
|
+
this.plugins.push(...plugins);
|
|
3030
|
+
return this;
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Register event hooks (for backward compatibility)
|
|
3034
|
+
*
|
|
3035
|
+
* @example
|
|
3036
|
+
* ```typescript
|
|
3037
|
+
* .withHooks({
|
|
3038
|
+
* onPaymentVerified: async (tx) => { ... },
|
|
3039
|
+
* onSubscriptionRenewed: async (sub) => { ... },
|
|
3040
|
+
* })
|
|
3041
|
+
* ```
|
|
3042
|
+
*/
|
|
3043
|
+
withHooks(hooks) {
|
|
3044
|
+
this.hooks = { ...this.hooks, ...hooks };
|
|
3045
|
+
return this;
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Set retry configuration
|
|
3049
|
+
*
|
|
3050
|
+
* @example
|
|
3051
|
+
* ```typescript
|
|
3052
|
+
* .withRetry({ maxAttempts: 5, baseDelay: 2000 })
|
|
3053
|
+
* ```
|
|
3054
|
+
*/
|
|
3055
|
+
withRetry(config) {
|
|
3056
|
+
this.options.retry = config;
|
|
3057
|
+
return this;
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* Enable circuit breaker
|
|
3061
|
+
*/
|
|
3062
|
+
withCircuitBreaker(enabled = true) {
|
|
3063
|
+
this.options.circuitBreaker = enabled;
|
|
3064
|
+
return this;
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Set custom logger
|
|
3068
|
+
*/
|
|
3069
|
+
withLogger(logger2) {
|
|
3070
|
+
this.options.logger = logger2;
|
|
3071
|
+
return this;
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Set environment
|
|
3075
|
+
*/
|
|
3076
|
+
forEnvironment(env) {
|
|
3077
|
+
this.options.environment = env;
|
|
3078
|
+
return this;
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Enable debug mode
|
|
3082
|
+
*/
|
|
3083
|
+
withDebug(enabled = true) {
|
|
3084
|
+
this.options.debug = enabled;
|
|
3085
|
+
return this;
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Set commission rate (0-100)
|
|
3089
|
+
*/
|
|
3090
|
+
withCommission(rate, gatewayFeeRate = 0) {
|
|
3091
|
+
this.options.commissionRate = rate;
|
|
3092
|
+
this.options.gatewayFeeRate = gatewayFeeRate;
|
|
3093
|
+
return this;
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Set category mappings (entity → category)
|
|
3097
|
+
*
|
|
3098
|
+
* @example
|
|
3099
|
+
* ```typescript
|
|
3100
|
+
* .withCategoryMappings({
|
|
3101
|
+
* PlatformSubscription: 'platform_subscription',
|
|
3102
|
+
* CourseEnrollment: 'course_enrollment',
|
|
3103
|
+
* ProductOrder: 'product_order',
|
|
3104
|
+
* })
|
|
3105
|
+
* ```
|
|
3106
|
+
*/
|
|
3107
|
+
withCategoryMappings(mappings) {
|
|
3108
|
+
this.categoryMappings = { ...this.categoryMappings, ...mappings };
|
|
3109
|
+
return this;
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Build the Revenue instance
|
|
3113
|
+
*/
|
|
3114
|
+
build() {
|
|
3115
|
+
if (!this.models) {
|
|
3116
|
+
throw new ConfigurationError(
|
|
3117
|
+
"Models are required. Use .withModels({ Transaction, Subscription })"
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
if (!this.models.Transaction) {
|
|
3121
|
+
throw new ConfigurationError(
|
|
3122
|
+
"Transaction model is required in models configuration"
|
|
3123
|
+
);
|
|
3124
|
+
}
|
|
3125
|
+
if (Object.keys(this.providers).length === 0) {
|
|
3126
|
+
throw new ConfigurationError(
|
|
3127
|
+
"At least one provider is required. Use .withProvider(name, provider)"
|
|
3128
|
+
);
|
|
3129
|
+
}
|
|
3130
|
+
const container = new Container();
|
|
3131
|
+
const defaultLogger = {
|
|
3132
|
+
debug: (msg, data) => this.options.debug && console.debug(`[Revenue] ${msg}`, data ?? ""),
|
|
3133
|
+
info: (msg, data) => console.info(`[Revenue] ${msg}`, data ?? ""),
|
|
3134
|
+
warn: (msg, data) => console.warn(`[Revenue] ${msg}`, data ?? ""),
|
|
3135
|
+
error: (msg, data) => console.error(`[Revenue] ${msg}`, data ?? "")
|
|
3136
|
+
};
|
|
3137
|
+
const resolvedOptions = {
|
|
3138
|
+
defaultCurrency: this.options.defaultCurrency ?? "USD",
|
|
3139
|
+
environment: this.options.environment ?? "development",
|
|
3140
|
+
debug: this.options.debug ?? false,
|
|
3141
|
+
retry: this.options.retry ?? { maxAttempts: 3 },
|
|
3142
|
+
idempotencyTtl: this.options.idempotencyTtl ?? 24 * 60 * 60 * 1e3,
|
|
3143
|
+
circuitBreaker: this.options.circuitBreaker ?? false,
|
|
3144
|
+
logger: this.options.logger ?? defaultLogger,
|
|
3145
|
+
commissionRate: this.options.commissionRate ?? 0,
|
|
3146
|
+
gatewayFeeRate: this.options.gatewayFeeRate ?? 0
|
|
3147
|
+
};
|
|
3148
|
+
const config = {
|
|
3149
|
+
defaultCurrency: resolvedOptions.defaultCurrency,
|
|
3150
|
+
commissionRate: resolvedOptions.commissionRate,
|
|
3151
|
+
gatewayFeeRate: resolvedOptions.gatewayFeeRate,
|
|
3152
|
+
categoryMappings: this.categoryMappings
|
|
3153
|
+
};
|
|
3154
|
+
container.singleton("models", this.models);
|
|
3155
|
+
container.singleton("providers", this.providers);
|
|
3156
|
+
container.singleton("hooks", this.hooks);
|
|
3157
|
+
container.singleton("config", config);
|
|
3158
|
+
const events = createEventBus();
|
|
3159
|
+
const pluginManager = new PluginManager();
|
|
3160
|
+
for (const plugin of this.plugins) {
|
|
3161
|
+
pluginManager.register(plugin);
|
|
3162
|
+
}
|
|
3163
|
+
const revenue = new Revenue(
|
|
3164
|
+
container,
|
|
3165
|
+
events,
|
|
3166
|
+
pluginManager,
|
|
3167
|
+
resolvedOptions,
|
|
3168
|
+
this.providers,
|
|
3169
|
+
config
|
|
3170
|
+
);
|
|
3171
|
+
const ctx = revenue.createContext();
|
|
3172
|
+
pluginManager.init(ctx).catch((err2) => {
|
|
3173
|
+
resolvedOptions.logger.error("Failed to initialize plugins", err2);
|
|
3174
|
+
});
|
|
3175
|
+
return revenue;
|
|
3176
|
+
}
|
|
3177
|
+
};
|
|
3178
|
+
function createRevenue(config) {
|
|
3179
|
+
let builder = Revenue.create(config.options);
|
|
3180
|
+
builder = builder.withModels(config.models);
|
|
3181
|
+
builder = builder.withProviders(config.providers);
|
|
3182
|
+
if (config.plugins) {
|
|
3183
|
+
builder = builder.withPlugins(config.plugins);
|
|
3184
|
+
}
|
|
3185
|
+
if (config.hooks) {
|
|
3186
|
+
builder = builder.withHooks(config.hooks);
|
|
3187
|
+
}
|
|
3188
|
+
return builder.build();
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// src/utils/money.ts
|
|
3192
|
+
var CURRENCIES = {
|
|
3193
|
+
USD: { code: "USD", decimals: 2, symbol: "$", name: "US Dollar" },
|
|
3194
|
+
EUR: { code: "EUR", decimals: 2, symbol: "\u20AC", name: "Euro" },
|
|
3195
|
+
GBP: { code: "GBP", decimals: 2, symbol: "\xA3", name: "British Pound" },
|
|
3196
|
+
BDT: { code: "BDT", decimals: 2, symbol: "\u09F3", name: "Bangladeshi Taka" },
|
|
3197
|
+
INR: { code: "INR", decimals: 2, symbol: "\u20B9", name: "Indian Rupee" },
|
|
3198
|
+
JPY: { code: "JPY", decimals: 0, symbol: "\xA5", name: "Japanese Yen" },
|
|
3199
|
+
CNY: { code: "CNY", decimals: 2, symbol: "\xA5", name: "Chinese Yuan" },
|
|
3200
|
+
AED: { code: "AED", decimals: 2, symbol: "\u062F.\u0625", name: "UAE Dirham" },
|
|
3201
|
+
SAR: { code: "SAR", decimals: 2, symbol: "\uFDFC", name: "Saudi Riyal" },
|
|
3202
|
+
SGD: { code: "SGD", decimals: 2, symbol: "S$", name: "Singapore Dollar" },
|
|
3203
|
+
AUD: { code: "AUD", decimals: 2, symbol: "A$", name: "Australian Dollar" },
|
|
3204
|
+
CAD: { code: "CAD", decimals: 2, symbol: "C$", name: "Canadian Dollar" }
|
|
3205
|
+
};
|
|
3206
|
+
var Money = class _Money {
|
|
3207
|
+
amount;
|
|
3208
|
+
currency;
|
|
3209
|
+
constructor(amount, currency) {
|
|
3210
|
+
if (!Number.isInteger(amount)) {
|
|
3211
|
+
throw new Error(`Money amount must be integer (smallest unit). Got: ${amount}`);
|
|
3212
|
+
}
|
|
3213
|
+
this.amount = amount;
|
|
3214
|
+
this.currency = currency.toUpperCase();
|
|
3215
|
+
}
|
|
3216
|
+
// ============ FACTORY METHODS ============
|
|
3217
|
+
/**
|
|
3218
|
+
* Create money from smallest unit (cents, paisa)
|
|
3219
|
+
* @example Money.cents(1999, 'USD') // $19.99
|
|
3220
|
+
*/
|
|
3221
|
+
static cents(amount, currency = "USD") {
|
|
3222
|
+
return new _Money(Math.round(amount), currency);
|
|
3223
|
+
}
|
|
3224
|
+
/**
|
|
3225
|
+
* Create money from major unit (dollars, taka)
|
|
3226
|
+
* @example Money.of(19.99, 'USD') // $19.99 (stored as 1999 cents)
|
|
3227
|
+
*/
|
|
3228
|
+
static of(amount, currency = "USD") {
|
|
3229
|
+
const config = CURRENCIES[currency.toUpperCase()] ?? { decimals: 2 };
|
|
3230
|
+
const multiplier = Math.pow(10, config.decimals);
|
|
3231
|
+
return new _Money(Math.round(amount * multiplier), currency);
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Create zero money
|
|
3235
|
+
*/
|
|
3236
|
+
static zero(currency = "USD") {
|
|
3237
|
+
return new _Money(0, currency);
|
|
3238
|
+
}
|
|
3239
|
+
// ============ SHORTHAND FACTORIES ============
|
|
3240
|
+
static usd(cents) {
|
|
3241
|
+
return _Money.cents(cents, "USD");
|
|
3242
|
+
}
|
|
3243
|
+
static eur(cents) {
|
|
3244
|
+
return _Money.cents(cents, "EUR");
|
|
3245
|
+
}
|
|
3246
|
+
static gbp(pence) {
|
|
3247
|
+
return _Money.cents(pence, "GBP");
|
|
3248
|
+
}
|
|
3249
|
+
static bdt(paisa) {
|
|
3250
|
+
return _Money.cents(paisa, "BDT");
|
|
3251
|
+
}
|
|
3252
|
+
static inr(paisa) {
|
|
3253
|
+
return _Money.cents(paisa, "INR");
|
|
3254
|
+
}
|
|
3255
|
+
static jpy(yen) {
|
|
3256
|
+
return _Money.cents(yen, "JPY");
|
|
3257
|
+
}
|
|
3258
|
+
// ============ ARITHMETIC ============
|
|
3259
|
+
/**
|
|
3260
|
+
* Add two money values (must be same currency)
|
|
3261
|
+
*/
|
|
3262
|
+
add(other) {
|
|
3263
|
+
this.assertSameCurrency(other);
|
|
3264
|
+
return new _Money(this.amount + other.amount, this.currency);
|
|
3265
|
+
}
|
|
3266
|
+
/**
|
|
3267
|
+
* Subtract money (must be same currency)
|
|
3268
|
+
*/
|
|
3269
|
+
subtract(other) {
|
|
3270
|
+
this.assertSameCurrency(other);
|
|
3271
|
+
return new _Money(this.amount - other.amount, this.currency);
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Multiply by a factor (rounds to nearest integer)
|
|
3275
|
+
*/
|
|
3276
|
+
multiply(factor) {
|
|
3277
|
+
return new _Money(Math.round(this.amount * factor), this.currency);
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* Divide by a divisor (rounds to nearest integer)
|
|
3281
|
+
*/
|
|
3282
|
+
divide(divisor) {
|
|
3283
|
+
if (divisor === 0) throw new Error("Cannot divide by zero");
|
|
3284
|
+
return new _Money(Math.round(this.amount / divisor), this.currency);
|
|
3285
|
+
}
|
|
3286
|
+
/**
|
|
3287
|
+
* Calculate percentage
|
|
3288
|
+
* @example money.percentage(10) // 10% of money
|
|
3289
|
+
*/
|
|
3290
|
+
percentage(percent) {
|
|
3291
|
+
return this.multiply(percent / 100);
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Allocate money among recipients (handles rounding)
|
|
3295
|
+
* @example Money.usd(100).allocate([1, 1, 1]) // [34, 33, 33] cents
|
|
3296
|
+
*/
|
|
3297
|
+
allocate(ratios) {
|
|
3298
|
+
const total = ratios.reduce((a, b) => a + b, 0);
|
|
3299
|
+
if (total === 0) throw new Error("Ratios must sum to more than 0");
|
|
3300
|
+
let remainder = this.amount;
|
|
3301
|
+
const results = [];
|
|
3302
|
+
for (let i = 0; i < ratios.length; i++) {
|
|
3303
|
+
const share = Math.floor(this.amount * ratios[i] / total);
|
|
3304
|
+
results.push(new _Money(share, this.currency));
|
|
3305
|
+
remainder -= share;
|
|
3306
|
+
}
|
|
3307
|
+
for (let i = 0; i < remainder; i++) {
|
|
3308
|
+
results[i] = new _Money(results[i].amount + 1, this.currency);
|
|
3309
|
+
}
|
|
3310
|
+
return results;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Split equally among n recipients
|
|
3314
|
+
*/
|
|
3315
|
+
split(parts) {
|
|
3316
|
+
return this.allocate(Array(parts).fill(1));
|
|
3317
|
+
}
|
|
3318
|
+
// ============ COMPARISON ============
|
|
3319
|
+
isZero() {
|
|
3320
|
+
return this.amount === 0;
|
|
3321
|
+
}
|
|
3322
|
+
isPositive() {
|
|
3323
|
+
return this.amount > 0;
|
|
3324
|
+
}
|
|
3325
|
+
isNegative() {
|
|
3326
|
+
return this.amount < 0;
|
|
3327
|
+
}
|
|
3328
|
+
equals(other) {
|
|
3329
|
+
return this.amount === other.amount && this.currency === other.currency;
|
|
3330
|
+
}
|
|
3331
|
+
greaterThan(other) {
|
|
3332
|
+
this.assertSameCurrency(other);
|
|
3333
|
+
return this.amount > other.amount;
|
|
3334
|
+
}
|
|
3335
|
+
lessThan(other) {
|
|
3336
|
+
this.assertSameCurrency(other);
|
|
3337
|
+
return this.amount < other.amount;
|
|
3338
|
+
}
|
|
3339
|
+
greaterThanOrEqual(other) {
|
|
3340
|
+
return this.greaterThan(other) || this.equals(other);
|
|
3341
|
+
}
|
|
3342
|
+
lessThanOrEqual(other) {
|
|
3343
|
+
return this.lessThan(other) || this.equals(other);
|
|
3344
|
+
}
|
|
3345
|
+
// ============ FORMATTING ============
|
|
3346
|
+
/**
|
|
3347
|
+
* Get amount in major unit (dollars, taka)
|
|
3348
|
+
*/
|
|
3349
|
+
toUnit() {
|
|
3350
|
+
const config = CURRENCIES[this.currency] ?? { decimals: 2 };
|
|
3351
|
+
return this.amount / Math.pow(10, config.decimals);
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* Format for display
|
|
3355
|
+
* @example Money.usd(1999).format() // "$19.99"
|
|
3356
|
+
*/
|
|
3357
|
+
format(locale = "en-US") {
|
|
3358
|
+
return new Intl.NumberFormat(locale, {
|
|
3359
|
+
style: "currency",
|
|
3360
|
+
currency: this.currency
|
|
3361
|
+
}).format(this.toUnit());
|
|
3362
|
+
}
|
|
3363
|
+
/**
|
|
3364
|
+
* Format without currency symbol
|
|
3365
|
+
*/
|
|
3366
|
+
formatAmount(locale = "en-US") {
|
|
3367
|
+
const config = CURRENCIES[this.currency] ?? { decimals: 2 };
|
|
3368
|
+
return new Intl.NumberFormat(locale, {
|
|
3369
|
+
minimumFractionDigits: config.decimals,
|
|
3370
|
+
maximumFractionDigits: config.decimals
|
|
3371
|
+
}).format(this.toUnit());
|
|
3372
|
+
}
|
|
3373
|
+
/**
|
|
3374
|
+
* Convert to JSON-serializable object
|
|
3375
|
+
*/
|
|
3376
|
+
toJSON() {
|
|
3377
|
+
return { amount: this.amount, currency: this.currency };
|
|
3378
|
+
}
|
|
3379
|
+
/**
|
|
3380
|
+
* Create from JSON
|
|
3381
|
+
*/
|
|
3382
|
+
static fromJSON(json) {
|
|
3383
|
+
return new _Money(json.amount, json.currency);
|
|
3384
|
+
}
|
|
3385
|
+
toString() {
|
|
3386
|
+
return `${this.currency} ${this.amount}`;
|
|
3387
|
+
}
|
|
3388
|
+
// ============ HELPERS ============
|
|
3389
|
+
assertSameCurrency(other) {
|
|
3390
|
+
if (this.currency !== other.currency) {
|
|
3391
|
+
throw new Error(
|
|
3392
|
+
`Currency mismatch: ${this.currency} vs ${other.currency}. Convert first.`
|
|
3393
|
+
);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
};
|
|
3397
|
+
function toSmallestUnit(amount, currency = "USD") {
|
|
3398
|
+
return Money.of(amount, currency).amount;
|
|
3399
|
+
}
|
|
3400
|
+
function fromSmallestUnit(amount, currency = "USD") {
|
|
3401
|
+
return Money.cents(amount, currency).toUnit();
|
|
3402
|
+
}
|
|
3403
|
+
var ObjectIdSchema = z.string().regex(
|
|
3404
|
+
/^[a-fA-F0-9]{24}$/,
|
|
3405
|
+
"Invalid ObjectId format"
|
|
3406
|
+
);
|
|
3407
|
+
var CurrencySchema = z.string().length(3, "Currency must be 3 characters").transform((val) => val.toUpperCase()).default("USD");
|
|
3408
|
+
var MoneyAmountSchema = z.number().int("Amount must be integer (smallest unit)").nonnegative("Amount cannot be negative");
|
|
3409
|
+
var MoneySchema = z.object({
|
|
3410
|
+
amount: MoneyAmountSchema,
|
|
3411
|
+
currency: z.string().length(3).default("USD")
|
|
3412
|
+
});
|
|
3413
|
+
var EmailSchema = z.string().email();
|
|
3414
|
+
var IdempotencyKeySchema = z.string().min(1).max(255).optional();
|
|
3415
|
+
var MetadataSchema = z.record(z.string(), z.unknown()).optional().default({});
|
|
3416
|
+
var CreatePaymentSchema = z.object({
|
|
3417
|
+
/** Amount in smallest currency unit (cents) */
|
|
3418
|
+
amount: MoneyAmountSchema,
|
|
3419
|
+
/** ISO 4217 currency code */
|
|
3420
|
+
currency: z.string().length(3).default("USD"),
|
|
3421
|
+
/** Customer identifier */
|
|
3422
|
+
customerId: z.string().min(1, "Customer ID is required"),
|
|
3423
|
+
/** Organization/merchant identifier */
|
|
3424
|
+
organizationId: z.string().min(1, "Organization ID is required"),
|
|
3425
|
+
/** Payment provider to use */
|
|
3426
|
+
provider: z.string().min(1, "Provider is required"),
|
|
3427
|
+
/** Idempotency key for safe retries */
|
|
3428
|
+
idempotencyKey: IdempotencyKeySchema,
|
|
3429
|
+
/** Description of the payment */
|
|
3430
|
+
description: z.string().optional(),
|
|
3431
|
+
/** Additional metadata */
|
|
3432
|
+
metadata: MetadataSchema,
|
|
3433
|
+
/** Success redirect URL */
|
|
3434
|
+
successUrl: z.string().url().optional(),
|
|
3435
|
+
/** Cancel redirect URL */
|
|
3436
|
+
cancelUrl: z.string().url().optional()
|
|
3437
|
+
});
|
|
3438
|
+
var VerifyPaymentSchema = z.object({
|
|
3439
|
+
/** Transaction ID or payment intent ID */
|
|
3440
|
+
id: z.string().min(1),
|
|
3441
|
+
/** Provider name (optional, auto-detected) */
|
|
3442
|
+
provider: z.string().optional(),
|
|
3443
|
+
/** Additional verification data */
|
|
3444
|
+
data: z.record(z.string(), z.unknown()).optional()
|
|
3445
|
+
});
|
|
3446
|
+
var RefundSchema = z.object({
|
|
3447
|
+
/** Transaction ID to refund */
|
|
3448
|
+
transactionId: z.string().min(1),
|
|
3449
|
+
/** Amount to refund (optional, full refund if not provided) */
|
|
3450
|
+
amount: MoneyAmountSchema.optional(),
|
|
3451
|
+
/** Reason for refund */
|
|
3452
|
+
reason: z.string().optional(),
|
|
3453
|
+
/** Idempotency key */
|
|
3454
|
+
idempotencyKey: IdempotencyKeySchema,
|
|
3455
|
+
/** Additional metadata */
|
|
3456
|
+
metadata: MetadataSchema
|
|
3457
|
+
});
|
|
3458
|
+
var SubscriptionStatusSchema = z.enum([
|
|
3459
|
+
"pending",
|
|
3460
|
+
"active",
|
|
3461
|
+
"paused",
|
|
3462
|
+
"cancelled",
|
|
3463
|
+
"expired",
|
|
3464
|
+
"past_due"
|
|
3465
|
+
]);
|
|
3466
|
+
var IntervalSchema = z.enum([
|
|
3467
|
+
"day",
|
|
3468
|
+
"week",
|
|
3469
|
+
"month",
|
|
3470
|
+
"year",
|
|
3471
|
+
"one_time"
|
|
3472
|
+
]);
|
|
3473
|
+
var CreateSubscriptionSchema = z.object({
|
|
3474
|
+
/** Customer ID */
|
|
3475
|
+
customerId: z.string().min(1),
|
|
3476
|
+
/** Organization ID */
|
|
3477
|
+
organizationId: z.string().min(1),
|
|
3478
|
+
/** Plan identifier */
|
|
3479
|
+
planKey: z.string().min(1),
|
|
3480
|
+
/** Amount per period (smallest unit) */
|
|
3481
|
+
amount: MoneyAmountSchema,
|
|
3482
|
+
/** Currency */
|
|
3483
|
+
currency: z.string().length(3).default("USD"),
|
|
3484
|
+
/** Billing interval */
|
|
3485
|
+
interval: IntervalSchema.default("month"),
|
|
3486
|
+
/** Interval count (e.g., 2 for bi-monthly) */
|
|
3487
|
+
intervalCount: z.number().int().positive().default(1),
|
|
3488
|
+
/** Payment provider */
|
|
3489
|
+
provider: z.string().min(1),
|
|
3490
|
+
/** Reference to external entity */
|
|
3491
|
+
referenceId: z.string().optional(),
|
|
3492
|
+
/** Reference model name */
|
|
3493
|
+
referenceModel: z.string().optional(),
|
|
3494
|
+
/** Idempotency key */
|
|
3495
|
+
idempotencyKey: IdempotencyKeySchema,
|
|
3496
|
+
/** Metadata */
|
|
3497
|
+
metadata: MetadataSchema,
|
|
3498
|
+
/** Trial period in days */
|
|
3499
|
+
trialDays: z.number().int().nonnegative().optional()
|
|
3500
|
+
});
|
|
3501
|
+
var CancelSubscriptionSchema = z.object({
|
|
3502
|
+
/** Subscription ID */
|
|
3503
|
+
subscriptionId: z.string().min(1),
|
|
3504
|
+
/** Cancel immediately or at period end */
|
|
3505
|
+
immediate: z.boolean().default(false),
|
|
3506
|
+
/** Cancellation reason */
|
|
3507
|
+
reason: z.string().optional()
|
|
3508
|
+
});
|
|
3509
|
+
var MonetizationTypeSchema = z.enum([
|
|
3510
|
+
"purchase",
|
|
3511
|
+
"subscription",
|
|
3512
|
+
"free"
|
|
3513
|
+
]);
|
|
3514
|
+
var CreateMonetizationSchema = z.object({
|
|
3515
|
+
/** Type of monetization */
|
|
3516
|
+
type: MonetizationTypeSchema.default("purchase"),
|
|
3517
|
+
/** Amount (smallest unit) - required for purchase/subscription */
|
|
3518
|
+
amount: MoneyAmountSchema.optional(),
|
|
3519
|
+
/** Currency */
|
|
3520
|
+
currency: z.string().length(3).default("USD"),
|
|
3521
|
+
/** Customer ID */
|
|
3522
|
+
customerId: z.string().min(1),
|
|
3523
|
+
/** Organization ID */
|
|
3524
|
+
organizationId: z.string().min(1),
|
|
3525
|
+
/** Payment provider */
|
|
3526
|
+
provider: z.string().min(1),
|
|
3527
|
+
/** Plan key for categorization */
|
|
3528
|
+
planKey: z.string().optional(),
|
|
3529
|
+
/** Reference ID */
|
|
3530
|
+
referenceId: z.string().optional(),
|
|
3531
|
+
/** Reference model */
|
|
3532
|
+
referenceModel: z.string().optional(),
|
|
3533
|
+
/** Idempotency key */
|
|
3534
|
+
idempotencyKey: IdempotencyKeySchema,
|
|
3535
|
+
/** Metadata */
|
|
3536
|
+
metadata: MetadataSchema,
|
|
3537
|
+
/** Subscription-specific: interval */
|
|
3538
|
+
interval: IntervalSchema.optional(),
|
|
3539
|
+
/** Subscription-specific: trial days */
|
|
3540
|
+
trialDays: z.number().int().nonnegative().optional()
|
|
3541
|
+
}).refine(
|
|
3542
|
+
(data) => {
|
|
3543
|
+
if (data.type !== "free" && !data.amount) {
|
|
3544
|
+
return false;
|
|
3545
|
+
}
|
|
3546
|
+
return true;
|
|
3547
|
+
},
|
|
3548
|
+
{ message: "Amount is required for non-free monetization types" }
|
|
3549
|
+
);
|
|
3550
|
+
var SplitRecipientSchema = z.object({
|
|
3551
|
+
/** Recipient ID */
|
|
3552
|
+
recipientId: z.string().min(1),
|
|
3553
|
+
/** Recipient type (user, organization, etc.) */
|
|
3554
|
+
recipientType: z.string().default("user"),
|
|
3555
|
+
/** Percentage of net amount (0-100) */
|
|
3556
|
+
percentage: z.number().min(0).max(100),
|
|
3557
|
+
/** Role description */
|
|
3558
|
+
role: z.string().optional()
|
|
3559
|
+
});
|
|
3560
|
+
var CommissionConfigSchema = z.object({
|
|
3561
|
+
/** Platform commission rate (0-100) */
|
|
3562
|
+
platformRate: z.number().min(0).max(100).default(0),
|
|
3563
|
+
/** Gateway fee rate (0-100) */
|
|
3564
|
+
gatewayFeeRate: z.number().min(0).max(100).default(0),
|
|
3565
|
+
/** Fixed gateway fee (smallest unit) */
|
|
3566
|
+
gatewayFixedFee: MoneyAmountSchema.default(0),
|
|
3567
|
+
/** Split recipients */
|
|
3568
|
+
splits: z.array(SplitRecipientSchema).optional(),
|
|
3569
|
+
/** Affiliate configuration */
|
|
3570
|
+
affiliate: z.object({
|
|
3571
|
+
recipientId: z.string(),
|
|
3572
|
+
recipientType: z.string().default("user"),
|
|
3573
|
+
rate: z.number().min(0).max(100)
|
|
3574
|
+
}).optional()
|
|
3575
|
+
});
|
|
3576
|
+
var HoldStatusSchema = z.enum([
|
|
3577
|
+
"none",
|
|
3578
|
+
"held",
|
|
3579
|
+
"partial_release",
|
|
3580
|
+
"released",
|
|
3581
|
+
"cancelled"
|
|
3582
|
+
]);
|
|
3583
|
+
var CreateHoldSchema = z.object({
|
|
3584
|
+
/** Transaction ID */
|
|
3585
|
+
transactionId: z.string().min(1),
|
|
3586
|
+
/** Hold amount (optional, defaults to full transaction amount) */
|
|
3587
|
+
amount: MoneyAmountSchema.optional(),
|
|
3588
|
+
/** Hold until date */
|
|
3589
|
+
holdUntil: z.date().optional(),
|
|
3590
|
+
/** Reason for hold */
|
|
3591
|
+
reason: z.string().optional()
|
|
3592
|
+
});
|
|
3593
|
+
var ReleaseHoldSchema = z.object({
|
|
3594
|
+
/** Transaction ID */
|
|
3595
|
+
transactionId: z.string().min(1),
|
|
3596
|
+
/** Amount to release (optional, full release if not provided) */
|
|
3597
|
+
amount: MoneyAmountSchema.optional(),
|
|
3598
|
+
/** Recipient ID */
|
|
3599
|
+
recipientId: z.string().min(1),
|
|
3600
|
+
/** Recipient type */
|
|
3601
|
+
recipientType: z.string().default("user"),
|
|
3602
|
+
/** Release notes */
|
|
3603
|
+
notes: z.string().optional()
|
|
3604
|
+
});
|
|
3605
|
+
var ProviderConfigSchema = z.record(z.string(), z.unknown());
|
|
3606
|
+
var RetryConfigSchema = z.object({
|
|
3607
|
+
/** Maximum retry attempts */
|
|
3608
|
+
maxAttempts: z.number().int().positive().default(3),
|
|
3609
|
+
/** Base delay in ms */
|
|
3610
|
+
baseDelay: z.number().positive().default(1e3),
|
|
3611
|
+
/** Maximum delay in ms */
|
|
3612
|
+
maxDelay: z.number().positive().default(3e4),
|
|
3613
|
+
/** Backoff multiplier */
|
|
3614
|
+
backoffMultiplier: z.number().positive().default(2),
|
|
3615
|
+
/** Jitter factor (0-1) */
|
|
3616
|
+
jitter: z.number().min(0).max(1).default(0.1)
|
|
3617
|
+
});
|
|
3618
|
+
var RevenueConfigSchema = z.object({
|
|
3619
|
+
/** Default currency */
|
|
3620
|
+
defaultCurrency: z.string().length(3).default("USD"),
|
|
3621
|
+
/** Commission configuration */
|
|
3622
|
+
commission: CommissionConfigSchema.optional(),
|
|
3623
|
+
/** Retry configuration */
|
|
3624
|
+
retry: RetryConfigSchema.optional(),
|
|
3625
|
+
/** Enable debug logging */
|
|
3626
|
+
debug: z.boolean().default(false),
|
|
3627
|
+
/** Environment */
|
|
3628
|
+
environment: z.enum(["development", "staging", "production"]).default("development")
|
|
3629
|
+
});
|
|
3630
|
+
function validate(schema, data) {
|
|
3631
|
+
return schema.parse(data);
|
|
3632
|
+
}
|
|
3633
|
+
function safeValidate(schema, data) {
|
|
3634
|
+
const result = schema.safeParse(data);
|
|
3635
|
+
if (result.success) {
|
|
3636
|
+
return { success: true, data: result.data };
|
|
3637
|
+
}
|
|
3638
|
+
return { success: false, error: result.error };
|
|
3639
|
+
}
|
|
3640
|
+
function formatZodError(error) {
|
|
3641
|
+
return error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
// src/enums/payment.enums.ts
|
|
3645
|
+
var PAYMENT_STATUS = {
|
|
3646
|
+
PENDING: "pending",
|
|
3647
|
+
VERIFIED: "verified",
|
|
3648
|
+
FAILED: "failed",
|
|
3649
|
+
REFUNDED: "refunded",
|
|
3650
|
+
CANCELLED: "cancelled"
|
|
3651
|
+
};
|
|
3652
|
+
var PAYMENT_STATUS_VALUES = Object.values(PAYMENT_STATUS);
|
|
3653
|
+
var PAYMENT_GATEWAY_TYPE = {
|
|
3654
|
+
MANUAL: "manual",
|
|
3655
|
+
STRIPE: "stripe",
|
|
3656
|
+
SSLCOMMERZ: "sslcommerz"
|
|
3657
|
+
};
|
|
3658
|
+
var PAYMENT_GATEWAY_TYPE_VALUES = Object.values(PAYMENT_GATEWAY_TYPE);
|
|
3659
|
+
var GATEWAY_TYPES = PAYMENT_GATEWAY_TYPE;
|
|
3660
|
+
var GATEWAY_TYPE_VALUES = PAYMENT_GATEWAY_TYPE_VALUES;
|
|
3661
|
+
|
|
3662
|
+
// src/enums/subscription.enums.ts
|
|
3663
|
+
var SUBSCRIPTION_STATUS = {
|
|
3664
|
+
ACTIVE: "active",
|
|
3665
|
+
PAUSED: "paused",
|
|
3666
|
+
CANCELLED: "cancelled",
|
|
3667
|
+
EXPIRED: "expired",
|
|
3668
|
+
PENDING: "pending",
|
|
3669
|
+
INACTIVE: "inactive"
|
|
3670
|
+
};
|
|
3671
|
+
var SUBSCRIPTION_STATUS_VALUES = Object.values(SUBSCRIPTION_STATUS);
|
|
3672
|
+
var PLAN_KEYS = {
|
|
3673
|
+
MONTHLY: "monthly",
|
|
3674
|
+
QUARTERLY: "quarterly",
|
|
3675
|
+
YEARLY: "yearly"
|
|
3676
|
+
};
|
|
3677
|
+
var PLAN_KEY_VALUES = Object.values(PLAN_KEYS);
|
|
3678
|
+
var baseMetadataSchema = new Schema(
|
|
3679
|
+
{
|
|
3680
|
+
// Flexible key-value metadata
|
|
3681
|
+
},
|
|
3682
|
+
{ _id: false, strict: false }
|
|
3683
|
+
);
|
|
3684
|
+
var referenceSchema = {
|
|
3685
|
+
referenceId: {
|
|
3686
|
+
type: Schema.Types.ObjectId,
|
|
3687
|
+
refPath: "referenceModel",
|
|
3688
|
+
index: true
|
|
3689
|
+
},
|
|
3690
|
+
referenceModel: {
|
|
3691
|
+
type: String,
|
|
3692
|
+
enum: ["Subscription", "Order", "Membership", "Booking", "Invoice"]
|
|
3693
|
+
}
|
|
3694
|
+
};
|
|
3695
|
+
var gatewaySchema = new Schema(
|
|
3696
|
+
{
|
|
3697
|
+
type: {
|
|
3698
|
+
type: String,
|
|
3699
|
+
required: true,
|
|
3700
|
+
index: true
|
|
3701
|
+
},
|
|
3702
|
+
sessionId: {
|
|
3703
|
+
type: String,
|
|
3704
|
+
sparse: true,
|
|
3705
|
+
index: true
|
|
3706
|
+
},
|
|
3707
|
+
paymentIntentId: {
|
|
3708
|
+
type: String,
|
|
3709
|
+
sparse: true,
|
|
3710
|
+
index: true
|
|
3711
|
+
},
|
|
3712
|
+
provider: {
|
|
3713
|
+
type: String
|
|
3714
|
+
},
|
|
3715
|
+
metadata: {
|
|
3716
|
+
type: Schema.Types.Mixed,
|
|
3717
|
+
default: {}
|
|
3718
|
+
},
|
|
3719
|
+
verificationData: {
|
|
3720
|
+
type: Schema.Types.Mixed
|
|
3721
|
+
}
|
|
3722
|
+
},
|
|
3723
|
+
{ _id: false }
|
|
3724
|
+
);
|
|
3725
|
+
var currentPaymentSchema = new Schema(
|
|
3726
|
+
{
|
|
3727
|
+
transactionId: {
|
|
3728
|
+
type: Schema.Types.ObjectId,
|
|
3729
|
+
ref: "Transaction",
|
|
3730
|
+
index: true
|
|
3731
|
+
},
|
|
3732
|
+
amount: {
|
|
3733
|
+
type: Number,
|
|
3734
|
+
min: 0
|
|
3735
|
+
},
|
|
3736
|
+
status: {
|
|
3737
|
+
type: String,
|
|
3738
|
+
enum: PAYMENT_STATUS_VALUES,
|
|
3739
|
+
default: "pending",
|
|
3740
|
+
index: true
|
|
3741
|
+
},
|
|
3742
|
+
method: {
|
|
3743
|
+
type: String
|
|
3744
|
+
// Users define payment methods in their transaction model
|
|
3745
|
+
},
|
|
3746
|
+
reference: {
|
|
3747
|
+
type: String,
|
|
3748
|
+
trim: true
|
|
3749
|
+
},
|
|
3750
|
+
verifiedAt: {
|
|
3751
|
+
type: Date
|
|
3752
|
+
},
|
|
3753
|
+
verifiedBy: {
|
|
3754
|
+
type: Schema.Types.ObjectId,
|
|
3755
|
+
ref: "User"
|
|
3756
|
+
},
|
|
3757
|
+
// Refund tracking fields
|
|
3758
|
+
refundedAmount: {
|
|
3759
|
+
type: Number,
|
|
3760
|
+
default: 0,
|
|
3761
|
+
min: 0
|
|
3762
|
+
},
|
|
3763
|
+
refundedAt: {
|
|
3764
|
+
type: Date
|
|
3765
|
+
}
|
|
3766
|
+
},
|
|
3767
|
+
{ _id: false }
|
|
3768
|
+
);
|
|
3769
|
+
var paymentSummarySchema = new Schema(
|
|
3770
|
+
{
|
|
3771
|
+
totalPayments: {
|
|
3772
|
+
type: Number,
|
|
3773
|
+
default: 0,
|
|
3774
|
+
min: 0
|
|
3775
|
+
},
|
|
3776
|
+
totalAmountPaid: {
|
|
3777
|
+
type: Number,
|
|
3778
|
+
default: 0,
|
|
3779
|
+
min: 0
|
|
3780
|
+
},
|
|
3781
|
+
lastPaymentDate: {
|
|
3782
|
+
type: Date
|
|
3783
|
+
},
|
|
3784
|
+
lastPaymentAmount: {
|
|
3785
|
+
type: Number,
|
|
3786
|
+
min: 0
|
|
3787
|
+
}
|
|
3788
|
+
},
|
|
3789
|
+
{ _id: false }
|
|
3790
|
+
);
|
|
3791
|
+
var paymentDetailsSchema = new Schema(
|
|
3792
|
+
{
|
|
3793
|
+
provider: { type: String },
|
|
3794
|
+
walletNumber: { type: String },
|
|
3795
|
+
walletType: { type: String },
|
|
3796
|
+
trxId: { type: String },
|
|
3797
|
+
bankName: { type: String },
|
|
3798
|
+
accountNumber: { type: String },
|
|
3799
|
+
accountName: { type: String },
|
|
3800
|
+
proofUrl: { type: String }
|
|
3801
|
+
},
|
|
3802
|
+
{ _id: false }
|
|
3803
|
+
);
|
|
3804
|
+
var tenantSnapshotSchema = new Schema(
|
|
3805
|
+
{
|
|
3806
|
+
paymentInstructions: { type: String },
|
|
3807
|
+
bkashNumber: { type: String },
|
|
3808
|
+
nagadNumber: { type: String },
|
|
3809
|
+
bankAccount: { type: String }
|
|
3810
|
+
},
|
|
3811
|
+
{ _id: false }
|
|
3812
|
+
);
|
|
3813
|
+
var commissionSchema = new Schema(
|
|
3814
|
+
{
|
|
3815
|
+
// Commission rate (e.g., 0.10 for 10%)
|
|
3816
|
+
rate: {
|
|
3817
|
+
type: Number,
|
|
3818
|
+
min: 0,
|
|
3819
|
+
max: 1
|
|
3820
|
+
},
|
|
3821
|
+
// Gross commission amount (before gateway fees)
|
|
3822
|
+
grossAmount: {
|
|
3823
|
+
type: Number,
|
|
3824
|
+
min: 0
|
|
3825
|
+
},
|
|
3826
|
+
// Gateway fee rate (e.g., 0.029 for 2.9%)
|
|
3827
|
+
gatewayFeeRate: {
|
|
3828
|
+
type: Number,
|
|
3829
|
+
min: 0,
|
|
3830
|
+
max: 1
|
|
3831
|
+
},
|
|
3832
|
+
// Gateway fee amount deducted from commission
|
|
3833
|
+
gatewayFeeAmount: {
|
|
3834
|
+
type: Number,
|
|
3835
|
+
min: 0
|
|
3836
|
+
},
|
|
3837
|
+
// Net commission (grossAmount - gatewayFeeAmount)
|
|
3838
|
+
netAmount: {
|
|
3839
|
+
type: Number,
|
|
3840
|
+
min: 0
|
|
3841
|
+
},
|
|
3842
|
+
// Commission status
|
|
3843
|
+
status: {
|
|
3844
|
+
type: String,
|
|
3845
|
+
enum: ["pending", "paid", "waived", "reversed"],
|
|
3846
|
+
default: "pending"
|
|
3847
|
+
},
|
|
3848
|
+
// For affiliate tracking
|
|
3849
|
+
affiliate: {
|
|
3850
|
+
recipientId: String,
|
|
3851
|
+
recipientType: {
|
|
3852
|
+
type: String,
|
|
3853
|
+
enum: ["user", "organization", "partner"]
|
|
3854
|
+
},
|
|
3855
|
+
rate: Number,
|
|
3856
|
+
grossAmount: Number,
|
|
3857
|
+
netAmount: Number
|
|
3858
|
+
},
|
|
3859
|
+
// For multi-party splits
|
|
3860
|
+
splits: [
|
|
3861
|
+
{
|
|
3862
|
+
type: String,
|
|
3863
|
+
recipientId: String,
|
|
3864
|
+
rate: Number,
|
|
3865
|
+
grossAmount: Number,
|
|
3866
|
+
netAmount: Number
|
|
3867
|
+
}
|
|
3868
|
+
]
|
|
3869
|
+
},
|
|
3870
|
+
{ _id: false }
|
|
3871
|
+
);
|
|
3872
|
+
var planSchema = new Schema(
|
|
3873
|
+
{
|
|
3874
|
+
key: {
|
|
3875
|
+
type: String,
|
|
3876
|
+
enum: PLAN_KEY_VALUES,
|
|
3877
|
+
required: true
|
|
3878
|
+
},
|
|
3879
|
+
name: {
|
|
3880
|
+
type: String,
|
|
3881
|
+
required: true
|
|
3882
|
+
},
|
|
3883
|
+
description: {
|
|
3884
|
+
type: String
|
|
3885
|
+
},
|
|
3886
|
+
amount: {
|
|
3887
|
+
type: Number,
|
|
3888
|
+
required: true,
|
|
3889
|
+
min: 0
|
|
3890
|
+
},
|
|
3891
|
+
currency: {
|
|
3892
|
+
type: String,
|
|
3893
|
+
default: "BDT"
|
|
3894
|
+
},
|
|
3895
|
+
interval: {
|
|
3896
|
+
type: String,
|
|
3897
|
+
enum: ["day", "week", "month", "year"],
|
|
3898
|
+
default: "month"
|
|
3899
|
+
},
|
|
3900
|
+
intervalCount: {
|
|
3901
|
+
type: Number,
|
|
3902
|
+
default: 1,
|
|
3903
|
+
min: 1
|
|
3904
|
+
},
|
|
3905
|
+
features: [
|
|
3906
|
+
{
|
|
3907
|
+
type: String
|
|
3908
|
+
}
|
|
3909
|
+
],
|
|
3910
|
+
metadata: {
|
|
3911
|
+
type: Schema.Types.Mixed,
|
|
3912
|
+
default: {}
|
|
3913
|
+
},
|
|
3914
|
+
isActive: {
|
|
3915
|
+
type: Boolean,
|
|
3916
|
+
default: true
|
|
3917
|
+
}
|
|
3918
|
+
},
|
|
3919
|
+
{ _id: false }
|
|
3920
|
+
);
|
|
3921
|
+
var subscriptionInfoSchema = new Schema(
|
|
3922
|
+
{
|
|
3923
|
+
planKey: {
|
|
3924
|
+
type: String,
|
|
3925
|
+
enum: PLAN_KEY_VALUES,
|
|
3926
|
+
required: true
|
|
3927
|
+
},
|
|
3928
|
+
status: {
|
|
3929
|
+
type: String,
|
|
3930
|
+
enum: SUBSCRIPTION_STATUS_VALUES,
|
|
3931
|
+
default: "pending",
|
|
3932
|
+
index: true
|
|
3933
|
+
},
|
|
3934
|
+
isActive: {
|
|
3935
|
+
type: Boolean,
|
|
3936
|
+
default: false,
|
|
3937
|
+
index: true
|
|
3938
|
+
},
|
|
3939
|
+
startDate: {
|
|
3940
|
+
type: Date
|
|
3941
|
+
},
|
|
3942
|
+
endDate: {
|
|
3943
|
+
type: Date,
|
|
3944
|
+
index: true
|
|
3945
|
+
},
|
|
3946
|
+
canceledAt: {
|
|
3947
|
+
type: Date
|
|
3948
|
+
},
|
|
3949
|
+
cancelAt: {
|
|
3950
|
+
type: Date
|
|
3951
|
+
},
|
|
3952
|
+
pausedAt: {
|
|
3953
|
+
type: Date
|
|
3954
|
+
},
|
|
3955
|
+
lastPaymentDate: {
|
|
3956
|
+
type: Date
|
|
3957
|
+
},
|
|
3958
|
+
lastPaymentAmount: {
|
|
3959
|
+
type: Number
|
|
3960
|
+
},
|
|
3961
|
+
renewalCount: {
|
|
3962
|
+
type: Number,
|
|
3963
|
+
default: 0
|
|
3964
|
+
}
|
|
3965
|
+
},
|
|
3966
|
+
{ _id: false }
|
|
3967
|
+
);
|
|
3968
|
+
|
|
3969
|
+
// src/schemas/escrow/hold.schema.ts
|
|
3970
|
+
var holdSchema = {
|
|
3971
|
+
status: {
|
|
3972
|
+
type: String,
|
|
3973
|
+
enum: HOLD_STATUS_VALUES,
|
|
3974
|
+
default: HOLD_STATUS.PENDING,
|
|
3975
|
+
index: true
|
|
3976
|
+
},
|
|
3977
|
+
heldAmount: {
|
|
3978
|
+
type: Number,
|
|
3979
|
+
required: false
|
|
3980
|
+
},
|
|
3981
|
+
releasedAmount: {
|
|
3982
|
+
type: Number,
|
|
3983
|
+
default: 0
|
|
3984
|
+
},
|
|
3985
|
+
reason: {
|
|
3986
|
+
type: String,
|
|
3987
|
+
enum: HOLD_REASON_VALUES,
|
|
3988
|
+
required: false
|
|
3989
|
+
},
|
|
3990
|
+
holdUntil: {
|
|
3991
|
+
type: Date,
|
|
3992
|
+
required: false
|
|
3993
|
+
},
|
|
3994
|
+
heldAt: Date,
|
|
3995
|
+
releasedAt: Date,
|
|
3996
|
+
cancelledAt: Date,
|
|
3997
|
+
releases: [
|
|
3998
|
+
{
|
|
3999
|
+
amount: Number,
|
|
4000
|
+
recipientId: String,
|
|
4001
|
+
recipientType: String,
|
|
4002
|
+
releasedAt: Date,
|
|
4003
|
+
releasedBy: String,
|
|
4004
|
+
reason: String,
|
|
4005
|
+
metadata: Object
|
|
4006
|
+
}
|
|
4007
|
+
],
|
|
4008
|
+
metadata: {
|
|
4009
|
+
type: Object,
|
|
4010
|
+
default: {}
|
|
4011
|
+
}
|
|
4012
|
+
};
|
|
4013
|
+
var splitSchema = new Schema(
|
|
4014
|
+
{
|
|
4015
|
+
type: {
|
|
4016
|
+
type: String,
|
|
4017
|
+
enum: SPLIT_TYPE_VALUES,
|
|
4018
|
+
required: true
|
|
4019
|
+
},
|
|
4020
|
+
recipientId: {
|
|
4021
|
+
type: String,
|
|
4022
|
+
required: true,
|
|
4023
|
+
index: true
|
|
4024
|
+
},
|
|
4025
|
+
recipientType: {
|
|
4026
|
+
type: String,
|
|
4027
|
+
enum: ["platform", "organization", "user", "affiliate", "partner"],
|
|
4028
|
+
required: true
|
|
4029
|
+
},
|
|
4030
|
+
rate: {
|
|
4031
|
+
type: Number,
|
|
4032
|
+
required: true,
|
|
4033
|
+
min: 0,
|
|
4034
|
+
max: 1
|
|
4035
|
+
},
|
|
4036
|
+
grossAmount: {
|
|
4037
|
+
type: Number,
|
|
4038
|
+
required: true,
|
|
4039
|
+
min: 0
|
|
4040
|
+
},
|
|
4041
|
+
gatewayFeeRate: {
|
|
4042
|
+
type: Number,
|
|
4043
|
+
default: 0,
|
|
4044
|
+
min: 0,
|
|
4045
|
+
max: 1
|
|
4046
|
+
},
|
|
4047
|
+
gatewayFeeAmount: {
|
|
4048
|
+
type: Number,
|
|
4049
|
+
default: 0,
|
|
4050
|
+
min: 0
|
|
4051
|
+
},
|
|
4052
|
+
netAmount: {
|
|
4053
|
+
type: Number,
|
|
4054
|
+
required: true,
|
|
4055
|
+
min: 0
|
|
4056
|
+
},
|
|
4057
|
+
status: {
|
|
4058
|
+
type: String,
|
|
4059
|
+
enum: SPLIT_STATUS_VALUES,
|
|
4060
|
+
default: SPLIT_STATUS.PENDING,
|
|
4061
|
+
index: true
|
|
4062
|
+
},
|
|
4063
|
+
dueDate: {
|
|
4064
|
+
type: Date
|
|
4065
|
+
},
|
|
4066
|
+
paidDate: {
|
|
4067
|
+
type: Date
|
|
4068
|
+
},
|
|
4069
|
+
payoutMethod: {
|
|
4070
|
+
type: String,
|
|
4071
|
+
enum: PAYOUT_METHOD_VALUES
|
|
4072
|
+
},
|
|
4073
|
+
payoutTransactionId: {
|
|
4074
|
+
type: String
|
|
4075
|
+
},
|
|
4076
|
+
metadata: {
|
|
4077
|
+
type: Schema.Types.Mixed,
|
|
4078
|
+
default: {}
|
|
4079
|
+
}
|
|
4080
|
+
},
|
|
4081
|
+
{ _id: false }
|
|
4082
|
+
);
|
|
4083
|
+
|
|
4084
|
+
// src/utils/transaction-type.ts
|
|
4085
|
+
var TRANSACTION_MANAGEMENT_TYPE = {
|
|
4086
|
+
MONETIZATION: "monetization",
|
|
4087
|
+
// Library-managed (subscriptions, purchases)
|
|
4088
|
+
MANUAL: "manual"
|
|
4089
|
+
// Admin-managed (expenses, income, adjustments)
|
|
4090
|
+
};
|
|
4091
|
+
var DEFAULT_MONETIZATION_CATEGORIES = [
|
|
4092
|
+
"subscription",
|
|
4093
|
+
"purchase"
|
|
4094
|
+
];
|
|
4095
|
+
function isMonetizationCategory(category, additionalCategories = []) {
|
|
4096
|
+
const allCategories = [...DEFAULT_MONETIZATION_CATEGORIES, ...additionalCategories];
|
|
4097
|
+
return allCategories.includes(category);
|
|
4098
|
+
}
|
|
4099
|
+
function isMonetizationTransaction(transaction, options = {}) {
|
|
4100
|
+
const {
|
|
4101
|
+
targetModels = ["Subscription", "Membership"],
|
|
4102
|
+
additionalCategories = []
|
|
4103
|
+
} = options;
|
|
4104
|
+
if (transaction.referenceModel && targetModels.includes(transaction.referenceModel)) {
|
|
4105
|
+
return true;
|
|
4106
|
+
}
|
|
4107
|
+
if (transaction.category) {
|
|
4108
|
+
return isMonetizationCategory(transaction.category, additionalCategories);
|
|
4109
|
+
}
|
|
4110
|
+
return false;
|
|
4111
|
+
}
|
|
4112
|
+
function isManualTransaction(transaction, options = {}) {
|
|
4113
|
+
return !isMonetizationTransaction(transaction, options);
|
|
4114
|
+
}
|
|
4115
|
+
function getTransactionType(transaction, options = {}) {
|
|
4116
|
+
return isMonetizationTransaction(transaction, options) ? TRANSACTION_MANAGEMENT_TYPE.MONETIZATION : TRANSACTION_MANAGEMENT_TYPE.MANUAL;
|
|
4117
|
+
}
|
|
4118
|
+
var PROTECTED_MONETIZATION_FIELDS = [
|
|
4119
|
+
"status",
|
|
4120
|
+
"amount",
|
|
4121
|
+
"platformCommission",
|
|
4122
|
+
"netAmount",
|
|
4123
|
+
"verifiedAt",
|
|
4124
|
+
"verifiedBy",
|
|
4125
|
+
"gateway",
|
|
4126
|
+
"webhook",
|
|
4127
|
+
"metadata.commission",
|
|
4128
|
+
"metadata.gateway",
|
|
4129
|
+
"type",
|
|
4130
|
+
"category",
|
|
4131
|
+
"referenceModel",
|
|
4132
|
+
"referenceId"
|
|
4133
|
+
];
|
|
4134
|
+
var EDITABLE_MONETIZATION_FIELDS_PRE_VERIFICATION = [
|
|
4135
|
+
"reference",
|
|
4136
|
+
"paymentDetails",
|
|
4137
|
+
"notes"
|
|
4138
|
+
];
|
|
4139
|
+
var MANUAL_TRANSACTION_CREATE_FIELDS = [
|
|
4140
|
+
"organizationId",
|
|
4141
|
+
"type",
|
|
4142
|
+
"category",
|
|
4143
|
+
"amount",
|
|
4144
|
+
"method",
|
|
4145
|
+
"reference",
|
|
4146
|
+
"paymentDetails",
|
|
4147
|
+
"notes",
|
|
4148
|
+
"date",
|
|
4149
|
+
// Transaction date (can be backdated)
|
|
4150
|
+
"description"
|
|
4151
|
+
];
|
|
4152
|
+
var MANUAL_TRANSACTION_UPDATE_FIELDS = [
|
|
4153
|
+
"amount",
|
|
4154
|
+
"method",
|
|
4155
|
+
"reference",
|
|
4156
|
+
"paymentDetails",
|
|
4157
|
+
"notes",
|
|
4158
|
+
"date",
|
|
4159
|
+
"description"
|
|
4160
|
+
];
|
|
4161
|
+
function getAllowedUpdateFields(transaction, options = {}) {
|
|
4162
|
+
const type = getTransactionType(transaction, options);
|
|
4163
|
+
if (type === TRANSACTION_MANAGEMENT_TYPE.MONETIZATION) {
|
|
4164
|
+
if (transaction.status === "pending") {
|
|
4165
|
+
return EDITABLE_MONETIZATION_FIELDS_PRE_VERIFICATION;
|
|
4166
|
+
}
|
|
4167
|
+
return [];
|
|
4168
|
+
}
|
|
4169
|
+
if (transaction.status === "verified" || transaction.status === "completed") {
|
|
4170
|
+
return ["notes"];
|
|
4171
|
+
}
|
|
4172
|
+
return MANUAL_TRANSACTION_UPDATE_FIELDS;
|
|
4173
|
+
}
|
|
4174
|
+
function validateFieldUpdate(transaction, fieldName, options = {}) {
|
|
4175
|
+
const allowedFields = getAllowedUpdateFields(transaction, options);
|
|
4176
|
+
if (allowedFields.includes(fieldName)) {
|
|
4177
|
+
return { allowed: true };
|
|
4178
|
+
}
|
|
4179
|
+
const type = getTransactionType(transaction, options);
|
|
4180
|
+
if (type === TRANSACTION_MANAGEMENT_TYPE.MONETIZATION) {
|
|
4181
|
+
if (PROTECTED_MONETIZATION_FIELDS.includes(fieldName)) {
|
|
4182
|
+
return {
|
|
4183
|
+
allowed: false,
|
|
4184
|
+
reason: `Field "${fieldName}" is protected for monetization transactions. Updates must go through payment flow.`
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
return {
|
|
4189
|
+
allowed: false,
|
|
4190
|
+
reason: `Field "${fieldName}" cannot be updated for ${transaction.status} transactions.`
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4193
|
+
function canSelfVerify(transaction, options = {}) {
|
|
4194
|
+
const type = getTransactionType(transaction, options);
|
|
4195
|
+
if (type === TRANSACTION_MANAGEMENT_TYPE.MANUAL) {
|
|
4196
|
+
return transaction.status === "pending";
|
|
4197
|
+
}
|
|
4198
|
+
return false;
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
// src/utils/logger.ts
|
|
4202
|
+
var _logger = console;
|
|
4203
|
+
function setLogger(customLogger) {
|
|
4204
|
+
_logger = customLogger;
|
|
4205
|
+
}
|
|
4206
|
+
var logger = {
|
|
4207
|
+
info: (...args) => {
|
|
4208
|
+
(_logger.info ?? _logger.log)?.call(_logger, ...args);
|
|
4209
|
+
},
|
|
4210
|
+
warn: (...args) => {
|
|
4211
|
+
(_logger.warn ?? _logger.log)?.call(_logger, "WARN:", ...args);
|
|
4212
|
+
},
|
|
4213
|
+
error: (...args) => {
|
|
4214
|
+
(_logger.error ?? _logger.log)?.call(_logger, "ERROR:", ...args);
|
|
4215
|
+
},
|
|
4216
|
+
debug: (...args) => {
|
|
4217
|
+
(_logger.debug ?? _logger.log)?.call(_logger, "DEBUG:", ...args);
|
|
4218
|
+
}
|
|
4219
|
+
};
|
|
4220
|
+
|
|
4221
|
+
// src/utils/subscription/period.ts
|
|
4222
|
+
function addDuration(startDate, duration, unit = "days") {
|
|
4223
|
+
const date2 = new Date(startDate);
|
|
4224
|
+
switch (unit) {
|
|
4225
|
+
case "months":
|
|
4226
|
+
case "month":
|
|
4227
|
+
date2.setMonth(date2.getMonth() + duration);
|
|
4228
|
+
return date2;
|
|
4229
|
+
case "years":
|
|
4230
|
+
case "year":
|
|
4231
|
+
date2.setFullYear(date2.getFullYear() + duration);
|
|
4232
|
+
return date2;
|
|
4233
|
+
case "weeks":
|
|
4234
|
+
case "week":
|
|
4235
|
+
date2.setDate(date2.getDate() + duration * 7);
|
|
4236
|
+
return date2;
|
|
4237
|
+
case "days":
|
|
4238
|
+
case "day":
|
|
4239
|
+
default:
|
|
4240
|
+
date2.setDate(date2.getDate() + duration);
|
|
4241
|
+
return date2;
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
function calculatePeriodRange(params) {
|
|
4245
|
+
const {
|
|
4246
|
+
currentEndDate = null,
|
|
4247
|
+
startDate = null,
|
|
4248
|
+
duration,
|
|
4249
|
+
unit = "days",
|
|
4250
|
+
now = /* @__PURE__ */ new Date()
|
|
4251
|
+
} = params;
|
|
4252
|
+
let periodStart;
|
|
4253
|
+
if (startDate) {
|
|
4254
|
+
periodStart = new Date(startDate);
|
|
4255
|
+
} else if (currentEndDate) {
|
|
4256
|
+
const end = new Date(currentEndDate);
|
|
4257
|
+
periodStart = end > now ? end : now;
|
|
4258
|
+
} else {
|
|
4259
|
+
periodStart = now;
|
|
4260
|
+
}
|
|
4261
|
+
const periodEnd = addDuration(periodStart, duration, unit);
|
|
4262
|
+
return { startDate: periodStart, endDate: periodEnd };
|
|
4263
|
+
}
|
|
4264
|
+
function calculateProratedAmount(params) {
|
|
4265
|
+
const {
|
|
4266
|
+
amountPaid,
|
|
4267
|
+
startDate,
|
|
4268
|
+
endDate,
|
|
4269
|
+
asOfDate = /* @__PURE__ */ new Date(),
|
|
4270
|
+
precision = 2
|
|
4271
|
+
} = params;
|
|
4272
|
+
if (!amountPaid || amountPaid <= 0) return 0;
|
|
4273
|
+
const start = new Date(startDate);
|
|
4274
|
+
const end = new Date(endDate);
|
|
4275
|
+
const asOf = new Date(asOfDate);
|
|
4276
|
+
const totalMs = end.getTime() - start.getTime();
|
|
4277
|
+
if (totalMs <= 0) return 0;
|
|
4278
|
+
const remainingMs = Math.max(0, end.getTime() - asOf.getTime());
|
|
4279
|
+
if (remainingMs <= 0) return 0;
|
|
4280
|
+
const ratio = remainingMs / totalMs;
|
|
4281
|
+
const amount = amountPaid * ratio;
|
|
4282
|
+
const factor = 10 ** precision;
|
|
4283
|
+
return Math.round(amount * factor) / factor;
|
|
4284
|
+
}
|
|
4285
|
+
function resolveIntervalToDuration(interval = "month", intervalCount = 1) {
|
|
4286
|
+
const normalized = (interval || "month").toLowerCase();
|
|
4287
|
+
const count = Number(intervalCount) > 0 ? Number(intervalCount) : 1;
|
|
4288
|
+
switch (normalized) {
|
|
4289
|
+
case "year":
|
|
4290
|
+
case "years":
|
|
4291
|
+
return { duration: count, unit: "years" };
|
|
4292
|
+
case "week":
|
|
4293
|
+
case "weeks":
|
|
4294
|
+
return { duration: count, unit: "weeks" };
|
|
4295
|
+
case "quarter":
|
|
4296
|
+
case "quarters":
|
|
4297
|
+
return { duration: count * 3, unit: "months" };
|
|
4298
|
+
case "day":
|
|
4299
|
+
case "days":
|
|
4300
|
+
return { duration: count, unit: "days" };
|
|
4301
|
+
case "month":
|
|
4302
|
+
case "months":
|
|
4303
|
+
default:
|
|
4304
|
+
return { duration: count, unit: "months" };
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
// src/utils/subscription/actions.ts
|
|
4309
|
+
function isSubscriptionActive(subscription) {
|
|
4310
|
+
if (!subscription) return false;
|
|
4311
|
+
if (!subscription.isActive) return false;
|
|
4312
|
+
if (subscription.endDate) {
|
|
4313
|
+
const now = /* @__PURE__ */ new Date();
|
|
4314
|
+
const endDate = new Date(subscription.endDate);
|
|
4315
|
+
if (endDate < now) return false;
|
|
4316
|
+
}
|
|
4317
|
+
return true;
|
|
4318
|
+
}
|
|
4319
|
+
function canRenewSubscription(entity) {
|
|
4320
|
+
if (!entity?.subscription) return false;
|
|
4321
|
+
return isSubscriptionActive(entity.subscription);
|
|
4322
|
+
}
|
|
4323
|
+
function canCancelSubscription(entity) {
|
|
4324
|
+
if (!entity?.subscription) return false;
|
|
4325
|
+
if (!isSubscriptionActive(entity.subscription)) return false;
|
|
4326
|
+
return !entity.subscription.canceledAt;
|
|
4327
|
+
}
|
|
4328
|
+
function canPauseSubscription(entity) {
|
|
4329
|
+
if (!entity?.subscription) return false;
|
|
4330
|
+
if (entity.status === SUBSCRIPTION_STATUS.PAUSED) return false;
|
|
4331
|
+
if (entity.status === SUBSCRIPTION_STATUS.CANCELLED) return false;
|
|
4332
|
+
return isSubscriptionActive(entity.subscription);
|
|
4333
|
+
}
|
|
4334
|
+
function canResumeSubscription(entity) {
|
|
4335
|
+
if (!entity?.subscription) return false;
|
|
4336
|
+
return entity.status === SUBSCRIPTION_STATUS.PAUSED;
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
// src/index.ts
|
|
4340
|
+
var index_default = {
|
|
4341
|
+
Revenue,
|
|
4342
|
+
createRevenue,
|
|
4343
|
+
PaymentProvider,
|
|
4344
|
+
RevenueError,
|
|
4345
|
+
Money,
|
|
4346
|
+
Result,
|
|
4347
|
+
EventBus
|
|
4348
|
+
};
|
|
4349
|
+
/**
|
|
4350
|
+
* @classytic/revenue
|
|
4351
|
+
* Enterprise Revenue Management System
|
|
4352
|
+
*
|
|
4353
|
+
* Modern • Type-safe • Resilient • Composable
|
|
4354
|
+
*
|
|
4355
|
+
* @version 1.0.0
|
|
4356
|
+
* @author Classytic
|
|
4357
|
+
* @license MIT
|
|
4358
|
+
*/
|
|
4359
|
+
|
|
4360
|
+
export { AlreadyVerifiedError, CancelSubscriptionSchema, CircuitBreaker, CircuitOpenError, CommissionConfigSchema, ConfigurationError, Container, CreateHoldSchema, CreateMonetizationSchema, CreatePaymentSchema, CreateSubscriptionSchema, CurrencySchema, EDITABLE_MONETIZATION_FIELDS_PRE_VERIFICATION, ERROR_CODES, EmailSchema, EscrowService, EventBus, GATEWAY_TYPES, GATEWAY_TYPE_VALUES, HOLD_REASON, HOLD_REASON_VALUES, HOLD_STATUS, HOLD_STATUS_VALUES, HoldStatusSchema, IdempotencyError, IdempotencyKeySchema, IdempotencyManager, IntervalSchema, InvalidAmountError, InvalidStateTransitionError, LIBRARY_CATEGORIES, LIBRARY_CATEGORY_VALUES, MANUAL_TRANSACTION_CREATE_FIELDS, MANUAL_TRANSACTION_UPDATE_FIELDS, MONETIZATION_TYPES, MONETIZATION_TYPE_VALUES, MemoryIdempotencyStore, MetadataSchema, MissingRequiredFieldError, ModelNotRegisteredError, MonetizationService, MonetizationTypeSchema, Money, MoneyAmountSchema, MoneySchema, NotFoundError, ObjectIdSchema, OperationError, PAYMENT_GATEWAY_TYPE, PAYMENT_GATEWAY_TYPE_VALUES, PAYMENT_STATUS, PAYMENT_STATUS_VALUES, PAYOUT_METHOD, PAYOUT_METHOD_VALUES, PLAN_KEYS, PLAN_KEY_VALUES, PROTECTED_MONETIZATION_FIELDS, PaymentIntent, PaymentIntentCreationError, PaymentProvider, PaymentResult, PaymentService, PaymentVerificationError, PluginManager, ProviderCapabilityError, ProviderConfigSchema, ProviderError, ProviderNotFoundError, RELEASE_REASON, RELEASE_REASON_VALUES, RefundError, RefundNotSupportedError, RefundResult, RefundSchema, ReleaseHoldSchema, Result, RetryConfigSchema, RetryExhaustedError, Revenue, RevenueBuilder, RevenueConfigSchema, RevenueError, SPLIT_STATUS, SPLIT_STATUS_VALUES, SPLIT_TYPE, SPLIT_TYPE_VALUES, SUBSCRIPTION_STATUS, SUBSCRIPTION_STATUS_VALUES, SplitRecipientSchema, StateError, SubscriptionNotActiveError, SubscriptionNotFoundError, SubscriptionStatusSchema, TRANSACTION_MANAGEMENT_TYPE, TRANSACTION_STATUS, TRANSACTION_STATUS_VALUES, TRANSACTION_TYPE, TRANSACTION_TYPE_VALUES, TransactionNotFoundError, TransactionService, ValidationError, VerifyPaymentSchema, WebhookEvent, addDuration, all, auditPlugin, baseMetadataSchema, calculateCommission, calculateCommissionWithSplits, calculateDelay, calculateOrganizationPayout, calculatePeriodRange, calculateProratedAmount, calculateSplits, canCancelSubscription, canPauseSubscription, canRenewSubscription, canResumeSubscription, canSelfVerify, commissionSchema, createCircuitBreaker, createEventBus, createIdempotencyManager, createRevenue, currentPaymentSchema, index_default as default, definePlugin, err, flatMap, formatZodError, fromSmallestUnit, gatewaySchema, getAllowedUpdateFields, getTransactionType, holdSchema, isCategoryValid, isErr, isManualTransaction, isMonetizationTransaction, isOk, isRetryable, isRetryableError, isRevenueError, isSubscriptionActive, logger, loggingPlugin, map, mapErr, match, metricsPlugin, ok, paymentDetailsSchema, paymentSummarySchema, planSchema, referenceSchema, resilientExecute, resolveCategory, resolveIntervalToDuration, retry, retryWithResult, reverseCommission, reverseSplits, safeValidate, setLogger, splitSchema, subscriptionInfoSchema, tenantSnapshotSchema, toSmallestUnit, tryCatch, tryCatchSync, unwrap, unwrapOr, validate, validateFieldUpdate };
|
|
4361
|
+
//# sourceMappingURL=index.js.map
|
|
4362
|
+
//# sourceMappingURL=index.js.map
|