@stamhoofd/backend 2.130.0 → 2.132.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 (42) 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/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  6. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  12. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +1 -1
  14. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  16. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  17. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  18. package/src/helpers/AuthenticatedStructures.ts +2 -2
  19. package/src/helpers/CheckSettlements.test.ts +190 -0
  20. package/src/helpers/CheckSettlements.ts +73 -45
  21. package/src/helpers/MembershipCharger.ts +26 -1
  22. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  23. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  24. package/src/helpers/StripePayoutExportData.ts +195 -0
  25. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  26. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  27. package/src/helpers/StripePayoutReporter.ts +585 -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/searchUitpasOrganizers.ts +2 -1
  37. package/tests/e2e/documents.test.ts +3 -1
  38. package/tests/helpers/MollieMocker.ts +73 -0
  39. package/tests/vitest.global.setup.ts +1 -1
  40. package/tests/vitest.setup.ts +4 -2
  41. package/vitest.config.js +10 -0
  42. /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
@@ -0,0 +1,280 @@
1
+ import type { CellValue } from '@stamhoofd/excel-writer';
2
+ import { ArchiverWriterAdapter, XlsxBuiltInNumberFormat, XlsxWriter } from '@stamhoofd/excel-writer';
3
+ import { Sorter } from '@stamhoofd/utility';
4
+ import { Writable } from 'node:stream';
5
+
6
+ import type { StripePayoutBreakdownData, StripePayoutData, StripePayoutExportData, StripePayoutItemData } from './StripePayoutExportData.js';
7
+ import { StripePayoutItemType } from './StripePayoutExportData.js';
8
+
9
+ function currencyCell(amount: number | null, width?: number): CellValue {
10
+ return {
11
+ value: amount === null ? null : amount / 1_0000,
12
+ width,
13
+ style: {
14
+ numberFormat: {
15
+ id: XlsxBuiltInNumberFormat.Currency2DecimalWithoutRed,
16
+ },
17
+ },
18
+ };
19
+ }
20
+
21
+ function dateCell(date: Date | null, width?: number): CellValue {
22
+ return {
23
+ value: date,
24
+ width,
25
+ style: {
26
+ numberFormat: {
27
+ id: XlsxBuiltInNumberFormat.DateSlash,
28
+ },
29
+ },
30
+ };
31
+ }
32
+
33
+ function textCell(value: string, width?: number): CellValue {
34
+ return { value, width };
35
+ }
36
+
37
+ const empty = textCell('');
38
+
39
+ export class StripePayoutExportExcel {
40
+ /**
41
+ * List of all items for every payout
42
+ */
43
+ private static async writePayouts(writer: XlsxWriter, sheet: symbol, includedPayouts: StripePayoutBreakdownData[]) {
44
+ await writer.addRow(sheet, [
45
+ textCell('Datum', 13),
46
+ textCell('Uitbetaald bedrag', 16),
47
+ textCell('Naam', 45),
48
+ textCell('Bedrag', 13),
49
+ textCell('Beschrijving', 60),
50
+ ]);
51
+
52
+ for (const breakdown of [...includedPayouts].sort((b, a) => Sorter.byDateValue(a.payout.arrivalDate, b.payout.arrivalDate))) {
53
+ for (const [index, item] of [...breakdown.items].sort((a, b) => Sorter.byStringValue(a.name, b.name)).entries()) {
54
+ await writer.addRow(sheet, [
55
+ index > 0 ? empty : dateCell(breakdown.payout.arrivalDate),
56
+ index > 0 ? empty : currencyCell(breakdown.payout.amount),
57
+ textCell(item.name),
58
+ currencyCell(item.amount),
59
+ textCell(item.description),
60
+ ]);
61
+ }
62
+
63
+ // Include total line
64
+ const total = breakdown.items.reduce((total, item) => total + item.amount, 0);
65
+ await writer.addRow(sheet, [
66
+ empty,
67
+ empty,
68
+ textCell('Totaal'),
69
+ currencyCell(total),
70
+ textCell(total === breakdown.payout.amount ? '✓' : 'Klopt niet!'),
71
+ ]);
72
+
73
+ // Empty line
74
+ await writer.addRow(sheet, []);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Group by account + month (= payment reference): shows the connected payments and invoices
80
+ */
81
+ private static async writeInvoices(writer: XlsxWriter, sheet: symbol, includedPayouts: StripePayoutBreakdownData[], allPayouts: StripePayoutBreakdownData[]) {
82
+ await writer.addRow(sheet, [
83
+ textCell('Referentie', 22),
84
+ textCell('Factuur', 35),
85
+ textCell('Factuurdatum', 14),
86
+ textCell('Factuurbedrag (incl. BTW)', 22),
87
+ textCell('Aangerekend bedrag', 18),
88
+ textCell('Uitbetalingsdatum', 16),
89
+ textCell('Uitbetaald bedrag', 16),
90
+ textCell('Opmerking', 25),
91
+ textCell('Klant', 30),
92
+ textCell('Ondernemingsnummer', 20),
93
+ textCell('BTW-nummer', 16),
94
+ textCell('Adres', 40),
95
+ ]);
96
+
97
+ // Group all data by account + reference
98
+ const groups = new Map<string, {
99
+ item: StripePayoutItemData;
100
+ items: { item: StripePayoutItemData; payout: StripePayoutData }[];
101
+ }>();
102
+
103
+ for (const breakdown of allPayouts) {
104
+ for (const item of breakdown.items) {
105
+ if (item.type !== StripePayoutItemType.Invoice) {
106
+ continue;
107
+ }
108
+
109
+ const key = (item.accountId ?? '') + '-' + (item.reference ?? '');
110
+ const group = groups.get(key) ?? {
111
+ item,
112
+ items: [],
113
+ };
114
+ group.items.push({
115
+ item,
116
+ payout: breakdown.payout,
117
+ });
118
+ groups.set(key, group);
119
+ }
120
+ }
121
+
122
+ const sortedGroups = [...groups.values()].sort((a, b) => Sorter.stack(
123
+ Sorter.byStringValue(a.item.reference ?? '', b.item.reference ?? ''),
124
+ Sorter.byStringValue(a.item.description, b.item.description),
125
+ ));
126
+
127
+ for (const group of sortedGroups) {
128
+ // Skip groups that don't have any payout in the requested period
129
+ if (!group.items.some(({ payout }) => includedPayouts.some(p => p.payout.id === payout.id))) {
130
+ continue;
131
+ }
132
+
133
+ const item = group.item;
134
+ const invoices = item.invoices;
135
+ const customer = invoices[0]?.customer ?? item.payments[0]?.customer ?? null;
136
+
137
+ const invoiceTotal = invoices.reduce((total, invoice) => total + invoice.totalWithVAT, 0);
138
+ const invoiceDate = invoices[0]?.invoicedAt ?? null;
139
+
140
+ for (const [index, { item: payoutItem, payout }] of [...group.items].sort((b, a) => Sorter.byDateValue(a.payout.arrivalDate, b.payout.arrivalDate)).entries()) {
141
+ await writer.addRow(sheet, [
142
+ index > 0 ? empty : textCell(item.reference ?? ''),
143
+ index > 0 ? empty : textCell(item.name),
144
+ index > 0 ? empty : dateCell(invoiceDate),
145
+ index > 0 ? empty : (invoices.length === 0 ? empty : currencyCell(invoiceTotal)),
146
+ index > 0 ? empty : (item.payments.length === 0 ? empty : currencyCell(item.paymentsTotal)),
147
+ dateCell(payout.arrivalDate),
148
+ currencyCell(payoutItem.amount),
149
+ empty,
150
+ index > 0 ? empty : textCell(customer?.dynamicName ?? item.description),
151
+ index > 0 ? empty : textCell(customer?.company?.companyNumber ?? '/'),
152
+ index > 0 ? empty : textCell(customer?.company?.VATNumber ?? '/'),
153
+ index > 0 ? empty : textCell(customer?.company?.address ? customer.company.address + '' : '/'),
154
+ ]);
155
+ }
156
+
157
+ // Include total line
158
+ const total = group.items.reduce((total, { item }) => total + item.amount, 0);
159
+ let remark = 'Nog geen betaling aangemaakt';
160
+ if (item.payments.length > 0) {
161
+ if (total !== item.paymentsTotal) {
162
+ remark = 'Nog onvolledig';
163
+ } else if (item.hasUninvoicedPayments || invoices.length === 0) {
164
+ remark = 'Nog niet gefactureerd';
165
+ } else {
166
+ remark = '✓';
167
+ }
168
+ }
169
+
170
+ await writer.addRow(sheet, [
171
+ empty,
172
+ empty,
173
+ empty,
174
+ empty,
175
+ empty,
176
+ textCell('Totaal'),
177
+ currencyCell(total),
178
+ textCell(remark),
179
+ ]);
180
+
181
+ // Empty line
182
+ await writer.addRow(sheet, []);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Group Stripe fees by month
188
+ */
189
+ private static async writeStripeInvoices(writer: XlsxWriter, sheet: symbol, includedPayouts: StripePayoutBreakdownData[], allPayouts: StripePayoutBreakdownData[]) {
190
+ await writer.addRow(sheet, [
191
+ textCell('Periode', 25),
192
+ textCell('Afgehouden bedrag', 18),
193
+ textCell('Uitbetalingsdatum', 16),
194
+ textCell('Beschrijving', 25),
195
+ ]);
196
+
197
+ // Group all data by Stripe invoice period
198
+ const groupedInvoices = new Map<string, { item: StripePayoutItemData; payout: StripePayoutData }[]>();
199
+
200
+ for (const breakdown of allPayouts) {
201
+ for (const item of breakdown.items) {
202
+ if (item.type !== StripePayoutItemType.StripeFees) {
203
+ continue;
204
+ }
205
+
206
+ const groupName = item.description;
207
+ const list = groupedInvoices.get(groupName) ?? [];
208
+ groupedInvoices.set(groupName, list);
209
+ list.push({
210
+ item,
211
+ payout: breakdown.payout,
212
+ });
213
+ }
214
+ }
215
+
216
+ for (const [period, group] of groupedInvoices) {
217
+ // Do not include invoices that have no payout in the period
218
+ if (!group.find(g => includedPayouts.find(p => p.payout.id === g.payout.id))) {
219
+ // Likely not completely fetched: don't include it
220
+ continue;
221
+ }
222
+
223
+ for (const [index, item] of group.entries()) {
224
+ await writer.addRow(sheet, [
225
+ index > 0 ? empty : textCell(period),
226
+ currencyCell(-item.item.amount),
227
+ dateCell(item.payout.arrivalDate),
228
+ empty,
229
+ ]);
230
+ }
231
+
232
+ // Include total line
233
+ const total = -group.reduce((total, item) => total + item.item.amount, 0);
234
+ await writer.addRow(sheet, [
235
+ empty,
236
+ currencyCell(total),
237
+ empty,
238
+ textCell('Totaal'),
239
+ ]);
240
+
241
+ // Empty line
242
+ await writer.addRow(sheet, []);
243
+ }
244
+ }
245
+
246
+ static async export(e: StripePayoutExportData): Promise<Buffer> {
247
+ const chunks: Buffer[] = [];
248
+ const output = new Writable({
249
+ write(chunk: Buffer, _encoding, callback) {
250
+ chunks.push(chunk);
251
+ callback();
252
+ },
253
+ });
254
+ const finishPromise = new Promise<void>((resolve, reject) => {
255
+ output.on('finish', () => resolve());
256
+ output.on('error', reject);
257
+ });
258
+
259
+ const zipWriterAdapter = new ArchiverWriterAdapter(output);
260
+ const writer = new XlsxWriter(zipWriterAdapter);
261
+
262
+ const payoutsSheet = await writer.addSheet('Uitbetalingen');
263
+ const invoicesSheet = await writer.addSheet('Facturen');
264
+ const stripeInvoicesSheet = await writer.addSheet('Stripe Facturen');
265
+ await writer.ready();
266
+
267
+ try {
268
+ await this.writePayouts(writer, payoutsSheet, e.includedPayouts);
269
+ await this.writeInvoices(writer, invoicesSheet, e.includedPayouts, e.payouts);
270
+ await this.writeStripeInvoices(writer, stripeInvoicesSheet, e.includedPayouts, e.payouts);
271
+ await writer.close();
272
+ } catch (error) {
273
+ await writer.abort();
274
+ throw error;
275
+ }
276
+
277
+ await finishPromise;
278
+ return Buffer.concat(chunks);
279
+ }
280
+ }