@stamhoofd/backend 2.140.0 → 2.142.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.
Files changed (65) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  12. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  13. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  14. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  15. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  16. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  17. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  18. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  19. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  22. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  23. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  24. package/src/excel-loaders/payments.ts +18 -1
  25. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  26. package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
  27. package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
  28. package/src/helpers/AuthenticatedStructures.ts +14 -1
  29. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  30. package/src/helpers/MollieSettlementSync.ts +323 -0
  31. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  32. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  33. package/src/helpers/SettlementExporter.test.ts +388 -0
  34. package/src/helpers/SettlementExporter.ts +593 -0
  35. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  36. package/src/helpers/SettlementSyncRunner.ts +64 -0
  37. package/src/helpers/StripeHelper.ts +2 -1
  38. package/src/helpers/StripeSettlementSync.test.ts +997 -0
  39. package/src/helpers/StripeSettlementSync.ts +1053 -0
  40. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  41. package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
  42. package/src/helpers/WebmasterReport.test.ts +109 -0
  43. package/src/helpers/WebmasterReport.ts +115 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  45. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +301 -0
  48. package/src/services/InvoiceService.ts +18 -1
  49. package/src/services/SettlementService.test.ts +632 -0
  50. package/src/services/SettlementService.ts +650 -0
  51. package/src/sql-filters/payment-settlement.test.ts +2 -2
  52. package/src/sql-filters/payments.ts +73 -0
  53. package/tests/helpers/MollieMocker.ts +64 -6
  54. package/tests/helpers/StripeMocker.ts +209 -17
  55. package/src/crons/stripe-payout-reports.ts +0 -69
  56. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  57. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  58. package/src/helpers/CheckSettlements.test.ts +0 -190
  59. package/src/helpers/CheckSettlements.ts +0 -237
  60. package/src/helpers/StripeInvoicer.ts +0 -419
  61. package/src/helpers/StripePayoutChecker.ts +0 -193
  62. package/src/helpers/StripePayoutExportData.ts +0 -195
  63. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  64. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  65. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -180,6 +180,79 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
180
180
  ),
181
181
  balanceItemPaymentsCompilers,
182
182
  ),
183
+ // The stored payouts this payment was part of (the m2m rows, not the legacy JSON blob above)
184
+ settlements: createExistsFilter(
185
+ SQL.select()
186
+ .from(
187
+ SQL.table('payment_settlements'),
188
+ ).join(
189
+ SQL.join(
190
+ SQL.table('settlements'),
191
+ ).where(
192
+ SQL.column('settlements', 'id'),
193
+ SQL.column('payment_settlements', 'settlementId'),
194
+ ),
195
+ ).where(
196
+ SQL.column('payment_settlements', 'paymentId'),
197
+ SQL.column('payments', 'id'),
198
+ ),
199
+ {
200
+ ...baseSQLFilterCompilers,
201
+ amount: createColumnFilter({
202
+ expression: SQL.column('payment_settlements', 'amount'),
203
+ type: SQLValueType.Number,
204
+ nullable: false,
205
+ }),
206
+ externalId: createColumnFilter({
207
+ expression: SQL.column('payment_settlements', 'externalId'),
208
+ type: SQLValueType.String,
209
+ nullable: false,
210
+ }),
211
+ occurredAt: createColumnFilter({
212
+ expression: SQL.column('payment_settlements', 'occurredAt'),
213
+ type: SQLValueType.Datetime,
214
+ nullable: false,
215
+ }),
216
+ settlement: {
217
+ ...baseSQLFilterCompilers,
218
+ id: createColumnFilter({
219
+ expression: SQL.column('settlements', 'id'),
220
+ type: SQLValueType.String,
221
+ nullable: false,
222
+ }),
223
+ provider: createColumnFilter({
224
+ expression: SQL.column('settlements', 'provider'),
225
+ type: SQLValueType.String,
226
+ nullable: false,
227
+ }),
228
+ externalId: createColumnFilter({
229
+ expression: SQL.column('settlements', 'externalId'),
230
+ type: SQLValueType.String,
231
+ nullable: false,
232
+ }),
233
+ reference: createColumnFilter({
234
+ expression: SQL.column('settlements', 'reference'),
235
+ type: SQLValueType.String,
236
+ nullable: false,
237
+ }),
238
+ settledAt: createColumnFilter({
239
+ expression: SQL.column('settlements', 'settledAt'),
240
+ type: SQLValueType.Datetime,
241
+ nullable: false,
242
+ }),
243
+ amount: createColumnFilter({
244
+ expression: SQL.column('settlements', 'amount'),
245
+ type: SQLValueType.Number,
246
+ nullable: false,
247
+ }),
248
+ stripeAccountId: createColumnFilter({
249
+ expression: SQL.column('settlements', 'stripeAccountId'),
250
+ type: SQLValueType.String,
251
+ nullable: true,
252
+ }),
253
+ },
254
+ },
255
+ ),
183
256
  };
