@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,419 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
2
|
+
import type { StripeAccount, Organization } from '@stamhoofd/models';
|
|
3
|
+
import { Invoice, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
4
|
+
import { Address, Company, PaymentCustomer, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
|
|
5
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
6
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
7
|
+
import nock from 'nock';
|
|
8
|
+
|
|
9
|
+
import { resetNock } from '../../tests/helpers/resetNock.js';
|
|
10
|
+
import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
|
|
11
|
+
import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
|
|
12
|
+
import { StripeInvoicer } from './StripeInvoicer.js';
|
|
13
|
+
import { StripePayoutBreakdownData, StripePayoutData, StripePayoutExportData, StripePayoutItemData, StripePayoutItemType } from './StripePayoutExportData.js';
|
|
14
|
+
import { StripePayoutReporter } from './StripePayoutReporter.js';
|
|
15
|
+
|
|
16
|
+
describe('StripePayoutReporter', () => {
|
|
17
|
+
let membershipOrganization: Organization;
|
|
18
|
+
const stripeMocker = new StripeMocker();
|
|
19
|
+
|
|
20
|
+
// Report over March 2026 (tests always run in UTC)
|
|
21
|
+
const month = new Date(2026, 2, 15);
|
|
22
|
+
const { start: monthStartUnix } = StripeInvoicer.getMonthUnixStartEnd(month);
|
|
23
|
+
const reference = 'stripe-fees-' + Formatter.dateIso(new Date(monthStartUnix * 1000));
|
|
24
|
+
const reportStart = new Date(2026, 2, 1);
|
|
25
|
+
const reportEnd = new Date(new Date(2026, 3, 1).getTime() - 1000);
|
|
26
|
+
|
|
27
|
+
const belgianAddress = () => Address.create({
|
|
28
|
+
street: 'Teststraat',
|
|
29
|
+
number: '1',
|
|
30
|
+
postalCode: '9000',
|
|
31
|
+
city: 'Gent',
|
|
32
|
+
country: Country.Belgium,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
membershipOrganization = await initMembershipOrganization();
|
|
37
|
+
membershipOrganization.meta.companies = [
|
|
38
|
+
Company.create({
|
|
39
|
+
name: 'Platform BV',
|
|
40
|
+
companyNumber: '0700000000',
|
|
41
|
+
VATNumber: 'BE0700000000',
|
|
42
|
+
address: belgianAddress(),
|
|
43
|
+
}),
|
|
44
|
+
];
|
|
45
|
+
await membershipOrganization.save();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
resetNock();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const init = async () => {
|
|
53
|
+
const organization = await new OrganizationFactory({}).create();
|
|
54
|
+
const stripeAccount = await stripeMocker.createStripeAccount(organization.id);
|
|
55
|
+
return { organization, stripeAccount };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One payout containing a charge of `chargeCents` with an application fee of `feeCents`,
|
|
60
|
+
* fully transferred to the connected account. The payout pays out the application fee
|
|
61
|
+
* minus the Stripe processing fee to the platform.
|
|
62
|
+
*/
|
|
63
|
+
const mockStripeData = (accountId: string, { feeCents = 250, chargeCents = 10000, stripeFeeCents = 25, feeRefundCents = 0, refundCents = 0, createdOffsetDays = 0 } = {}) => {
|
|
64
|
+
const createdUnix = monthStartUnix + (14 + createdOffsetDays) * 24 * 3600;
|
|
65
|
+
const payout = {
|
|
66
|
+
id: stripeMocker.createId('po'),
|
|
67
|
+
object: 'payout',
|
|
68
|
+
status: 'paid',
|
|
69
|
+
amount: feeCents - feeRefundCents - stripeFeeCents,
|
|
70
|
+
arrival_date: createdUnix + 5 * 24 * 3600,
|
|
71
|
+
statement_descriptor: 'STAMHOOFD',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const transactions: unknown[] = [
|
|
75
|
+
{
|
|
76
|
+
id: stripeMocker.createId('txn'),
|
|
77
|
+
type: 'charge',
|
|
78
|
+
amount: chargeCents,
|
|
79
|
+
fee: stripeFeeCents,
|
|
80
|
+
created: createdUnix,
|
|
81
|
+
source: { object: 'charge', id: stripeMocker.createId('ch'), amount: chargeCents, on_behalf_of: accountId },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: stripeMocker.createId('txn'),
|
|
85
|
+
type: 'transfer',
|
|
86
|
+
amount: -chargeCents,
|
|
87
|
+
fee: 0,
|
|
88
|
+
created: createdUnix,
|
|
89
|
+
source: { object: 'transfer', id: stripeMocker.createId('tr'), destination: accountId },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: stripeMocker.createId('txn'),
|
|
93
|
+
type: 'application_fee',
|
|
94
|
+
amount: feeCents,
|
|
95
|
+
fee: 0,
|
|
96
|
+
created: createdUnix,
|
|
97
|
+
source: { object: 'application_fee', id: stripeMocker.createId('fee'), amount: feeCents, account: accountId },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: stripeMocker.createId('txn'),
|
|
101
|
+
type: 'payout',
|
|
102
|
+
amount: -(feeCents - feeRefundCents - stripeFeeCents),
|
|
103
|
+
fee: 0,
|
|
104
|
+
created: createdUnix + 5 * 24 * 3600,
|
|
105
|
+
source: null,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
if (refundCents > 0) {
|
|
110
|
+
// A customer refund: paid back from the connected account balance via a transfer refund
|
|
111
|
+
transactions.push({
|
|
112
|
+
id: stripeMocker.createId('txn'),
|
|
113
|
+
type: 'refund',
|
|
114
|
+
amount: -refundCents,
|
|
115
|
+
fee: 0,
|
|
116
|
+
created: createdUnix,
|
|
117
|
+
source: {
|
|
118
|
+
object: 'refund',
|
|
119
|
+
id: stripeMocker.createId('re'),
|
|
120
|
+
amount: refundCents,
|
|
121
|
+
charge: { object: 'charge', id: stripeMocker.createId('ch'), amount: chargeCents, on_behalf_of: accountId },
|
|
122
|
+
},
|
|
123
|
+
}, {
|
|
124
|
+
id: stripeMocker.createId('txn'),
|
|
125
|
+
type: 'transfer_refund',
|
|
126
|
+
amount: refundCents,
|
|
127
|
+
fee: 0,
|
|
128
|
+
created: createdUnix,
|
|
129
|
+
source: { object: 'transfer', id: stripeMocker.createId('tr'), destination: accountId },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (feeRefundCents > 0) {
|
|
134
|
+
transactions.push({
|
|
135
|
+
id: stripeMocker.createId('txn'),
|
|
136
|
+
type: 'application_fee_refund',
|
|
137
|
+
amount: -feeRefundCents,
|
|
138
|
+
fee: 0,
|
|
139
|
+
created: createdUnix,
|
|
140
|
+
source: {
|
|
141
|
+
object: 'fee_refund',
|
|
142
|
+
id: stripeMocker.createId('fr'),
|
|
143
|
+
amount: feeRefundCents,
|
|
144
|
+
fee: { object: 'application_fee', id: stripeMocker.createId('fee'), amount: feeCents, account: accountId },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
nock('https://api.stripe.com')
|
|
150
|
+
.get('/v1/payouts')
|
|
151
|
+
.query(true)
|
|
152
|
+
.reply(200, { object: 'list', data: [payout], has_more: false, url: '/v1/payouts' });
|
|
153
|
+
|
|
154
|
+
nock('https://api.stripe.com')
|
|
155
|
+
.get('/v1/balance_transactions')
|
|
156
|
+
.query(true)
|
|
157
|
+
.reply(200, { object: 'list', data: transactions, has_more: false, url: '/v1/balance_transactions' });
|
|
158
|
+
|
|
159
|
+
return { payout, transactions };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const createPayment = async (organization: Organization, stripeAccount: StripeAccount, price: number) => {
|
|
163
|
+
const payment = new Payment();
|
|
164
|
+
payment.organizationId = membershipOrganization.id;
|
|
165
|
+
payment.payingOrganizationId = organization.id;
|
|
166
|
+
payment.stripeAccountId = stripeAccount.id;
|
|
167
|
+
payment.reference = reference;
|
|
168
|
+
payment.method = PaymentMethod.AccountDeductions;
|
|
169
|
+
payment.provider = PaymentProvider.Stripe;
|
|
170
|
+
payment.status = PaymentStatus.Succeeded;
|
|
171
|
+
payment.type = PaymentType.Payment;
|
|
172
|
+
payment.price = price;
|
|
173
|
+
payment.paidAt = new Date(monthStartUnix * 1000);
|
|
174
|
+
payment.customer = PaymentCustomer.create({
|
|
175
|
+
company: Company.create({
|
|
176
|
+
name: 'Testvereniging VZW',
|
|
177
|
+
companyNumber: '0500000000',
|
|
178
|
+
VATNumber: 'BE0500000000',
|
|
179
|
+
address: belgianAddress(),
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
await payment.save();
|
|
183
|
+
return payment;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const createInvoice = async (organization: Organization, payment: Payment, { number, totalWithVAT, VATTotalAmount }: { number: string; totalWithVAT: number; VATTotalAmount: number }) => {
|
|
187
|
+
const invoice = new Invoice();
|
|
188
|
+
invoice.organizationId = membershipOrganization.id;
|
|
189
|
+
invoice.payingOrganizationId = organization.id;
|
|
190
|
+
invoice.number = number;
|
|
191
|
+
invoice.customer = payment.customer!;
|
|
192
|
+
invoice.seller = membershipOrganization.meta.companies[0];
|
|
193
|
+
invoice.totalWithVAT = totalWithVAT;
|
|
194
|
+
invoice.VATTotalAmount = VATTotalAmount;
|
|
195
|
+
invoice.totalWithoutVAT = totalWithVAT - VATTotalAmount;
|
|
196
|
+
invoice.invoicedAt = new Date();
|
|
197
|
+
await invoice.save();
|
|
198
|
+
|
|
199
|
+
payment.invoiceId = invoice.id;
|
|
200
|
+
await payment.save();
|
|
201
|
+
return invoice;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const buildReport = async () => {
|
|
205
|
+
const reporter = new StripePayoutReporter({
|
|
206
|
+
secretKey: STAMHOOFD.STRIPE_SECRET_KEY!,
|
|
207
|
+
sellingOrganization: membershipOrganization,
|
|
208
|
+
});
|
|
209
|
+
await reporter.build(reportStart, reportEnd);
|
|
210
|
+
return reporter;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
test('A payment that has been invoiced is reported with its invoice number and makes the report valid', async () => {
|
|
214
|
+
const { organization, stripeAccount } = await init();
|
|
215
|
+
mockStripeData(stripeAccount.accountId, { feeCents: 250, stripeFeeCents: 25 });
|
|
216
|
+
|
|
217
|
+
// 2.50 euro in platform units (1/100 cent)
|
|
218
|
+
const payment = await createPayment(organization, stripeAccount, 250_00);
|
|
219
|
+
await createInvoice(organization, payment, { number: '2026-101', totalWithVAT: 250_00, VATTotalAmount: 4339 });
|
|
220
|
+
|
|
221
|
+
const reporter = await buildReport();
|
|
222
|
+
const payoutExport = reporter.toStructure();
|
|
223
|
+
|
|
224
|
+
expect(payoutExport.payouts.length).toBe(1);
|
|
225
|
+
|
|
226
|
+
const breakdown = payoutExport.payouts[0];
|
|
227
|
+
expect(breakdown.payout.amount).toBe(225_00);
|
|
228
|
+
expect(breakdown.isValid).toBe(true);
|
|
229
|
+
|
|
230
|
+
const invoiceItem = breakdown.items.find(i => i.type === StripePayoutItemType.Invoice)!;
|
|
231
|
+
expect(invoiceItem.name).toBe('Factuur 2026-101');
|
|
232
|
+
expect(invoiceItem.amount).toBe(250_00);
|
|
233
|
+
expect(invoiceItem.reference).toBe(reference);
|
|
234
|
+
expect(invoiceItem.payments.map(p => p.id)).toEqual([payment.id]);
|
|
235
|
+
expect(invoiceItem.invoices.map(i => i.number)).toEqual(['2026-101']);
|
|
236
|
+
|
|
237
|
+
const feesItem = breakdown.items.find(i => i.type === StripePayoutItemType.StripeFees)!;
|
|
238
|
+
expect(feesItem.amount).toBe(-25_00);
|
|
239
|
+
|
|
240
|
+
expect(payoutExport.completePayouts.length).toBe(1);
|
|
241
|
+
expect(payoutExport.isValid).toBe(true);
|
|
242
|
+
expect(payoutExport.totalPaidOut).toBe(225_00);
|
|
243
|
+
expect(payoutExport.totalStripeFees).toBe(25_00);
|
|
244
|
+
expect(payoutExport.totalInvoices).toBe(250_00);
|
|
245
|
+
expect(payoutExport.totalVAT).toBe(4339);
|
|
246
|
+
expect(payoutExport.net).toBe(225_00 - 4339);
|
|
247
|
+
|
|
248
|
+
// The report can be exported to Excel
|
|
249
|
+
const buffer = await reporter.toExcel(payoutExport);
|
|
250
|
+
expect(buffer.length).toBeGreaterThan(0);
|
|
251
|
+
|
|
252
|
+
// Extra recipients receive the report because it is valid
|
|
253
|
+
await reporter.sendEmail({
|
|
254
|
+
to: [{ email: 'requester@example.com' }],
|
|
255
|
+
extraRecipientsWhenValid: [{ email: 'accountant@example.com' }],
|
|
256
|
+
});
|
|
257
|
+
const email = (await EmailMocker.transactional.getSucceededEmails()).find(e => e.subject.startsWith('Stripe Uitbetalingen'));
|
|
258
|
+
expect(email).toBeDefined();
|
|
259
|
+
expect(email!.to).toContain('requester@example.com');
|
|
260
|
+
expect(email!.to).toContain('accountant@example.com');
|
|
261
|
+
expect(email!.text).not.toContain('RAPPORT ONVOLLEDIG');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('A payment that has not been invoiced yet is visible in the report and marks it as incomplete', async () => {
|
|
265
|
+
const { organization, stripeAccount } = await init();
|
|
266
|
+
mockStripeData(stripeAccount.accountId, { feeCents: 250, stripeFeeCents: 25 });
|
|
267
|
+
|
|
268
|
+
const payment = await createPayment(organization, stripeAccount, 250_00);
|
|
269
|
+
|
|
270
|
+
const reporter = await buildReport();
|
|
271
|
+
const payoutExport = reporter.toStructure();
|
|
272
|
+
|
|
273
|
+
const breakdown = payoutExport.payouts[0];
|
|
274
|
+
expect(breakdown.isValid).toBe(true); // amounts of the payout itself are consistent
|
|
275
|
+
|
|
276
|
+
const invoiceItem = breakdown.items.find(i => i.type === StripePayoutItemType.Invoice)!;
|
|
277
|
+
expect(invoiceItem.name).toBe('Betaling aangemaakt, nog niet gefactureerd');
|
|
278
|
+
expect(invoiceItem.amount).toBe(250_00);
|
|
279
|
+
expect(invoiceItem.payments.map(p => p.id)).toEqual([payment.id]);
|
|
280
|
+
expect(invoiceItem.invoices).toEqual([]);
|
|
281
|
+
|
|
282
|
+
// Not invoiced yet: the payout is not complete and the report is invalid
|
|
283
|
+
expect(payoutExport.completePayouts.length).toBe(0);
|
|
284
|
+
expect(payoutExport.isValid).toBe(false);
|
|
285
|
+
|
|
286
|
+
// Extra recipients are skipped for invalid reports
|
|
287
|
+
await reporter.sendEmail({
|
|
288
|
+
to: [{ email: 'requester@example.com' }],
|
|
289
|
+
extraRecipientsWhenValid: [{ email: 'accountant@example.com' }],
|
|
290
|
+
});
|
|
291
|
+
const email = (await EmailMocker.transactional.getSucceededEmails()).find(e => e.subject.startsWith('Stripe Uitbetalingen'));
|
|
292
|
+
expect(email).toBeDefined();
|
|
293
|
+
expect(email!.to).toContain('requester@example.com');
|
|
294
|
+
expect(email!.to).not.toContain('accountant@example.com');
|
|
295
|
+
expect(email!.text).toContain('RAPPORT ONVOLLEDIG');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('Application fees without a created payment are visible in the report and mark it as incomplete', async () => {
|
|
299
|
+
const { stripeAccount } = await init();
|
|
300
|
+
mockStripeData(stripeAccount.accountId, { feeCents: 250, stripeFeeCents: 25 });
|
|
301
|
+
|
|
302
|
+
const reporter = await buildReport();
|
|
303
|
+
const payoutExport = reporter.toStructure();
|
|
304
|
+
|
|
305
|
+
const invoiceItem = payoutExport.payouts[0].items.find(i => i.type === StripePayoutItemType.Invoice)!;
|
|
306
|
+
expect(invoiceItem.name).toBe('Nog geen betaling aangemaakt');
|
|
307
|
+
expect(invoiceItem.amount).toBe(250_00);
|
|
308
|
+
expect(invoiceItem.payments).toEqual([]);
|
|
309
|
+
|
|
310
|
+
expect(payoutExport.completePayouts.length).toBe(0);
|
|
311
|
+
expect(payoutExport.isValid).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('Refunded application fees reduce the amount that should be invoiced', async () => {
|
|
315
|
+
const { organization, stripeAccount } = await init();
|
|
316
|
+
mockStripeData(stripeAccount.accountId, { feeCents: 250, feeRefundCents: 50, stripeFeeCents: 25 });
|
|
317
|
+
|
|
318
|
+
// 2 euro invoiced: 2.50 euro of fees minus 0.50 euro refunded
|
|
319
|
+
const payment = await createPayment(organization, stripeAccount, 200_00);
|
|
320
|
+
await createInvoice(organization, payment, { number: '2026-103', totalWithVAT: 200_00, VATTotalAmount: 3471 });
|
|
321
|
+
|
|
322
|
+
const reporter = await buildReport();
|
|
323
|
+
const payoutExport = reporter.toStructure();
|
|
324
|
+
|
|
325
|
+
const breakdown = payoutExport.payouts[0];
|
|
326
|
+
expect(breakdown.payout.amount).toBe(175_00);
|
|
327
|
+
expect(breakdown.isValid).toBe(true);
|
|
328
|
+
|
|
329
|
+
const invoiceItem = breakdown.items.find(i => i.type === StripePayoutItemType.Invoice)!;
|
|
330
|
+
expect(invoiceItem.amount).toBe(200_00);
|
|
331
|
+
|
|
332
|
+
expect(payoutExport.completePayouts.length).toBe(1);
|
|
333
|
+
expect(payoutExport.isValid).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('A payout containing a customer refund can be reported', async () => {
|
|
337
|
+
const { organization, stripeAccount } = await init();
|
|
338
|
+
mockStripeData(stripeAccount.accountId, { feeCents: 250, stripeFeeCents: 25, refundCents: 1000 });
|
|
339
|
+
|
|
340
|
+
const payment = await createPayment(organization, stripeAccount, 250_00);
|
|
341
|
+
await createInvoice(organization, payment, { number: '2026-104', totalWithVAT: 250_00, VATTotalAmount: 4339 });
|
|
342
|
+
|
|
343
|
+
const reporter = await buildReport();
|
|
344
|
+
const payoutExport = reporter.toStructure();
|
|
345
|
+
|
|
346
|
+
// The refund and its transfer refund cancel each other out on the payout
|
|
347
|
+
const breakdown = payoutExport.payouts[0];
|
|
348
|
+
expect(breakdown.payout.amount).toBe(225_00);
|
|
349
|
+
expect(breakdown.isValid).toBe(true);
|
|
350
|
+
|
|
351
|
+
expect(payoutExport.completePayouts.length).toBe(1);
|
|
352
|
+
expect(payoutExport.isValid).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('Payouts outside the requested period are not counted as skipped', async () => {
|
|
356
|
+
const { stripeAccount } = await init();
|
|
357
|
+
// The payout is fetched (within the extra month margin), but arrives after the requested period
|
|
358
|
+
mockStripeData(stripeAccount.accountId, { createdOffsetDays: 20 });
|
|
359
|
+
|
|
360
|
+
const reporter = await buildReport();
|
|
361
|
+
const payoutExport = reporter.toStructure();
|
|
362
|
+
|
|
363
|
+
expect(payoutExport.payouts.length).toBe(1);
|
|
364
|
+
expect(payoutExport.includedPayouts.length).toBe(0);
|
|
365
|
+
expect(payoutExport.isValid).toBe(true);
|
|
366
|
+
|
|
367
|
+
await reporter.sendEmail({ to: [{ email: 'requester@example.com' }] });
|
|
368
|
+
const email = (await EmailMocker.transactional.getSucceededEmails()).find(e => e.subject.startsWith('Stripe Uitbetalingen'));
|
|
369
|
+
expect(email!.text).toContain('Overgeslagen uitbetalingen (onvolledig): 0');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('Payout-level mismatches that cancel each other out still make the report invalid', () => {
|
|
373
|
+
const payoutExport = new StripePayoutExportData({ start: reportStart, end: reportEnd });
|
|
374
|
+
|
|
375
|
+
const buildBreakdown = (payoutAmount: number, itemAmount: number) => new StripePayoutBreakdownData({
|
|
376
|
+
payout: new StripePayoutData({
|
|
377
|
+
id: stripeMocker.createId('po'),
|
|
378
|
+
amount: payoutAmount,
|
|
379
|
+
arrivalDate: new Date(2026, 2, 15),
|
|
380
|
+
statementDescriptor: 'STAMHOOFD',
|
|
381
|
+
}),
|
|
382
|
+
items: [
|
|
383
|
+
new StripePayoutItemData({
|
|
384
|
+
name: 'Stripe Factuur',
|
|
385
|
+
type: StripePayoutItemType.StripeFees,
|
|
386
|
+
amount: itemAmount,
|
|
387
|
+
}),
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// One payout paid out 1 euro too much and the other 1 euro too little: the aggregate
|
|
392
|
+
// totals still match, but the report should not be considered valid
|
|
393
|
+
payoutExport.payouts.push(buildBreakdown(100_00, 200_00), buildBreakdown(300_00, 200_00));
|
|
394
|
+
|
|
395
|
+
expect(payoutExport.totalPaidOut).toBe(400_00);
|
|
396
|
+
expect(payoutExport.completePayouts.length).toBe(2);
|
|
397
|
+
expect(payoutExport.payouts[0].isValid).toBe(false);
|
|
398
|
+
expect(payoutExport.isValid).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('An invoiced payment with a different amount than the application fees marks the report as incomplete', async () => {
|
|
402
|
+
const { organization, stripeAccount } = await init();
|
|
403
|
+
mockStripeData(stripeAccount.accountId, { feeCents: 250, stripeFeeCents: 25 });
|
|
404
|
+
|
|
405
|
+
// Payment of 2 euro, while 2.50 euro of application fees were charged
|
|
406
|
+
const payment = await createPayment(organization, stripeAccount, 200_00);
|
|
407
|
+
await createInvoice(organization, payment, { number: '2026-102', totalWithVAT: 200_00, VATTotalAmount: 3471 });
|
|
408
|
+
|
|
409
|
+
const reporter = await buildReport();
|
|
410
|
+
const payoutExport = reporter.toStructure();
|
|
411
|
+
|
|
412
|
+
const invoiceItem = payoutExport.payouts[0].items.find(i => i.type === StripePayoutItemType.Invoice)!;
|
|
413
|
+
expect(invoiceItem.name).toBe('Factuur 2026-102');
|
|
414
|
+
expect(invoiceItem.amount).toBe(250_00);
|
|
415
|
+
|
|
416
|
+
expect(payoutExport.completePayouts.length).toBe(0);
|
|
417
|
+
expect(payoutExport.isValid).toBe(false);
|
|
418
|
+
});
|
|
419
|
+
});
|