@stamhoofd/backend 2.130.0 → 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 (35) 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/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  13. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  14. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  15. package/src/helpers/MembershipCharger.ts +26 -1
  16. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  17. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  18. package/src/helpers/StripePayoutExportData.ts +195 -0
  19. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  20. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  21. package/src/helpers/StripePayoutReporter.ts +585 -0
  22. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  23. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  24. package/src/services/uitpas/UitpasService.ts +1 -1
  25. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  26. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  27. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  28. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  29. package/src/services/uitpas/registerTicketSales.ts +1 -1
  30. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  31. package/tests/e2e/documents.test.ts +3 -1
  32. package/tests/vitest.global.setup.ts +1 -1
  33. package/tests/vitest.setup.ts +4 -2
  34. package/vitest.config.js +10 -0
  35. /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
@@ -0,0 +1,195 @@
1
+ import type { Invoice, Payment } from '@stamhoofd/models';
2
+
3
+ export enum StripePayoutItemType {
4
+ Invoice = 'Invoice',
5
+ StripeFees = 'StripeFees',
6
+ StripeReserved = 'StripeReserved',
7
+ }
8
+
9
+ export class StripePayoutItemData {
10
+ name = '';
11
+ type = StripePayoutItemType.Invoice;
12
+
13
+ /**
14
+ * In platform price units (1/100 cent)
15
+ */
16
+ amount = 0;
17
+ description = '';
18
+
19
+ /**
20
+ * Stripe account id + payment reference: only set for Invoice items, used to group the same
21
+ * account/month combination across multiple payouts.
22
+ */
23
+ accountId: string | null = null;
24
+ reference: string | null = null;
25
+
26
+ /**
27
+ * Payments created in the database for these application fees (can be uninvoiced)
28
+ */
29
+ payments: Payment[] = [];
30
+
31
+ /**
32
+ * Invoices connected to those payments (only invoices with a number)
33
+ */
34
+ invoices: Invoice[] = [];
35
+
36
+ constructor(data: Partial<StripePayoutItemData>) {
37
+ Object.assign(this, data);
38
+ }
39
+
40
+ get paymentsTotal() {
41
+ return this.payments.reduce((total, payment) => total + payment.price, 0);
42
+ }
43
+
44
+ get hasUninvoicedPayments() {
45
+ return this.payments.some(p => p.invoiceId === null);
46
+ }
47
+ }
48
+
49
+ export class StripePayoutData {
50
+ id: string;
51
+ amount: number; // in platform price units (1/100 cent)
52
+ arrivalDate: Date;
53
+ statementDescriptor: string;
54
+
55
+ constructor(data: { id: string; amount: number; arrivalDate: Date; statementDescriptor: string }) {
56
+ this.id = data.id;
57
+ this.amount = data.amount;
58
+ this.arrivalDate = data.arrivalDate;
59
+ this.statementDescriptor = data.statementDescriptor;
60
+ }
61
+ }
62
+
63
+ export class StripePayoutBreakdownData {
64
+ payout: StripePayoutData;
65
+ items: StripePayoutItemData[] = [];
66
+
67
+ constructor(data: { payout: StripePayoutData; items?: StripePayoutItemData[] }) {
68
+ this.payout = data.payout;
69
+ this.items = data.items ?? [];
70
+ }
71
+
72
+ /**
73
+ * Whether the payout amount matches the sum of the items
74
+ */
75
+ get isValid() {
76
+ return this.payout.amount === this.items.reduce((total, item) => total + item.amount, 0);
77
+ }
78
+
79
+ isComplete(payoutExport: StripePayoutExportData) {
80
+ for (const item of this.items) {
81
+ if (item.type !== StripePayoutItemType.Invoice) {
82
+ continue;
83
+ }
84
+
85
+ if (!payoutExport.isItemComplete(item)) {
86
+ return false;
87
+ }
88
+ }
89
+ return true;
90
+ }
91
+ }
92
+
93
+ export class StripePayoutExportData {
94
+ /**
95
+ * All fetched payouts (we need to fetch more payouts than requested in order to complete all information,
96
+ * because the fees of a given month might have been paid out in other payouts than the requested ones)
97
+ */
98
+ payouts: StripePayoutBreakdownData[] = [];
99
+
100
+ start: Date;
101
+ end: Date;
102
+
103
+ constructor(data: { start: Date; end: Date }) {
104
+ this.start = data.start;
105
+ this.end = data.end;
106
+ }
107
+
108
+ get includedPayouts() {
109
+ return this.payouts.filter(p => p.payout.arrivalDate >= this.start && p.payout.arrivalDate <= this.end);
110
+ }
111
+
112
+ /**
113
+ * All payouts for which all application fees have a matching payment that has been invoiced
114
+ */
115
+ get completePayouts() {
116
+ return this.includedPayouts.filter(p => p.isComplete(this));
117
+ }
118
+
119
+ /**
120
+ * An item is complete if all application fees for the account + month are covered by
121
+ * created payments, and all of those payments have been invoiced.
122
+ */
123
+ isItemComplete(item: StripePayoutItemData) {
124
+ if (item.type !== StripePayoutItemType.Invoice) {
125
+ return true;
126
+ }
127
+
128
+ if (item.payments.length === 0) {
129
+ return false;
130
+ }
131
+
132
+ if (item.hasUninvoicedPayments || item.invoices.length === 0) {
133
+ return false;
134
+ }
135
+
136
+ // The sum of the application fees across all fetched payouts should equal the total amount
137
+ // of the created payments for this account + month
138
+ const totalAcrossPayouts = this.payouts
139
+ .flatMap(p => p.items)
140
+ .filter(i => i.accountId === item.accountId && i.reference === item.reference)
141
+ .reduce((total, i) => total + i.amount, 0);
142
+
143
+ return totalAcrossPayouts === item.paymentsTotal;
144
+ }
145
+
146
+ get totalPaidOut() {
147
+ return this.completePayouts.reduce((total, payout) => total + payout.payout.amount, 0);
148
+ }
149
+
150
+ get totalStripeFees() {
151
+ return this.completePayouts.reduce((total, payout) => total + payout.items.filter(i => i.type === StripePayoutItemType.StripeFees).reduce((total, item) => total - item.amount, 0), 0);
152
+ }
153
+
154
+ get totalStripeReserved() {
155
+ return this.completePayouts.reduce((total, payout) => total + payout.items.filter(i => i.type === StripePayoutItemType.StripeReserved).reduce((total, item) => total - item.amount, 0), 0);
156
+ }
157
+
158
+ get totalInvoices() {
159
+ return this.completePayouts.reduce((total, payout) => total + payout.items.filter(i => i.type === StripePayoutItemType.Invoice).reduce((total, item) => total + item.amount, 0), 0);
160
+ }
161
+
162
+ get totalVAT() {
163
+ let VAT = 0;
164
+ for (const payout of this.completePayouts) {
165
+ for (const item of payout.items) {
166
+ if (item.type !== StripePayoutItemType.Invoice) {
167
+ continue;
168
+ }
169
+
170
+ const invoiceVAT = item.invoices.reduce((total, invoice) => total + invoice.VATTotalAmount, 0);
171
+ const invoiceTotal = item.invoices.reduce((total, invoice) => total + invoice.totalWithVAT, 0);
172
+
173
+ if (invoiceTotal === 0) {
174
+ continue;
175
+ }
176
+
177
+ // Estimate applicable VAT based on the VAT rate of the connected invoices
178
+ // (an invoice can contain more than only these fees, so we apply the rate, not the absolute amount)
179
+ VAT += invoiceVAT / invoiceTotal * item.amount;
180
+ }
181
+ }
182
+ return Math.round(VAT);
183
+ }
184
+
185
+ get net() {
186
+ return this.totalPaidOut - this.totalVAT;
187
+ }
188
+
189
+ get isValid() {
190
+ return this.totalPaidOut === this.totalInvoices - this.totalStripeFees - this.totalStripeReserved
191
+ // Aggregate totals can mask payout-level mismatches that cancel each other out
192
+ && this.includedPayouts.every(p => p.isValid)
193
+ && this.completePayouts.length === this.includedPayouts.length;
194
+ }
195
+ }
@@ -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
+ }