@stamhoofd/backend 2.140.0 → 2.141.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 (61) 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/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  11. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  12. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  15. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  16. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  17. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  20. package/src/excel-loaders/payments.ts +18 -1
  21. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  22. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  23. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  24. package/src/helpers/AuthenticatedStructures.ts +14 -1
  25. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  26. package/src/helpers/MollieSettlementSync.ts +323 -0
  27. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  28. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  29. package/src/helpers/SettlementExporter.test.ts +366 -0
  30. package/src/helpers/SettlementExporter.ts +583 -0
  31. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  32. package/src/helpers/SettlementSyncRunner.ts +64 -0
  33. package/src/helpers/StripeHelper.ts +2 -1
  34. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  35. package/src/helpers/StripeSettlementSync.ts +947 -0
  36. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  37. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  38. package/src/helpers/WebmasterReport.test.ts +109 -0
  39. package/src/helpers/WebmasterReport.ts +115 -0
  40. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  41. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  42. package/src/services/ApplicationFeeService.test.ts +256 -0
  43. package/src/services/ApplicationFeeService.ts +283 -0
  44. package/src/services/InvoiceService.ts +18 -1
  45. package/src/services/SettlementService.test.ts +559 -0
  46. package/src/services/SettlementService.ts +607 -0
  47. package/src/sql-filters/payment-settlement.test.ts +2 -2
  48. package/src/sql-filters/payments.ts +73 -0
  49. package/tests/helpers/MollieMocker.ts +64 -6
  50. package/tests/helpers/StripeMocker.ts +209 -17
  51. package/src/crons/stripe-payout-reports.ts +0 -69
  52. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  53. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  54. package/src/helpers/CheckSettlements.test.ts +0 -190
  55. package/src/helpers/CheckSettlements.ts +0 -237
  56. package/src/helpers/StripeInvoicer.ts +0 -419
  57. package/src/helpers/StripePayoutChecker.ts +0 -193
  58. package/src/helpers/StripePayoutExportData.ts +0 -195
  59. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  60. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  61. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,583 @@
