@stamhoofd/backend 2.140.0 → 2.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +366 -0
- package/src/helpers/SettlementExporter.ts +583 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +828 -0
- package/src/helpers/StripeSettlementSync.ts +947 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
- package/src/helpers/WebmasterReport.test.ts +109 -0
- package/src/helpers/WebmasterReport.ts +115 -0
- package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
- package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/helpers/MollieMocker.ts +64 -6
- package/tests/helpers/StripeMocker.ts +209 -17
- package/src/crons/stripe-payout-reports.ts +0 -69
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
- package/src/helpers/CheckSettlements.test.ts +0 -190
- package/src/helpers/CheckSettlements.ts +0 -237
- package/src/helpers/StripeInvoicer.ts +0 -419
- package/src/helpers/StripePayoutChecker.ts +0 -193
- package/src/helpers/StripePayoutExportData.ts +0 -195
- package/src/helpers/StripePayoutExportExcel.ts +0 -280
- package/src/helpers/StripePayoutReporter.test.ts +0 -419
- package/src/helpers/StripePayoutReporter.ts +0 -585
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import type { Payment } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemPayment, Order, Platform } from '@stamhoofd/models';
|
|
4
|
+
import { Payment as PaymentModel } from '@stamhoofd/models';
|
|
5
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
6
|
+
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
7
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
8
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
9
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
10
|
+
import { SQL } from '@stamhoofd/sql';
|
|
11
|
+
import type { PaymentProvider } from '@stamhoofd/structures';
|
|
12
|
+
import { PaymentMethod, SettlementReference } from '@stamhoofd/structures';
|
|
13
|
+
import type { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
14
|
+
import type { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
15
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
16
|
+
|
|
17
|
+
export type SettlementData = {
|
|
18
|
+
provider: PaymentProvider;
|
|
19
|
+
externalId: string;
|
|
20
|
+
stripeAccountId?: string | null;
|
|
21
|
+
organizationId: string;
|
|
22
|
+
reference?: string;
|
|
23
|
+
amount: number;
|
|
24
|
+
currency?: string;
|
|
25
|
+
status?: SettlementStatus;
|
|
26
|
+
settledAt: Date;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PaymentLineData = {
|
|
30
|
+
paymentId: string;
|
|
31
|
+
amount: number;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* NULL for a line derived from application fees: it has no provider transaction behind it.
|
|
35
|
+
*/
|
|
36
|
+
externalId: string | null;
|
|
37
|
+
occurredAt: Date;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ChargeData = {
|
|
41
|
+
type: SettlementChargeType;
|
|
42
|
+
externalId: string;
|
|
43
|
+
amount: number;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The charged organization: always the one of the payout it is deducted from, and of the
|
|
47
|
+
* payment it relates to.
|
|
48
|
+
*/
|
|
49
|
+
organizationId: string;
|
|
50
|
+
|
|
51
|
+
settlementId?: string | null;
|
|
52
|
+
applicationFeeId?: string | null;
|
|
53
|
+
paymentId?: string | null;
|
|
54
|
+
stripeAccountId?: string | null;
|
|
55
|
+
providerInvoiceId?: string | null;
|
|
56
|
+
description?: string;
|
|
57
|
+
occurredAt: Date;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Rows updated per statement when a whole month of charges is stamped at once.
|
|
62
|
+
*/
|
|
63
|
+
const CHARGE_UPDATE_BATCH_SIZE = 500;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Collects which rows the provider still reports in a settlement while a sync walks it. A stored
|
|
67
|
+
* row of the settlement that is not in here after the walk has moved or disappeared at the
|
|
68
|
+
* provider, and gets swept.
|
|
69
|
+
*/
|
|
70
|
+
export class ReportedRows {
|
|
71
|
+
readonly paymentLineExternalIds = new Set<string>();
|
|
72
|
+
readonly chargeExternalIds = new Set<string>();
|
|
73
|
+
readonly applicationFeeIds = new Set<string>();
|
|
74
|
+
|
|
75
|
+
paymentLine(line: PaymentSettlement) {
|
|
76
|
+
if (line.externalId === null) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.paymentLineExternalIds.add(line.externalId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
charge(charge: SettlementCharge) {
|
|
83
|
+
this.chargeExternalIds.add(charge.externalId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
charges(charges: SettlementCharge[]) {
|
|
87
|
+
for (const charge of charges) {
|
|
88
|
+
this.charge(charge);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
applicationFee(fee: ApplicationFee) {
|
|
93
|
+
this.applicationFeeIds.add(fee.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
applicationFees(fees: ApplicationFee[]) {
|
|
97
|
+
for (const fee of fees) {
|
|
98
|
+
this.applicationFee(fee);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* All writes to the settlements tables go through this service. Every write is an upsert on the
|
|
105
|
+
* deterministic unique key of its table, so re-running a sync can never duplicate rows.
|
|
106
|
+
*/
|
|
107
|
+
export class SettlementService {
|
|
108
|
+
/**
|
|
109
|
+
* Serializes syncs of the same settlement, so cron and manual backfill can't race. In-process
|
|
110
|
+
* only: across multiple API instances the tables' unique keys are the real guard, so a
|
|
111
|
+
* concurrent sync surfaces as a duplicate-key error and is retryable.
|
|
112
|
+
*/
|
|
113
|
+
static lock<T>(provider: PaymentProvider, externalId: string, handler: () => Promise<T>): Promise<T> {
|
|
114
|
+
return QueueHandler.schedule('settlement-sync-' + provider + '-' + externalId, handler);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Month bucket of a charge, in server-local time: the same boundaries as
|
|
119
|
+
* getMonthUnixStartEnd, which drive the monthly invoice grouping.
|
|
120
|
+
*/
|
|
121
|
+
static getPeriodStart(date: Date): Date {
|
|
122
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The same month bucket as getPeriodStart, as a 'YYYY-MM' key.
|
|
127
|
+
*/
|
|
128
|
+
static getPeriodKey(date: Date): string {
|
|
129
|
+
return date.getFullYear() + '-' + (date.getMonth() + 1).toString().padStart(2, '0');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The same month bucket as getPeriodStart, as unix second bounds (inclusive end, one second
|
|
134
|
+
* before the next month).
|
|
135
|
+
*/
|
|
136
|
+
static getMonthUnixStartEnd(date: Date) {
|
|
137
|
+
const start = Math.floor((new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0).getTime()) / 1000);
|
|
138
|
+
const end = Math.ceil((new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime() - 1000) / 1000);
|
|
139
|
+
return { start, end };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every settlement belongs to an organization. The platform's own provider accounts (the
|
|
144
|
+
* platform Stripe account, the platform Mollie token) belong to the platform membership
|
|
145
|
+
* organization: without one configured, platform payouts cannot be stored.
|
|
146
|
+
*/
|
|
147
|
+
static async getPlatformOrganizationId(): Promise<string> {
|
|
148
|
+
const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
|
|
149
|
+
if (!membershipOrganizationId) {
|
|
150
|
+
throw new SimpleError({
|
|
151
|
+
code: 'missing_membership_organization',
|
|
152
|
+
message: 'Platform has no membership organization configured, so platform payouts cannot be attributed to an organization',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return membershipOrganizationId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Upsert on (provider, externalId). Never touches syncedAt/syncFailureCount: those belong to
|
|
160
|
+
* finishSync/markSyncFailed.
|
|
161
|
+
*/
|
|
162
|
+
static async upsertSettlement(data: SettlementData): Promise<Settlement> {
|
|
163
|
+
const settlement = await Settlement.select()
|
|
164
|
+
.where('provider', data.provider)
|
|
165
|
+
.where('externalId', data.externalId)
|
|
166
|
+
.first(false) ?? new Settlement();
|
|
167
|
+
|
|
168
|
+
settlement.provider = data.provider;
|
|
169
|
+
settlement.externalId = data.externalId;
|
|
170
|
+
settlement.organizationId = data.organizationId;
|
|
171
|
+
settlement.amount = data.amount;
|
|
172
|
+
settlement.settledAt = data.settledAt;
|
|
173
|
+
|
|
174
|
+
if (data.stripeAccountId !== undefined) {
|
|
175
|
+
settlement.stripeAccountId = data.stripeAccountId;
|
|
176
|
+
}
|
|
177
|
+
if (data.reference !== undefined) {
|
|
178
|
+
settlement.reference = data.reference;
|
|
179
|
+
}
|
|
180
|
+
if (data.currency !== undefined) {
|
|
181
|
+
settlement.currency = data.currency;
|
|
182
|
+
}
|
|
183
|
+
if (data.status !== undefined) {
|
|
184
|
+
settlement.status = data.status;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await settlement.save();
|
|
188
|
+
return settlement;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Upsert on (settlementId, externalId): one payment can be part of multiple settlements, but a
|
|
193
|
+
* provider transaction appears in a settlement only once. A derived fee line has no provider
|
|
194
|
+
* transaction, so it upserts on (settlementId, paymentId): one per fee payment and payout.
|
|
195
|
+
*/
|
|
196
|
+
static async upsertPaymentLine(settlement: Settlement, data: PaymentLineData): Promise<PaymentSettlement> {
|
|
197
|
+
const query = PaymentSettlement.select()
|
|
198
|
+
.where('settlementId', settlement.id)
|
|
199
|
+
.where('externalId', data.externalId);
|
|
200
|
+
|
|
201
|
+
if (data.externalId === null) {
|
|
202
|
+
query.where('paymentId', data.paymentId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const line = await query.first(false) ?? new PaymentSettlement();
|
|
206
|
+
|
|
207
|
+
line.settlementId = settlement.id;
|
|
208
|
+
line.organizationId = settlement.organizationId;
|
|
209
|
+
line.externalId = data.externalId;
|
|
210
|
+
line.paymentId = data.paymentId;
|
|
211
|
+
line.amount = data.amount;
|
|
212
|
+
line.occurredAt = data.occurredAt;
|
|
213
|
+
|
|
214
|
+
await line.save();
|
|
215
|
+
|
|
216
|
+
// A provider transaction sits in exactly one payout: when it moved, the row it left behind
|
|
217
|
+
// would count the same money twice until that payout happens to be walked again. Derived
|
|
218
|
+
// fee lines are the exception, they legitimately exist per payout
|
|
219
|
+
if (data.externalId !== null) {
|
|
220
|
+
const moved = await PaymentSettlement.select()
|
|
221
|
+
.where('externalId', data.externalId)
|
|
222
|
+
.where('settlementId', '!=', settlement.id)
|
|
223
|
+
.fetch();
|
|
224
|
+
|
|
225
|
+
for (const stale of moved) {
|
|
226
|
+
await stale.delete();
|
|
227
|
+
}
|
|
228
|
+
await this.refreshTotalsForIds(Formatter.uniqueArray(moved.map(l => l.settlementId)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return line;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Upsert on the globally unique externalId. Fields that are undefined keep their stored value,
|
|
236
|
+
* so the fee sync (which doesn't know the settlement yet) can't unlink a charge the payout sync
|
|
237
|
+
* attached earlier. balanceItemId is deliberately not settable here: only markInvoiced writes
|
|
238
|
+
* it, when the fee is invoiced.
|
|
239
|
+
*/
|
|
240
|
+
static async upsertCharge(data: ChargeData): Promise<SettlementCharge> {
|
|
241
|
+
const charge = await SettlementCharge.select()
|
|
242
|
+
.where('externalId', data.externalId)
|
|
243
|
+
.first(false) ?? new SettlementCharge();
|
|
244
|
+
|
|
245
|
+
// A charge that moves to another payout leaves the one it came from with cached totals
|
|
246
|
+
// that still count it
|
|
247
|
+
const previousSettlementId = charge.settlementId;
|
|
248
|
+
|
|
249
|
+
charge.type = data.type;
|
|
250
|
+
charge.externalId = data.externalId;
|
|
251
|
+
charge.amount = data.amount;
|
|
252
|
+
charge.organizationId = data.organizationId;
|
|
253
|
+
charge.occurredAt = data.occurredAt;
|
|
254
|
+
|
|
255
|
+
if (data.settlementId !== undefined) {
|
|
256
|
+
charge.settlementId = data.settlementId;
|
|
257
|
+
}
|
|
258
|
+
if (data.applicationFeeId !== undefined) {
|
|
259
|
+
charge.applicationFeeId = data.applicationFeeId;
|
|
260
|
+
}
|
|
261
|
+
if (data.paymentId !== undefined) {
|
|
262
|
+
charge.paymentId = data.paymentId;
|
|
263
|
+
}
|
|
264
|
+
if (data.stripeAccountId !== undefined) {
|
|
265
|
+
charge.stripeAccountId = data.stripeAccountId;
|
|
266
|
+
}
|
|
267
|
+
if (data.providerInvoiceId !== undefined) {
|
|
268
|
+
charge.providerInvoiceId = data.providerInvoiceId;
|
|
269
|
+
}
|
|
270
|
+
if (data.description !== undefined) {
|
|
271
|
+
charge.description = data.description;
|
|
272
|
+
}
|
|
273
|
+
await charge.save();
|
|
274
|
+
|
|
275
|
+
if (previousSettlementId && previousSettlementId !== charge.settlementId) {
|
|
276
|
+
await this.refreshTotalsForIds([previousSettlementId]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return charge;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Stamps which invoice bills a charge to the charged party. Kept here so all
|
|
284
|
+
* settlement_charges writes stay in this service; ApplicationFeeService decides when.
|
|
285
|
+
*/
|
|
286
|
+
static async setChargeProviderInvoiceId(settlementChargeId: string, providerInvoiceId: string | null) {
|
|
287
|
+
await this.setChargeProviderInvoiceIds([settlementChargeId], providerInvoiceId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One invoice can bill a whole month of fees, so the charges are stamped in one statement per
|
|
292
|
+
* batch instead of loading and saving every row.
|
|
293
|
+
*/
|
|
294
|
+
static async setChargeProviderInvoiceIds(settlementChargeIds: string[], providerInvoiceId: string | null) {
|
|
295
|
+
for (let offset = 0; offset < settlementChargeIds.length; offset += CHARGE_UPDATE_BATCH_SIZE) {
|
|
296
|
+
const batch = settlementChargeIds.slice(offset, offset + CHARGE_UPDATE_BATCH_SIZE);
|
|
297
|
+
await SQL.update(SettlementCharge.table)
|
|
298
|
+
.set('providerInvoiceId', providerInvoiceId)
|
|
299
|
+
.where('id', batch)
|
|
300
|
+
.update();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* After a complete walk of a settlement: remove rows the provider no longer reports in this
|
|
306
|
+
* settlement (a transaction can move to another payout). Rows that only exist because of the
|
|
307
|
+
* payout are deleted; rows that outlive the payout link are only unlinked: derived fee lines
|
|
308
|
+
* (owned by updatePaymentSettlementsForAccountDeductionPayment), deduction charges referenced
|
|
309
|
+
* by an application fee (RESTRICT FK), and the application fee rows themselves.
|
|
310
|
+
*
|
|
311
|
+
* Returns the fees unlinked from this settlement, so the caller can refresh the derived lines
|
|
312
|
+
* of their fee payments.
|
|
313
|
+
*/
|
|
314
|
+
static async sweepSettlement(settlement: Settlement, reported: ReportedRows): Promise<{ unlinkedFees: ApplicationFee[] }> {
|
|
315
|
+
const lines = await PaymentSettlement.select()
|
|
316
|
+
.where('settlementId', settlement.id)
|
|
317
|
+
.fetch();
|
|
318
|
+
|
|
319
|
+
const sweptPaymentIds = new Set<string>();
|
|
320
|
+
for (const line of lines) {
|
|
321
|
+
if (line.externalId === null) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!reported.paymentLineExternalIds.has(line.externalId)) {
|
|
325
|
+
await line.delete();
|
|
326
|
+
sweptPaymentIds.add(line.paymentId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The legacy blob points at one of the lines, so a payment that lost one has to be
|
|
331
|
+
// repointed at what is left
|
|
332
|
+
if (sweptPaymentIds.size > 0) {
|
|
333
|
+
const payments = await PaymentModel.getByIDs(...sweptPaymentIds);
|
|
334
|
+
for (const payment of payments) {
|
|
335
|
+
await this.updateLegacySettlementReference(payment);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const charges = await SettlementCharge.select()
|
|
340
|
+
.where('settlementId', settlement.id)
|
|
341
|
+
.fetch();
|
|
342
|
+
|
|
343
|
+
const unreportedCharges = charges.filter(charge => !reported.chargeExternalIds.has(charge.externalId));
|
|
344
|
+
const referencedChargeIds = unreportedCharges.length > 0
|
|
345
|
+
? new Set((await ApplicationFee.select()
|
|
346
|
+
.where('settlementChargeId', unreportedCharges.map(c => c.id))
|
|
347
|
+
.fetch()).map(fee => fee.settlementChargeId))
|
|
348
|
+
: new Set<string>();
|
|
349
|
+
|
|
350
|
+
for (const charge of unreportedCharges) {
|
|
351
|
+
if (referencedChargeIds.has(charge.id)) {
|
|
352
|
+
charge.settlementId = null;
|
|
353
|
+
await charge.save();
|
|
354
|
+
} else {
|
|
355
|
+
await charge.delete();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const fees = await ApplicationFee.select()
|
|
360
|
+
.where('settlementId', settlement.id)
|
|
361
|
+
.fetch();
|
|
362
|
+
|
|
363
|
+
const unlinkedFees: ApplicationFee[] = [];
|
|
364
|
+
for (const fee of fees) {
|
|
365
|
+
if (!reported.applicationFeeIds.has(fee.id)) {
|
|
366
|
+
fee.settlementId = null;
|
|
367
|
+
await fee.save();
|
|
368
|
+
unlinkedFees.push(fee);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { unlinkedFees };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Recomputes the cached reconciliation columns from the stored rows: `unexplainedAmount` should
|
|
377
|
+
* be 0 — a non-zero value is a real question to answer — and `pendingFees` holds what is
|
|
378
|
+
* received but not invoiced yet, which takes up to a month and only becomes a problem when it
|
|
379
|
+
* stays non-zero too long.
|
|
380
|
+
*
|
|
381
|
+
* Every write that changes what a payout holds ends here, or the export and the problem report
|
|
382
|
+
* keep reading numbers from the last sync.
|
|
383
|
+
*/
|
|
384
|
+
static async refreshTotals(settlement: Settlement): Promise<Settlement> {
|
|
385
|
+
await this.applyTotals(settlement);
|
|
386
|
+
await settlement.save();
|
|
387
|
+
return settlement;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The recomputation itself, without saving: callers that write more of the settlement in the
|
|
392
|
+
* same breath save once.
|
|
393
|
+
*/
|
|
394
|
+
private static async applyTotals(settlement: Settlement): Promise<void> {
|
|
395
|
+
const paymentSum = await PaymentSettlement.select()
|
|
396
|
+
.where('settlementId', settlement.id)
|
|
397
|
+
.sum(SQL.column('amount')) ?? 0;
|
|
398
|
+
|
|
399
|
+
const chargeSum = await SettlementCharge.select()
|
|
400
|
+
.where('settlementId', settlement.id)
|
|
401
|
+
.sum(SQL.column('amount')) ?? 0;
|
|
402
|
+
|
|
403
|
+
// Once a fee is invoiced it drops out of this sum and its payment's derived line takes
|
|
404
|
+
// over, so the two never count the same fee twice
|
|
405
|
+
const pendingFees = await ApplicationFee.select()
|
|
406
|
+
.where('settlementId', settlement.id)
|
|
407
|
+
.where('balanceItemId', null)
|
|
408
|
+
.sum(SQL.column('amount')) ?? 0;
|
|
409
|
+
|
|
410
|
+
settlement.pendingFees = pendingFees;
|
|
411
|
+
settlement.unexplainedAmount = settlement.amount - paymentSum - chargeSum - pendingFees;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Same for settlements the caller only knows by id.
|
|
416
|
+
*/
|
|
417
|
+
static async refreshTotalsForIds(settlementIds: string[]): Promise<void> {
|
|
418
|
+
if (settlementIds.length === 0) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const settlements = await Settlement.select().where('id', settlementIds).fetch();
|
|
422
|
+
for (const settlement of settlements) {
|
|
423
|
+
await this.refreshTotals(settlement);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Marks a complete, error-free sync: caches the reconciliation delta and sets syncedAt.
|
|
429
|
+
*/
|
|
430
|
+
static async finishSync(settlement: Settlement, { transactionCount }: { transactionCount: number }): Promise<Settlement> {
|
|
431
|
+
await this.applyTotals(settlement);
|
|
432
|
+
|
|
433
|
+
settlement.transactionCount = transactionCount;
|
|
434
|
+
settlement.syncFailureCount = 0;
|
|
435
|
+
|
|
436
|
+
const syncedAt = new Date();
|
|
437
|
+
syncedAt.setMilliseconds(0);
|
|
438
|
+
settlement.syncedAt = syncedAt;
|
|
439
|
+
|
|
440
|
+
await settlement.save();
|
|
441
|
+
return settlement;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Rebuilds the derived payment lines of an AccountDeductions fee payment: one line per platform
|
|
446
|
+
* payout that contains fees billed by this payment, amount = the sum of those fees. When the
|
|
447
|
+
* total of the lines matches the payment's price, the payment is completely paid out.
|
|
448
|
+
*/
|
|
449
|
+
static async updatePaymentSettlementsForAccountDeductionPayment(payment: Payment): Promise<void> {
|
|
450
|
+
if (payment.method !== PaymentMethod.AccountDeductions) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const balanceItemPayments = await BalanceItemPayment.select()
|
|
455
|
+
.where('paymentId', payment.id)
|
|
456
|
+
.fetch();
|
|
457
|
+
|
|
458
|
+
const fees = balanceItemPayments.length > 0
|
|
459
|
+
? await ApplicationFee.select()
|
|
460
|
+
.where('balanceItemId', balanceItemPayments.map(b => b.balanceItemId))
|
|
461
|
+
.fetch()
|
|
462
|
+
: [];
|
|
463
|
+
|
|
464
|
+
const perSettlement = new Map<string, { amount: number; occurredAt: Date }>();
|
|
465
|
+
for (const fee of fees) {
|
|
466
|
+
if (!fee.settlementId) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const group = perSettlement.get(fee.settlementId);
|
|
470
|
+
if (group) {
|
|
471
|
+
group.amount += fee.amount;
|
|
472
|
+
if (fee.occurredAt > group.occurredAt) {
|
|
473
|
+
group.occurredAt = fee.occurredAt;
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
perSettlement.set(fee.settlementId, { amount: fee.amount, occurredAt: fee.occurredAt });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Every payout whose stored rows change has to be recomputed: a fee that was pending is now
|
|
481
|
+
// explained by the line instead, and a payout that lost its line has it pending again
|
|
482
|
+
const touchedSettlementIds = new Set<string>(perSettlement.keys());
|
|
483
|
+
|
|
484
|
+
const existingLines = await PaymentSettlement.select()
|
|
485
|
+
.where('paymentId', payment.id)
|
|
486
|
+
.fetch();
|
|
487
|
+
for (const line of existingLines) {
|
|
488
|
+
if (line.externalId === null && !perSettlement.has(line.settlementId)) {
|
|
489
|
+
touchedSettlementIds.add(line.settlementId);
|
|
490
|
+
await line.delete();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (perSettlement.size > 0) {
|
|
495
|
+
const settlements = await Settlement.select()
|
|
496
|
+
.where('id', [...perSettlement.keys()])
|
|
497
|
+
.fetch();
|
|
498
|
+
for (const settlement of settlements) {
|
|
499
|
+
const group = perSettlement.get(settlement.id)!;
|
|
500
|
+
await this.upsertPaymentLine(settlement, {
|
|
501
|
+
paymentId: payment.id,
|
|
502
|
+
amount: group.amount,
|
|
503
|
+
externalId: null,
|
|
504
|
+
occurredAt: group.occurredAt,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
await this.refreshTotalsForIds([...touchedSettlementIds]);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Refreshes the derived lines of every fee payment that billed one of these balance items:
|
|
514
|
+
* called after a walk linked or unlinked invoiced fees, so the lines follow the fees.
|
|
515
|
+
*/
|
|
516
|
+
static async updatePaymentSettlementsForAccountDeductionBalanceItems(balanceItemIds: string[]): Promise<void> {
|
|
517
|
+
if (balanceItemIds.length === 0) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const balanceItemPayments = await BalanceItemPayment.select()
|
|
522
|
+
.where('balanceItemId', balanceItemIds)
|
|
523
|
+
.fetch();
|
|
524
|
+
const paymentIds = Formatter.uniqueArray(balanceItemPayments.map(b => b.paymentId));
|
|
525
|
+
if (paymentIds.length === 0) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const payments = await PaymentModel.getByIDs(...paymentIds);
|
|
530
|
+
for (const payment of payments) {
|
|
531
|
+
await this.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A failed sync leaves syncedAt NULL: that is the whole error queue.
|
|
537
|
+
*/
|
|
538
|
+
static async markSyncFailed(settlement: Settlement): Promise<Settlement> {
|
|
539
|
+
settlement.syncedAt = null;
|
|
540
|
+
settlement.syncFailureCount += 1;
|
|
541
|
+
await settlement.save();
|
|
542
|
+
return settlement;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Dual-write of the legacy payments.settlement JSON blob from the new rows. The blob holds one
|
|
547
|
+
* settlement, so the primary is picked deterministically: largest |amount| line, earliest
|
|
548
|
+
* settledAt as tiebreaker — re-syncs never flip-flop the column.
|
|
549
|
+
*/
|
|
550
|
+
static async updateLegacySettlementReference(payment: Payment): Promise<void> {
|
|
551
|
+
const lines = await PaymentSettlement.select()
|
|
552
|
+
.where('paymentId', payment.id)
|
|
553
|
+
.fetch();
|
|
554
|
+
|
|
555
|
+
if (lines.length === 0) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const settlements = await Settlement.select()
|
|
560
|
+
.where('id', lines.map(l => l.settlementId))
|
|
561
|
+
.where('organizationId', payment.organizationId)
|
|
562
|
+
.fetch();
|
|
563
|
+
const settlementsById = new Map(settlements.map(s => [s.id, s]));
|
|
564
|
+
|
|
565
|
+
// Only payouts of the payment's own organization settle it. None stored yet (e.g. that
|
|
566
|
+
// payout isn't synced) means the blob stays untouched.
|
|
567
|
+
const candidates = lines.filter(line => settlementsById.has(line.settlementId));
|
|
568
|
+
if (candidates.length === 0) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const primary = candidates.sort((a, b) => {
|
|
573
|
+
if (Math.abs(a.amount) !== Math.abs(b.amount)) {
|
|
574
|
+
return Math.abs(b.amount) - Math.abs(a.amount);
|
|
575
|
+
}
|
|
576
|
+
const settledA = settlementsById.get(a.settlementId)!.settledAt.getTime();
|
|
577
|
+
const settledB = settlementsById.get(b.settlementId)!.settledAt.getTime();
|
|
578
|
+
if (settledA !== settledB) {
|
|
579
|
+
return settledA - settledB;
|
|
580
|
+
}
|
|
581
|
+
return (a.externalId ?? '').localeCompare(b.externalId ?? '');
|
|
582
|
+
})[0];
|
|
583
|
+
|
|
584
|
+
const settlement = settlementsById.get(primary.settlementId)!;
|
|
585
|
+
|
|
586
|
+
payment.settlement = SettlementReference.create({
|
|
587
|
+
id: settlement.externalId,
|
|
588
|
+
reference: settlement.reference,
|
|
589
|
+
settledAt: settlement.settledAt,
|
|
590
|
+
amount: settlement.amount,
|
|
591
|
+
// Nothing writes the deprecated fee field anymore; preserve a historically stored
|
|
592
|
+
// value while the primary settlement doesn't change
|
|
593
|
+
fee: payment.settlement?.id === settlement.externalId ? payment.settlement.fee : 0,
|
|
594
|
+
});
|
|
595
|
+
const saved = await payment.save();
|
|
596
|
+
|
|
597
|
+
if (saved) {
|
|
598
|
+
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
599
|
+
const order = await Order.getForPayment(null, payment.id);
|
|
600
|
+
if (order) {
|
|
601
|
+
order.updatedAt = new Date();
|
|
602
|
+
order.forceSaveProperty('updatedAt');
|
|
603
|
+
await order.save();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { compileToSQLFilter } from '@stamhoofd/sql';
|
|
2
|
-
import { PaymentMethod, PaymentProvider, PaymentStatus,
|
|
2
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus, SettlementReference } from '@stamhoofd/structures';
|
|
3
3
|
import { toBalanceItemFilter } from '@stamhoofd/structures/breakdown/breakdownFilters.js';
|
|
4
4
|
import type { SettleablePayment } from '@stamhoofd/structures/PaymentSettlement.js';
|
|
5
5
|
import { ACCOUNT_DEDUCTIONS_ID, FAILED_PAYMENT_ID, getPaymentSettlement, PENDING_PAYMENT_ID } from '@stamhoofd/structures/PaymentSettlement.js';
|
|
@@ -7,7 +7,7 @@ import { balanceItemFilterCompilers } from './balance-items.js';
|
|
|
7
7
|
import { paymentFilterCompilers } from './payments.js';
|
|
8
8
|
|
|
9
9
|
describe('paymentSettlementFilterCompilers', () => {
|
|
10
|
-
const settlement =
|
|
10
|
+
const settlement = SettlementReference.create({
|
|
11
11
|
id: 'settlement-1',
|
|
12
12
|
reference: 'ST-2026-01',
|
|
13
13
|
settledAt: new Date(2026, 0, 15),
|