@stamhoofd/backend 2.129.3 → 2.131.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 (43) hide show
  1. package/package.json +17 -17
  2. package/src/crons/index.ts +1 -0
  3. package/src/crons/stripe-payout-reports.ts +69 -0
  4. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.test.ts +324 -2
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  8. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  10. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +44 -3
  12. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  13. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +1 -1
  14. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +61 -2
  15. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +14 -1
  16. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  17. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  18. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  19. package/src/helpers/AuthenticatedStructures.ts +5 -7
  20. package/src/helpers/MembershipCharger.ts +26 -1
  21. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  22. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  23. package/src/helpers/StripePayoutExportData.ts +195 -0
  24. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  25. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  26. package/src/helpers/StripePayoutReporter.ts +585 -0
  27. package/src/migrations/1783342463-remove-platform-uitpas-flag.ts +25 -0
  28. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  29. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  30. package/src/services/uitpas/UitpasService.ts +1 -1
  31. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  32. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  33. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  34. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  35. package/src/services/uitpas/registerTicketSales.ts +1 -1
  36. package/src/services/uitpas/searchUitpasEvents.ts +22 -18
  37. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  38. package/src/sql-filters/organizations.ts +52 -0
  39. package/tests/e2e/documents.test.ts +3 -1
  40. package/tests/vitest.global.setup.ts +1 -1
  41. package/tests/vitest.setup.ts +4 -2
  42. package/vitest.config.js +10 -0
  43. /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