184
257
 
185
258
  /**
@@ -53,6 +53,13 @@ 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
+
56
63
  export type MollieMockSettlement = {
57
64
  id: string;
58
65
  reference: string;
@@ -61,6 +68,10 @@ export type MollieMockSettlement = {
61
68
  createdAt: string;
62
69
  /** null for the still-open settlement, a date once it has been paid out */
63
70
  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 }>>;
64
75
  /** Mollie payment ids (tr_...) settled in this settlement */
65
76
  paymentIds: string[];
66
77
  /** Mollie refund ids (re_...) settled in this settlement */
@@ -94,6 +105,12 @@ export class MollieMocker {
94
105
  refunds: MollieMockRefund[] = [];
95
106
  settlements: MollieMockSettlement[] = [];
96
107
 
108
+ /**
109
+ * Cap the settlements list page size below the requested limit, to exercise pagination
110
+ * without creating hundreds of settlements.
111
+ */
112
+ settlementsPageSize: number | null = null;
113
+
97
114
  #forceFailure = false;
98
115
 
99
116
  reset() {
@@ -103,6 +120,7 @@ export class MollieMocker {
103
120
  this.chargebacks = [];
104
121
  this.refunds = [];
105
122
  this.settlements = [];
123
+ this.settlementsPageSize = null;
106
124
  this.#forceFailure = false;
107
125
  }
108
126
 
@@ -224,10 +242,10 @@ export class MollieMocker {
224
242
  return this.#listResource('chargebacks', this.chargebacks.map(c => this.#chargebackResource(c)));
225
243
  }
226
244
 
227
- // settlements + nested payments/refunds (drives the settlements cron)
245
+ // settlements + nested payments/refunds (drives the settlements sync)
228
246
  if (parts[0] === 'settlements' && method === 'GET') {
229
247
  if (parts.length === 1) {
230
- return this.#listResource('settlements', this.settlements.map(s => this.#settlementResource(s)));
248
+ return this.#listSettlements(uri);
231
249
  }
232
250
  const settlement = this.settlements.find(s => s.id === parts[1]);
233
251
  if (settlement && parts.length === 3 && parts[2] === 'payments') {
@@ -554,9 +572,9 @@ export class MollieMocker {
554
572
 
555
573
  /**
556
574
  * Register a settled (paid out) settlement that groups the given payments and refunds.
557
- * Used to drive the settlements cron (CheckSettlements).
575
+ * Used to drive the MollieSettlementSync walk.
558
576
  */
559
- createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date } = {}): MollieMockSettlement {
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 {
560
578
  const settlement: MollieMockSettlement = {
561
579
  id: this.createId('stl'),
562
580
  reference: '1234567.' + (this.settlements.length + 1).toString().padStart(4, '0') + '.01',
@@ -564,6 +582,8 @@ export class MollieMocker {
564
582
  amount: { currency: 'EUR', value: options.value ?? '0.00' },
565
583
  createdAt: new Date().toISOString(),
566
584
  settledAt: (options.settledAt ?? new Date()).toISOString(),
585
+ invoiceId: options.invoiceId ?? null,
586
+ periods: options.periods ?? {},
567
587
  paymentIds: (options.payments ?? []).map(p => p.id),
568
588
  refundIds: (options.refunds ?? []).map(r => r.id),
569
589
  chargebackIds: (options.chargebacks ?? []).map(c => c.id),
@@ -572,6 +592,42 @@ export class MollieMocker {
572
592
  return settlement;
573
593
  }
574
594
 
595
+ /**
596
+ * Mollie lists settlements newest-first, with the open settlement (settledAt null) on top;
597
+ * the sync relies on this to stop at the first settlement before its window. Pagination works
598
+ * like Mollie's: `from` is the id of the first settlement of the page, and the `next` link
599
+ * points at the first settlement of the following page.
600
+ */
601
+ #listSettlements(uri: string): [number, unknown] {
602
+ const query = new URLSearchParams(uri.split('?')[1] ?? '');
603
+
604
+ const sorted = this.settlements.slice().sort((a, b) => {
605
+ if (a.settledAt === null || b.settledAt === null) {
606
+ if (a.settledAt === b.settledAt) {
607
+ return 0;
608
+ }
609
+ return a.settledAt === null ? -1 : 1;
610
+ }
611
+ return new Date(b.settledAt).getTime() - new Date(a.settledAt).getTime();
612
+ });
613
+
614
+ const requestedLimit = parseInt(query.get('limit') ?? '250');
615
+ const limit = Math.min(requestedLimit, this.settlementsPageSize ?? requestedLimit);
616
+
617
+ const from = query.get('from');
618
+ const startIndex = from ? sorted.findIndex(s => s.id === from) : 0;
619
+ if (startIndex === -1) {
620
+ return [404, { status: 404, title: 'Not Found', detail: 'No settlement exists with token ' + from }];
621
+ }
622
+
623
+ const page = sorted.slice(startIndex, startIndex + limit);
624
+ const nextItem = sorted[startIndex + limit];
625
+
626
+ return this.#listResource('settlements', page.map(s => this.#settlementResource(s)), {
627
+ next: nextItem ? 'https://api.mollie.com/v2/settlements?limit=' + requestedLimit + '&from=' + nextItem.id : null,
628
+ });
629
+ }
630
+
575
631
  #settlementResource(settlement: MollieMockSettlement) {
576
632
  return {
577
633
  resource: 'settlement',
@@ -581,20 +637,22 @@ export class MollieMocker {
581
637
  amount: settlement.amount,
582
638
  createdAt: settlement.createdAt,
583
639
  settledAt: settlement.settledAt ?? null,
640
+ invoiceId: settlement.invoiceId,
641
+ periods: settlement.periods,
584
642
  _links: { self: { href: 'https://api.mollie.com/v2/settlements/' + settlement.id, type: 'application/hal+json' } },
585
643
  };
586
644
  }
587
645
 
588
646
  // ---- List helper ------------------------------------------------------
589
647
 
590
- #listResource(binderName: string, items: unknown[]): [number, unknown] {
648
+ #listResource(binderName: string, items: unknown[], options: { next?: string | null } = {}): [number, unknown] {
591
649
  return [200, {
592
650
  count: items.length,
593
651
  _embedded: { [binderName]: items },
594
652
  _links: {
595
653
  self: { href: 'https://api.mollie.com/v2/' + binderName, type: 'application/hal+json' },
596
654
  documentation: { href: 'https://docs.mollie.com', type: 'text/html' },
597
- next: null,
655
+ next: options.next ? { href: options.next, type: 'application/hal+json' } : null,
598
656
  previous: null,
599
657
  },
600
658
  }];
@@ -10,14 +10,27 @@ import { testServer } from './TestServer.js';
10
10
  import { resetNock } from './resetNock.js';
11
11
 
12
12
  export type PaymentIntent = { id: string; return_url?: string };
13
+
14
+ /**
15
+ * Loose Stripe API object: the mocker serves back exactly what a test built, so only the id is
16
+ * mandatory.
17
+ */
18
+ export type StripeObject = { id: string } & Record<string, any>;
19
+
13
20
  export class StripeMocker {
14
21
  paymentIntents: PaymentIntent[] = [];
15
- charges: { id: string }[] = [];
22
+ charges: StripeObject[] = [];
23
+ payouts: StripeObject[] = [];
24
+ balanceTransactions: StripeObject[] = [];
25
+ checkoutSessions: StripeObject[] = [];
16
26
  #forceFailure = false;
17
27
 
18
28
  reset() {
19
29
  this.paymentIntents = [];
20
30
  this.charges = [];
31
+ this.payouts = [];
32
+ this.balanceTransactions = [];
33
+ this.checkoutSessions = [];
21
34
  this.#forceFailure = false;
22
35
  }
23
36
 
@@ -30,25 +43,15 @@ export class StripeMocker {
30
43
  throw new Error('Invalid STRIPE_SECRET_KEY. Even in test mode it should start with sk_test_');
31
44
  }
32
45
 
46
+ const mocker = this;
33
47
  nock('https://api.stripe.com')
34
48
  .persist()
35
49
  .get(/v1\/.*/)
36
- .reply((uri, body) => {
37
- const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/(\w+)){0,2}/) || [null];
38
-
39
- if (!match) {
40
- return [500];
41
- }
42
-
43
- if (resource === 'payment_intents') {
44
- return this.#getPaymentIntent(id);
45
- }
46
-
47
- if (resource === 'charges') {
48
- return this.#getCharge(id);
49
- }
50
-
51
- return [500];
50
+ // A regular function: nock exposes the intercepted request (and its Stripe-Account
51
+ // header) on `this`
52
+ .reply(function (uri) {
53
+ const stripeAccount = (this.req.headers['stripe-account'] as string | undefined) ?? null;
54
+ return mocker.#handleGet(uri, stripeAccount);
52
55
  });
53
56
 
54
57
  nock('https://api.stripe.com')
@@ -84,12 +87,201 @@ export class StripeMocker {
84
87
  clear() {
85
88
  this.paymentIntents = [];
86
89
  this.charges = [];
90
+ this.payouts = [];
91
+ this.balanceTransactions = [];
92
+ this.checkoutSessions = [];
87
93
  }
88
94
 
89
95
  createId(prefix: string) {
90
96
  return prefix + '_' + uuidv4().replaceAll('-', '');
91
97
  }
92
98
 
99
+ #handleGet(uri: string, stripeAccount: string | null) {
100
+ const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/(\w+)){0,2}/) || [null];
101
+
102
+ if (!match) {
103
+ return [500];
104
+ }
105
+
106
+ const query = this.decodeBody(uri.split('?')[1] ?? '') as Record<string, any>;
107
+
108
+ if (resource === 'payment_intents') {
109
+ return this.#getPaymentIntent(id);
110
+ }
111
+
112
+ if (resource === 'charges') {
113
+ return this.#getCharge(id);
114
+ }
115
+
116
+ if (resource === 'payouts') {
117
+ return this.#listPayouts(id, query, stripeAccount);
118
+ }
119
+
120
+ if (resource === 'balance_transactions') {
121
+ return this.#listBalanceTransactions(query, stripeAccount);
122
+ }
123
+
124
+ if (resource === 'checkout' && id === 'sessions') {
125
+ return this.#listCheckoutSessions(query);
126
+ }
127
+
128
+ return [500];
129
+ }
130
+
131
+ /**
132
+ * Pages like the real API does, so the SDK's auto-pagination is actually exercised: a payout
133
+ * with more transactions than one page is the normal case in production.
134
+ */
135
+ #list(data: StripeObject[], url: string, query: Record<string, any> = {}) {
136
+ let page = data;
137
+
138
+ if (query.starting_after) {
139
+ const index = page.findIndex(item => item.id === query.starting_after);
140
+ page = index === -1 ? [] : page.slice(index + 1);
141
+ }
142
+
143
+ const limit = query.limit ? parseInt(String(query.limit)) : 10;
144
+ const hasMore = page.length > limit;
145
+
146
+ return [200, { object: 'list', data: page.slice(0, limit), has_more: hasMore, url }];
147
+ }
148
+
149
+ #listPayouts(id: string | undefined, query: Record<string, any>, stripeAccount: string | null) {
150
+ let data = this.payouts.filter(p => (p.stripeAccount ?? null) === stripeAccount);
151
+
152
+ if (id) {
153
+ const payout = data.find(p => p.id === id);
154
+ return payout ? [200, payout] : [404];
155
+ }
156
+
157
+ if (query.status) {
158
+ data = data.filter(p => p.status === query.status);
159
+ }
160
+ if (query.arrival_date?.gte !== undefined) {
161
+ data = data.filter(p => p.arrival_date >= query.arrival_date.gte);
162
+ }
163
+ if (query.arrival_date?.lte !== undefined) {
164
+ data = data.filter(p => p.arrival_date <= query.arrival_date.lte);
165
+ }
166
+ return this.#list(data, '/v1/payouts', query);
167
+ }
168
+
169
+ #listBalanceTransactions(query: Record<string, any>, stripeAccount: string | null) {
170
+ let data = this.balanceTransactions.filter(t => (t.stripeAccount ?? null) === stripeAccount);
171
+
172
+ if (query.payout) {
173
+ data = data.filter(t => t.payout === query.payout);
174
+ }
175
+ if (query.type) {
176
+ data = data.filter(t => t.type === query.type);
177
+ }
178
+ if (query.created?.gte !== undefined) {
179
+ data = data.filter(t => t.created >= query.created.gte);
180
+ }
181
+ if (query.created?.lte !== undefined) {
182
+ data = data.filter(t => t.created <= query.created.lte);
183
+ }
184
+ return this.#list(data, '/v1/balance_transactions', query);
185
+ }
186
+
187
+ #listCheckoutSessions(query: Record<string, any>) {
188
+ let data = this.checkoutSessions;
189
+
190
+ if (query.payment_intent) {
191
+ data = data.filter(s => s.payment_intent === query.payment_intent);
192
+ }
193
+ return this.#list(data, '/v1/checkout/sessions', query);
194
+ }
195
+
196
+ /**
197
+ * Amounts are in Stripe cents, like the real API.
198
+ */
199
+ createPayout(data: { amount: number; arrivalDate: Date; status?: string; statementDescriptor?: string; stripeAccount?: string | null } & Record<string, any>): StripeObject {
200
+ const { arrivalDate, statementDescriptor, stripeAccount, ...rest } = data;
201
+ const payout: StripeObject = {
202
+ id: this.createId('po'),
203
+ object: 'payout',
204
+ status: 'paid',
205
+ // Stripe only reports the transactions of a payout it finished reconciling, and never
206
+ // for manual payouts
207
+ reconciliation_status: 'completed',
208
+ automatic: true,
209
+ currency: 'eur',
210
+ arrival_date: Math.floor(arrivalDate.getTime() / 1000),
211
+ statement_descriptor: statementDescriptor ?? 'STAMHOOFD',
212
+ stripeAccount: stripeAccount ?? null,
213
+ ...rest,
214
+ };
215
+ this.payouts.push(payout);
216
+ return payout;
217
+ }
218
+
219
+ /**
220
+ * The `payout` field links the transaction to a payout, `stripeAccount` scopes it to a
221
+ * connected account (null = platform account). Sources are served exactly as passed, so tests
222
+ * control the expansions.
223
+ */
224
+ createBalanceTransaction(data: { type: string; amount: number; created: Date; payout?: string | null; stripeAccount?: string | null; fee?: number; source?: unknown } & Record<string, any>): StripeObject {
225
+ const { created, payout, stripeAccount, ...rest } = data;
226
+ const feeDetails = (rest.fee_details ?? []) as { amount: number }[];
227
+
228
+ const transaction: StripeObject = {
229
+ id: this.createId('txn'),
230
+ object: 'balance_transaction',
231
+ currency: 'eur',
232
+ fee_details: [],
233
+ created: Math.floor(created.getTime() / 1000),
234
+ payout: payout ?? null,
235
+ stripeAccount: stripeAccount ?? null,
236
+ ...rest,
237
+ // Stripe guarantees these: fee is the sum of its details, net is what the balance
238
+ // actually moved. Deriving them keeps fixtures from describing impossible transactions
239
+ fee: feeDetails.reduce((total, detail) => total + detail.amount, 0),
240
+ net: data.amount - feeDetails.reduce((total, detail) => total + detail.amount, 0),
241
+ };
242
+ this.balanceTransactions.push(transaction);
243
+ return transaction;
244
+ }
245
+
246
+ /**
247
+ * An expanded ApplicationFee object, used as the source of application_fee transactions and as
248
+ * charge.application_fee expansions.
249
+ */
250
+ createApplicationFee(data: { amount: number; account: string; originatingTransaction?: StripeObject | null; charge?: StripeObject | null } & Record<string, any>): StripeObject {
251
+ const { account, originatingTransaction, charge, ...rest } = data;
252
+ return {
253
+ id: this.createId('fee'),
254
+ object: 'application_fee',
255
+ account,
256
+ originating_transaction: originatingTransaction ?? null,
257
+ charge: charge ?? null,
258
+ ...rest,
259
+ };
260
+ }
261
+
262
+ createChargeObject(data: Record<string, any> = {}): StripeObject {
263
+ const charge: StripeObject = {
264
+ id: this.createId('ch'),
265
+ object: 'charge',
266
+ metadata: {},
267
+ ...data,
268
+ };
269
+ this.charges.push(charge);
270
+ return charge;
271
+ }
272
+
273
+ createCheckoutSession(data: { paymentIntent: string } & Record<string, any>): StripeObject {
274
+ const { paymentIntent, ...rest } = data;
275
+ const session: StripeObject = {
276
+ id: this.createId('cs'),
277
+ object: 'checkout.session',
278
+ payment_intent: paymentIntent,
279
+ ...rest,
280
+ };
281
+ this.checkoutSessions.push(session);
282
+ return session;
283
+ }
284
+
93
285
  #getPaymentIntent(id: string) {
