@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.
- package/package.json +17 -17
- package/src/crons/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
- package/src/helpers/MembershipCharger.ts +26 -1
- package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
- package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
- package/src/helpers/StripePayoutExportData.ts +195 -0
- package/src/helpers/StripePayoutExportExcel.ts +280 -0
- package/src/helpers/StripePayoutReporter.test.ts +419 -0
- package/src/helpers/StripePayoutReporter.ts +585 -0
- package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
- package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
- package/src/services/uitpas/UitpasService.ts +1 -1
- package/src/services/uitpas/cancelTicketSales.ts +1 -1
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
- package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
- package/src/services/uitpas/registerTicketSales.ts +1 -1
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/tests/e2e/documents.test.ts +3 -1
- package/tests/vitest.global.setup.ts +1 -1
- package/tests/vitest.setup.ts +4 -2
- package/vitest.config.js +10 -0
- /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,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
|
+
});
|