@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.
@@ -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, QueueHandler } from '@stamhoofd/queues';
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 updatePaymentSettlementsForAccountDeductionPayment(payment: Payment): Promise<void> {
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 updatePaymentSettlementsForAccountDeductionBalanceItems(balanceItemIds: string[]): Promise<void> {
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.updatePaymentSettlementsForAccountDeductionPayment(payment);
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; invoiceId?: string | null; periods?: Record<string, Record<string, { costs: MollieMockSettlementCost[]; invoiceId?: string | null }>> } = {}): MollieMockSettlement {
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] {