94
286
  const intent = this.paymentIntents.find(i => i.id === id);
95
287
  if (!intent) {
@@ -1,69 +0,0 @@
1
- import { registerCron } from '@stamhoofd/crons';
2
- import { Organization, Platform } from '@stamhoofd/models';
3
- import { Formatter } from '@stamhoofd/utility';
4
- import { StripePayoutReporter } from '../helpers/StripePayoutReporter.js';
5
-
6
- registerCron('stripe-payout-reports', createStripePayoutReports);
7
-
8
- let lastStripeReport: Date | null = null;
9
-
10
- /**
11
- * Sends a monthly report of all Stripe payouts of the previous month, so we can check
12
- * whether everything we charged via Stripe application fees was invoiced correctly.
13
- */
14
- async function createStripePayoutReports() {
15
- if (STAMHOOFD.environment !== 'production') {
16
- return;
17
- }
18
-
19
- if (STAMHOOFD.userMode === 'platform') {
20
- return;
21
- }
22
-
23
- if (STAMHOOFD.STRIPE_CONNECT_METHOD === 'standard') {
24
- return;
25
- }
26
-
27
- if (new Date().getHours() > 5 || new Date().getHours() < 2) {
28
- // Fix for sending emails after deployments
29
- return;
30
- }
31
-
32
- // Send the report on the 6th day of the month, once
33
- const today = new Date();
34
- if (today.getDate() !== 6 || (lastStripeReport && Formatter.dateWithoutDay(lastStripeReport) === Formatter.dateWithoutDay(today))) {
35
- return;
36
- }
37
-
38
- if (!STAMHOOFD.STRIPE_SECRET_KEY) {
39
- console.log('No stripe key set');
40
- return;
41
- }
42
-
43
- const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
44
- if (!membershipOrganizationId) {
45
- return;
46
- }
47
-
48
- const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
49
-
50
- console.log('Creating Stripe payout report...');
51
-
52
- // Previous month
53
- const previousMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
54
- const end = new Date(new Date(today.getFullYear(), today.getMonth(), 1).getTime() - 1000);
55
-
56
- const reporter = new StripePayoutReporter({
57
- secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
58
- sellingOrganization: membershipOrganization,
59
- });
60
-
61
- await reporter.build(previousMonthStart, end);
62
- await reporter.sendEmail({
63
- to: [{ email: 'simon@stamhoofd.be' }],
64
- // Only send the report to the extra (accounting) recipients when it is complete and valid
65
- extraRecipientsWhenValid: (STAMHOOFD.STRIPE_PAYOUT_REPORT_EMAILS ?? []).map(email => ({ email })),
66
- });
67
-
68
- lastStripeReport = new Date();
69
- }
@@ -1,103 +0,0 @@
1
- import { Request } from '@simonbackx/simple-endpoints';
2
- import { EmailMocker } from '@stamhoofd/email';
3
- import type { Organization, Token, User } from '@stamhoofd/models';
4
- import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
5
- import { Token as TokenModel } from '@stamhoofd/models';
6
- import { QueueHandler } from '@stamhoofd/queues';
7
- import { Company } from '@stamhoofd/structures';
8
- import { STExpect } from '@stamhoofd/test-utils';
9
- import nock from 'nock';
10
-
11
- import { resetNock } from '../../../../../tests/helpers/resetNock.js';
12
- import { testServer } from '../../../../../tests/helpers/TestServer.js';
13
- import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
14
- import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
15
- import { GetStripePayoutsExportStatusEndpoint } from './GetStripePayoutsExportStatusEndpoint.js';
16
- import { StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
17
-
18
- describe('Endpoint.StripePayoutsExport', () => {
19
- const endpoint = new StripePayoutsExportEndpoint();
20
- const statusEndpoint = new GetStripePayoutsExportStatusEndpoint();
21
-
22
- let membershipOrganization: Organization;
23
- let admin: User;
24
- let adminToken: Token;
25
-
26
- beforeAll(async () => {
27
- membershipOrganization = await initMembershipOrganization();
28
- membershipOrganization.meta.companies = [
29
- Company.create({
30
- name: 'Platform BV',
31
- companyNumber: '0700000000',
32
- VATNumber: 'BE0700000000',
33
- }),
34
- ];
35
- await membershipOrganization.save();
36
-
37
- ({ admin, adminToken } = await initPlatformAdmin());
38
- });
39
-
40
- afterEach(() => {
41
- resetNock();
42
- });
43
-
44
- const post = async (organization: Organization, token: Token) => {
45
- const request = Request.buildJson('POST', '/stripe/payouts', organization.getApiHost(), {
46
- start: new Date(2026, 2, 1).getTime(),
47
- end: new Date(2026, 3, 1).getTime() - 1000,
48
- });
49
- request.headers.authorization = 'Bearer ' + token.accessToken;
50
- return await testServer.test(endpoint, request);
51
- };
52
-
53
- const getStatus = async (organization: Organization, token: Token) => {
54
- const request = Request.buildJson('GET', '/stripe/payouts/status', organization.getApiHost());
55
- request.headers.authorization = 'Bearer ' + token.accessToken;
56
- return await testServer.test(statusEndpoint, request);
57
- };
58
-
59
- test('A platform admin can export payout reports for the membership organization', async () => {
60
- nock('https://api.stripe.com')
61
- .get('/v1/payouts')
62
- .query(true)
63
- .reply(200, { object: 'list', data: [], has_more: false, url: '/v1/payouts' });
64
-
65
- const response = await post(membershipOrganization, adminToken);
66
- expect(response.status).toBe(200);
67
-
68
- // Wait for the scheduled report to complete
69
- await QueueHandler.awaitAll();
70
-
71
- const emails = await EmailMocker.transactional.getSucceededEmails();
72
- const reportEmail = emails.find(e => e.subject.startsWith('Stripe Uitbetalingen'));
73
- expect(reportEmail).toBeDefined();
74
- expect(reportEmail!.attachments).toHaveLength(1);
75
-
76
- // The report is sent to the user that requested it
77
- expect(reportEmail!.to).toContain(admin.email);
78
-
79
- // The queue is empty again
80
- const statusResponse = await getStatus(membershipOrganization, adminToken);
81
- expect(statusResponse.body).toEqual([]);
82
- });
83
-
84
- test('A user without platform full access cannot export payout reports', async () => {
85
- const user = await new UserFactory({ organization: membershipOrganization }).create();
86
- const token = await TokenModel.createToken(user);
87
-
88
- await expect(post(membershipOrganization, token)).rejects.toThrow(
89
- STExpect.simpleError({ code: 'permission_denied' }),
90
- );
91
- await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
92
- STExpect.simpleError({ code: 'permission_denied' }),
93
- );
94
- });
95
-
96
- test('Payout reports are not available for other organizations', async () => {
97
- const otherOrganization = await new OrganizationFactory({}).create();
98
-
99
- await expect(post(otherOrganization, adminToken)).rejects.toThrow(
100
- STExpect.simpleError({ code: 'not_available' }),
101
- );
102
- });
103
- });