1
+ import type { CellValue } from '@stamhoofd/excel-writer';
2
+ import { ArchiverWriterAdapter, XlsxBuiltInNumberFormat, XlsxWriter } from '@stamhoofd/excel-writer';
3
+ import type { EmailInterfaceRecipient } from '@stamhoofd/email';
4
+ import { Email } from '@stamhoofd/email';
5
+ import type { Organization } from '@stamhoofd/models';
6
+ import { BalanceItemPayment, Invoice, Payment } from '@stamhoofd/models';
7
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
8
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
9
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
10
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
11
+ import { SQL } from '@stamhoofd/sql';
12
+ import type { PaymentProvider } from '@stamhoofd/structures';
13
+ import { PaymentMethod } from '@stamhoofd/structures';
14
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
15
+ import { Formatter } from '@stamhoofd/utility';
16
+ import { Writable } from 'node:stream';
17
+
18
+ import { SettlementService } from '../services/SettlementService.js';
19
+
20
+ const SETTLEMENT_BATCH_SIZE = 100;
21
+
22
+ /**
23
+ * A settlement can hold as many payment lines and charges as it settled payments, so they are
24
+ * streamed in batches of this size.
25
+ */
26
+ const ROW_BATCH_SIZE = 500;
27
+
28
+ function currencyCell(amount: number | null, width?: number): CellValue {
29
+ return {
30
+ value: amount === null ? null : amount / 1_0000,
31
+ width,
32
+ style: {
33
+ numberFormat: {
34
+ id: XlsxBuiltInNumberFormat.Currency2DecimalWithoutRed,
35
+ },
36
+ },
37
+ };
38
+ }
39
+
40
+ function dateCell(date: Date | null, width?: number): CellValue {
41
+ return {
42
+ value: date,
43
+ width,
44
+ style: {
45
+ numberFormat: {
46
+ id: XlsxBuiltInNumberFormat.DateSlash,
47
+ },
48
+ },
49
+ };
50
+ }
51
+
52
+ function textCell(value: string, width?: number): CellValue {
53
+ return { value, width };
54
+ }
55
+
56
+ const empty = textCell('');
57
+
58
+ /**
59
+ * The totals of one invoice the exported organization receives. Amounts are stored as billed
60
+ * (positive).
61
+ */
62
+ type ProviderInvoiceTotals = {
63
+ invoicedBy: string;
64
+
65
+ /**
66
+ * The invoice id the charges are stamped with, or a derived month bucket while the invoice
67
+ * document doesn't exist yet (`stamped` tells them apart).
68
+ */
69
+ invoiceId: string;
70
+ stamped: boolean;
71
+
72
+ /**
73
+ * Whether the platform bills this itself, so the invoice behind it is one of ours to check.
74
+ */
75
+ isSellingOrganization: boolean;
76
+ transactionFees: number;
77
+ serviceFees: number;
78
+ accountFees: number;
79
+ vat: number;
80
+ };
81
+
82
+ /**
83
+ * Builds the settlements reconciliation report of one organization from the stored tables only: no
84
+ * provider API calls. The sheets stream per settlement batch, because payment_settlements grows
85
+ * like payments.
86
+ */
87
+ export class SettlementExporter {
88
+ start: Date;
89
+ end: Date;
90
+ provider: PaymentProvider | null;
91
+
92
+ /**
93
+ * The exported organization: the owner of the exported settlements.
94
+ */
95
+ organization: Organization;
96
+
97
+ /**
98
+ * The platform membership organization: the seller of the platform's fee invoices.
99
+ */
100
+ sellingOrganization: Organization;
101
+
102
+ /**
103
+ * Called per processed settlement, to report progress.
104
+ */
105
+ callback: (() => void) | null = null;
106
+
107
+ /**
108
+ * Invoice totals accumulated from the Kosten rows (settlements in range).
109
+ */
110
+ private invoiceTotals = new Map<string, ProviderInvoiceTotals>();
111
+
112
+ /**
113
+ * Whether any exported payout is still waiting for application fees to be invoiced: only then
114
+ * does that column say anything.
115
+ */
116
+ private hasPendingFees = false;
117
+
118
+ constructor({ start, end, provider, organization, sellingOrganization }: { start: Date; end: Date; provider?: PaymentProvider | null; organization: Organization; sellingOrganization: Organization }) {
119
+ this.start = start;
120
+ this.end = end;
121
+ this.provider = provider ?? null;
122
+ this.organization = organization;
123
+ this.sellingOrganization = sellingOrganization;
124
+ }
125
+
126
+ private selectSettlements() {
127
+ let query = Settlement.select()
128
+ .where('organizationId', this.organization.id)
129
+ .where('settledAt', '>=', this.start)
130
+ .where('settledAt', '<=', this.end);
131
+
132
+ if (this.provider) {
133
+ query = query.where('provider', this.provider);
134
+ }
135
+ return query;
136
+ }
137
+
138
+ async build(): Promise<Buffer> {
139
+ const chunks: Buffer[] = [];
140
+ const output = new Writable({
141
+ write(chunk: Buffer, _encoding, callback) {
142
+ chunks.push(chunk);
143
+ callback();
144
+ },
145
+ });
146
+ const finishPromise = new Promise<void>((resolve, reject) => {
147
+ output.on('finish', () => resolve());
148
+ output.on('error', reject);
149
+ });
150
+
151
+ const zipWriterAdapter = new ArchiverWriterAdapter(output);
152
+ const writer = new XlsxWriter(zipWriterAdapter);
153
+
154
+ const settlementsSheet = await writer.addSheet('Uitbetalingen');
155
+ const paymentsSheet = await writer.addSheet('Betalingen');
156
+ const chargesSheet = await writer.addSheet('Kosten');
157
+ const providerInvoicesSheet = await writer.addSheet('Facturen provider');
158
+ await writer.ready();
159
+
160
+ try {
161
+ await this.writeSettlementSheets(writer, { settlementsSheet, paymentsSheet, chargesSheet });
162
+ await this.writeProviderInvoices(writer, providerInvoicesSheet);
163
+ await writer.close();
164
+ } catch (error) {
165
+ await writer.abort();
166
+ throw error;
167
+ }
168
+
169
+ await finishPromise;
170
+ return Buffer.concat(chunks);
171
+ }
172
+
173
+ /**
174
+ * Sheets 1-3 in one streaming pass over the settlements in range.
175
+ */
176
+ private async writeSettlementSheets(writer: XlsxWriter, sheets: { settlementsSheet: symbol; paymentsSheet: symbol; chargesSheet: symbol }) {
177
+ // The settlement headers are small (one row per payout) and the batched iterator only
178
+ // supports id order, so they are collected to sort them; everything that grows with the
179
+ // number of payments streams per settlement below
180
+ this.invoiceTotals.clear();
181
+
182
+ const settlements: Settlement[] = [];
183
+ for await (const batch of this.selectSettlements().limit(SETTLEMENT_BATCH_SIZE).allBatched()) {
184
+ settlements.push(...batch);
185
+ }
186
+ settlements.sort((a, b) => a.settledAt.getTime() - b.settledAt.getTime() || a.externalId.localeCompare(b.externalId));
187
+
188
+ // Only the organization that charges application fees ever receives any, so for everyone
189
+ // else the column would be empty
190
+ this.hasPendingFees = settlements.some(settlement => settlement.pendingFees !== 0);
191
+
192
+ await writer.addRow(sheets.settlementsSheet, [
193
+ textCell('Provider', 10),
194
+ textCell('Uitbetaling', 30),
195
+ textCell('Referentie', 25),
196
+ textCell('Uitbetaald op', 13),
197
+ textCell('Bedrag', 13),
198
+ textCell('Totaal betalingen', 16),
199
+ textCell('Totaal kosten', 13),
200
+ textCell('Status', 10),
201
+ textCell('Gesynchroniseerd', 16),
202
+ textCell('Transacties', 11),
203
+ textCell('Onverklaard', 13),
204
+ ...(this.hasPendingFees ? [textCell('Niet-gefactureerde kosten', 22)] : []),
205
+ textCell('Check', 20),
206
+ ]);
207
+
208
+ await writer.addRow(sheets.paymentsSheet, [
209
+ textCell('Uitbetaling', 30),
210
+ textCell('Uitbetaald op', 13),
211
+ textCell('Betaling', 36),
212
+ textCell('Type', 12),
213
+ textCell('Bedrag', 13),
214
+ textCell('Transactie', 30),
215
+ textCell('Datum', 13),
216
+ ...(this.organization.meta.invoicesEnabled
217
+ ? [
218
+ textCell('Factuur', 15),
219
+ textCell('Factuurdatum', 13),
220
+ textCell('Factuurtotaal', 13),
221
+ ]
222
+ : []),
223
+ ]);
224
+
225
+ await writer.addRow(sheets.chargesSheet, [
226
+ textCell('Uitbetaling', 30),
227
+ textCell('Type', 32),
228
+ textCell('Bedrag', 13),
229
+ textCell('Beschrijving', 45),
230
+ textCell('Factuur provider', 18),
231
+ textCell('Betaling', 36),
232
+ ]);
233
+
234
+ for (const settlement of settlements) {
235
+ await this.writeSettlement(writer, sheets, settlement);
236
+ this.callback?.();
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The verdict of one payout: what it paid out has to be explained by its payments and its
242
+ * costs. Fees we received but haven't invoiced yet have no payment line of their own, so they
243
+ * explain their part of the difference until the invoicer creates one — that is missing data,
244
+ * not a mismatch, and it says so.
245
+ */
246
+ static getSettlementCheck(settlement: Settlement, { linesTotal, chargesTotal }: { linesTotal: number; chargesTotal: number }): string {
247
+ if (settlement.amount - linesTotal - chargesTotal - settlement.pendingFees !== 0) {
248
+ return 'Ontbrekende gegevens';
249
+ }
250
+ if (settlement.pendingFees !== 0) {
251
+ return 'Kosten nog niet gefactureerd';
252
+ }
253
+ return '✓';
254
+ }
255
+
256
+ private async writeSettlement(writer: XlsxWriter, sheets: { settlementsSheet: symbol; paymentsSheet: symbol; chargesSheet: symbol }, settlement: Settlement) {
257
+ const linesTotal = await this.writePaymentLines(writer, sheets.paymentsSheet, settlement);
258
+ const chargesTotal = await this.writeCharges(writer, sheets.chargesSheet, settlement);
259
+
260
+ await writer.addRow(sheets.settlementsSheet, [
261
+ textCell(settlement.provider),
262
+ textCell(settlement.externalId),
263
+ textCell(settlement.reference),
264
+ dateCell(settlement.settledAt),
265
+ currencyCell(settlement.amount),
266
+ currencyCell(linesTotal),
267
+ currencyCell(chargesTotal),
268
+ textCell(settlement.status),
269
+ textCell(settlement.syncedAt ? '✓' : 'Niet gesynchroniseerd'),
270
+ { value: settlement.transactionCount },
271
+ currencyCell(settlement.unexplainedAmount),
272
+ ...(this.hasPendingFees ? [currencyCell(settlement.pendingFees)] : []),
273
+ textCell(SettlementExporter.getSettlementCheck(settlement, { linesTotal, chargesTotal })),
274
+ ]);
275
+ }
276
+
277
+ /**
278
+ * Streams the payment lines of one settlement, and returns their total.
279
+ */
280
+ private async writePaymentLines(writer: XlsxWriter, sheet: symbol, settlement: Settlement): Promise<number> {
281
+ let total = 0;
282
+ let index = 0;
283
+
284
+ for await (const lines of PaymentSettlement.select()
285
+ .where('settlementId', settlement.id)
286
+ .limit(ROW_BATCH_SIZE)
287
+ .allBatched()) {
288
+ const payments = await Payment.select()
289
+ .where('id', Formatter.uniqueArray(lines.map(l => l.paymentId)))
290
+ .fetch();
291
+ const invoiceIds = this.organization.meta.invoicesEnabled
292
+ ? Formatter.uniqueArray(payments.map(p => p.invoiceId).filter((id): id is string => id !== null))
293
+ : [];
294
+ const invoices = invoiceIds.length > 0
295
+ ? await Invoice.select().where('id', invoiceIds).fetch()
296
+ : [];
297
+
298
+ for (const line of lines) {
299
+ const payment = payments.find(p => p.id === line.paymentId);
300
+ const invoice = payment?.invoiceId ? invoices.find(i => i.id === payment.invoiceId) : undefined;
301
+ await writer.addRow(sheet, [
302
+ index > 0 ? empty : textCell(settlement.externalId),
303
+ index > 0 ? empty : dateCell(settlement.settledAt),
304
+ textCell(line.paymentId),
305
+ textCell(payment?.type ?? ''),
306
+ currencyCell(line.amount),
307
+ textCell(line.externalId ?? ''),
308
+ dateCell(line.occurredAt),
309
+ ...(this.organization.meta.invoicesEnabled
310
+ ? [
311
+ textCell(invoice?.number ?? ''),
312
+ dateCell(invoice?.invoicedAt ?? null),
313
+ currencyCell(invoice ? invoice.totalWithVAT : null),
314
+ ]
315
+ : []),
316
+ ]);
317
+ total += line.amount;
318
+ index += 1;
319
+ }
320
+ }
321
+
322
+ if (index > 0) {
323
+ await writer.addRow(sheet, []);
324
+ }
325
+ return total;
326
+ }
327
+
328
+ /**
329
+ * Streams the charges of one settlement (also accumulating the provider invoice totals), and
330
+ * returns their total.
331
+ */
332
+ private async writeCharges(writer: XlsxWriter, sheet: symbol, settlement: Settlement): Promise<number> {
333
+ let total = 0;
334
+ let index = 0;
335
+
336
+ for await (const charges of SettlementCharge.select()
337
+ .where('settlementId', settlement.id)
338
+ .limit(ROW_BATCH_SIZE)
339
+ .allBatched()) {
340
+ for (const charge of charges) {
341
+ await writer.addRow(sheet, [
342
+ index > 0 ? empty : textCell(settlement.externalId),
343
+ textCell(charge.type),
344
+ currencyCell(charge.amount),
345
+ textCell(charge.description),
346
+ textCell(charge.providerInvoiceId ?? ''),
347
+ textCell(charge.paymentId ?? ''),
348
+ ]);
349
+
350
+ this.addToProviderInvoice(settlement, charge);
351
+ total += charge.amount;
352
+ index += 1;
353
+ }
354
+ }
355
+
356
+ if (index > 0) {
357
+ await writer.addRow(sheet, []);
358
+ }
359
+ return total;
360
+ }
361
+
362
+ /**
363
+ * The invoice that bills a charge to the exported organization. Three parties send such
364
+ * invoices: the provider of the settlement (Mollie, and Stripe both for our platform account
365
+ * and for Standard accounts) bills its own fees and their VAT; the platform membership
366
+ * organization bills the application fees it deducted. Every other charge type is money
367
+ * movement (transfers, reserves, disputes): no invoice bills those.
368
+ */
369
+ private getInvoicingParty(settlement: Settlement, charge: SettlementCharge): { invoicedBy: string; invoiceId: string; stamped: boolean; isSellingOrganization: boolean } | null {
370
+ switch (charge.type) {
371
+ case SettlementChargeType.ProviderTransactionFee:
372
+ case SettlementChargeType.ProviderAccountFee:
373
+ case SettlementChargeType.Tax:
374
+ // A missing invoice id only happens for Mollie costs whose invoice document isn't
375
+ // created yet: group those per month until it is
376
+ return {
377
+ invoicedBy: settlement.provider,
378
+ invoiceId: charge.providerInvoiceId ?? (settlement.provider.toLowerCase() + '-' + SettlementService.getPeriodKey(charge.occurredAt)),
379
+ stamped: charge.providerInvoiceId !== null,
380
+ isSellingOrganization: false,
381
+ };
382
+
383
+ case SettlementChargeType.ApplicationFeeService:
384
+ case SettlementChargeType.ApplicationFeeTransfer:
385
+ // The Stamhoofd invoice number stamped when the fee payment was invoiced, or the
386
+ // month bucket while that invoice doesn't exist yet
387
+ return {
388
+ invoicedBy: this.sellingOrganization.name,
389
+ invoiceId: charge.providerInvoiceId ?? SettlementService.getPeriodKey(charge.occurredAt),
390
+ stamped: charge.providerInvoiceId !== null,
391
+ isSellingOrganization: true,
392
+ };
393
+
394
+ case SettlementChargeType.Reserve:
395
+ case SettlementChargeType.BalanceMovement:
396
+ case SettlementChargeType.Adjustment:
397
+ return null;
398
+ }
399
+ }
400
+
401
+ private addToProviderInvoice(settlement: Settlement, charge: SettlementCharge) {
402
+ const invoice = this.getInvoicingParty(settlement, charge);
403
+ if (!invoice) {
404
+ return;
405
+ }
406
+
407
+ const key = invoice.invoicedBy + ':' + invoice.invoiceId;
408
+ const totals = this.invoiceTotals.get(key) ?? { ...invoice, transactionFees: 0, serviceFees: 0, accountFees: 0, vat: 0 };
409
+
410
+ // Charges reduce the payout (negative); the invoice bills them as positive amounts
411
+ const billed = -charge.amount;
412
+ switch (charge.type) {
413
+ case SettlementChargeType.ProviderTransactionFee:
414
+ case SettlementChargeType.ApplicationFeeTransfer:
415
+ totals.transactionFees += billed;
416
+ break;
417
+ case SettlementChargeType.ApplicationFeeService:
418
+ totals.serviceFees += billed;
419
+ break;
420
+ case SettlementChargeType.ProviderAccountFee:
421
+ totals.accountFees += billed;
422
+ break;
423
+ default:
424
+ totals.vat += billed;
425
+ }
426
+ this.invoiceTotals.set(key, totals);
427
+ }
428
+
429
+ /**
430
+ * One row per invoice the exported organization receives: the stored fee rows that the invoice
431
+ * document must match — Mollie's and Stripe's own invoices, and the platform's monthly fee
432
+ * invoice (so an organization can verify what the platform billed them). Derived only from the
433
+ * settlements this export selected, so it always agrees with the Kosten sheet; comparing with
434
+ * the real document is the human step.
435
+ */
436
+ /**
437
+ * The accumulated invoice rows (after build), sorted as the sheet writes them.
438
+ */
439
+ getProviderInvoiceTotals(): ProviderInvoiceTotals[] {
440
+ return [...this.invoiceTotals.values()].sort((a, b) => a.invoicedBy.localeCompare(b.invoicedBy) || a.invoiceId.localeCompare(b.invoiceId));
441
+ }
442
+
443
+ /**
444
+ * What the sheet adds to an invoice row: the charges of the same invoice that fall outside the
445
+ * exported range, and whether the invoice bills exactly what was charged.
446
+ */
447
+ async getProviderInvoiceStatus(totals: ProviderInvoiceTotals): Promise<{ total: number; outsideExport: number | null; check: string }> {
448
+ const total = totals.transactionFees + totals.serviceFees + totals.accountFees + totals.vat;
449
+ const outsideExport = totals.stamped ? (await this.getInvoiceChargesTotal(totals.invoiceId)) - total : null;
450
+
451
+ return {
452
+ total,
453
+ outsideExport,
454
+ check: await this.getProviderInvoiceCheck(totals, total + (outsideExport ?? 0)),
455
+ };
456
+ }
457
+
458
+ private async writeProviderInvoices(writer: XlsxWriter, sheet: symbol) {
459
+ await writer.addRow(sheet, [
460
+ textCell('Gefactureerd door', 17),
461
+ textCell('Factuur', 20),
462
+ textCell('Transactiekosten', 16),
463
+ textCell('Servicekosten', 16),
464
+ textCell('Accountkosten', 16),
465
+ textCell('BTW', 13),
466
+ textCell('Totaal', 13),
467
+ textCell('Kosten buiten dit overzicht', 22),
468
+ textCell('Check', 28),
469
+ ]);
470
+
471
+ for (const totals of this.getProviderInvoiceTotals()) {
472
+ const { total, outsideExport, check } = await this.getProviderInvoiceStatus(totals);
473
+
474
+ await writer.addRow(sheet, [
475
+ textCell(totals.invoicedBy),
476
+ textCell(totals.invoiceId),
477
+ currencyCell(totals.transactionFees),
478
+ currencyCell(totals.serviceFees),
479
+ currencyCell(totals.accountFees),
480
+ currencyCell(totals.vat),
481
+ currencyCell(total),
482
+ currencyCell(outsideExport),
483
+ textCell(check),
484
+ ]);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Everything the invoice bills the exported organization, also outside the exported range:
490
+ * comparing a document against a partial sum would always mismatch. Scoped to the organization,
491
+ * because a provider invoice id like `stripe-2026-01` groups the charges of every organization.
492
+ */
493
+ private async getInvoiceChargesTotal(providerInvoiceId: string): Promise<number> {
494
+ const sum = await SettlementCharge.select()
495
+ .where('providerInvoiceId', providerInvoiceId)
496
+ .where('organizationId', this.organization.id)
497
+ .sum(SQL.column('amount')) ?? 0;
498
+
499
+ // Charges reduce the payout (negative); the invoice bills them as positive amounts
500
+ return -sum;
501
+ }
502
+
503
+ /**
504
+ * Whether the invoice bills exactly these charges. Only verifiable for the platform's own fee
505
+ * invoices: the provider's documents (Stripe, Mollie) aren't stored locally.
506
+ *
507
+ * An invoice also bills everything else the organization owed that month (memberships,
508
+ * packages), so it is not the invoice total that has to match, but the part of it that bills
509
+ * these fees: the balance items the fees themselves point at.
510
+ */
511
+ private async getProviderInvoiceCheck(totals: ProviderInvoiceTotals, fullTotal: number): Promise<string> {
512
+ if (!totals.isSellingOrganization) {
513
+ return '';
514
+ }
515
+
516
+ if (!totals.stamped) {
517
+ return 'Nog niet (volledig) gefactureerd';
518
+ }
519
+
520
+ const invoice = await Invoice.select()
521
+ .where('organizationId', this.sellingOrganization.id)
522
+ .where('number', totals.invoiceId)
523
+ .first(false);
524
+
525
+ if (!invoice) {
526
+ return 'Nog niet (volledig) gefactureerd';
527
+ }
528
+
529
+ // What this invoice bills for exactly these fees: their balance items, as far as this
530
+ // invoice's payments settled them
531
+ const charges = await SettlementCharge.select()
532
+ .where('providerInvoiceId', totals.invoiceId)
533
+ .where('organizationId', this.organization.id)
534
+ .fetch();
535
+ const fees = charges.length > 0
536
+ ? await ApplicationFee.select().where('settlementChargeId', charges.map(c => c.id)).fetch()
537
+ : [];
538
+ const balanceItemIds = Formatter.uniqueArray(fees.map(fee => fee.balanceItemId).filter((id): id is string => id !== null));
539
+ if (balanceItemIds.length === 0) {
540
+ return 'Nog niet (volledig) gefactureerd';
541
+ }
542
+
543
+ const invoicedPayments = await Payment.select()
544
+ .where('invoiceId', invoice.id)
545
+ .fetch();
546
+ if (invoicedPayments.length === 0) {
547
+ return 'Nog niet (volledig) gefactureerd';
548
+ }
549
+
550
+ const invoicedFees = await BalanceItemPayment.select()
551
+ .where('balanceItemId', balanceItemIds)
552
+ .where('paymentId', invoicedPayments.map(p => p.id))
553
+ .sum(SQL.column('price')) ?? 0;
554
+
555
+ if (invoicedFees !== fullTotal) {
556
+ return 'Nog niet (volledig) gefactureerd';
557
+ }
558
+ return '✓';
559
+ }
560
+
561
+ async sendEmail({ to }: { to: EmailInterfaceRecipient[] }): Promise<void> {
562
+ const buffer = await this.build();
563
+
564
+ const startMonth = Formatter.dateWithoutDay(this.start, { timezone: 'UTC' });
565
+ const endMonth = Formatter.dateWithoutDay(this.end, { timezone: 'UTC' });
566
+ const subject = 'Uitbetalingen export ' + startMonth + ((endMonth !== startMonth) ? (' - ' + endMonth) : '');
567
+
568
+ Email.send({
569
+ from: Email.getWebmasterFromEmail(),
570
+ to,
571
+ subject,
572
+ text: 'In bijlage het overzicht van de opgeslagen uitbetalingen van ' + Formatter.dateTime(this.start) + ' tot ' + Formatter.dateTime(this.end) + '.\n',
573
+ type: 'transactional',
574
+ attachments: [
575
+ {
576
+ filename: Formatter.fileSlug(subject) + '.xlsx',
577
+ content: buffer,
578
+ contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
579
+ },
580
+ ],
581
+ });
582
+ }
583
+ }