@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.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +366 -0
- package/src/helpers/SettlementExporter.ts +583 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +828 -0
- package/src/helpers/StripeSettlementSync.ts +947 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
- package/src/helpers/WebmasterReport.test.ts +109 -0
- package/src/helpers/WebmasterReport.ts +115 -0
- package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
- package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/helpers/MollieMocker.ts +64 -6
- package/tests/helpers/StripeMocker.ts +209 -17
- package/src/crons/stripe-payout-reports.ts +0 -69
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
- package/src/helpers/CheckSettlements.test.ts +0 -190
- package/src/helpers/CheckSettlements.ts +0 -237
- package/src/helpers/StripeInvoicer.ts +0 -419
- package/src/helpers/StripePayoutChecker.ts +0 -193
- package/src/helpers/StripePayoutExportData.ts +0 -195
- package/src/helpers/StripePayoutExportExcel.ts +0 -280
- package/src/helpers/StripePayoutReporter.test.ts +0 -419
- 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
|
+
}
|