@@ -0,0 +1,585 @@
1
+ import type { EmailInterfaceRecipient } from '@stamhoofd/email';
2
+ import { Email } from '@stamhoofd/email';
3
+ import { Invoice, Organization, Payment, StripeAccount } from '@stamhoofd/models';
4
+ import { QueueHandler } from '@stamhoofd/queues';
5
+ import { SQL } from '@stamhoofd/sql';
6
+ import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
7
+ import { Formatter } from '@stamhoofd/utility';
8
+ import Stripe from 'stripe';
9
+
10
+ import { StripeInvoicer } from './StripeInvoicer.js';
11
+ import { StripePayoutBreakdownData, StripePayoutData, StripePayoutExportData, StripePayoutItemData, StripePayoutItemType } from './StripePayoutExportData.js';
12
+ import { StripePayoutExportExcel } from './StripePayoutExportExcel.js';
13
+ import { passthroughFetch } from './passthroughFetch.js';
14
+
15
+ /**
16
+ * Returns the payment reference for a given balance item date, matching the references
17
+ * used by StripeInvoicer when creating the payments. So we can group by payment/invoice.
18
+ */
19
+ function getDateReference(date: Date) {
20
+ return 'stripe-fees-' + Formatter.dateIso(getDateStartDate(date));
21
+ }
22
+
23
+ /**
24
+ * Returns the start of the month for a given balance item date (same boundaries as StripeInvoicer).
25
+ */
26
+ function getDateStartDate(date: Date) {
27
+ const { start } = StripeInvoicer.getMonthUnixStartEnd(date);
28
+ return new Date(start * 1000);
29
+ }
30
+
31
+ /**
32
+ * Convert an amount in Stripe cents to the platform price unit (1/100 cent)
33
+ */
34
+ function fromStripeAmount(amount: number) {
35
+ return amount * 100;
36
+ }
37
+
38
+ export class StripeAccountReport {
39
+ accountId: string;
40
+ reference: string;
41
+ sellingOrganization: Organization;
42
+
43
+ stripeAccount: StripeAccount | null = null;
44
+ organization: Organization | null = null;
45
+ payments: Payment[] = [];
46
+ invoices: Invoice[] = [];
47
+
48
+ constructor(accountId: string, reference: string, sellingOrganization: Organization) {
49
+ this.accountId = accountId;
50
+ this.reference = reference;
51
+ this.sellingOrganization = sellingOrganization;
52
+ }
53
+
54
+ async load() {
55
+ const stripeAccount = await StripeAccount.select().where('accountId', this.accountId).first(false);
56
+
57
+ if (!stripeAccount) {
58
+ console.error('Stripe account not found for account', this.accountId);
59
+ return;
60
+ }
61
+
62
+ this.stripeAccount = stripeAccount;
63
+
64
+ const organization = await Organization.getByID(stripeAccount.organizationId);
65
+
66
+ if (!organization) {
67
+ console.error('Organization not found for account', this.accountId);
68
+ return;
69
+ }
70
+ this.organization = organization;
71
+
72
+ // Load the payments that were created for these application fees (same query as StripeInvoicer uses to check for existing payments)
73
+ this.payments = await Payment.select()
74
+ .where('organizationId', this.sellingOrganization.id)
75
+ .where('payingOrganizationId', organization.id)
76
+ .where(
77
+ // required because the same organization can have multiple stripe accounts. Null = legacy
78
+ SQL.where('stripeAccountId', stripeAccount.id)
79
+ .or('stripeAccountId', null),
80
+ )
81
+ .where('reference', this.reference)
82
+ .where('method', PaymentMethod.AccountDeductions)
83
+ .where('provider', PaymentProvider.Stripe)
84
+ .where('status', PaymentStatus.Succeeded)
85
+ .fetch();
86
+
87
+ // Load the invoices these payments were grouped in (created later by the invoices cron)
88
+ const invoiceIds = Formatter.uniqueArray(this.payments.map(p => p.invoiceId).filter(id => id !== null));
89
+ if (invoiceIds.length > 0) {
90
+ this.invoices = (await Invoice.select().where('id', invoiceIds).fetch())
91
+ .filter(i => i.number !== null);
92
+ }
93
+ }
94
+
95
+ // From platform <-> submerchant (in platform price units, 1/100 cent)
96
+ applicationFees = 0; // to platform
97
+ applicationFeesRefunded = 0; // to submerchant
98
+
99
+ transfers = 0; // to submerchant
100
+ transfersRefunded = 0; // to platform
101
+
102
+ // Customers <-> platform account
103
+ charges = 0;
104
+ refunds = 0;
105
+ disputes = 0;
106
+
107
+ minimumDate: Date | null = null;
108
+ maximumDate: Date | null = null;
109
+
110
+ get toInvoiceAmount() {
111
+ return this.applicationFees
112
+ - this.applicationFeesRefunded;
113
+ }
114
+
115
+ get paymentsTotal() {
116
+ return this.payments.reduce((total, payment) => total + payment.price, 0);
117
+ }
118
+
119
+ /**
120
+ * Checks whether all charges that are made on the main account were transferred to the submerchant and leaves no balance on the main account
121
+ */
122
+ get isValid() {
123
+ return this.transfers === this.charges && (this.refunds + this.disputes) === this.transfersRefunded;
124
+ }
125
+
126
+ addDate(date: Date) {
127
+ if (!this.minimumDate || date < this.minimumDate) {
128
+ this.minimumDate = date;
129
+ }
130
+ if (!this.maximumDate || date > this.maximumDate) {
131
+ this.maximumDate = date;
132
+ }
133
+ }
134
+ }
135
+
136
+ export class StripeMainAccountReport {
137
+ startDate: Date;
138
+
139
+ constructor(startDate: Date) {
140
+ this.startDate = startDate;
141
+ }
142
+
143
+ get description() {
144
+ return Formatter.capitalizeFirstLetter(Formatter.dateWithoutDay(this.startDate));
145
+ }
146
+
147
+ fees = 0;
148
+ reserved = 0;
149
+ }
150
+
151
+ export class StripeReport {
152
+ sellingOrganization: Organization;
153
+ accounts: Map<string, StripeAccountReport> = new Map();
154
+ mainAccount: Map<string, StripeMainAccountReport> = new Map();
155
+
156
+ check = 0;
157
+
158
+ constructor(sellingOrganization: Organization) {
159
+ this.sellingOrganization = sellingOrganization;
160
+ }
161
+
162
+ async getAccountReport(balanceTransaction: Stripe.BalanceTransaction, accountId: string) {
163
+ const reference = getDateReference(new Date(balanceTransaction.created * 1000));
164
+ const group = reference + '-' + accountId;
165
+ let currentAmount = this.accounts.get(group);
166
+ if (currentAmount === undefined) {
167
+ currentAmount = new StripeAccountReport(accountId, reference, this.sellingOrganization);
168
+ await currentAmount.load();
169
+ this.accounts.set(group, currentAmount);
170
+ }
171
+ return currentAmount;
172
+ }
173
+
174
+ getMainAccountReport(balanceTransaction: Stripe.BalanceTransaction) {
175
+ const monthDate = getDateStartDate(new Date(balanceTransaction.created * 1000));
176
+ let currentAmount = this.mainAccount.get(Formatter.dateIso(monthDate));
177
+ if (currentAmount === undefined) {
178
+ currentAmount = new StripeMainAccountReport(monthDate);
179
+ this.mainAccount.set(Formatter.dateIso(monthDate), currentAmount);
180
+ }
181
+ return currentAmount;
182
+ }
183
+
184
+ async add(balanceTransaction: Stripe.BalanceTransaction) {
185
+ this.check += fromStripeAmount(balanceTransaction.amount);
186
+
187
+ if (balanceTransaction.type === 'application_fee') {
188
+ const source = balanceTransaction.source as Stripe.ApplicationFee;
189
+ const account = typeof source.account === 'string' ? source.account : source.account.id;
190
+
191
+ if (account) {
192
+ const currentAmount = await this.getAccountReport(balanceTransaction, account);
193
+ currentAmount.applicationFees += fromStripeAmount(source.amount);
194
+ currentAmount.addDate(new Date(balanceTransaction.created * 1000));
195
+ } else {
196
+ throw new Error('No account found for application fee ' + balanceTransaction.id);
197
+ }
198
+
199
+ // Handled
200
+ return;
201
+ }
202
+
203
+ if (balanceTransaction.type === 'application_fee_refund') {
204
+ // The source is a fee refund: the connected account is stored on the refunded application fee itself
205
+ const source = balanceTransaction.source as Stripe.FeeRefund;
206
+ const fee = typeof source.fee === 'string' || !source.fee ? null : source.fee;
207
+
208
+ if (!fee) {
209
+ throw new Error('Received unexpanded fee for refunded application fee ' + balanceTransaction.id);
210
+ }
211
+
212
+ const account = typeof fee.account === 'string' ? fee.account : fee.account.id;
213
+
214
+ if (account) {
215
+ const currentAmount = await this.getAccountReport(balanceTransaction, account);
216
+ currentAmount.applicationFeesRefunded += fromStripeAmount(source.amount);
217
+ currentAmount.addDate(new Date(balanceTransaction.created * 1000));
218
+ } else {
219
+ throw new Error('No account found for refunded application fee ' + balanceTransaction.id);
220
+ }
221
+
222
+ // Handled
223
+ return;
224
+ }
225
+
226
+ // Local payment method = payment, card = charge
227
+ if (balanceTransaction.type === 'payment' || balanceTransaction.type === 'charge') {
228
+ const source = balanceTransaction.source;
229
+ if (typeof source === 'string' || !source) {
230
+ console.error('Unexpected charge', balanceTransaction);
231
+ throw new Error('Received unexpanded source for charge ' + balanceTransaction.id);
232
+ }
233
+ const charge = source as Stripe.Charge;
234
+ const account = charge.on_behalf_of ? (typeof charge.on_behalf_of === 'string' ? charge.on_behalf_of : charge.on_behalf_of.id) : null;
235
+
236
+ if (account) {
237
+ const currentAmount = await this.getAccountReport(balanceTransaction, account);
238
+ currentAmount.charges += fromStripeAmount(charge.amount);
239
+ currentAmount.addDate(new Date(balanceTransaction.created * 1000));
240
+ // Costs on main account
241
+ this.getMainAccountReport(balanceTransaction).fees += fromStripeAmount(balanceTransaction.fee);
242
+ } else {
243
+ console.error('Unexpected charge without charge.on_behalf_of', balanceTransaction);
244
+ throw new Error('No account found for charge ' + balanceTransaction.id);
245
+ }
246
+
247
+ // Handled
248
+ return;
249
+ }
250
+
251
+ // Local payment method = payment_refund, card = refund
252
+ if (balanceTransaction.type === 'payment_refund' || balanceTransaction.type === 'refund') {
253
+ const source = balanceTransaction.source;
254
+ if (typeof source === 'string' || !source) {
255
+ console.error('Unexpected refund', balanceTransaction);
256
+ throw new Error('Received unexpanded source for refund ' + balanceTransaction.id);
257
+ }
258
+
259
+ // The source is a refund: the connected account is stored on the refunded charge
260
+ const refund = source as Stripe.Refund;
261
+ const charge = typeof refund.charge === 'string' || !refund.charge ? null : refund.charge;
262
+
263
+ if (!charge) {
264
+ console.error('Unexpected refund', balanceTransaction);
265
+ throw new Error('Received unexpanded charge for refund ' + balanceTransaction.id);
266
+ }
267
+
268
+ const account = charge.on_behalf_of ? (typeof charge.on_behalf_of === 'string' ? charge.on_behalf_of : charge.on_behalf_of.id) : null;
269
+
270
+ if (account) {
271
+ const currentAmount = await this.getAccountReport(balanceTransaction, account);
272
+ currentAmount.refunds += fromStripeAmount(refund.amount);
273
+ currentAmount.addDate(new Date(balanceTransaction.created * 1000));
274
+ // Costs on main account
275
+ this.getMainAccountReport(balanceTransaction).fees += fromStripeAmount(balanceTransaction.fee);
276
+ } else {
277
+ console.error('Unexpected refund without charge.on_behalf_of', balanceTransaction);
278
+ throw new Error('No account found for refund ' + balanceTransaction.id);
279
+ }
280
+
281
+ // Handled
282
+ return;
283
+ }
284
+
285
+ if (balanceTransaction.type === 'transfer') {
286
+ // amount is negative here
287
+ // use source.destination to get related account id
288
+
289
+ const source = balanceTransaction.source as Stripe.Transfer;
290
+
291
+ if (!source.destination) {
292
+ // This is a charge on the main account
293
+ throw new Error('Unexpected transfer on main account without destination account ' + balanceTransaction.id);
294
+ }
295
+
296
+ const account = typeof source.destination === 'string' ? source.destination : source.destination.id;
297
+
298
+ if (account) {
299
+ const currentAmount = await this.getAccountReport(balanceTransaction, account);
300
+ currentAmount.transfers += fromStripeAmount(-balanceTransaction.amount);
301
+ currentAmount.addDate(new Date(balanceTransaction.created * 1000));
302
+ } else {
303
+ throw new Error('No account found for transfer ' + balanceTransaction.id);
304
+ }
305
+
306
+ // Handled
307
+ return;
308
+ }
309
+
310
+ if (balanceTransaction.type === 'transfer_cancel' || balanceTransaction.type === 'transfer_failure' || balanceTransaction.type === 'transfer_refund') {
311
+ const source = balanceTransaction.source as Stripe.Transfer;
312
+
313
+ if (!source.destination) {
314
+ // This is a charge on the main account
315
+ throw new Error('Unexpected transfer refund on main account without destination account ' + balanceTransaction.id);
316
+ }
317
+
318
+ const account = typeof source.destination === 'string' ? source.destination : source.destination.id;
319
+
320
+ if (account) {
321
+ const currentAmount = await this.getAccountReport(balanceTransaction, account);
322
+ currentAmount.transfersRefunded += fromStripeAmount(balanceTransaction.amount);
323
+ currentAmount.addDate(new Date(balanceTransaction.created * 1000));
324
+ } else {
325
+ throw new Error('No account found for transfer refund ' + balanceTransaction.id);
326
+ }
327
+
328
+ // Handled
329
+ return;
330
+ }
331
+
332
+ // Network costs
333
+ if (balanceTransaction.type === 'stripe_fee' || (balanceTransaction.type as string) === 'network_cost') {
334
+ this.getMainAccountReport(balanceTransaction).fees += fromStripeAmount(-balanceTransaction.amount);
335
+ return;
336
+ }
337
+
338
+ // Disputes (adjustment)
339
+ if (balanceTransaction.type === 'adjustment') {
340
+ this.getMainAccountReport(balanceTransaction).fees += fromStripeAmount(balanceTransaction.fee);
341
+ const source = balanceTransaction.source;
342
+
343
+ if (typeof source === 'string') {
344
+ throw new Error('Received unexpanded source for adjustment ' + balanceTransaction.id);
345
+ }
346
+
347
+ if (source && source.object === 'dispute') {
348
+ console.error('Unexpected dispute without charge or destination ', balanceTransaction);
349
+ throw new Error('Unexpected dispute without charge or destination ' + balanceTransaction.id);
350
+ }
351
+
352
+ return;
353
+ }
354
+
355
+ if (balanceTransaction.type === 'payout') {
356
+ // Nothing to process
357
+ return;
358
+ }
359
+
360
+ if (balanceTransaction.type === 'reserve_transaction') {
361
+ // temporary blocked funds
362
+ this.getMainAccountReport(balanceTransaction).reserved += fromStripeAmount(-balanceTransaction.amount);
363
+ return;
364
+ }
365
+
366
+ // Throw an error for types we don't support yet
367
+ throw new Error('Unhandled balance transaction type ' + balanceTransaction.type + ' ' + balanceTransaction.id);
368
+ }
369
+ }
370
+
371
+ export class StripePayoutReport {
372
+ payout: Stripe.Payout;
373
+ sellingOrganization: Organization;
374
+ report: StripeReport;
375
+ callback?: () => void;
376
+
377
+ constructor(payout: Stripe.Payout, sellingOrganization: Organization) {
378
+ this.payout = payout;
379
+ this.sellingOrganization = sellingOrganization;
380
+ this.report = new StripeReport(sellingOrganization);
381
+ }
382
+
383
+ async build(stripe: Stripe) {
384
+ const report = new StripeReport(this.sellingOrganization);
385
+ let c = 0;
386
+
387
+ for await (const balanceItem of stripe.balanceTransactions.list({
388
+ payout: this.payout.id,
389
+ // charge = for refunds, fee = for application fee refunds
390
+ expand: ['data.source.charge', 'data.source.fee'],
391
+ limit: 100,
392
+ })) {
393
+ await report.add(balanceItem);
394
+ c += 1;
395
+
396
+ if (c % 100 === 0) {
397
+ console.log('Processed ' + c + ' balance items');
398
+ }
399
+
400
+ if (this.callback) {
401
+ this.callback();
402
+ }
403
+ }
404
+
405
+ this.report = report;
406
+ }
407
+ }
408
+
409
+ export class StripePayoutReporter {
410
+ private stripe: Stripe;
411
+ sellingOrganization: Organization;
412
+ reports: StripePayoutReport[] = [];
413
+ startDate: Date = new Date();
414
+ endDate: Date = new Date();
415
+ callback?: () => void;
416
+
417
+ constructor({ secretKey, sellingOrganization }: { secretKey: string; sellingOrganization: Organization }) {
418
+ this.stripe = new Stripe(secretKey, {
419
+ apiVersion: '2024-06-20',
420
+ typescript: true,
421
+ maxNetworkRetries: 1,
422
+ timeout: 10000,
423
+ httpClient: STAMHOOFD.environment === 'test'
424
+ ? Stripe.createFetchHttpClient(passthroughFetch)
425
+ : undefined,
426
+ });
427
+ this.sellingOrganization = sellingOrganization;
428
+ }
429
+
430
+ // Loop all payouts
431
+ async build(start: Date, end: Date) {
432
+ this.startDate = start;
433
+ this.endDate = end;
434
+
435
+ // Also pull in payouts one month before and after, so we don't have any missing data for the requested payouts
436
+ const payoutStartDate = new Date(start.getFullYear(), start.getMonth() - 1, 1, 0, 0, 0, 0);
437
+ const payoutEndDate = new Date(end.getFullYear(), end.getMonth() + 2, 1, 0, 0, 0, 0);
438
+
439
+ const params: Stripe.PayoutListParams = {
440
+ status: 'paid',
441
+ arrival_date: {
442
+ gte: Math.floor(payoutStartDate.getTime() / 1000),
443
+ lte: Math.floor(payoutEndDate.getTime() / 1000),
444
+ },
445
+ };
446
+
447
+ const pendingPromises: Promise<void>[] = [];
448
+
449
+ for await (const payout of this.stripe.payouts.list(params)) {
450
+ pendingPromises.push(QueueHandler.schedule('stripe-payout-report', async () => {
451
+ console.log('Building report for payout ' + payout.id);
452
+ // Create a report for this payout
453
+ const report = new StripePayoutReport(payout, this.sellingOrganization);
454
+ report.callback = this.callback;
455
+ await report.build(this.stripe);
456
+ this.reports.push(report);
457
+ }, 10));
458
+ }
459
+
460
+ await Promise.all(pendingPromises);
461
+ }
462
+
463
+ toStructure(): StripePayoutExportData {
464
+ // Convert report to Stripe payout export
465
+ const payoutExport = new StripePayoutExportData({
466
+ start: this.startDate,
467
+ end: this.endDate,
468
+ });
469
+
470
+ for (const report of this.reports) {
471
+ const breakdown = new StripePayoutBreakdownData({
472
+ payout: new StripePayoutData({
473
+ id: report.payout.id,
474
+ amount: fromStripeAmount(report.payout.amount),
475
+ arrivalDate: new Date(report.payout.arrival_date * 1000),
476
+ statementDescriptor: report.payout.statement_descriptor ?? '/',
477
+ }),
478
+ });
479
+ payoutExport.payouts.push(breakdown);
480
+
481
+ // Add total costs
482
+ for (const mainAccount of report.report.mainAccount.values()) {
483
+ if (mainAccount.fees) {
484
+ breakdown.items.push(new StripePayoutItemData({
485
+ name: 'Stripe Factuur',
486
+ type: StripePayoutItemType.StripeFees,
487
+ amount: -mainAccount.fees,
488
+ description: mainAccount.description,
489
+ }));
490
+ }
491
+
492
+ if (mainAccount.reserved) {
493
+ breakdown.items.push(new StripePayoutItemData({
494
+ name: 'Stripe Gereserveerd',
495
+ type: StripePayoutItemType.StripeReserved,
496
+ amount: -mainAccount.reserved,
497
+ description: mainAccount.description,
498
+ }));
499
+ }
500
+ }
501
+
502
+ // Add each individual account
503
+ for (const account of report.report.accounts.values()) {
504
+ if (account.toInvoiceAmount === 0 && account.invoices.length === 0 && account.payments.length === 0) {
505
+ continue;
506
+ }
507
+ const description = (account.organization?.name ?? 'Onbekende vereniging') + ' - ' + (account.minimumDate ? Formatter.dateTime(account.minimumDate) : '?') + ' tot ' + (account.maximumDate ? Formatter.dateTime(account.maximumDate) : '?');
508
+
509
+ breakdown.items.push(new StripePayoutItemData({
510
+ name: this.getAccountItemName(account),
511
+ type: StripePayoutItemType.Invoice,
512
+ amount: account.toInvoiceAmount,
513
+ description,
514
+ accountId: account.accountId,
515
+ reference: account.reference,
516
+ payments: account.payments,
517
+ invoices: account.invoices,
518
+ }));
519
+ }
520
+ }
521
+
522
+ return payoutExport;
523
+ }
524
+
525
+ private getAccountItemName(account: StripeAccountReport) {
526
+ if (account.payments.length === 0) {
527
+ return 'Nog geen betaling aangemaakt';
528
+ }
529
+
530
+ const numbers = account.invoices.map(i => i.number).filter(n => n !== null);
531
+ if (numbers.length === 0) {
532
+ return 'Betaling aangemaakt, nog niet gefactureerd';
533
+ }
534
+
535
+ const name = 'Factuur ' + numbers.join(', ');
536
+ if (account.payments.some(p => p.invoiceId === null)) {
537
+ return name + ' (deels nog niet gefactureerd)';
538
+ }
539
+ return name;
540
+ }
541
+
542
+ async toExcel(pExport?: StripePayoutExportData) {
543
+ // Convert report to Stripe payout export
544
+ const payoutExport = pExport ?? this.toStructure();
545
+ const buffer = await StripePayoutExportExcel.export(payoutExport);
546
+ return buffer;
547
+ }
548
+
549
+ async sendEmail({ to, extraRecipientsWhenValid }: { to: EmailInterfaceRecipient[]; extraRecipientsWhenValid?: EmailInterfaceRecipient[] }): Promise<void> {
550
+ // E-mail the report excel
551
+ const payoutExport = this.toStructure();
552
+ const buffer = await this.toExcel(payoutExport);
553
+ const startMonth = Formatter.dateWithoutDay(this.startDate, { timezone: 'UTC' });
554
+ const endMonth = Formatter.dateWithoutDay(this.endDate, { timezone: 'UTC' });
555
+ const subject = 'Stripe Uitbetalingen ' + startMonth + ((endMonth !== startMonth) ? (' - ' + endMonth) : '');
556
+
557
+ const completePayouts = payoutExport.completePayouts;
558
+ const sellerName = this.sellingOrganization.meta.companies[0]?.name ?? this.sellingOrganization.name;
559
+
560
+ Email.send({
561
+ from: Email.getWebmasterFromEmail(),
562
+ to: payoutExport.isValid
563
+ ? [...to, ...(extraRecipientsWhenValid ?? [])]
564
+ : to,
565
+ subject: subject,
566
+ text: 'In bijlage het overzicht van de Stripe uitbetalingen van ' + Formatter.dateTime(this.startDate) + ' tot ' + Formatter.dateTime(this.endDate) + ' voor ' + sellerName + '.\n'
567
+ + (payoutExport.isValid ? '' : ('\nRAPPORT ONVOLLEDIG!\n'))
568
+ + '\nTotaal uitbetaald: ' + Formatter.price(payoutExport.totalPaidOut) + ` (${completePayouts.length} uitbetalingen)`
569
+ + '\nTotaal Stripe Kosten: ' + Formatter.price(payoutExport.totalStripeFees)
570
+ + '\nTotaal doorgefactureerd aan klanten: ' + Formatter.price(payoutExport.totalInvoices) + ' (incl. BTW)'
571
+ + '\nGeschatte BTW terug te betalen: ' + Formatter.price(payoutExport.totalVAT)
572
+ + '\nGeschatte winst: ' + Formatter.price(payoutExport.net)
573
+ // Only count the payouts in the requested period: extra payouts are fetched around it to complete the data
574
+ + '\n\nOvergeslagen uitbetalingen (onvolledig): ' + (payoutExport.includedPayouts.length - completePayouts.length) + '\n',
575
+ type: 'transactional',
576
+ attachments: [
577
+ {
578
+ filename: Formatter.fileSlug(subject) + '.xlsx',
579
+ content: buffer,
580
+ contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
581
+ },
582
+ ],
583
+ });
584
+ }
585
+ }
@@ -0,0 +1,25 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Platform } from '@stamhoofd/models';
3
+
4
+ export default new Migration(async () => {
5
+ if (STAMHOOFD.environment === 'test') {
6
+ console.log('skipped in tests');
7
+ return;
8
+ }
9
+
10
+ const platform = await Platform.getForEditing();
11
+
12
+ if (!platform.config.featureFlags.includes('uitpas')) {
13
+ console.log('Platform does not have the uitpas feature flag, nothing to migrate');
14
+ return;
15
+ }
16
+
17
+ // The uitpas (UiTPAS social tariff on webshops) feature flag is now controlled
18
+ // per organization instead of platform-wide. Remove it from the platform config
19
+ // so it no longer implicitly enables the feature for every organization.
20
+ platform.config.featureFlags = platform.config.featureFlags.filter(f => f !== 'uitpas');
21
+ await platform.save();
22
+ await Platform.clearCache();
23
+
24
+ console.log('Removed the uitpas feature flag from the platform config');
25
+ });
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+
3
+ export default new Migration(async () => {
4
+ if (STAMHOOFD.environment === 'test') {
5
+ // The backfill is covered directly by RegistrationPeriodRelationBackfiller.test.ts
6
+ console.log('skipped in tests');
7
+ return;
8
+ }
9
+
10
+ // Imported dynamically so the migration loader does not need to resolve the helper at link time.
11
+ const { backfillRegistrationPeriodRelations } = await import('../helpers/RegistrationPeriodRelationBackfiller.js');
12
+ await backfillRegistrationPeriodRelations();
13
+ });