@stamhoofd/backend 2.143.0 → 2.144.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/boot.ts +1 -0
- package/src/crons/fake-settlements.test.ts +13 -11
- package/src/crons/fake-settlements.ts +22 -38
- package/src/endpoints/global/files/UploadFile.ts +53 -25
- package/src/endpoints/global/files/UploadImage.ts +32 -6
- package/src/helpers/ApplicationFeeInvoicer.ts +1 -1
- package/src/helpers/Context.ts +1 -15
- package/src/helpers/MollieSettlementSync.test.ts +94 -41
- package/src/helpers/MollieSettlementSync.ts +174 -78
- package/src/helpers/StripeSettlementSync.test.ts +0 -85
- package/src/helpers/StripeSettlementSync.ts +37 -63
- package/src/helpers/ViesHelper.ts +18 -11
- package/src/helpers/streamForBreakdown.ts +1 -1
- package/src/services/SettlementService.test.ts +13 -85
- package/src/services/SettlementService.ts +5 -113
- package/src/sql-filters/balance-items.ts +10 -0
- package/tests/helpers/MollieMocker.ts +89 -16
|
@@ -6,7 +6,8 @@ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
|
6
6
|
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
7
7
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
8
8
|
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
9
|
-
import { AbortSignal
|
|
9
|
+
import type { AbortSignal } from '@stamhoofd/queues';
|
|
10
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
10
11
|
import { SQL } from '@stamhoofd/sql';
|
|
11
12
|
import type { PaymentProvider } from '@stamhoofd/structures';
|
|
12
13
|
import { PaymentMethod, SettlementReference } from '@stamhoofd/structures';
|
|
@@ -67,44 +68,6 @@ const CHARGE_UPDATE_BATCH_SIZE = 500;
|
|
|
67
68
|
*/
|
|
68
69
|
const FEE_BATCH_SIZE = 500;
|
|
69
70
|
|
|
70
|
-
/**
|
|
71
|
-
* Collects which rows the provider still reports in a settlement while a sync walks it. A stored
|
|
72
|
-
* row of the settlement that is not in here after the walk has moved or disappeared at the
|
|
73
|
-
* provider, and gets swept.
|
|
74
|
-
*/
|
|
75
|
-
export class ReportedRows {
|
|
76
|
-
readonly paymentLineExternalIds = new Set<string>();
|
|
77
|
-
readonly chargeExternalIds = new Set<string>();
|
|
78
|
-
readonly applicationFeeIds = new Set<string>();
|
|
79
|
-
|
|
80
|
-
paymentLine(line: PaymentSettlement) {
|
|
81
|
-
if (line.externalId === null) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
this.paymentLineExternalIds.add(line.externalId);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
charge(charge: SettlementCharge) {
|
|
88
|
-
this.chargeExternalIds.add(charge.externalId);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
charges(charges: SettlementCharge[]) {
|
|
92
|
-
for (const charge of charges) {
|
|
93
|
-
this.charge(charge);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
applicationFee(fee: ApplicationFee) {
|
|
98
|
-
this.applicationFeeIds.add(fee.id);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
applicationFees(fees: ApplicationFee[]) {
|
|
102
|
-
for (const fee of fees) {
|
|
103
|
-
this.applicationFee(fee);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
71
|
/**
|
|
109
72
|
* All writes to the settlements tables go through this service. Every write is an upsert on the
|
|
110
73
|
* deterministic unique key of its table, so re-running a sync can never duplicate rows.
|
|
@@ -317,77 +280,6 @@ export class SettlementService {
|
|
|
317
280
|
}
|
|
318
281
|
}
|
|
319
282
|
|
|
320
|
-
/**
|
|
321
|
-
* After a complete walk of a settlement: remove rows the provider no longer reports in this
|
|
322
|
-
* settlement (a transaction can move to another payout). Rows that only exist because of the
|
|
323
|
-
* payout are deleted; rows that outlive the payout link are only unlinked: derived fee lines
|
|
324
|
-
* (owned by updatePaymentSettlementsForAccountDeductionPayment), deduction charges referenced
|
|
325
|
-
* by an application fee, and the application fee rows themselves.
|
|
326
|
-
*
|
|
327
|
-
* Returns the fees unlinked from this settlement, so the caller can refresh the derived lines
|
|
328
|
-
* of their fee payments.
|
|
329
|
-
*/
|
|
330
|
-
static async sweepSettlement(settlement: Settlement, reported: ReportedRows): Promise<{ unlinkedFees: ApplicationFee[] }> {
|
|
331
|
-
const lines = await PaymentSettlement.select()
|
|
332
|
-
.where('settlementId', settlement.id)
|
|
333
|
-
.fetch();
|
|
334
|
-
|
|
335
|
-
const sweptPaymentIds = new Set<string>();
|
|
336
|
-
for (const line of lines) {
|
|
337
|
-
if (line.externalId === null) {
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
if (!reported.paymentLineExternalIds.has(line.externalId)) {
|
|
341
|
-
await line.delete();
|
|
342
|
-
sweptPaymentIds.add(line.paymentId);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// The legacy blob points at one of the lines, so a payment that lost one has to be
|
|
347
|
-
// repointed at what is left
|
|
348
|
-
if (sweptPaymentIds.size > 0) {
|
|
349
|
-
const payments = await PaymentModel.getByIDs(...sweptPaymentIds);
|
|
350
|
-
for (const payment of payments) {
|
|
351
|
-
await this.updateLegacySettlementReference(payment);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const charges = await SettlementCharge.select()
|
|
356
|
-
.where('settlementId', settlement.id)
|
|
357
|
-
.fetch();
|
|
358
|
-
|
|
359
|
-
const unreportedCharges = charges.filter(charge => !reported.chargeExternalIds.has(charge.externalId));
|
|
360
|
-
const referencedChargeIds = unreportedCharges.length > 0
|
|
361
|
-
? new Set((await ApplicationFee.select()
|
|
362
|
-
.where('settlementChargeId', unreportedCharges.map(c => c.id))
|
|
363
|
-
.fetch()).map(fee => fee.settlementChargeId))
|
|
364
|
-
: new Set<string>();
|
|
365
|
-
|
|
366
|
-
for (const charge of unreportedCharges) {
|
|
367
|
-
if (referencedChargeIds.has(charge.id)) {
|
|
368
|
-
charge.settlementId = null;
|
|
369
|
-
await charge.save();
|
|
370
|
-
} else {
|
|
371
|
-
await charge.delete();
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const fees = await ApplicationFee.select()
|
|
376
|
-
.where('settlementId', settlement.id)
|
|
377
|
-
.fetch();
|
|
378
|
-
|
|
379
|
-
const unlinkedFees: ApplicationFee[] = [];
|
|
380
|
-
for (const fee of fees) {
|
|
381
|
-
if (!reported.applicationFeeIds.has(fee.id)) {
|
|
382
|
-
fee.settlementId = null;
|
|
383
|
-
await fee.save();
|
|
384
|
-
unlinkedFees.push(fee);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return { unlinkedFees };
|
|
389
|
-
}
|
|
390
|
-
|
|
391
283
|
/**
|
|
392
284
|
* Recomputes the cached reconciliation columns from the stored rows: `unexplainedAmount` should
|
|
393
285
|
* be 0 — a non-zero value is a real question to answer — and `pendingFees` holds what is
|
|
@@ -500,7 +392,7 @@ export class SettlementService {
|
|
|
500
392
|
* payout that contains fees billed by this payment, amount = the sum of those fees. When the
|
|
501
393
|
* total of the lines matches the payment's price, the payment is completely paid out.
|
|
502
394
|
*/
|
|
503
|
-
static async
|
|
395
|
+
static async updatePaymentSettlementsForApplicationFeePayment(payment: Payment): Promise<void> {
|
|
504
396
|
if (payment.method !== PaymentMethod.AccountDeductions) {
|
|
505
397
|
return;
|
|
506
398
|
}
|
|
@@ -567,7 +459,7 @@ export class SettlementService {
|
|
|
567
459
|
* Refreshes the derived lines of every fee payment that billed one of these balance items:
|
|
568
460
|
* called after a walk linked or unlinked invoiced fees, so the lines follow the fees.
|
|
569
461
|
*/
|
|
570
|
-
static async
|
|
462
|
+
static async updatePaymentSettlementsForApplicationFeeBalanceItems(balanceItemIds: string[]): Promise<void> {
|
|
571
463
|
if (balanceItemIds.length === 0) {
|
|
572
464
|
return;
|
|
573
465
|
}
|
|
@@ -582,7 +474,7 @@ export class SettlementService {
|
|
|
582
474
|
|
|
583
475
|
const payments = await PaymentModel.getByIDs(...paymentIds);
|
|
584
476
|
for (const payment of payments) {
|
|
585
|
-
await this.
|
|
477
|
+
await this.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
586
478
|
}
|
|
587
479
|
}
|
|
588
480
|
|
|
@@ -99,6 +99,16 @@ export const balanceItemFilterCompilers: SQLFilterDefinitions = {
|
|
|
99
99
|
type: SQLValueType.String,
|
|
100
100
|
nullable: true,
|
|
101
101
|
}),
|
|
102
|
+
orderId: createColumnFilter({
|
|
103
|
+
expression: SQL.column('balance_items', 'orderId'),
|
|
104
|
+
type: SQLValueType.String,
|
|
105
|
+
nullable: true,
|
|
106
|
+
}),
|
|
107
|
+
registrationId: createColumnFilter({
|
|
108
|
+
expression: SQL.column('balance_items', 'registrationId'),
|
|
109
|
+
type: SQLValueType.String,
|
|
110
|
+
nullable: true,
|
|
111
|
+
}),
|
|
102
112
|
type: createColumnFilter({
|
|
103
113
|
expression: SQL.column('balance_items', 'type'),
|
|
104
114
|
type: SQLValueType.String,
|
|
@@ -53,13 +53,6 @@ export type MollieMockRefund = {
|
|
|
53
53
|
metadata: Record<string, unknown> | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
export type MollieMockSettlementCost = {
|
|
57
|
-
description: string;
|
|
58
|
-
method: string | null;
|
|
59
|
-
amountNet: { currency: string; value: string };
|
|
60
|
-
amountVat: { currency: string; value: string } | null;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
56
|
export type MollieMockSettlement = {
|
|
64
57
|
id: string;
|
|
65
58
|
reference: string;
|
|
@@ -68,10 +61,6 @@ export type MollieMockSettlement = {
|
|
|
68
61
|
createdAt: string;
|
|
69
62
|
/** null for the still-open settlement, a date once it has been paid out */
|
|
70
63
|
settledAt: string | null;
|
|
71
|
-
/** id of the invoice Mollie created for the settlement costs, null until it exists */
|
|
72
|
-
invoiceId: string | null;
|
|
73
|
-
/** Mollie's own costs per period, keyed year → month */
|
|
74
|
-
periods: Record<string, Record<string, { costs: MollieMockSettlementCost[]; invoiceId?: string | null }>>;
|
|
75
64
|
/** Mollie payment ids (tr_...) settled in this settlement */
|
|
76
65
|
paymentIds: string[];
|
|
77
66
|
/** Mollie refund ids (re_...) settled in this settlement */
|
|
@@ -80,6 +69,15 @@ export type MollieMockSettlement = {
|
|
|
80
69
|
chargebackIds: string[];
|
|
81
70
|
};
|
|
82
71
|
|
|
72
|
+
export type MollieMockBalanceTransaction = {
|
|
73
|
+
id: string;
|
|
74
|
+
type: string;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
deductions: { currency: string; value: string } | null;
|
|
77
|
+
deductionDetails: { fees?: { currency: string; value: string } } | null;
|
|
78
|
+
context: Record<string, string> | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
83
81
|
const MOLLIE_CHECKOUT_URL = 'https://molliecheckout/';
|
|
84
82
|
|
|
85
83
|
/**
|
|
@@ -104,6 +102,7 @@ export class MollieMocker {
|
|
|
104
102
|
chargebacks: MollieMockChargeback[] = [];
|
|
105
103
|
refunds: MollieMockRefund[] = [];
|
|
106
104
|
settlements: MollieMockSettlement[] = [];
|
|
105
|
+
balanceTransactions: MollieMockBalanceTransaction[] = [];
|
|
107
106
|
|
|
108
107
|
/**
|
|
109
108
|
* Cap the settlements list page size below the requested limit, to exercise pagination
|
|
@@ -111,6 +110,11 @@ export class MollieMocker {
|
|
|
111
110
|
*/
|
|
112
111
|
settlementsPageSize: number | null = null;
|
|
113
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Same, for the balance transactions list.
|
|
115
|
+
*/
|
|
116
|
+
balanceTransactionsPageSize: number | null = null;
|
|
117
|
+
|
|
114
118
|
#forceFailure = false;
|
|
115
119
|
|
|
116
120
|
reset() {
|
|
@@ -120,7 +124,9 @@ export class MollieMocker {
|
|
|
120
124
|
this.chargebacks = [];
|
|
121
125
|
this.refunds = [];
|
|
122
126
|
this.settlements = [];
|
|
127
|
+
this.balanceTransactions = [];
|
|
123
128
|
this.settlementsPageSize = null;
|
|
129
|
+
this.balanceTransactionsPageSize = null;
|
|
124
130
|
this.#forceFailure = false;
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -242,6 +248,11 @@ export class MollieMocker {
|
|
|
242
248
|
return this.#listResource('chargebacks', this.chargebacks.map(c => this.#chargebackResource(c)));
|
|
243
249
|
}
|
|
244
250
|
|
|
251
|
+
// balance transactions (drives the transaction fee part of the settlements sync)
|
|
252
|
+
if (parts[0] === 'balances' && method === 'GET' && parts.length === 3 && parts[2] === 'transactions') {
|
|
253
|
+
return this.#listBalanceTransactions(uri);
|
|
254
|
+
}
|
|
255
|
+
|
|
245
256
|
// settlements + nested payments/refunds (drives the settlements sync)
|
|
246
257
|
if (parts[0] === 'settlements' && method === 'GET') {
|
|
247
258
|
if (parts.length === 1) {
|
|
@@ -574,7 +585,7 @@ export class MollieMocker {
|
|
|
574
585
|
* Register a settled (paid out) settlement that groups the given payments and refunds.
|
|
575
586
|
* Used to drive the MollieSettlementSync walk.
|
|
576
587
|
*/
|
|
577
|
-
createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date
|
|
588
|
+
createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date } = {}): MollieMockSettlement {
|
|
578
589
|
const settlement: MollieMockSettlement = {
|
|
579
590
|
id: this.createId('stl'),
|
|
580
591
|
reference: '1234567.' + (this.settlements.length + 1).toString().padStart(4, '0') + '.01',
|
|
@@ -582,8 +593,6 @@ export class MollieMocker {
|
|
|
582
593
|
amount: { currency: 'EUR', value: options.value ?? '0.00' },
|
|
583
594
|
createdAt: new Date().toISOString(),
|
|
584
595
|
settledAt: (options.settledAt ?? new Date()).toISOString(),
|
|
585
|
-
invoiceId: options.invoiceId ?? null,
|
|
586
|
-
periods: options.periods ?? {},
|
|
587
596
|
paymentIds: (options.payments ?? []).map(p => p.id),
|
|
588
597
|
refundIds: (options.refunds ?? []).map(r => r.id),
|
|
589
598
|
chargebackIds: (options.chargebacks ?? []).map(c => c.id),
|
|
@@ -637,12 +646,76 @@ export class MollieMocker {
|
|
|
637
646
|
amount: settlement.amount,
|
|
638
647
|
createdAt: settlement.createdAt,
|
|
639
648
|
settledAt: settlement.settledAt ?? null,
|
|
640
|
-
invoiceId: settlement.invoiceId,
|
|
641
|
-
periods: settlement.periods,
|
|
642
649
|
_links: { self: { href: 'https://api.mollie.com/v2/settlements/' + settlement.id, type: 'application/hal+json' } },
|
|
643
650
|
};
|
|
644
651
|
}
|
|
645
652
|
|
|
653
|
+
// ---- Balance transactions ---------------------------------------------
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Register the balance transaction of a payment, refund or chargeback, carrying the fee Mollie
|
|
657
|
+
* deducted for it. `fee` and `deductions` are positive euro values, stored negated like Mollie
|
|
658
|
+
* reports them. Pass only `deductions` for a transaction without the deductionDetails breakdown.
|
|
659
|
+
*/
|
|
660
|
+
createBalanceTransaction(options: {
|
|
661
|
+
type: 'payment' | 'refund' | 'chargeback';
|
|
662
|
+
entryId: string;
|
|
663
|
+
fee?: string;
|
|
664
|
+
deductions?: string;
|
|
665
|
+
createdAt?: Date;
|
|
666
|
+
}): MollieMockBalanceTransaction {
|
|
667
|
+
const contextKey = options.type === 'payment' ? 'paymentId' : options.type === 'refund' ? 'refundId' : 'chargebackId';
|
|
668
|
+
const deductions = options.deductions ?? options.fee;
|
|
669
|
+
|
|
670
|
+
const transaction: MollieMockBalanceTransaction = {
|
|
671
|
+
id: this.createId('baltr'),
|
|
672
|
+
type: options.type,
|
|
673
|
+
createdAt: (options.createdAt ?? new Date()).toISOString(),
|
|
674
|
+
deductions: deductions ? { currency: 'EUR', value: '-' + deductions } : null,
|
|
675
|
+
deductionDetails: options.fee ? { fees: { currency: 'EUR', value: '-' + options.fee } } : null,
|
|
676
|
+
context: { [contextKey]: options.entryId },
|
|
677
|
+
};
|
|
678
|
+
this.balanceTransactions.push(transaction);
|
|
679
|
+
return transaction;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Mollie lists balance transactions newest-first, paginated like the settlements list.
|
|
684
|
+
*/
|
|
685
|
+
#listBalanceTransactions(uri: string): [number, unknown] {
|
|
686
|
+
const query = new URLSearchParams(uri.split('?')[1] ?? '');
|
|
687
|
+
|
|
688
|
+
const sorted = this.balanceTransactions.slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
689
|
+
|
|
690
|
+
const requestedLimit = parseInt(query.get('limit') ?? '250');
|
|
691
|
+
const limit = Math.min(requestedLimit, this.balanceTransactionsPageSize ?? requestedLimit);
|
|
692
|
+
|
|
693
|
+
const from = query.get('from');
|
|
694
|
+
const startIndex = from ? sorted.findIndex(t => t.id === from) : 0;
|
|
695
|
+
if (startIndex === -1) {
|
|
696
|
+
return [404, { status: 404, title: 'Not Found', detail: 'No balance transaction exists with token ' + from }];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const page = sorted.slice(startIndex, startIndex + limit);
|
|
700
|
+
const nextItem = sorted[startIndex + limit];
|
|
701
|
+
|
|
702
|
+
return this.#listResource('balance_transactions', page.map(t => this.#balanceTransactionResource(t)), {
|
|
703
|
+
next: nextItem ? 'https://api.mollie.com/v2/balances/primary/transactions?limit=' + requestedLimit + '&from=' + nextItem.id : null,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
#balanceTransactionResource(transaction: MollieMockBalanceTransaction) {
|
|
708
|
+
return {
|
|
709
|
+
resource: 'balance-transaction',
|
|
710
|
+
id: transaction.id,
|
|
711
|
+
type: transaction.type,
|
|
712
|
+
createdAt: transaction.createdAt,
|
|
713
|
+
deductions: transaction.deductions,
|
|
714
|
+
deductionDetails: transaction.deductionDetails,
|
|
715
|
+
context: transaction.context,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
646
719
|
// ---- List helper ------------------------------------------------------
|
|
647
720
|
|
|
648
721
|
#listResource(binderName: string, items: unknown[], options: { next?: string | null } = {}): [number, unknown] {
|