@classytic/revenue 2.0.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +33 -10
- package/dist/bank-feed-BlQeq2rK.mjs +133 -0
- package/dist/bank-feed.enums-BadqNJTC.d.mts +118 -0
- package/dist/bank-feed.enums-kYTLTTbe.mjs +165 -0
- package/dist/bridges/index.d.mts +1 -1
- package/dist/core/state-machines.d.mts +25 -2
- package/dist/core/state-machines.mjs +43 -3
- package/dist/engine-types-Jctrbasz.d.mts +1160 -0
- package/dist/enums/index.d.mts +4 -3
- package/dist/enums/index.mjs +4 -3
- package/dist/{errors-DHa8JVQ-.mjs → errors-LYYg9wcs.mjs} +23 -1
- package/dist/{escrow.schema-D5X32LwX.d.mts → escrow.schema-YuBgjL-I.d.mts} +27 -27
- package/dist/{event-constants-CEMitnIV.mjs → event-constants-Dn1TKahe.mjs} +6 -0
- package/dist/events/index.d.mts +2 -2
- package/dist/events/index.mjs +3 -3
- package/dist/index.d.mts +32 -13
- package/dist/index.mjs +142 -19
- package/dist/providers/index.d.mts +2 -2
- package/dist/providers/index.mjs +2 -2
- package/dist/registry-h8sasoLh.d.mts +145 -0
- package/dist/repositories/create-repositories.d.mts +1 -1
- package/dist/repositories/create-repositories.mjs +1 -1
- package/dist/{revenue-bridges-sdlrR85c.d.mts → revenue-bridges-BtkWFsJu.d.mts} +107 -1
- package/dist/{revenue-event-catalog-LqxPnsU_.mjs → revenue-event-catalog-BvjNVnPd.mjs} +77 -3
- package/dist/{revenue-event-catalog-BX3g7RUi.d.mts → revenue-event-catalog-JpJcyK1E.d.mts} +198 -2
- package/dist/settlement.repository-BAdc9qGl.mjs +1444 -0
- package/dist/shared/index.d.mts +1 -1
- package/dist/shared/index.mjs +2 -2
- package/dist/{subscription.enums-tfoAgsTv.mjs → subscription.enums-95othr0i.mjs} +1 -40
- package/dist/{transaction.enums-u4MshXcL.d.mts → subscription.enums-k24kLpF7.d.mts} +1 -36
- package/dist/validators/index.d.mts +158 -2
- package/dist/validators/index.mjs +95 -2
- package/package.json +7 -7
- package/dist/engine-types-CcjIb4Fy.d.mts +0 -611
- package/dist/registry-DhFMsSn5.mjs +0 -150
- package/dist/registry-SvIGPAx_.d.mts +0 -143
- package/dist/settlement.repository-DHIPx5S4.mjs +0 -771
- /package/dist/{audit-B39B0Sdq.mjs → audit-Ba2XB2C4.mjs} +0 -0
- /package/dist/{audit-DZ0eTr9g.d.mts → audit-DRKuLBFO.d.mts} +0 -0
- /package/dist/{context-DRqSeTPM.d.mts → context-pjP1QeE3.d.mts} +0 -0
- /package/dist/{escrow.schema-BBv9oVEW.mjs → escrow.schema-C-b41z_G.mjs} +0 -0
- /package/dist/{monetization.enums-BtiU3t8o.mjs → monetization.enums-B9HBOecd.mjs} +0 -0
- /package/dist/{monetization.enums-D2xbxXJM.d.mts → monetization.enums-DzAI4sT7.d.mts} +0 -0
- /package/dist/{splits-BAfY-a9P.mjs → splits-CNfQj92L.mjs} +0 -0
|
@@ -0,0 +1,1444 @@
|
|
|
1
|
+
import { _ as TRANSACTION_STATUS, a as TRANSACTION_KIND, s as initialStatusFor } from "./bank-feed.enums-kYTLTTbe.mjs";
|
|
2
|
+
import { n as createEvent, t as REVENUE_EVENTS } from "./event-constants-Dn1TKahe.mjs";
|
|
3
|
+
import { g as SETTLEMENT_STATUS, r as SUBSCRIPTION_STATUS, w as HOLD_STATUS } from "./subscription.enums-95othr0i.mjs";
|
|
4
|
+
import { f as SettlementNotFoundError, g as WrongTransactionKindError, h as ValidationError, m as TransactionNotFoundError, n as BankFeedImportError, p as SubscriptionNotFoundError } from "./errors-LYYg9wcs.mjs";
|
|
5
|
+
import { SETTLEMENT_STATE_MACHINE, SUBSCRIPTION_STATE_MACHINE, TRANSACTION_STATE_MACHINE, smFor } from "./core/state-machines.mjs";
|
|
6
|
+
import { a as reverseTax, c as reverseCommission, n as calculateSplits, s as calculateCommission, t as calculateOrganizationPayout } from "./splits-CNfQj92L.mjs";
|
|
7
|
+
import { Repository, repoOptionsFromCtx, withTransaction } from "@classytic/mongokit";
|
|
8
|
+
|
|
9
|
+
//#region src/repositories/base.repository.ts
|
|
10
|
+
/**
|
|
11
|
+
* RevenueRepositoryBase — shared scaffolding for every revenue repo.
|
|
12
|
+
*
|
|
13
|
+
* Eliminates the three places ctx-threading and event-dispatch were
|
|
14
|
+
* hand-rolled (transaction / subscription / settlement) and previously
|
|
15
|
+
* drifted: subscription + settlement repos forgot to forward
|
|
16
|
+
* `ctx.organizationId` into their inner `getById` / `update` calls,
|
|
17
|
+
* which surfaced as `Missing 'organizationId' in context for 'getById'`
|
|
18
|
+
* the moment a host enabled `multiTenantPlugin` (the canonical 2026-Q2
|
|
19
|
+
* bug — fixed in 2.1.1).
|
|
20
|
+
*
|
|
21
|
+
* Two responsibilities:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Thread request context into mongokit options.** Every domain
|
|
24
|
+
* verb that touches the DB (read or write) ends with a mongokit
|
|
25
|
+
* method whose options bag is what `multiTenantPlugin`,
|
|
26
|
+
* `softDeletePlugin`, the audit plugins, and `withTransaction`
|
|
27
|
+
* read from. {@link optsFromCtx} centralises that translation
|
|
28
|
+
* using mongokit's typed extractor — adding a new canonical field
|
|
29
|
+
* to `RevenueContext` is now a single line, not three.
|
|
30
|
+
*
|
|
31
|
+
* 2. **Dispatch domain events.** Outbox-save (session-bound when the
|
|
32
|
+
* caller threads a Mongoose `ClientSession`) followed by
|
|
33
|
+
* transport-publish, with isolated try/catch so a transport
|
|
34
|
+
* failure never aborts the business write. PACKAGE_RULES P8 / §5.5.
|
|
35
|
+
*
|
|
36
|
+
* Subclasses inject their domain-specific deps via {@link inject}; this
|
|
37
|
+
* base only requires the cross-cutting trio (events / outbox / logger)
|
|
38
|
+
* which every revenue repo has.
|
|
39
|
+
*
|
|
40
|
+
* @typeParam TDoc - The Mongoose document type the subclass operates on.
|
|
41
|
+
* @typeParam TDeps - The full deps shape (must extend `BaseRevenueRepoDeps`).
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* export class SubscriptionRepository extends RevenueRepositoryBase<
|
|
46
|
+
* SubscriptionDocument,
|
|
47
|
+
* SubscriptionRepoDeps
|
|
48
|
+
* > {
|
|
49
|
+
* async activate(id: string, ctx: RevenueContext = {}) {
|
|
50
|
+
* const sub = await this.getById(id, this.optsFromCtx(ctx));
|
|
51
|
+
* // ...
|
|
52
|
+
* await this.dispatch(createEvent(...), ctx);
|
|
53
|
+
* return updated;
|
|
54
|
+
* }
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Abstract base for `TransactionRepository`, `SubscriptionRepository`,
|
|
60
|
+
* `SettlementRepository`.
|
|
61
|
+
*
|
|
62
|
+
* Concrete subclasses MUST:
|
|
63
|
+
* - declare a `Deps` interface that extends {@link BaseRevenueRepoDeps}
|
|
64
|
+
* - call `super(model, plugins)` from the constructor
|
|
65
|
+
* - call `inject(deps)` once during engine boot
|
|
66
|
+
*
|
|
67
|
+
* Subclasses SHOULD use {@link optsFromCtx} for every mongokit call
|
|
68
|
+
* that takes an options bag, and {@link dispatch} for every event
|
|
69
|
+
* publish.
|
|
70
|
+
*/
|
|
71
|
+
var RevenueRepositoryBase = class extends Repository {
|
|
72
|
+
/**
|
|
73
|
+
* Subclass-specific deps. `!` because the engine wires this once via
|
|
74
|
+
* `inject(deps)` immediately after construction; calling any domain
|
|
75
|
+
* verb before injection is a programming error and the runtime
|
|
76
|
+
* will fail-loud with `Cannot read properties of undefined`.
|
|
77
|
+
*/
|
|
78
|
+
deps;
|
|
79
|
+
constructor(model, plugins = []) {
|
|
80
|
+
super(model, plugins);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Wire engine-managed deps. Called exactly once per repository
|
|
84
|
+
* instance during {@link createRevenue} bootstrap. Subclasses with
|
|
85
|
+
* extra steps (caching, prebuilding state machine maps) override and
|
|
86
|
+
* call `super.inject(deps)`.
|
|
87
|
+
*/
|
|
88
|
+
inject(deps) {
|
|
89
|
+
this.deps = deps;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Translate {@link RevenueContext} into a mongokit options bag.
|
|
93
|
+
*
|
|
94
|
+
* Forwards every canonical field mongokit's bundled plugins read —
|
|
95
|
+
* `organizationId` (multiTenant), `userId` / `user` (audit),
|
|
96
|
+
* `session` (transactions), `requestId` (observability) — plus
|
|
97
|
+
* the revenue-specific `_bypassTenant` flag for platform-admin
|
|
98
|
+
* cross-org reads.
|
|
99
|
+
*
|
|
100
|
+
* Pass `extra` for caller-specific options like `throwOnNotFound`,
|
|
101
|
+
* `lean`, `populate`, `select` — the spread is `extra`-first so
|
|
102
|
+
* ctx wins on a key collision (intentional: callers shouldn't
|
|
103
|
+
* be smuggling tenant fields through `extra`).
|
|
104
|
+
*
|
|
105
|
+
* @param ctx - The request-scoped revenue context.
|
|
106
|
+
* @param extra - Additional mongokit options merged in.
|
|
107
|
+
*/
|
|
108
|
+
optsFromCtx(ctx = {}, extra = {}) {
|
|
109
|
+
const out = {
|
|
110
|
+
...extra,
|
|
111
|
+
...repoOptionsFromCtx(ctx)
|
|
112
|
+
};
|
|
113
|
+
if (ctx._bypassTenant === true) out._bypassTenant = true;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Persist an event to the host outbox, then publish to the in-process
|
|
118
|
+
* transport. The two sides have asymmetric failure handling — see
|
|
119
|
+
* PACKAGE_RULES §P8.
|
|
120
|
+
*
|
|
121
|
+
* When `ctx.session` is set, the outbox `save` runs inside the same
|
|
122
|
+
* Mongoose transaction (true P8 session-bound write); when absent, the
|
|
123
|
+
* outbox row lands after commit — still durable via the host's relay,
|
|
124
|
+
* with only a small at-most-once window on process crash.
|
|
125
|
+
*
|
|
126
|
+
* 1. **`outbox.save` failures PROPAGATE.** If we can't durably record
|
|
127
|
+
* the event, the caller's transaction MUST roll back so the
|
|
128
|
+
* business doc and the event row land atomically (or neither
|
|
129
|
+
* lands). Swallowing a save failure breaks the transactional-
|
|
130
|
+
* outbox correctness argument — the parent doc would land while
|
|
131
|
+
* the event vanishes.
|
|
132
|
+
*
|
|
133
|
+
* 2. **`events.publish` failures are SWALLOWED.** The host's outbox
|
|
134
|
+
* relay re-publishes from the durable row on its next poll. Even
|
|
135
|
+
* without an outbox, in-process subscribers shouldn't be able to
|
|
136
|
+
* break the business operation — they're best-effort consumers.
|
|
137
|
+
*
|
|
138
|
+
* @param event - Pre-built domain event (use `createEvent(REVENUE_EVENTS.X, payload, ctx, meta)`).
|
|
139
|
+
* @param ctx - The same context that produced the business write.
|
|
140
|
+
*/
|
|
141
|
+
async dispatch(event, ctx = {}) {
|
|
142
|
+
if (this.deps.outbox) try {
|
|
143
|
+
await this.deps.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
|
|
144
|
+
} catch (err) {
|
|
145
|
+
this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await this.deps.events.publish(event);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/repositories/transaction.repository.ts
|
|
158
|
+
var TransactionRepository = class extends RevenueRepositoryBase {
|
|
159
|
+
constructor(model, plugins = []) {
|
|
160
|
+
super(model, plugins);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Save an event to the host-owned outbox, session-bound when available.
|
|
164
|
+
*
|
|
165
|
+
* Called INSIDE a `withTransaction` body. The outbox row commits
|
|
166
|
+
* atomically with the business write (P8 true session-bound write) —
|
|
167
|
+
* if outbox.save fails, this method re-throws so `withTransaction`
|
|
168
|
+
* rolls the parent write back. Without propagation, the parent doc
|
|
169
|
+
* would commit while the event row vanishes, defeating the whole
|
|
170
|
+
* transactional-outbox correctness argument.
|
|
171
|
+
*
|
|
172
|
+
* Logging happens before the re-throw so the failure surfaces in
|
|
173
|
+
* observability without losing the original stack trace.
|
|
174
|
+
*/
|
|
175
|
+
async saveToOutbox(event, session) {
|
|
176
|
+
if (!this.deps.outbox) return;
|
|
177
|
+
try {
|
|
178
|
+
await this.deps.outbox.save(event, session !== void 0 ? { session } : {});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Publish an event to the in-process `EventTransport` after commit.
|
|
186
|
+
* Transport failure is logged and swallowed — the host relay re-delivers
|
|
187
|
+
* from the durable outbox row on the next poll, so in-process
|
|
188
|
+
* subscribers missing an event is recoverable. Best-effort by design.
|
|
189
|
+
*/
|
|
190
|
+
async publishToTransport(event) {
|
|
191
|
+
try {
|
|
192
|
+
await this.deps.events.publish(event);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Creates transaction + calls provider. Returns the created transaction doc. */
|
|
198
|
+
async createPaymentIntent(params, ctx = {}) {
|
|
199
|
+
const currency = params.currency ?? this.deps.defaultCurrency;
|
|
200
|
+
const provider = this.deps.providers.get(params.gateway);
|
|
201
|
+
if (params.idempotencyKey) {
|
|
202
|
+
const existing = await this.getByQuery({ idempotencyKey: params.idempotencyKey }, this.optsFromCtx(ctx, { throwOnNotFound: false }));
|
|
203
|
+
if (existing) return existing;
|
|
204
|
+
}
|
|
205
|
+
const commissionRate = this.deps.commission?.defaultRate ?? 0;
|
|
206
|
+
const gatewayFeeRate = this.deps.commission?.gatewayFeeRate ?? 0;
|
|
207
|
+
const commission = calculateCommission(params.amount, commissionRate, gatewayFeeRate);
|
|
208
|
+
let gatewayData = { type: params.gateway };
|
|
209
|
+
if (params.amount > 0) {
|
|
210
|
+
const intent = await provider.createIntent({
|
|
211
|
+
amount: {
|
|
212
|
+
amount: params.amount,
|
|
213
|
+
currency
|
|
214
|
+
},
|
|
215
|
+
metadata: params.metadata,
|
|
216
|
+
...params.paymentData
|
|
217
|
+
});
|
|
218
|
+
gatewayData = {
|
|
219
|
+
type: params.gateway,
|
|
220
|
+
sessionId: intent.sessionId,
|
|
221
|
+
paymentIntentId: intent.paymentIntentId ?? intent.id,
|
|
222
|
+
metadata: {
|
|
223
|
+
clientSecret: intent.clientSecret,
|
|
224
|
+
paymentUrl: intent.paymentUrl,
|
|
225
|
+
instructions: intent.instructions
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const transaction = await this.create({
|
|
230
|
+
organizationId: ctx.organizationId,
|
|
231
|
+
customerId: params.data?.customerId ?? null,
|
|
232
|
+
type: params.monetizationType === "subscription" ? "subscription" : "purchase",
|
|
233
|
+
flow: "inflow",
|
|
234
|
+
tags: params.monetizationType ? [params.monetizationType] : [],
|
|
235
|
+
amount: params.amount,
|
|
236
|
+
currency,
|
|
237
|
+
fee: commission?.gatewayFeeAmount ?? 0,
|
|
238
|
+
tax: 0,
|
|
239
|
+
net: params.amount - (commission?.gatewayFeeAmount ?? 0),
|
|
240
|
+
method: params.gateway,
|
|
241
|
+
status: params.amount === 0 ? TRANSACTION_STATUS.VERIFIED : TRANSACTION_STATUS.PENDING,
|
|
242
|
+
gateway: gatewayData,
|
|
243
|
+
commission: commission ?? void 0,
|
|
244
|
+
sourceId: params.data?.sourceId,
|
|
245
|
+
sourceModel: params.data?.sourceModel,
|
|
246
|
+
idempotencyKey: params.idempotencyKey,
|
|
247
|
+
metadata: params.metadata
|
|
248
|
+
}, this.optsFromCtx(ctx));
|
|
249
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.MONETIZATION_CREATED, {
|
|
250
|
+
monetizationType: params.monetizationType,
|
|
251
|
+
transaction
|
|
252
|
+
}, ctx, {
|
|
253
|
+
resource: "transaction",
|
|
254
|
+
resourceId: transaction.publicId
|
|
255
|
+
}), ctx);
|
|
256
|
+
return transaction;
|
|
257
|
+
}
|
|
258
|
+
/** Verifies payment via provider, updates status. Returns the updated doc. */
|
|
259
|
+
async verify(paymentIntentId, options = {}, ctx = {}) {
|
|
260
|
+
const readOpts = this.optsFromCtx(ctx, { throwOnNotFound: false });
|
|
261
|
+
let transaction = await this.getByQuery({ "gateway.sessionId": paymentIntentId }, readOpts);
|
|
262
|
+
if (!transaction) transaction = await this.getByQuery({ "gateway.paymentIntentId": paymentIntentId }, readOpts);
|
|
263
|
+
if (!transaction) transaction = await this.getById(paymentIntentId, readOpts);
|
|
264
|
+
if (!transaction) throw new TransactionNotFoundError(paymentIntentId);
|
|
265
|
+
const provider = this.deps.providers.get(transaction.method);
|
|
266
|
+
const intentId = transaction.gateway?.paymentIntentId ?? transaction.gateway?.sessionId ?? paymentIntentId;
|
|
267
|
+
const paymentResult = await provider.verifyPayment(intentId);
|
|
268
|
+
let newStatus;
|
|
269
|
+
if (paymentResult.status === "succeeded") newStatus = TRANSACTION_STATUS.VERIFIED;
|
|
270
|
+
else if (paymentResult.status === "failed") newStatus = TRANSACTION_STATUS.FAILED;
|
|
271
|
+
else if (paymentResult.status === "requires_action") newStatus = TRANSACTION_STATUS.REQUIRES_ACTION;
|
|
272
|
+
else newStatus = TRANSACTION_STATUS.PROCESSING;
|
|
273
|
+
TRANSACTION_STATE_MACHINE.validate(transaction.status, newStatus, String(transaction._id));
|
|
274
|
+
const updates = { status: newStatus };
|
|
275
|
+
if (newStatus === TRANSACTION_STATUS.VERIFIED) {
|
|
276
|
+
updates.verifiedAt = /* @__PURE__ */ new Date();
|
|
277
|
+
updates.verifiedBy = options.verifiedBy;
|
|
278
|
+
} else if (newStatus === TRANSACTION_STATUS.FAILED) {
|
|
279
|
+
updates.failedAt = /* @__PURE__ */ new Date();
|
|
280
|
+
updates.failureReason = "Payment verification failed";
|
|
281
|
+
}
|
|
282
|
+
const updated = await this.update(transaction._id, updates, this.optsFromCtx(ctx));
|
|
283
|
+
if (newStatus === TRANSACTION_STATUS.VERIFIED) {
|
|
284
|
+
await this.deps.bridges.ledger?.onPaymentVerified?.(updated, ctx);
|
|
285
|
+
await this.deps.bridges.notification?.onPaymentVerified?.(updated, ctx);
|
|
286
|
+
}
|
|
287
|
+
const eventName = newStatus === TRANSACTION_STATUS.VERIFIED ? REVENUE_EVENTS.PAYMENT_VERIFIED : newStatus === TRANSACTION_STATUS.FAILED ? REVENUE_EVENTS.PAYMENT_FAILED : newStatus === TRANSACTION_STATUS.REQUIRES_ACTION ? REVENUE_EVENTS.PAYMENT_REQUIRES_ACTION : REVENUE_EVENTS.PAYMENT_PROCESSING;
|
|
288
|
+
await this.dispatch(createEvent(eventName, {
|
|
289
|
+
transaction: updated,
|
|
290
|
+
paymentResult,
|
|
291
|
+
verifiedBy: options.verifiedBy
|
|
292
|
+
}, ctx, {
|
|
293
|
+
resource: "transaction",
|
|
294
|
+
resourceId: updated?.publicId
|
|
295
|
+
}), ctx);
|
|
296
|
+
return updated;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Creates refund transaction, updates original. Returns the refund transaction doc.
|
|
300
|
+
*
|
|
301
|
+
* The provider call happens OUTSIDE the transaction — it's a non-idempotent external
|
|
302
|
+
* side effect we can't roll back. The two Mongo writes (create refund + update original)
|
|
303
|
+
* run inside `withTransaction` so they commit atomically or both abort. Bridges and
|
|
304
|
+
* event emission run AFTER commit because they're independent side effects; rolling
|
|
305
|
+
* them back would not undo external state anyway.
|
|
306
|
+
*
|
|
307
|
+
* Powered by mongokit 3.6's module-level `withTransaction` helper. Automatically
|
|
308
|
+
* retries on `TransientTransactionError` / `UnknownTransactionCommitResult`.
|
|
309
|
+
*/
|
|
310
|
+
async refund(transactionId, amount, options = {}, ctx = {}) {
|
|
311
|
+
const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
|
|
312
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
313
|
+
const refundAmount = amount ?? transaction.amount;
|
|
314
|
+
const existingRefunded = transaction.refundedAmount ?? 0;
|
|
315
|
+
const isPartialRefund = existingRefunded + refundAmount < transaction.amount;
|
|
316
|
+
const newStatus = isPartialRefund ? TRANSACTION_STATUS.PARTIALLY_REFUNDED : TRANSACTION_STATUS.REFUNDED;
|
|
317
|
+
TRANSACTION_STATE_MACHINE.validate(transaction.status, newStatus, transactionId);
|
|
318
|
+
const provider = this.deps.providers.get(transaction.method);
|
|
319
|
+
const paymentId = transaction.gateway?.paymentIntentId ?? transaction.gateway?.sessionId ?? transactionId;
|
|
320
|
+
await provider.refund(paymentId, refundAmount, { reason: options.reason });
|
|
321
|
+
const reversedCommission = reverseCommission(transaction.commission, transaction.amount, refundAmount);
|
|
322
|
+
const reversedTax = transaction.tax ? reverseTax({
|
|
323
|
+
isApplicable: true,
|
|
324
|
+
rate: 0,
|
|
325
|
+
baseAmount: transaction.amount,
|
|
326
|
+
taxAmount: transaction.tax,
|
|
327
|
+
totalAmount: transaction.amount + transaction.tax,
|
|
328
|
+
pricesIncludeTax: false
|
|
329
|
+
}, transaction.amount, refundAmount) : void 0;
|
|
330
|
+
const pendingEvents = [];
|
|
331
|
+
const refundTransaction = await withTransaction(this.Model.db, async (session) => {
|
|
332
|
+
const writeOpts = this.optsFromCtx(ctx, { session });
|
|
333
|
+
const refundTxn = await this.create({
|
|
334
|
+
organizationId: transaction.organizationId,
|
|
335
|
+
customerId: transaction.customerId,
|
|
336
|
+
type: "refund",
|
|
337
|
+
flow: "outflow",
|
|
338
|
+
tags: ["refund"],
|
|
339
|
+
amount: refundAmount,
|
|
340
|
+
currency: transaction.currency,
|
|
341
|
+
fee: reversedCommission?.gatewayFeeAmount ?? 0,
|
|
342
|
+
tax: reversedTax?.taxAmount ?? 0,
|
|
343
|
+
net: refundAmount - (reversedCommission?.gatewayFeeAmount ?? 0) - (reversedTax?.taxAmount ?? 0),
|
|
344
|
+
method: transaction.method,
|
|
345
|
+
status: TRANSACTION_STATUS.VERIFIED,
|
|
346
|
+
gateway: transaction.gateway,
|
|
347
|
+
commission: reversedCommission ?? void 0,
|
|
348
|
+
relatedTransactionId: transaction._id,
|
|
349
|
+
sourceId: transaction.sourceId,
|
|
350
|
+
sourceModel: transaction.sourceModel,
|
|
351
|
+
verifiedAt: /* @__PURE__ */ new Date(),
|
|
352
|
+
metadata: { reason: options.reason }
|
|
353
|
+
}, writeOpts);
|
|
354
|
+
await this.update(transactionId, {
|
|
355
|
+
status: newStatus,
|
|
356
|
+
refundedAmount: existingRefunded + refundAmount,
|
|
357
|
+
refundedAt: /* @__PURE__ */ new Date()
|
|
358
|
+
}, writeOpts);
|
|
359
|
+
const event = createEvent(REVENUE_EVENTS.PAYMENT_REFUNDED, {
|
|
360
|
+
transaction,
|
|
361
|
+
refundTransaction: refundTxn,
|
|
362
|
+
refundAmount,
|
|
363
|
+
reason: options.reason,
|
|
364
|
+
isPartialRefund
|
|
365
|
+
}, ctx, {
|
|
366
|
+
resource: "transaction",
|
|
367
|
+
resourceId: transaction.publicId
|
|
368
|
+
});
|
|
369
|
+
await this.saveToOutbox(event, session);
|
|
370
|
+
pendingEvents.push(event);
|
|
371
|
+
return refundTxn;
|
|
372
|
+
});
|
|
373
|
+
await this.deps.bridges.ledger?.onRefundProcessed?.(transaction, refundTransaction, ctx);
|
|
374
|
+
await this.deps.bridges.notification?.onRefundProcessed?.(refundTransaction, ctx);
|
|
375
|
+
for (const ev of pendingEvents) await this.publishToTransport(ev);
|
|
376
|
+
return refundTransaction;
|
|
377
|
+
}
|
|
378
|
+
/** Handles provider webhook. Returns the updated transaction doc (or null if not found). */
|
|
379
|
+
async handleWebhook(providerName, payload, headers, ctx = {}) {
|
|
380
|
+
const webhookEvent = await this.deps.providers.get(providerName).handleWebhook(payload, headers);
|
|
381
|
+
const readOpts = this.optsFromCtx(ctx, { throwOnNotFound: false });
|
|
382
|
+
const sessionId = webhookEvent.data?.sessionId;
|
|
383
|
+
const intentId = webhookEvent.data?.paymentIntentId;
|
|
384
|
+
let transaction = sessionId ? await this.getByQuery({ "gateway.sessionId": sessionId }, readOpts) : null;
|
|
385
|
+
if (!transaction && intentId) transaction = await this.getByQuery({ "gateway.paymentIntentId": intentId }, readOpts);
|
|
386
|
+
if (!transaction) return null;
|
|
387
|
+
if (transaction.webhook?.eventId === webhookEvent.id) return transaction;
|
|
388
|
+
const nextWebhook = {
|
|
389
|
+
eventId: webhookEvent.id,
|
|
390
|
+
eventType: webhookEvent.type,
|
|
391
|
+
receivedAt: /* @__PURE__ */ new Date(),
|
|
392
|
+
processedAt: /* @__PURE__ */ new Date(),
|
|
393
|
+
data: webhookEvent.data
|
|
394
|
+
};
|
|
395
|
+
const updated = await this.findOneAndUpdate({
|
|
396
|
+
_id: transaction._id,
|
|
397
|
+
"webhook.eventId": { $ne: webhookEvent.id }
|
|
398
|
+
}, { $set: { webhook: nextWebhook } }, { returnDocument: "after" });
|
|
399
|
+
if (!updated) return await this.getByQuery({ _id: transaction._id }, readOpts) ?? transaction;
|
|
400
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.WEBHOOK_PROCESSED, {
|
|
401
|
+
webhookType: webhookEvent.type,
|
|
402
|
+
provider: providerName,
|
|
403
|
+
event: webhookEvent,
|
|
404
|
+
transaction: updated
|
|
405
|
+
}, ctx, {
|
|
406
|
+
resource: "transaction",
|
|
407
|
+
resourceId: updated?.publicId
|
|
408
|
+
}), ctx);
|
|
409
|
+
return updated;
|
|
410
|
+
}
|
|
411
|
+
/** Places hold on verified transaction. Returns the updated doc. */
|
|
412
|
+
async hold(transactionId, options = {}, ctx = {}) {
|
|
413
|
+
const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
|
|
414
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
415
|
+
if (transaction.status !== TRANSACTION_STATUS.VERIFIED) throw new ValidationError("Can only hold verified transactions", { status: transaction.status });
|
|
416
|
+
const holdAmount = options.amount ?? transaction.amount;
|
|
417
|
+
const updated = await this.update(transactionId, { hold: {
|
|
418
|
+
status: HOLD_STATUS.HELD,
|
|
419
|
+
heldAmount: holdAmount,
|
|
420
|
+
releasedAmount: 0,
|
|
421
|
+
reason: options.reason ?? "manual_hold",
|
|
422
|
+
heldAt: /* @__PURE__ */ new Date(),
|
|
423
|
+
holdUntil: options.holdUntil,
|
|
424
|
+
releases: [],
|
|
425
|
+
metadata: options.metadata
|
|
426
|
+
} }, this.optsFromCtx(ctx));
|
|
427
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.ESCROW_HELD, {
|
|
428
|
+
transaction: updated,
|
|
429
|
+
heldAmount: holdAmount,
|
|
430
|
+
reason: options.reason
|
|
431
|
+
}, ctx, {
|
|
432
|
+
resource: "transaction",
|
|
433
|
+
resourceId: updated?.publicId
|
|
434
|
+
}), ctx);
|
|
435
|
+
return updated;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Releases held funds. Returns the updated transaction doc.
|
|
439
|
+
*
|
|
440
|
+
* The hold update and the escrow_release transaction create happen inside
|
|
441
|
+
* `withTransaction` — a mid-flow crash can't leave the hold marked released
|
|
442
|
+
* without the corresponding outflow record (or vice versa).
|
|
443
|
+
*/
|
|
444
|
+
async release(transactionId, options, ctx = {}) {
|
|
445
|
+
const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
|
|
446
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
447
|
+
if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD && transaction.hold.status !== HOLD_STATUS.PARTIALLY_RELEASED) throw new ValidationError("Transaction does not have an active hold");
|
|
448
|
+
const releaseAmount = options.amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
|
|
449
|
+
const newReleasedAmount = transaction.hold.releasedAmount + releaseAmount;
|
|
450
|
+
const isFullRelease = newReleasedAmount >= transaction.hold.heldAmount;
|
|
451
|
+
const release = {
|
|
452
|
+
amount: releaseAmount,
|
|
453
|
+
recipientId: options.recipientId,
|
|
454
|
+
recipientType: options.recipientType,
|
|
455
|
+
releasedAt: /* @__PURE__ */ new Date(),
|
|
456
|
+
releasedBy: options.releasedBy,
|
|
457
|
+
reason: options.reason,
|
|
458
|
+
metadata: options.metadata
|
|
459
|
+
};
|
|
460
|
+
const pendingEvents = [];
|
|
461
|
+
const updated = await withTransaction(this.Model.db, async (session) => {
|
|
462
|
+
const writeOpts = this.optsFromCtx(ctx, { session });
|
|
463
|
+
const result = await this.update(transactionId, { hold: {
|
|
464
|
+
...transaction.hold,
|
|
465
|
+
status: isFullRelease ? HOLD_STATUS.RELEASED : HOLD_STATUS.PARTIALLY_RELEASED,
|
|
466
|
+
releasedAmount: newReleasedAmount,
|
|
467
|
+
releasedAt: isFullRelease ? /* @__PURE__ */ new Date() : transaction.hold.releasedAt,
|
|
468
|
+
releases: [...transaction.hold.releases ?? [], release]
|
|
469
|
+
} }, writeOpts);
|
|
470
|
+
if (options.createTransaction !== false) await this.create({
|
|
471
|
+
organizationId: transaction.organizationId,
|
|
472
|
+
customerId: options.recipientId,
|
|
473
|
+
type: "escrow_release",
|
|
474
|
+
flow: "outflow",
|
|
475
|
+
tags: ["escrow", "release"],
|
|
476
|
+
amount: releaseAmount,
|
|
477
|
+
currency: transaction.currency,
|
|
478
|
+
fee: 0,
|
|
479
|
+
tax: 0,
|
|
480
|
+
net: releaseAmount,
|
|
481
|
+
method: transaction.method,
|
|
482
|
+
status: TRANSACTION_STATUS.VERIFIED,
|
|
483
|
+
relatedTransactionId: transaction._id,
|
|
484
|
+
sourceId: transaction.sourceId,
|
|
485
|
+
sourceModel: transaction.sourceModel,
|
|
486
|
+
verifiedAt: /* @__PURE__ */ new Date(),
|
|
487
|
+
metadata: options.metadata
|
|
488
|
+
}, writeOpts);
|
|
489
|
+
const event = createEvent(REVENUE_EVENTS.ESCROW_RELEASED, {
|
|
490
|
+
transaction: result,
|
|
491
|
+
releaseAmount,
|
|
492
|
+
recipientId: options.recipientId,
|
|
493
|
+
recipientType: options.recipientType,
|
|
494
|
+
isFullRelease,
|
|
495
|
+
isPartialRelease: !isFullRelease
|
|
496
|
+
}, ctx, {
|
|
497
|
+
resource: "transaction",
|
|
498
|
+
resourceId: result?.publicId
|
|
499
|
+
});
|
|
500
|
+
await this.saveToOutbox(event, session);
|
|
501
|
+
pendingEvents.push(event);
|
|
502
|
+
return result;
|
|
503
|
+
});
|
|
504
|
+
for (const ev of pendingEvents) await this.publishToTransport(ev);
|
|
505
|
+
return updated;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Splits payment among recipients. Returns the updated transaction doc.
|
|
509
|
+
*
|
|
510
|
+
* N + 2 writes (one create per recipient, one update on the parent, one
|
|
511
|
+
* platform_revenue create) all commit atomically. Partial splits are the
|
|
512
|
+
* worst class of bug in a payments system — this is exactly what
|
|
513
|
+
* `withTransaction` is for.
|
|
514
|
+
*/
|
|
515
|
+
async split(transactionId, rules, ctx = {}) {
|
|
516
|
+
const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
|
|
517
|
+
if (!transaction) throw new TransactionNotFoundError(transactionId);
|
|
518
|
+
const gatewayFeeRate = this.deps.commission?.gatewayFeeRate ?? 0;
|
|
519
|
+
const splits = calculateSplits(transaction.amount, rules, gatewayFeeRate);
|
|
520
|
+
const orgPayout = calculateOrganizationPayout(transaction.amount, splits);
|
|
521
|
+
const pendingEvents = [];
|
|
522
|
+
const updated = await withTransaction(this.Model.db, async (session) => {
|
|
523
|
+
const writeOpts = this.optsFromCtx(ctx, { session });
|
|
524
|
+
for (const s of splits) await this.create({
|
|
525
|
+
organizationId: transaction.organizationId,
|
|
526
|
+
customerId: s.recipientId,
|
|
527
|
+
type: "commission",
|
|
528
|
+
flow: "outflow",
|
|
529
|
+
tags: ["split", s.type],
|
|
530
|
+
amount: s.grossAmount,
|
|
531
|
+
currency: transaction.currency,
|
|
532
|
+
fee: s.gatewayFeeAmount,
|
|
533
|
+
tax: 0,
|
|
534
|
+
net: s.netAmount,
|
|
535
|
+
method: transaction.method,
|
|
536
|
+
status: TRANSACTION_STATUS.VERIFIED,
|
|
537
|
+
relatedTransactionId: transaction._id,
|
|
538
|
+
sourceId: transaction.sourceId,
|
|
539
|
+
sourceModel: transaction.sourceModel,
|
|
540
|
+
verifiedAt: /* @__PURE__ */ new Date()
|
|
541
|
+
}, writeOpts);
|
|
542
|
+
const result = await this.update(transactionId, {
|
|
543
|
+
splits,
|
|
544
|
+
metadata: {
|
|
545
|
+
...transaction.metadata,
|
|
546
|
+
organizationPayout: orgPayout
|
|
547
|
+
}
|
|
548
|
+
}, writeOpts);
|
|
549
|
+
await this.create({
|
|
550
|
+
organizationId: transaction.organizationId,
|
|
551
|
+
type: "platform_revenue",
|
|
552
|
+
flow: "inflow",
|
|
553
|
+
tags: ["split", "platform"],
|
|
554
|
+
amount: orgPayout,
|
|
555
|
+
currency: transaction.currency,
|
|
556
|
+
fee: 0,
|
|
557
|
+
tax: 0,
|
|
558
|
+
net: orgPayout,
|
|
559
|
+
method: transaction.method,
|
|
560
|
+
status: TRANSACTION_STATUS.VERIFIED,
|
|
561
|
+
relatedTransactionId: transaction._id,
|
|
562
|
+
verifiedAt: /* @__PURE__ */ new Date()
|
|
563
|
+
}, writeOpts);
|
|
564
|
+
const event = createEvent(REVENUE_EVENTS.ESCROW_SPLIT, {
|
|
565
|
+
transaction: result,
|
|
566
|
+
splits,
|
|
567
|
+
organizationPayout: orgPayout
|
|
568
|
+
}, ctx, {
|
|
569
|
+
resource: "transaction",
|
|
570
|
+
resourceId: transaction.publicId
|
|
571
|
+
});
|
|
572
|
+
await this.saveToOutbox(event, session);
|
|
573
|
+
pendingEvents.push(event);
|
|
574
|
+
return result;
|
|
575
|
+
});
|
|
576
|
+
for (const ev of pendingEvents) await this.publishToTransport(ev);
|
|
577
|
+
return updated;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Idempotent bulk import of bank-feed rows.
|
|
581
|
+
*
|
|
582
|
+
* Each row is upserted by `(orgId, bankAccountId, externalId)` — the
|
|
583
|
+
* partial unique index declared in `create-models.ts`. Re-running the
|
|
584
|
+
* same Plaid sync, OFX upload, or QBO CDC drain produces zero new
|
|
585
|
+
* inserts on the second call (modified counts may rise as
|
|
586
|
+
* descriptions/categories evolve upstream).
|
|
587
|
+
*
|
|
588
|
+
* Signed bank `amount` is normalized into the (`amount` >= 0, `flow`)
|
|
589
|
+
* shape revenue uses internally so downstream queries (`flow:
|
|
590
|
+
* 'inflow'`) work uniformly across kinds.
|
|
591
|
+
*
|
|
592
|
+
* Emits one `revenue:transaction.imported` event per **inserted** row
|
|
593
|
+
* (not per row in `rows` — re-imports do not re-fire). Hosts wanting
|
|
594
|
+
* batch-level signal subscribe to the per-doc events and aggregate.
|
|
595
|
+
*
|
|
596
|
+
* Per-row failures (validation, hash collisions on a non-unique
|
|
597
|
+
* `externalId`) collect into `errors[]` instead of aborting the whole
|
|
598
|
+
* batch — the typical Plaid drain pulls thousands of rows; one bad
|
|
599
|
+
* row should not block the rest.
|
|
600
|
+
*
|
|
601
|
+
* @param rows Canonical bank transactions, structurally compatible
|
|
602
|
+
* with `@classytic/fin-io` parsers' output.
|
|
603
|
+
* @param opts `bankAccountId` (required, polymorphic ID) and
|
|
604
|
+
* `source` (provenance — `'plaid'`, `'ofx'`, …).
|
|
605
|
+
*/
|
|
606
|
+
async import(rows, opts, ctx = {}) {
|
|
607
|
+
const startedAt = Date.now();
|
|
608
|
+
if (!Array.isArray(rows) || rows.length === 0) return {
|
|
609
|
+
inserted: 0,
|
|
610
|
+
updated: 0,
|
|
611
|
+
skipped: 0,
|
|
612
|
+
errors: [],
|
|
613
|
+
durationMs: 0
|
|
614
|
+
};
|
|
615
|
+
const repo = this;
|
|
616
|
+
if (!repo.bulkWrite) throw new BankFeedImportError("TransactionRepository requires `batchOperationsPlugin` for `import()`. Pass it via `createRevenue({ repositoryPlugins: { transaction: [batchOperationsPlugin()] } })` — or rely on the engine default which wires it automatically.");
|
|
617
|
+
const errors = [];
|
|
618
|
+
const tenantOption = ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {};
|
|
619
|
+
const ops = [];
|
|
620
|
+
for (const row of rows) {
|
|
621
|
+
if (!row.externalId || typeof row.externalId !== "string") {
|
|
622
|
+
errors.push({
|
|
623
|
+
externalId: String(row.externalId),
|
|
624
|
+
reason: "missing_external_id",
|
|
625
|
+
row
|
|
626
|
+
});
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const signed = row.amount.amount;
|
|
630
|
+
if (!Number.isFinite(signed) || !Number.isInteger(signed)) {
|
|
631
|
+
errors.push({
|
|
632
|
+
externalId: row.externalId,
|
|
633
|
+
reason: "invalid_amount",
|
|
634
|
+
row
|
|
635
|
+
});
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const isInflow = signed >= 0;
|
|
639
|
+
const absoluteAmount = Math.abs(signed);
|
|
640
|
+
const filter = {
|
|
641
|
+
bankAccountId: opts.bankAccountId,
|
|
642
|
+
externalId: row.externalId
|
|
643
|
+
};
|
|
644
|
+
if (ctx.organizationId !== void 0) filter.organizationId = ctx.organizationId;
|
|
645
|
+
const set = {
|
|
646
|
+
amount: absoluteAmount,
|
|
647
|
+
currency: row.amount.currency,
|
|
648
|
+
flow: isInflow ? "inflow" : "outflow",
|
|
649
|
+
postedDate: row.postedDate,
|
|
650
|
+
description: row.description,
|
|
651
|
+
method: opts.method ?? opts.source
|
|
652
|
+
};
|
|
653
|
+
if (row.valueDate !== void 0) set.valueDate = row.valueDate;
|
|
654
|
+
if (row.counterparty !== void 0) set.counterparty = row.counterparty;
|
|
655
|
+
if (row.reference !== void 0) set.reference = row.reference;
|
|
656
|
+
if (row.category !== void 0) set.vendorCategory = row.category;
|
|
657
|
+
if (row.balanceAfter !== void 0) set.balanceAfter = row.balanceAfter.amount;
|
|
658
|
+
const setOnInsert = {
|
|
659
|
+
kind: TRANSACTION_KIND.BANK_FEED,
|
|
660
|
+
status: initialStatusFor(TRANSACTION_KIND.BANK_FEED),
|
|
661
|
+
bankAccountId: opts.bankAccountId,
|
|
662
|
+
externalId: row.externalId,
|
|
663
|
+
source: opts.source,
|
|
664
|
+
type: "bank_feed",
|
|
665
|
+
tags: ["bank_feed", opts.source],
|
|
666
|
+
fee: 0,
|
|
667
|
+
tax: 0,
|
|
668
|
+
net: absoluteAmount,
|
|
669
|
+
deletedAt: null
|
|
670
|
+
};
|
|
671
|
+
if (ctx.organizationId !== void 0) setOnInsert.organizationId = ctx.organizationId;
|
|
672
|
+
ops.push({ updateOne: {
|
|
673
|
+
filter,
|
|
674
|
+
update: {
|
|
675
|
+
$set: set,
|
|
676
|
+
$setOnInsert: setOnInsert
|
|
677
|
+
},
|
|
678
|
+
upsert: true
|
|
679
|
+
} });
|
|
680
|
+
}
|
|
681
|
+
if (ops.length === 0) return {
|
|
682
|
+
inserted: 0,
|
|
683
|
+
updated: 0,
|
|
684
|
+
skipped: rows.length,
|
|
685
|
+
errors,
|
|
686
|
+
durationMs: Date.now() - startedAt
|
|
687
|
+
};
|
|
688
|
+
const sessionOption = ctx.session !== void 0 ? { session: ctx.session } : {};
|
|
689
|
+
const result = await repo.bulkWrite(ops, {
|
|
690
|
+
ordered: false,
|
|
691
|
+
...sessionOption,
|
|
692
|
+
...tenantOption
|
|
693
|
+
});
|
|
694
|
+
const inserted = (result.upsertedCount ?? 0) + (result.insertedCount ?? 0);
|
|
695
|
+
const updated = result.modifiedCount ?? 0;
|
|
696
|
+
const upsertedIds = Object.values(result.upsertedIds ?? {});
|
|
697
|
+
if (upsertedIds.length > 0) for (let i = 0; i < upsertedIds.length; i++) {
|
|
698
|
+
const id = upsertedIds[i];
|
|
699
|
+
if (id === void 0 || id === null) continue;
|
|
700
|
+
const doc = await this.getById(String(id), this.optsFromCtx(ctx, { throwOnNotFound: false }));
|
|
701
|
+
if (!doc) continue;
|
|
702
|
+
const txn = doc;
|
|
703
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_IMPORTED, {
|
|
704
|
+
transaction: txn,
|
|
705
|
+
source: opts.source,
|
|
706
|
+
bankAccountId: opts.bankAccountId,
|
|
707
|
+
externalId: txn.externalId ?? ""
|
|
708
|
+
}, ctx, {
|
|
709
|
+
resource: "transaction",
|
|
710
|
+
resourceId: txn.publicId
|
|
711
|
+
}), ctx);
|
|
712
|
+
await this.deps.bridges.ledger?.onTransactionImported?.(txn, ctx);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
inserted,
|
|
716
|
+
updated,
|
|
717
|
+
skipped: errors.length,
|
|
718
|
+
errors,
|
|
719
|
+
durationMs: Date.now() - startedAt
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Drain a bank-feed provider into the collection.
|
|
724
|
+
*
|
|
725
|
+
* Pulls pages from `provider.fetchTransactions()` (Plaid cursor, QBO
|
|
726
|
+
* CDC) and feeds each batch through `import()`. Yields the running
|
|
727
|
+
* report so a host cron can stream-progress-report to logs / metrics.
|
|
728
|
+
*
|
|
729
|
+
* Stops when the provider returns no new rows AND no removals AND no
|
|
730
|
+
* `nextCursor`. Caller is responsible for persisting the final cursor
|
|
731
|
+
* in their own checkpoint table — `result.nextCursor` is returned so
|
|
732
|
+
* the host can write it after a successful drain.
|
|
733
|
+
*
|
|
734
|
+
* Plaid `removed[]` rows (and any provider that retracts entries) are
|
|
735
|
+
* routed through `removeByFeed` so the host's LedgerBridge can void
|
|
736
|
+
* any JE that was already posted.
|
|
737
|
+
*/
|
|
738
|
+
async drainSync(providerName, params, ctx = {}) {
|
|
739
|
+
if (!this.deps.bankFeedProviders) throw new ValidationError("`bankFeedProviders` not wired on the engine. Pass `providers.bankFeed` to `createRevenue`.");
|
|
740
|
+
const provider = this.deps.bankFeedProviders.get(providerName);
|
|
741
|
+
let totalImported = 0;
|
|
742
|
+
let totalUpdated = 0;
|
|
743
|
+
let totalRemoved = 0;
|
|
744
|
+
let lastCursor;
|
|
745
|
+
const errors = [];
|
|
746
|
+
for await (const page of provider.drain(params)) {
|
|
747
|
+
if (page.transactions && page.transactions.length > 0) {
|
|
748
|
+
const report = await this.import(page.transactions, {
|
|
749
|
+
bankAccountId: params.bankAccountId,
|
|
750
|
+
source: providerName
|
|
751
|
+
}, ctx);
|
|
752
|
+
totalImported += report.inserted;
|
|
753
|
+
totalUpdated += report.updated;
|
|
754
|
+
if (report.errors.length > 0) errors.push(...report.errors);
|
|
755
|
+
}
|
|
756
|
+
if (page.removed && page.removed.length > 0) {
|
|
757
|
+
const removed = await this.removeByFeed(page.removed.map((r) => r.externalId), {
|
|
758
|
+
bankAccountId: params.bankAccountId,
|
|
759
|
+
source: providerName
|
|
760
|
+
}, ctx);
|
|
761
|
+
totalRemoved += removed.removed;
|
|
762
|
+
}
|
|
763
|
+
if (page.nextCursor) lastCursor = page.nextCursor;
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
totalImported,
|
|
767
|
+
totalUpdated,
|
|
768
|
+
totalRemoved,
|
|
769
|
+
...lastCursor !== void 0 ? { nextCursor: lastCursor } : {},
|
|
770
|
+
errors
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Parse an upload (OFX / CAMT.053 / MT940 / CSV) via a registered
|
|
775
|
+
* bank-feed provider, then `import()` the result.
|
|
776
|
+
*
|
|
777
|
+
* Convenience over manually calling `provider.parseUpload()` and
|
|
778
|
+
* threading the canonical rows into `import()` — the file-upload
|
|
779
|
+
* route handler is one line.
|
|
780
|
+
*/
|
|
781
|
+
async parseAndImport(providerName, upload, ctx = {}) {
|
|
782
|
+
if (!this.deps.bankFeedProviders) throw new ValidationError("`bankFeedProviders` not wired on the engine.");
|
|
783
|
+
const provider = this.deps.bankFeedProviders.get(providerName);
|
|
784
|
+
if (!provider.parseUpload) throw new ValidationError(`Provider '${providerName}' does not support parseUpload`);
|
|
785
|
+
const parsed = await provider.parseUpload({
|
|
786
|
+
buffer: upload.buffer,
|
|
787
|
+
...upload.format !== void 0 ? { format: upload.format } : {},
|
|
788
|
+
bankAccountId: upload.bankAccountId
|
|
789
|
+
});
|
|
790
|
+
return this.import(parsed.transactions, {
|
|
791
|
+
bankAccountId: upload.bankAccountId,
|
|
792
|
+
source: providerName
|
|
793
|
+
}, ctx);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Hand-keyed entry — treasurer logs a cash deposit, owner injects
|
|
797
|
+
* capital, refund correction. Created in `pending` (manual SM); host
|
|
798
|
+
* proceeds with `match()` → `journalize()` to post it to the ledger.
|
|
799
|
+
*
|
|
800
|
+
* `kind: 'manual'` is enforced — calls passing other kinds throw.
|
|
801
|
+
*/
|
|
802
|
+
async createManual(data, ctx = {}) {
|
|
803
|
+
return await this.create({
|
|
804
|
+
organizationId: ctx.organizationId,
|
|
805
|
+
kind: TRANSACTION_KIND.MANUAL,
|
|
806
|
+
type: data.type,
|
|
807
|
+
flow: data.flow,
|
|
808
|
+
tags: ["manual"],
|
|
809
|
+
amount: data.amount,
|
|
810
|
+
currency: data.currency,
|
|
811
|
+
fee: 0,
|
|
812
|
+
tax: 0,
|
|
813
|
+
net: data.amount,
|
|
814
|
+
method: "manual",
|
|
815
|
+
status: initialStatusFor(TRANSACTION_KIND.MANUAL),
|
|
816
|
+
source: "manual",
|
|
817
|
+
...data.description !== void 0 ? { description: data.description } : {},
|
|
818
|
+
...data.counterparty !== void 0 ? { counterparty: data.counterparty } : {},
|
|
819
|
+
...data.reference !== void 0 ? { reference: data.reference } : {},
|
|
820
|
+
...data.postedDate !== void 0 ? { postedDate: data.postedDate } : {},
|
|
821
|
+
...data.valueDate !== void 0 ? { valueDate: data.valueDate } : {},
|
|
822
|
+
...data.bankAccountId !== void 0 ? { bankAccountId: data.bankAccountId } : {},
|
|
823
|
+
...data.sourceId !== void 0 ? { sourceId: data.sourceId } : {},
|
|
824
|
+
...data.sourceModel !== void 0 ? { sourceModel: data.sourceModel } : {},
|
|
825
|
+
...data.metadata !== void 0 ? { metadata: data.metadata } : {}
|
|
826
|
+
}, this.optsFromCtx(ctx));
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Match a bank-feed / manual transaction to GL accounts, optionally
|
|
830
|
+
* cross-linking to an upstream payment-flow transaction.
|
|
831
|
+
*
|
|
832
|
+
* Atomic state CAS via `claim()` — the `where: { kind: { $in: [...] } }`
|
|
833
|
+
* predicate prevents a payment-flow row from being matched through this
|
|
834
|
+
* verb. Multi-source `from` (`['imported', 'matched']`) supports
|
|
835
|
+
* re-match after `unmatch()` (`matched → imported → matched`) without
|
|
836
|
+
* losing the prior mapping if the host wants to overwrite it.
|
|
837
|
+
*
|
|
838
|
+
* After a successful claim, `LedgerBridge.onTransactionMatched` runs
|
|
839
|
+
* — the canonical implementation creates a journal entry and chains
|
|
840
|
+
* `journalize()` to record the JE ref. The bridge call is OUTSIDE the
|
|
841
|
+
* claim's CAS window because JE posting is a side effect that may
|
|
842
|
+
* take seconds (cross-process call to ledger).
|
|
843
|
+
*/
|
|
844
|
+
async match(id, data, ctx = {}) {
|
|
845
|
+
const existing = await this.getById(id, this.optsFromCtx(ctx));
|
|
846
|
+
if (!existing) throw new TransactionNotFoundError(id);
|
|
847
|
+
if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
|
|
848
|
+
smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.MATCHED, id);
|
|
849
|
+
const patch = {
|
|
850
|
+
$set: {
|
|
851
|
+
matching: {
|
|
852
|
+
...data.mapping,
|
|
853
|
+
...data.matchedBy !== void 0 ? { matchedBy: data.matchedBy } : {},
|
|
854
|
+
matchedAt: /* @__PURE__ */ new Date()
|
|
855
|
+
},
|
|
856
|
+
...data.matchedBy !== void 0 ? { verifiedBy: data.matchedBy } : {},
|
|
857
|
+
verifiedAt: /* @__PURE__ */ new Date()
|
|
858
|
+
},
|
|
859
|
+
$unset: { journalEntryRef: 1 }
|
|
860
|
+
};
|
|
861
|
+
if (data.relatedTransactionId !== void 0) patch.$set.relatedTransactionId = data.relatedTransactionId;
|
|
862
|
+
const claimed = await this.claim(existing._id, {
|
|
863
|
+
from: [
|
|
864
|
+
TRANSACTION_STATUS.IMPORTED,
|
|
865
|
+
TRANSACTION_STATUS.MATCHED,
|
|
866
|
+
TRANSACTION_STATUS.PENDING
|
|
867
|
+
],
|
|
868
|
+
to: TRANSACTION_STATUS.MATCHED,
|
|
869
|
+
where: { kind: existing.kind }
|
|
870
|
+
}, patch, this.optsFromCtx(ctx));
|
|
871
|
+
if (!claimed) throw new ValidationError(`Transaction ${id} could not be matched (race-loss or illegal state)`);
|
|
872
|
+
await this.deps.bridges.ledger?.onTransactionMatched?.(claimed, data.mapping, ctx);
|
|
873
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_MATCHED, {
|
|
874
|
+
transaction: claimed,
|
|
875
|
+
mapping: data.mapping,
|
|
876
|
+
...data.relatedTransactionId !== void 0 ? { relatedTransactionId: data.relatedTransactionId } : {},
|
|
877
|
+
...data.matchedBy !== void 0 ? { matchedBy: data.matchedBy } : {}
|
|
878
|
+
}, ctx, {
|
|
879
|
+
resource: "transaction",
|
|
880
|
+
resourceId: claimed.publicId
|
|
881
|
+
}), ctx);
|
|
882
|
+
return claimed;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Revert a matched transaction back to `imported`. Clears the
|
|
886
|
+
* `matching` block and `relatedTransactionId`. Notifies the
|
|
887
|
+
* LedgerBridge (which typically voids the journal entry) AFTER the
|
|
888
|
+
* state CAS lands.
|
|
889
|
+
*
|
|
890
|
+
* Only legal for `kind: 'bank_feed'` — manual entries don't allow
|
|
891
|
+
* un-match (the manual SM has no `matched → pending` edge).
|
|
892
|
+
*/
|
|
893
|
+
async unmatch(id, options = {}, ctx = {}) {
|
|
894
|
+
const existing = await this.getById(id, this.optsFromCtx(ctx));
|
|
895
|
+
if (!existing) throw new TransactionNotFoundError(id);
|
|
896
|
+
if (existing.kind !== TRANSACTION_KIND.BANK_FEED) throw new WrongTransactionKindError(id, "bank_feed", existing.kind);
|
|
897
|
+
const priorJournalEntryRef = existing.journalEntryRef;
|
|
898
|
+
const claimed = await this.claim(existing._id, {
|
|
899
|
+
from: TRANSACTION_STATUS.MATCHED,
|
|
900
|
+
to: TRANSACTION_STATUS.IMPORTED,
|
|
901
|
+
where: { kind: TRANSACTION_KIND.BANK_FEED }
|
|
902
|
+
}, { $unset: {
|
|
903
|
+
matching: 1,
|
|
904
|
+
relatedTransactionId: 1,
|
|
905
|
+
journalEntryRef: 1,
|
|
906
|
+
verifiedBy: 1,
|
|
907
|
+
verifiedAt: 1
|
|
908
|
+
} }, this.optsFromCtx(ctx));
|
|
909
|
+
if (!claimed) throw new ValidationError(`Transaction ${id} could not be unmatched (current state is not 'matched')`);
|
|
910
|
+
await this.deps.bridges.ledger?.onTransactionUnmatched?.(claimed, priorJournalEntryRef, ctx);
|
|
911
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_UNMATCHED, {
|
|
912
|
+
transaction: claimed,
|
|
913
|
+
...options.unmatchedBy !== void 0 ? { unmatchedBy: options.unmatchedBy } : {}
|
|
914
|
+
}, ctx, {
|
|
915
|
+
resource: "transaction",
|
|
916
|
+
resourceId: claimed.publicId
|
|
917
|
+
}), ctx);
|
|
918
|
+
return claimed;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Stamp the journal entry reference and transition `matched →
|
|
922
|
+
* journalized`. Typical caller is the `LedgerBridge.onTransactionMatched`
|
|
923
|
+
* implementation — after creating a JE, it calls this verb so the row
|
|
924
|
+
* carries the back-reference.
|
|
925
|
+
*/
|
|
926
|
+
async journalize(id, data, ctx = {}) {
|
|
927
|
+
const existing = await this.getById(id, this.optsFromCtx(ctx));
|
|
928
|
+
if (!existing) throw new TransactionNotFoundError(id);
|
|
929
|
+
if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
|
|
930
|
+
smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.JOURNALIZED, id);
|
|
931
|
+
const claimed = await this.claim(existing._id, {
|
|
932
|
+
from: TRANSACTION_STATUS.MATCHED,
|
|
933
|
+
to: TRANSACTION_STATUS.JOURNALIZED,
|
|
934
|
+
where: { kind: existing.kind }
|
|
935
|
+
}, { $set: { journalEntryRef: data.journalEntryRef } }, this.optsFromCtx(ctx));
|
|
936
|
+
if (!claimed) throw new ValidationError(`Transaction ${id} could not be journalized (current state is not 'matched')`);
|
|
937
|
+
await this.deps.bridges.ledger?.onTransactionJournalized?.(claimed, data.journalEntryRef, ctx);
|
|
938
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_JOURNALIZED, {
|
|
939
|
+
transaction: claimed,
|
|
940
|
+
journalEntryRef: data.journalEntryRef,
|
|
941
|
+
...data.journalizedBy !== void 0 ? { journalizedBy: data.journalizedBy } : {}
|
|
942
|
+
}, ctx, {
|
|
943
|
+
resource: "transaction",
|
|
944
|
+
resourceId: claimed.publicId
|
|
945
|
+
}), ctx);
|
|
946
|
+
return claimed;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Operator skip — marks an imported / matched / pending row as
|
|
950
|
+
* rejected (terminal). Use cases: duplicate of an already-imported
|
|
951
|
+
* row, non-cash entry the host doesn't want in the ledger, manual
|
|
952
|
+
* correction overrides.
|
|
953
|
+
*
|
|
954
|
+
* `relatedTransactionId` is preserved; reversal is the host's call.
|
|
955
|
+
*/
|
|
956
|
+
async reject(id, data, ctx = {}) {
|
|
957
|
+
const existing = await this.getById(id, this.optsFromCtx(ctx));
|
|
958
|
+
if (!existing) throw new TransactionNotFoundError(id);
|
|
959
|
+
if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
|
|
960
|
+
smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.REJECTED, id);
|
|
961
|
+
const claimed = await this.claim(existing._id, {
|
|
962
|
+
from: [
|
|
963
|
+
TRANSACTION_STATUS.IMPORTED,
|
|
964
|
+
TRANSACTION_STATUS.MATCHED,
|
|
965
|
+
TRANSACTION_STATUS.PENDING
|
|
966
|
+
],
|
|
967
|
+
to: TRANSACTION_STATUS.REJECTED,
|
|
968
|
+
where: { kind: existing.kind }
|
|
969
|
+
}, { $set: {
|
|
970
|
+
failureReason: data.reason,
|
|
971
|
+
failedAt: /* @__PURE__ */ new Date(),
|
|
972
|
+
...data.rejectedBy !== void 0 ? { verifiedBy: data.rejectedBy } : {}
|
|
973
|
+
} }, this.optsFromCtx(ctx));
|
|
974
|
+
if (!claimed) throw new ValidationError(`Transaction ${id} could not be rejected (illegal current state)`);
|
|
975
|
+
await this.deps.bridges.ledger?.onTransactionRejected?.(claimed, data.reason, ctx);
|
|
976
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_REJECTED, {
|
|
977
|
+
transaction: claimed,
|
|
978
|
+
reason: data.reason,
|
|
979
|
+
...data.rejectedBy !== void 0 ? { rejectedBy: data.rejectedBy } : {}
|
|
980
|
+
}, ctx, {
|
|
981
|
+
resource: "transaction",
|
|
982
|
+
resourceId: claimed.publicId
|
|
983
|
+
}), ctx);
|
|
984
|
+
return claimed;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Soft-delete bank-feed rows that the upstream feed has retracted
|
|
988
|
+
* (Plaid `removed[]`, OFX correction).
|
|
989
|
+
*
|
|
990
|
+
* Each row is matched by `(orgId, bankAccountId, externalId)`; rows
|
|
991
|
+
* already journalized are NOT silently kept — they're surfaced in
|
|
992
|
+
* `retainedJournalized` so the caller can surface them in the UI
|
|
993
|
+
* ("the feed retracted these N rows but they're posted; reverse
|
|
994
|
+
* manually"). The host's `LedgerBridge` should post a reversing JE
|
|
995
|
+
* for those before any subsequent `delete()` can succeed.
|
|
996
|
+
*
|
|
997
|
+
* @returns `removed` (count soft-deleted), `retainedJournalized`
|
|
998
|
+
* (rows kept because they're already in the GL).
|
|
999
|
+
*/
|
|
1000
|
+
async removeByFeed(externalIds, opts, ctx = {}) {
|
|
1001
|
+
if (externalIds.length === 0) return {
|
|
1002
|
+
removed: 0,
|
|
1003
|
+
retainedJournalized: []
|
|
1004
|
+
};
|
|
1005
|
+
const allFilter = {
|
|
1006
|
+
kind: TRANSACTION_KIND.BANK_FEED,
|
|
1007
|
+
bankAccountId: opts.bankAccountId,
|
|
1008
|
+
externalId: { $in: externalIds },
|
|
1009
|
+
deletedAt: null
|
|
1010
|
+
};
|
|
1011
|
+
const allDocs = await this.findAll(allFilter, this.optsFromCtx(ctx));
|
|
1012
|
+
if (!Array.isArray(allDocs) || allDocs.length === 0) return {
|
|
1013
|
+
removed: 0,
|
|
1014
|
+
retainedJournalized: []
|
|
1015
|
+
};
|
|
1016
|
+
const retainedJournalized = [];
|
|
1017
|
+
const removable = [];
|
|
1018
|
+
for (const doc of allDocs) if (doc.status === TRANSACTION_STATUS.JOURNALIZED) retainedJournalized.push(doc);
|
|
1019
|
+
else removable.push(doc);
|
|
1020
|
+
let removed = 0;
|
|
1021
|
+
for (const doc of removable) {
|
|
1022
|
+
await this.delete(doc._id, this.optsFromCtx(ctx));
|
|
1023
|
+
removed += 1;
|
|
1024
|
+
await this.deps.bridges.ledger?.onTransactionRemovedByFeed?.(doc, ctx);
|
|
1025
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_REMOVED_BY_FEED, {
|
|
1026
|
+
transaction: doc,
|
|
1027
|
+
source: opts.source,
|
|
1028
|
+
externalId: doc.externalId ?? ""
|
|
1029
|
+
}, ctx, {
|
|
1030
|
+
resource: "transaction",
|
|
1031
|
+
resourceId: doc.publicId
|
|
1032
|
+
}), ctx);
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
removed,
|
|
1036
|
+
retainedJournalized
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Find candidate matches for cross-referencing a payment-flow row to
|
|
1041
|
+
* its bank deposit (or vice-versa).
|
|
1042
|
+
*
|
|
1043
|
+
* Heuristic:
|
|
1044
|
+
* - same currency by default; cross-currency requires `fxRate` on
|
|
1045
|
+
* the candidate row (multi-currency reconciliation).
|
|
1046
|
+
* - amount within `amountTolerancePct` (default 1%) — accounts for
|
|
1047
|
+
* gateway fees / FX rounding.
|
|
1048
|
+
* - posted/created within `toleranceDays` of the target date
|
|
1049
|
+
* (default 3 days — covers ACH delays, weekend settlement).
|
|
1050
|
+
* - terminal verified states only (`verified` / `completed` for
|
|
1051
|
+
* payment_flow, `imported` / `matched` for bank_feed).
|
|
1052
|
+
*
|
|
1053
|
+
* Returned candidates are unsorted; callers rank by their own
|
|
1054
|
+
* confidence model (counterparty fuzzy match, currency identity,
|
|
1055
|
+
* exact-amount preference, …).
|
|
1056
|
+
*/
|
|
1057
|
+
async findMatchCandidates(filter, ctx = {}) {
|
|
1058
|
+
const tolerance = filter.toleranceDays ?? 3;
|
|
1059
|
+
const pct = filter.amountTolerancePct ?? .01;
|
|
1060
|
+
const start = /* @__PURE__ */ new Date(filter.postedDate.getTime() - tolerance * 864e5);
|
|
1061
|
+
const end = new Date(filter.postedDate.getTime() + tolerance * 864e5);
|
|
1062
|
+
const minAmount = filter.amount * (1 - pct);
|
|
1063
|
+
const maxAmount = filter.amount * (1 + pct);
|
|
1064
|
+
const targetKind = filter.kind ?? TRANSACTION_KIND.PAYMENT_FLOW;
|
|
1065
|
+
const validStatuses = targetKind === TRANSACTION_KIND.PAYMENT_FLOW ? [TRANSACTION_STATUS.VERIFIED, TRANSACTION_STATUS.COMPLETED] : [TRANSACTION_STATUS.IMPORTED, TRANSACTION_STATUS.MATCHED];
|
|
1066
|
+
const dateClauses = targetKind === TRANSACTION_KIND.BANK_FEED ? [{ postedDate: {
|
|
1067
|
+
$gte: start,
|
|
1068
|
+
$lte: end
|
|
1069
|
+
} }] : [{ verifiedAt: {
|
|
1070
|
+
$gte: start,
|
|
1071
|
+
$lte: end
|
|
1072
|
+
} }, { createdAt: {
|
|
1073
|
+
$gte: start,
|
|
1074
|
+
$lte: end
|
|
1075
|
+
} }];
|
|
1076
|
+
const query = {
|
|
1077
|
+
kind: targetKind,
|
|
1078
|
+
status: { $in: validStatuses },
|
|
1079
|
+
amount: {
|
|
1080
|
+
$gte: minAmount,
|
|
1081
|
+
$lte: maxAmount
|
|
1082
|
+
},
|
|
1083
|
+
$or: dateClauses
|
|
1084
|
+
};
|
|
1085
|
+
if (filter.currency !== void 0) query.currency = filter.currency;
|
|
1086
|
+
if (filter.counterpartyName !== void 0) query["counterparty.name"] = {
|
|
1087
|
+
$regex: escapeRegex(filter.counterpartyName),
|
|
1088
|
+
$options: "i"
|
|
1089
|
+
};
|
|
1090
|
+
const docs = await this.findAll(query, this.optsFromCtx(ctx, { limit: 50 }));
|
|
1091
|
+
return Array.isArray(docs) ? docs : [];
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Running balance for a bank account as of `asOf` (defaults to now).
|
|
1095
|
+
*
|
|
1096
|
+
* Uses mongokit's tenant-scoped read via `findAll` — inflows minus
|
|
1097
|
+
* outflows over `kind: 'bank_feed'`, terminal states only. For audit
|
|
1098
|
+
* pages where exact-to-the-cent reconciliation is required, prefer
|
|
1099
|
+
* the most recent row's `balanceAfter` (banks ship that field on
|
|
1100
|
+
* every entry).
|
|
1101
|
+
*/
|
|
1102
|
+
async getRunningBalance(bankAccountId, asOf = /* @__PURE__ */ new Date(), ctx = {}) {
|
|
1103
|
+
const filter = {
|
|
1104
|
+
kind: TRANSACTION_KIND.BANK_FEED,
|
|
1105
|
+
bankAccountId,
|
|
1106
|
+
postedDate: { $lte: asOf },
|
|
1107
|
+
status: { $in: [
|
|
1108
|
+
TRANSACTION_STATUS.IMPORTED,
|
|
1109
|
+
TRANSACTION_STATUS.MATCHED,
|
|
1110
|
+
TRANSACTION_STATUS.JOURNALIZED
|
|
1111
|
+
] },
|
|
1112
|
+
deletedAt: null
|
|
1113
|
+
};
|
|
1114
|
+
const rows = await this.findAll(filter, this.optsFromCtx(ctx));
|
|
1115
|
+
let balance = 0;
|
|
1116
|
+
let currency = null;
|
|
1117
|
+
for (const row of rows) {
|
|
1118
|
+
if (currency === null) currency = row.currency;
|
|
1119
|
+
balance += row.flow === "inflow" ? row.amount : -row.amount;
|
|
1120
|
+
}
|
|
1121
|
+
return {
|
|
1122
|
+
balance,
|
|
1123
|
+
currency,
|
|
1124
|
+
rowCount: rows.length,
|
|
1125
|
+
asOf
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
/** Escape user-provided strings before embedding in `$regex`. */
|
|
1130
|
+
function escapeRegex(input) {
|
|
1131
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/repositories/subscription.repository.ts
|
|
1136
|
+
/**
|
|
1137
|
+
* SubscriptionRepository — data layer + domain verbs for the recurring-
|
|
1138
|
+
* billing lifecycle.
|
|
1139
|
+
*
|
|
1140
|
+
* **CRUD inherited** from mongokit (via {@link RevenueRepositoryBase}):
|
|
1141
|
+
* `getById`, `getByQuery`, `getAll`, `create`, `update`, `delete`,
|
|
1142
|
+
* `findOneAndUpdate`, `count`, `exists`, `claim`, `cursor`, `updateMany`,
|
|
1143
|
+
* `deleteMany`. All participate in `multiTenantPlugin` scope filtering
|
|
1144
|
+
* when wired.
|
|
1145
|
+
*
|
|
1146
|
+
* **Domain verbs (state transitions):** `activate`, `cancel`, `pause`,
|
|
1147
|
+
* `resume`. Each runs the state-machine guard (`SUBSCRIPTION_STATE_MACHINE`
|
|
1148
|
+
* — invalid transitions throw, never silently no-op), persists the
|
|
1149
|
+
* resulting writes through {@link RevenueRepositoryBase.optsFromCtx} so
|
|
1150
|
+
* tenant scope is preserved end-to-end, then dispatches its
|
|
1151
|
+
* `revenue:subscription.*` event via {@link RevenueRepositoryBase.dispatch}.
|
|
1152
|
+
*
|
|
1153
|
+
* **Multi-tenant correctness.** Every internal `getById`/`update` call
|
|
1154
|
+
* threads `ctx.organizationId` through `optsFromCtx(ctx)`. Without this
|
|
1155
|
+
* threading the inner read would either throw
|
|
1156
|
+
* `Missing 'organizationId' in context` (when `multiTenantPlugin` is
|
|
1157
|
+
* required) or — worse — return another tenant's subscription matching
|
|
1158
|
+
* the same `_id` shape (when `required: false`). 2.1.0 had this bug; 2.1.1+
|
|
1159
|
+
* is correct.
|
|
1160
|
+
*
|
|
1161
|
+
* @example Activate a pending sub
|
|
1162
|
+
* ```ts
|
|
1163
|
+
* const ctx = { organizationId: 'org_42', actorId: 'user_99' };
|
|
1164
|
+
* const sub = await subRepo.create(
|
|
1165
|
+
* { customerId: 'cust_1', planKey: 'monthly', amount: 999, currency: 'USD',
|
|
1166
|
+
* status: SUBSCRIPTION_STATUS.PENDING, isActive: false },
|
|
1167
|
+
* ctx,
|
|
1168
|
+
* );
|
|
1169
|
+
* await subRepo.activate(String(sub._id), {}, ctx);
|
|
1170
|
+
* ```
|
|
1171
|
+
*/
|
|
1172
|
+
var SubscriptionRepository = class extends RevenueRepositoryBase {
|
|
1173
|
+
constructor(model, plugins = []) {
|
|
1174
|
+
super(model, plugins);
|
|
1175
|
+
}
|
|
1176
|
+
async activate(subscriptionId, options = {}, ctx = {}) {
|
|
1177
|
+
const opts = this.optsFromCtx(ctx);
|
|
1178
|
+
const sub = await this.getById(subscriptionId, opts);
|
|
1179
|
+
if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1180
|
+
SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.ACTIVE, subscriptionId);
|
|
1181
|
+
const now = options.timestamp ?? /* @__PURE__ */ new Date();
|
|
1182
|
+
const endDate = new Date(now);
|
|
1183
|
+
if (sub.planKey === "monthly") endDate.setMonth(endDate.getMonth() + 1);
|
|
1184
|
+
else if (sub.planKey === "quarterly") endDate.setMonth(endDate.getMonth() + 3);
|
|
1185
|
+
else if (sub.planKey === "yearly") endDate.setFullYear(endDate.getFullYear() + 1);
|
|
1186
|
+
else endDate.setDate(endDate.getDate() + 30);
|
|
1187
|
+
const updated = await this.update(subscriptionId, {
|
|
1188
|
+
status: SUBSCRIPTION_STATUS.ACTIVE,
|
|
1189
|
+
isActive: true,
|
|
1190
|
+
activatedAt: now,
|
|
1191
|
+
endDate
|
|
1192
|
+
}, this.optsFromCtx(ctx, { throwOnNotFound: true }));
|
|
1193
|
+
if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1194
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_ACTIVATED, {
|
|
1195
|
+
subscription: updated,
|
|
1196
|
+
activatedAt: now
|
|
1197
|
+
}, ctx, {
|
|
1198
|
+
resource: "subscription",
|
|
1199
|
+
resourceId: updated?.publicId
|
|
1200
|
+
}), ctx);
|
|
1201
|
+
return updated;
|
|
1202
|
+
}
|
|
1203
|
+
async cancel(subscriptionId, options = {}, ctx = {}) {
|
|
1204
|
+
const opts = this.optsFromCtx(ctx);
|
|
1205
|
+
const sub = await this.getById(subscriptionId, opts);
|
|
1206
|
+
if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1207
|
+
SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.CANCELLED, subscriptionId);
|
|
1208
|
+
const updates = {
|
|
1209
|
+
status: SUBSCRIPTION_STATUS.CANCELLED,
|
|
1210
|
+
isActive: false,
|
|
1211
|
+
canceledAt: /* @__PURE__ */ new Date(),
|
|
1212
|
+
cancellationReason: options.reason
|
|
1213
|
+
};
|
|
1214
|
+
if (!options.immediate && sub.endDate) {
|
|
1215
|
+
updates.cancelAt = sub.endDate;
|
|
1216
|
+
updates.status = sub.status;
|
|
1217
|
+
updates.isActive = sub.isActive;
|
|
1218
|
+
delete updates.canceledAt;
|
|
1219
|
+
}
|
|
1220
|
+
const updated = await this.update(subscriptionId, updates, this.optsFromCtx(ctx, { throwOnNotFound: true }));
|
|
1221
|
+
if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1222
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
|
1223
|
+
subscription: updated,
|
|
1224
|
+
immediate: options.immediate,
|
|
1225
|
+
reason: options.reason
|
|
1226
|
+
}, ctx, {
|
|
1227
|
+
resource: "subscription",
|
|
1228
|
+
resourceId: updated?.publicId
|
|
1229
|
+
}), ctx);
|
|
1230
|
+
return updated;
|
|
1231
|
+
}
|
|
1232
|
+
async pause(subscriptionId, options = {}, ctx = {}) {
|
|
1233
|
+
const opts = this.optsFromCtx(ctx);
|
|
1234
|
+
const sub = await this.getById(subscriptionId, opts);
|
|
1235
|
+
if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1236
|
+
SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.PAUSED, subscriptionId);
|
|
1237
|
+
const updated = await this.update(subscriptionId, {
|
|
1238
|
+
status: SUBSCRIPTION_STATUS.PAUSED,
|
|
1239
|
+
isActive: false,
|
|
1240
|
+
pausedAt: /* @__PURE__ */ new Date(),
|
|
1241
|
+
pauseReason: options.reason
|
|
1242
|
+
}, this.optsFromCtx(ctx, { throwOnNotFound: true }));
|
|
1243
|
+
if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1244
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_PAUSED, {
|
|
1245
|
+
subscription: updated,
|
|
1246
|
+
reason: options.reason
|
|
1247
|
+
}, ctx, {
|
|
1248
|
+
resource: "subscription",
|
|
1249
|
+
resourceId: updated?.publicId
|
|
1250
|
+
}), ctx);
|
|
1251
|
+
return updated;
|
|
1252
|
+
}
|
|
1253
|
+
async resume(subscriptionId, options = {}, ctx = {}) {
|
|
1254
|
+
const opts = this.optsFromCtx(ctx);
|
|
1255
|
+
const sub = await this.getById(subscriptionId, opts);
|
|
1256
|
+
if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1257
|
+
SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.ACTIVE, subscriptionId);
|
|
1258
|
+
const updates = {
|
|
1259
|
+
status: SUBSCRIPTION_STATUS.ACTIVE,
|
|
1260
|
+
isActive: true
|
|
1261
|
+
};
|
|
1262
|
+
if (options.extendPeriod && sub.pausedAt && sub.endDate) {
|
|
1263
|
+
const pauseDuration = Date.now() - sub.pausedAt.getTime();
|
|
1264
|
+
updates.endDate = new Date(sub.endDate.getTime() + pauseDuration);
|
|
1265
|
+
}
|
|
1266
|
+
const updated = await this.update(subscriptionId, updates, this.optsFromCtx(ctx, { throwOnNotFound: true }));
|
|
1267
|
+
if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
|
|
1268
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_RESUMED, {
|
|
1269
|
+
subscription: updated,
|
|
1270
|
+
extendPeriod: options.extendPeriod
|
|
1271
|
+
}, ctx, {
|
|
1272
|
+
resource: "subscription",
|
|
1273
|
+
resourceId: updated?.publicId
|
|
1274
|
+
}), ctx);
|
|
1275
|
+
return updated;
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
//#endregion
|
|
1280
|
+
//#region src/repositories/settlement.repository.ts
|
|
1281
|
+
/**
|
|
1282
|
+
* SettlementRepository — payouts to recipients (organizations, vendors,
|
|
1283
|
+
* affiliates).
|
|
1284
|
+
*
|
|
1285
|
+
* **CRUD inherited** via {@link RevenueRepositoryBase}. **Domain verbs:**
|
|
1286
|
+
* `schedule`, `processPending`, `complete`, `fail`. State machine:
|
|
1287
|
+
* `pending → processing → completed | failed`; `failed` with
|
|
1288
|
+
* `retry: true` resets to `pending` with a delayed `scheduledAt`.
|
|
1289
|
+
*
|
|
1290
|
+
* **Multi-tenant correctness.** Every read/write threads `ctx` through
|
|
1291
|
+
* {@link RevenueRepositoryBase.optsFromCtx} so `multiTenantPlugin`
|
|
1292
|
+
* scope filters apply. 2.1.0 had several `getById`/`update` calls that
|
|
1293
|
+
* dropped ctx — fixed in 2.1.1+.
|
|
1294
|
+
*
|
|
1295
|
+
* Bridges (`ledger`, `notification`) fire on `complete()` so a host
|
|
1296
|
+
* can pin double-entry book-keeping or push a "you got paid" email
|
|
1297
|
+
* without the repo knowing about either subsystem.
|
|
1298
|
+
*/
|
|
1299
|
+
var SettlementRepository = class extends RevenueRepositoryBase {
|
|
1300
|
+
constructor(model, plugins = []) {
|
|
1301
|
+
super(model, plugins);
|
|
1302
|
+
}
|
|
1303
|
+
async schedule(params, ctx = {}) {
|
|
1304
|
+
const settlement = await this.create({
|
|
1305
|
+
...params,
|
|
1306
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
1307
|
+
scheduledAt: params.scheduledAt ?? /* @__PURE__ */ new Date(),
|
|
1308
|
+
retryCount: 0
|
|
1309
|
+
}, this.optsFromCtx(ctx));
|
|
1310
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_SCHEDULED, {
|
|
1311
|
+
settlement,
|
|
1312
|
+
scheduledAt: settlement.scheduledAt
|
|
1313
|
+
}, ctx, {
|
|
1314
|
+
resource: "settlement",
|
|
1315
|
+
resourceId: settlement.publicId
|
|
1316
|
+
}), ctx);
|
|
1317
|
+
return settlement;
|
|
1318
|
+
}
|
|
1319
|
+
async processPending(options = {}, ctx = {}) {
|
|
1320
|
+
const query = {
|
|
1321
|
+
status: "pending",
|
|
1322
|
+
scheduledAt: { $lte: /* @__PURE__ */ new Date() }
|
|
1323
|
+
};
|
|
1324
|
+
if (options.organizationId) query.organizationId = options.organizationId;
|
|
1325
|
+
if (options.payoutMethod) query.payoutMethod = options.payoutMethod;
|
|
1326
|
+
const pending = (await this.getAll({
|
|
1327
|
+
filters: query,
|
|
1328
|
+
limit: options.limit ?? 50,
|
|
1329
|
+
sort: { scheduledAt: 1 }
|
|
1330
|
+
}, this.optsFromCtx(ctx))).data ?? [];
|
|
1331
|
+
if (options.dryRun) return {
|
|
1332
|
+
processed: pending.length,
|
|
1333
|
+
succeeded: 0,
|
|
1334
|
+
failed: 0,
|
|
1335
|
+
settlements: pending,
|
|
1336
|
+
errors: []
|
|
1337
|
+
};
|
|
1338
|
+
const out = {
|
|
1339
|
+
processed: 0,
|
|
1340
|
+
succeeded: 0,
|
|
1341
|
+
failed: 0,
|
|
1342
|
+
settlements: [],
|
|
1343
|
+
errors: []
|
|
1344
|
+
};
|
|
1345
|
+
for (const settlement of pending) {
|
|
1346
|
+
try {
|
|
1347
|
+
SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.PROCESSING, String(settlement._id));
|
|
1348
|
+
await this.update(settlement._id, {
|
|
1349
|
+
status: SETTLEMENT_STATUS.PROCESSING,
|
|
1350
|
+
processedAt: /* @__PURE__ */ new Date()
|
|
1351
|
+
}, this.optsFromCtx(ctx));
|
|
1352
|
+
out.succeeded++;
|
|
1353
|
+
out.settlements.push(settlement);
|
|
1354
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_PROCESSING, {
|
|
1355
|
+
settlement,
|
|
1356
|
+
processedAt: /* @__PURE__ */ new Date()
|
|
1357
|
+
}, ctx, {
|
|
1358
|
+
resource: "settlement",
|
|
1359
|
+
resourceId: settlement.publicId
|
|
1360
|
+
}), ctx);
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
out.failed++;
|
|
1363
|
+
out.errors.push({
|
|
1364
|
+
settlementId: settlement._id,
|
|
1365
|
+
error: err
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
out.processed++;
|
|
1369
|
+
}
|
|
1370
|
+
return out;
|
|
1371
|
+
}
|
|
1372
|
+
async complete(settlementId, details = {}, ctx = {}) {
|
|
1373
|
+
const opts = this.optsFromCtx(ctx);
|
|
1374
|
+
const settlement = await this.getById(settlementId, opts);
|
|
1375
|
+
if (!settlement) throw new SettlementNotFoundError(settlementId);
|
|
1376
|
+
SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.COMPLETED, settlementId);
|
|
1377
|
+
const updates = {
|
|
1378
|
+
status: SETTLEMENT_STATUS.COMPLETED,
|
|
1379
|
+
completedAt: /* @__PURE__ */ new Date(),
|
|
1380
|
+
notes: details.notes,
|
|
1381
|
+
metadata: {
|
|
1382
|
+
...settlement.metadata,
|
|
1383
|
+
...details.metadata
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
if (details.transferReference) updates.bankTransferDetails = {
|
|
1387
|
+
...settlement.bankTransferDetails,
|
|
1388
|
+
transferReference: details.transferReference,
|
|
1389
|
+
transferredAt: details.transferredAt ?? /* @__PURE__ */ new Date()
|
|
1390
|
+
};
|
|
1391
|
+
if (details.transactionHash) updates.cryptoDetails = {
|
|
1392
|
+
...settlement.cryptoDetails,
|
|
1393
|
+
transactionHash: details.transactionHash,
|
|
1394
|
+
transferredAt: details.transferredAt ?? /* @__PURE__ */ new Date()
|
|
1395
|
+
};
|
|
1396
|
+
const updated = await this.update(settlementId, updates, this.optsFromCtx(ctx, { throwOnNotFound: true }));
|
|
1397
|
+
if (!updated) throw new SettlementNotFoundError(settlementId);
|
|
1398
|
+
await this.deps.bridges.ledger?.onSettlementCompleted?.(updated, ctx);
|
|
1399
|
+
await this.deps.bridges.notification?.onSettlementCompleted?.(updated, ctx);
|
|
1400
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_COMPLETED, {
|
|
1401
|
+
settlement: updated,
|
|
1402
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
1403
|
+
}, ctx, {
|
|
1404
|
+
resource: "settlement",
|
|
1405
|
+
resourceId: updated?.publicId
|
|
1406
|
+
}), ctx);
|
|
1407
|
+
return updated;
|
|
1408
|
+
}
|
|
1409
|
+
async fail(settlementId, reason, options = {}, ctx = {}) {
|
|
1410
|
+
const opts = this.optsFromCtx(ctx);
|
|
1411
|
+
const settlement = await this.getById(settlementId, opts);
|
|
1412
|
+
if (!settlement) throw new SettlementNotFoundError(settlementId);
|
|
1413
|
+
if (options.retry) await this.update(settlementId, {
|
|
1414
|
+
status: SETTLEMENT_STATUS.PENDING,
|
|
1415
|
+
retryCount: (settlement.retryCount ?? 0) + 1,
|
|
1416
|
+
failureReason: reason,
|
|
1417
|
+
failureCode: options.code,
|
|
1418
|
+
scheduledAt: new Date(Date.now() + 36e5)
|
|
1419
|
+
}, opts);
|
|
1420
|
+
else {
|
|
1421
|
+
SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.FAILED, settlementId);
|
|
1422
|
+
await this.update(settlementId, {
|
|
1423
|
+
status: SETTLEMENT_STATUS.FAILED,
|
|
1424
|
+
failedAt: /* @__PURE__ */ new Date(),
|
|
1425
|
+
failureReason: reason,
|
|
1426
|
+
failureCode: options.code
|
|
1427
|
+
}, opts);
|
|
1428
|
+
}
|
|
1429
|
+
const updated = await this.getById(settlementId, opts);
|
|
1430
|
+
await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_FAILED, {
|
|
1431
|
+
settlement: updated,
|
|
1432
|
+
reason,
|
|
1433
|
+
code: options.code,
|
|
1434
|
+
retry: options.retry
|
|
1435
|
+
}, ctx, {
|
|
1436
|
+
resource: "settlement",
|
|
1437
|
+
resourceId: updated?.publicId
|
|
1438
|
+
}), ctx);
|
|
1439
|
+
return updated;
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
//#endregion
|
|
1444
|
+
export { SubscriptionRepository as n, TransactionRepository as r, SettlementRepository as t };
|