@stamhoofd/backend 2.139.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/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
- 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 +14 -14
- 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/TwoFactorHelper.ts +1 -1
- 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/helpers/streamForBreakdown.ts +2 -2
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/PaymentService.ts +8 -0
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/filters/orders.test.ts +635 -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
|
@@ -53,6 +53,13 @@ export type MollieMockRefund = {
|
|
|
53
53
|
metadata: Record<string, unknown> | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
export type MollieMockSettlementCost = {
|
|
57
|
+
description: string;
|
|
58
|
+
method: string | null;
|
|
59
|
+
amountNet: { currency: string; value: string };
|
|
60
|
+
amountVat: { currency: string; value: string } | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
56
63
|
export type MollieMockSettlement = {
|
|
57
64
|
id: string;
|
|
58
65
|
reference: string;
|
|
@@ -61,6 +68,10 @@ export type MollieMockSettlement = {
|
|
|
61
68
|
createdAt: string;
|
|
62
69
|
/** null for the still-open settlement, a date once it has been paid out */
|
|
63
70
|
settledAt: string | null;
|
|
71
|
+
/** id of the invoice Mollie created for the settlement costs, null until it exists */
|
|
72
|
+
invoiceId: string | null;
|
|
73
|
+
/** Mollie's own costs per period, keyed year → month */
|
|
74
|
+
periods: Record<string, Record<string, { costs: MollieMockSettlementCost[]; invoiceId?: string | null }>>;
|
|
64
75
|
/** Mollie payment ids (tr_...) settled in this settlement */
|
|
65
76
|
paymentIds: string[];
|
|
66
77
|
/** Mollie refund ids (re_...) settled in this settlement */
|
|
@@ -94,6 +105,12 @@ export class MollieMocker {
|
|
|
94
105
|
refunds: MollieMockRefund[] = [];
|
|
95
106
|
settlements: MollieMockSettlement[] = [];
|
|
96
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Cap the settlements list page size below the requested limit, to exercise pagination
|
|
110
|
+
* without creating hundreds of settlements.
|
|
111
|
+
*/
|
|
112
|
+
settlementsPageSize: number | null = null;
|
|
113
|
+
|
|
97
114
|
#forceFailure = false;
|
|
98
115
|
|
|
99
116
|
reset() {
|
|
@@ -103,6 +120,7 @@ export class MollieMocker {
|
|
|
103
120
|
this.chargebacks = [];
|
|
104
121
|
this.refunds = [];
|
|
105
122
|
this.settlements = [];
|
|
123
|
+
this.settlementsPageSize = null;
|
|
106
124
|
this.#forceFailure = false;
|
|
107
125
|
}
|
|
108
126
|
|
|
@@ -224,10 +242,10 @@ export class MollieMocker {
|
|
|
224
242
|
return this.#listResource('chargebacks', this.chargebacks.map(c => this.#chargebackResource(c)));
|
|
225
243
|
}
|
|
226
244
|
|
|
227
|
-
// settlements + nested payments/refunds (drives the settlements
|
|
245
|
+
// settlements + nested payments/refunds (drives the settlements sync)
|
|
228
246
|
if (parts[0] === 'settlements' && method === 'GET') {
|
|
229
247
|
if (parts.length === 1) {
|
|
230
|
-
return this.#
|
|
248
|
+
return this.#listSettlements(uri);
|
|
231
249
|
}
|
|
232
250
|
const settlement = this.settlements.find(s => s.id === parts[1]);
|
|
233
251
|
if (settlement && parts.length === 3 && parts[2] === 'payments') {
|
|
@@ -554,9 +572,9 @@ export class MollieMocker {
|
|
|
554
572
|
|
|
555
573
|
/**
|
|
556
574
|
* Register a settled (paid out) settlement that groups the given payments and refunds.
|
|
557
|
-
* Used to drive the
|
|
575
|
+
* Used to drive the MollieSettlementSync walk.
|
|
558
576
|
*/
|
|
559
|
-
createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date } = {}): MollieMockSettlement {
|
|
577
|
+
createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date; invoiceId?: string | null; periods?: Record<string, Record<string, { costs: MollieMockSettlementCost[]; invoiceId?: string | null }>> } = {}): MollieMockSettlement {
|
|
560
578
|
const settlement: MollieMockSettlement = {
|
|
561
579
|
id: this.createId('stl'),
|
|
562
580
|
reference: '1234567.' + (this.settlements.length + 1).toString().padStart(4, '0') + '.01',
|
|
@@ -564,6 +582,8 @@ export class MollieMocker {
|
|
|
564
582
|
amount: { currency: 'EUR', value: options.value ?? '0.00' },
|
|
565
583
|
createdAt: new Date().toISOString(),
|
|
566
584
|
settledAt: (options.settledAt ?? new Date()).toISOString(),
|
|
585
|
+
invoiceId: options.invoiceId ?? null,
|
|
586
|
+
periods: options.periods ?? {},
|
|
567
587
|
paymentIds: (options.payments ?? []).map(p => p.id),
|
|
568
588
|
refundIds: (options.refunds ?? []).map(r => r.id),
|
|
569
589
|
chargebackIds: (options.chargebacks ?? []).map(c => c.id),
|
|
@@ -572,6 +592,42 @@ export class MollieMocker {
|
|
|
572
592
|
return settlement;
|
|
573
593
|
}
|
|
574
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Mollie lists settlements newest-first, with the open settlement (settledAt null) on top;
|
|
597
|
+
* the sync relies on this to stop at the first settlement before its window. Pagination works
|
|
598
|
+
* like Mollie's: `from` is the id of the first settlement of the page, and the `next` link
|
|
599
|
+
* points at the first settlement of the following page.
|
|
600
|
+
*/
|
|
601
|
+
#listSettlements(uri: string): [number, unknown] {
|
|
602
|
+
const query = new URLSearchParams(uri.split('?')[1] ?? '');
|
|
603
|
+
|
|
604
|
+
const sorted = this.settlements.slice().sort((a, b) => {
|
|
605
|
+
if (a.settledAt === null || b.settledAt === null) {
|
|
606
|
+
if (a.settledAt === b.settledAt) {
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
return a.settledAt === null ? -1 : 1;
|
|
610
|
+
}
|
|
611
|
+
return new Date(b.settledAt).getTime() - new Date(a.settledAt).getTime();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const requestedLimit = parseInt(query.get('limit') ?? '250');
|
|
615
|
+
const limit = Math.min(requestedLimit, this.settlementsPageSize ?? requestedLimit);
|
|
616
|
+
|
|
617
|
+
const from = query.get('from');
|
|
618
|
+
const startIndex = from ? sorted.findIndex(s => s.id === from) : 0;
|
|
619
|
+
if (startIndex === -1) {
|
|
620
|
+
return [404, { status: 404, title: 'Not Found', detail: 'No settlement exists with token ' + from }];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const page = sorted.slice(startIndex, startIndex + limit);
|
|
624
|
+
const nextItem = sorted[startIndex + limit];
|
|
625
|
+
|
|
626
|
+
return this.#listResource('settlements', page.map(s => this.#settlementResource(s)), {
|
|
627
|
+
next: nextItem ? 'https://api.mollie.com/v2/settlements?limit=' + requestedLimit + '&from=' + nextItem.id : null,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
575
631
|
#settlementResource(settlement: MollieMockSettlement) {
|
|
576
632
|
return {
|
|
577
633
|
resource: 'settlement',
|
|
@@ -581,20 +637,22 @@ export class MollieMocker {
|
|
|
581
637
|
amount: settlement.amount,
|
|
582
638
|
createdAt: settlement.createdAt,
|
|
583
639
|
settledAt: settlement.settledAt ?? null,
|
|
640
|
+
invoiceId: settlement.invoiceId,
|
|
641
|
+
periods: settlement.periods,
|
|
584
642
|
_links: { self: { href: 'https://api.mollie.com/v2/settlements/' + settlement.id, type: 'application/hal+json' } },
|
|
585
643
|
};
|
|
586
644
|
}
|
|
587
645
|
|
|
588
646
|
// ---- List helper ------------------------------------------------------
|
|
589
647
|
|
|
590
|
-
#listResource(binderName: string, items: unknown[]): [number, unknown] {
|
|
648
|
+
#listResource(binderName: string, items: unknown[], options: { next?: string | null } = {}): [number, unknown] {
|
|
591
649
|
return [200, {
|
|
592
650
|
count: items.length,
|
|
593
651
|
_embedded: { [binderName]: items },
|
|
594
652
|
_links: {
|
|
595
653
|
self: { href: 'https://api.mollie.com/v2/' + binderName, type: 'application/hal+json' },
|
|
596
654
|
documentation: { href: 'https://docs.mollie.com', type: 'text/html' },
|
|
597
|
-
next: null,
|
|
655
|
+
next: options.next ? { href: options.next, type: 'application/hal+json' } : null,
|
|
598
656
|
previous: null,
|
|
599
657
|
},
|
|
600
658
|
}];
|
|
@@ -10,14 +10,27 @@ import { testServer } from './TestServer.js';
|
|
|
10
10
|
import { resetNock } from './resetNock.js';
|
|
11
11
|
|
|
12
12
|
export type PaymentIntent = { id: string; return_url?: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loose Stripe API object: the mocker serves back exactly what a test built, so only the id is
|
|
16
|
+
* mandatory.
|
|
17
|
+
*/
|
|
18
|
+
export type StripeObject = { id: string } & Record<string, any>;
|
|
19
|
+
|
|
13
20
|
export class StripeMocker {
|
|
14
21
|
paymentIntents: PaymentIntent[] = [];
|
|
15
|
-
charges:
|
|
22
|
+
charges: StripeObject[] = [];
|
|
23
|
+
payouts: StripeObject[] = [];
|
|
24
|
+
balanceTransactions: StripeObject[] = [];
|
|
25
|
+
checkoutSessions: StripeObject[] = [];
|
|
16
26
|
#forceFailure = false;
|
|
17
27
|
|
|
18
28
|
reset() {
|
|
19
29
|
this.paymentIntents = [];
|
|
20
30
|
this.charges = [];
|
|
31
|
+
this.payouts = [];
|
|
32
|
+
this.balanceTransactions = [];
|
|
33
|
+
this.checkoutSessions = [];
|
|
21
34
|
this.#forceFailure = false;
|
|
22
35
|
}
|
|
23
36
|
|
|
@@ -30,25 +43,15 @@ export class StripeMocker {
|
|
|
30
43
|
throw new Error('Invalid STRIPE_SECRET_KEY. Even in test mode it should start with sk_test_');
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
const mocker = this;
|
|
33
47
|
nock('https://api.stripe.com')
|
|
34
48
|
.persist()
|
|
35
49
|
.get(/v1\/.*/)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (resource === 'payment_intents') {
|
|
44
|
-
return this.#getPaymentIntent(id);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (resource === 'charges') {
|
|
48
|
-
return this.#getCharge(id);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return [500];
|
|
50
|
+
// A regular function: nock exposes the intercepted request (and its Stripe-Account
|
|
51
|
+
// header) on `this`
|
|
52
|
+
.reply(function (uri) {
|
|
53
|
+
const stripeAccount = (this.req.headers['stripe-account'] as string | undefined) ?? null;
|
|
54
|
+
return mocker.#handleGet(uri, stripeAccount);
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
nock('https://api.stripe.com')
|
|
@@ -84,12 +87,201 @@ export class StripeMocker {
|
|
|
84
87
|
clear() {
|
|
85
88
|
this.paymentIntents = [];
|
|
86
89
|
this.charges = [];
|
|
90
|
+
this.payouts = [];
|
|
91
|
+
this.balanceTransactions = [];
|
|
92
|
+
this.checkoutSessions = [];
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
createId(prefix: string) {
|
|
90
96
|
return prefix + '_' + uuidv4().replaceAll('-', '');
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
#handleGet(uri: string, stripeAccount: string | null) {
|
|
100
|
+
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/(\w+)){0,2}/) || [null];
|
|
101
|
+
|
|
102
|
+
if (!match) {
|
|
103
|
+
return [500];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const query = this.decodeBody(uri.split('?')[1] ?? '') as Record<string, any>;
|
|
107
|
+
|
|
108
|
+
if (resource === 'payment_intents') {
|
|
109
|
+
return this.#getPaymentIntent(id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (resource === 'charges') {
|
|
113
|
+
return this.#getCharge(id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (resource === 'payouts') {
|
|
117
|
+
return this.#listPayouts(id, query, stripeAccount);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (resource === 'balance_transactions') {
|
|
121
|
+
return this.#listBalanceTransactions(query, stripeAccount);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (resource === 'checkout' && id === 'sessions') {
|
|
125
|
+
return this.#listCheckoutSessions(query);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return [500];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pages like the real API does, so the SDK's auto-pagination is actually exercised: a payout
|
|
133
|
+
* with more transactions than one page is the normal case in production.
|
|
134
|
+
*/
|
|
135
|
+
#list(data: StripeObject[], url: string, query: Record<string, any> = {}) {
|
|
136
|
+
let page = data;
|
|
137
|
+
|
|
138
|
+
if (query.starting_after) {
|
|
139
|
+
const index = page.findIndex(item => item.id === query.starting_after);
|
|
140
|
+
page = index === -1 ? [] : page.slice(index + 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const limit = query.limit ? parseInt(String(query.limit)) : 10;
|
|
144
|
+
const hasMore = page.length > limit;
|
|
145
|
+
|
|
146
|
+
return [200, { object: 'list', data: page.slice(0, limit), has_more: hasMore, url }];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#listPayouts(id: string | undefined, query: Record<string, any>, stripeAccount: string | null) {
|
|
150
|
+
let data = this.payouts.filter(p => (p.stripeAccount ?? null) === stripeAccount);
|
|
151
|
+
|
|
152
|
+
if (id) {
|
|
153
|
+
const payout = data.find(p => p.id === id);
|
|
154
|
+
return payout ? [200, payout] : [404];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (query.status) {
|
|
158
|
+
data = data.filter(p => p.status === query.status);
|
|
159
|
+
}
|
|
160
|
+
if (query.arrival_date?.gte !== undefined) {
|
|
161
|
+
data = data.filter(p => p.arrival_date >= query.arrival_date.gte);
|
|
162
|
+
}
|
|
163
|
+
if (query.arrival_date?.lte !== undefined) {
|
|
164
|
+
data = data.filter(p => p.arrival_date <= query.arrival_date.lte);
|
|
165
|
+
}
|
|
166
|
+
return this.#list(data, '/v1/payouts', query);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#listBalanceTransactions(query: Record<string, any>, stripeAccount: string | null) {
|
|
170
|
+
let data = this.balanceTransactions.filter(t => (t.stripeAccount ?? null) === stripeAccount);
|
|
171
|
+
|
|
172
|
+
if (query.payout) {
|
|
173
|
+
data = data.filter(t => t.payout === query.payout);
|
|
174
|
+
}
|
|
175
|
+
if (query.type) {
|
|
176
|
+
data = data.filter(t => t.type === query.type);
|
|
177
|
+
}
|
|
178
|
+
if (query.created?.gte !== undefined) {
|
|
179
|
+
data = data.filter(t => t.created >= query.created.gte);
|
|
180
|
+
}
|
|
181
|
+
if (query.created?.lte !== undefined) {
|
|
182
|
+
data = data.filter(t => t.created <= query.created.lte);
|
|
183
|
+
}
|
|
184
|
+
return this.#list(data, '/v1/balance_transactions', query);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#listCheckoutSessions(query: Record<string, any>) {
|
|
188
|
+
let data = this.checkoutSessions;
|
|
189
|
+
|
|
190
|
+
if (query.payment_intent) {
|
|
191
|
+
data = data.filter(s => s.payment_intent === query.payment_intent);
|
|
192
|
+
}
|
|
193
|
+
return this.#list(data, '/v1/checkout/sessions', query);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Amounts are in Stripe cents, like the real API.
|
|
198
|
+
*/
|
|
199
|
+
createPayout(data: { amount: number; arrivalDate: Date; status?: string; statementDescriptor?: string; stripeAccount?: string | null } & Record<string, any>): StripeObject {
|
|
200
|
+
const { arrivalDate, statementDescriptor, stripeAccount, ...rest } = data;
|
|
201
|
+
const payout: StripeObject = {
|
|
202
|
+
id: this.createId('po'),
|
|
203
|
+
object: 'payout',
|
|
204
|
+
status: 'paid',
|
|
205
|
+
// Stripe only reports the transactions of a payout it finished reconciling, and never
|
|
206
|
+
// for manual payouts
|
|
207
|
+
reconciliation_status: 'completed',
|
|
208
|
+
automatic: true,
|
|
209
|
+
currency: 'eur',
|
|
210
|
+
arrival_date: Math.floor(arrivalDate.getTime() / 1000),
|
|
211
|
+
statement_descriptor: statementDescriptor ?? 'STAMHOOFD',
|
|
212
|
+
stripeAccount: stripeAccount ?? null,
|
|
213
|
+
...rest,
|
|
214
|
+
};
|
|
215
|
+
this.payouts.push(payout);
|
|
216
|
+
return payout;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The `payout` field links the transaction to a payout, `stripeAccount` scopes it to a
|
|
221
|
+
* connected account (null = platform account). Sources are served exactly as passed, so tests
|
|
222
|
+
* control the expansions.
|
|
223
|
+
*/
|
|
224
|
+
createBalanceTransaction(data: { type: string; amount: number; created: Date; payout?: string | null; stripeAccount?: string | null; fee?: number; source?: unknown } & Record<string, any>): StripeObject {
|
|
225
|
+
const { created, payout, stripeAccount, ...rest } = data;
|
|
226
|
+
const feeDetails = (rest.fee_details ?? []) as { amount: number }[];
|
|
227
|
+
|
|
228
|
+
const transaction: StripeObject = {
|
|
229
|
+
id: this.createId('txn'),
|
|
230
|
+
object: 'balance_transaction',
|
|
231
|
+
currency: 'eur',
|
|
232
|
+
fee_details: [],
|
|
233
|
+
created: Math.floor(created.getTime() / 1000),
|
|
234
|
+
payout: payout ?? null,
|
|
235
|
+
stripeAccount: stripeAccount ?? null,
|
|
236
|
+
...rest,
|
|
237
|
+
// Stripe guarantees these: fee is the sum of its details, net is what the balance
|
|
238
|
+
// actually moved. Deriving them keeps fixtures from describing impossible transactions
|
|
239
|
+
fee: feeDetails.reduce((total, detail) => total + detail.amount, 0),
|
|
240
|
+
net: data.amount - feeDetails.reduce((total, detail) => total + detail.amount, 0),
|
|
241
|
+
};
|
|
242
|
+
this.balanceTransactions.push(transaction);
|
|
243
|
+
return transaction;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* An expanded ApplicationFee object, used as the source of application_fee transactions and as
|
|
248
|
+
* charge.application_fee expansions.
|
|
249
|
+
*/
|
|
250
|
+
createApplicationFee(data: { amount: number; account: string; originatingTransaction?: StripeObject | null; charge?: StripeObject | null } & Record<string, any>): StripeObject {
|
|
251
|
+
const { account, originatingTransaction, charge, ...rest } = data;
|
|
252
|
+
return {
|
|
253
|
+
id: this.createId('fee'),
|
|
254
|
+
object: 'application_fee',
|
|
255
|
+
account,
|
|
256
|
+
originating_transaction: originatingTransaction ?? null,
|
|
257
|
+
charge: charge ?? null,
|
|
258
|
+
...rest,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
createChargeObject(data: Record<string, any> = {}): StripeObject {
|
|
263
|
+
const charge: StripeObject = {
|
|
264
|
+
id: this.createId('ch'),
|
|
265
|
+
object: 'charge',
|
|
266
|
+
metadata: {},
|
|
267
|
+
...data,
|
|
268
|
+
};
|
|
269
|
+
this.charges.push(charge);
|
|
270
|
+
return charge;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
createCheckoutSession(data: { paymentIntent: string } & Record<string, any>): StripeObject {
|
|
274
|
+
const { paymentIntent, ...rest } = data;
|
|
275
|
+
const session: StripeObject = {
|
|
276
|
+
id: this.createId('cs'),
|
|
277
|
+
object: 'checkout.session',
|
|
278
|
+
payment_intent: paymentIntent,
|
|
279
|
+
...rest,
|
|
280
|
+
};
|
|
281
|
+
this.checkoutSessions.push(session);
|
|
282
|
+
return session;
|
|
283
|
+
}
|
|
284
|
+
|
|
93
285
|
#getPaymentIntent(id: string) {
|
|
94
286
|
const intent = this.paymentIntents.find(i => i.id === id);
|
|
95
287
|
if (!intent) {
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
-
import { Organization, Platform } from '@stamhoofd/models';
|
|
3
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
-
import { StripePayoutReporter } from '../helpers/StripePayoutReporter.js';
|
|
5
|
-
|
|
6
|
-
registerCron('stripe-payout-reports', createStripePayoutReports);
|
|
7
|
-
|
|
8
|
-
let lastStripeReport: Date | null = null;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Sends a monthly report of all Stripe payouts of the previous month, so we can check
|
|
12
|
-
* whether everything we charged via Stripe application fees was invoiced correctly.
|
|
13
|
-
*/
|
|
14
|
-
async function createStripePayoutReports() {
|
|
15
|
-
if (STAMHOOFD.environment !== 'production') {
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (STAMHOOFD.userMode === 'platform') {
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (STAMHOOFD.STRIPE_CONNECT_METHOD === 'standard') {
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (new Date().getHours() > 5 || new Date().getHours() < 2) {
|
|
28
|
-
// Fix for sending emails after deployments
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Send the report on the 6th day of the month, once
|
|
33
|
-
const today = new Date();
|
|
34
|
-
if (today.getDate() !== 6 || (lastStripeReport && Formatter.dateWithoutDay(lastStripeReport) === Formatter.dateWithoutDay(today))) {
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (!STAMHOOFD.STRIPE_SECRET_KEY) {
|
|
39
|
-
console.log('No stripe key set');
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
|
|
44
|
-
if (!membershipOrganizationId) {
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
|
|
49
|
-
|
|
50
|
-
console.log('Creating Stripe payout report...');
|
|
51
|
-
|
|
52
|
-
// Previous month
|
|
53
|
-
const previousMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
54
|
-
const end = new Date(new Date(today.getFullYear(), today.getMonth(), 1).getTime() - 1000);
|
|
55
|
-
|
|
56
|
-
const reporter = new StripePayoutReporter({
|
|
57
|
-
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
58
|
-
sellingOrganization: membershipOrganization,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
await reporter.build(previousMonthStart, end);
|
|
62
|
-
await reporter.sendEmail({
|
|
63
|
-
to: [{ email: 'simon@stamhoofd.be' }],
|
|
64
|
-
// Only send the report to the extra (accounting) recipients when it is complete and valid
|
|
65
|
-
extraRecipientsWhenValid: (STAMHOOFD.STRIPE_PAYOUT_REPORT_EMAILS ?? []).map(email => ({ email })),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
lastStripeReport = new Date();
|
|
69
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { EmailMocker } from '@stamhoofd/email';
|
|
3
|
-
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
4
|
-
import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
|
|
5
|
-
import { Token as TokenModel } from '@stamhoofd/models';
|
|
6
|
-
import { QueueHandler } from '@stamhoofd/queues';
|
|
7
|
-
import { Company } from '@stamhoofd/structures';
|
|
8
|
-
import { STExpect } from '@stamhoofd/test-utils';
|
|
9
|
-
import nock from 'nock';
|
|
10
|
-
|
|
11
|
-
import { resetNock } from '../../../../../tests/helpers/resetNock.js';
|
|
12
|
-
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
13
|
-
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
14
|
-
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
15
|
-
import { GetStripePayoutsExportStatusEndpoint } from './GetStripePayoutsExportStatusEndpoint.js';
|
|
16
|
-
import { StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
|
|
17
|
-
|
|
18
|
-
describe('Endpoint.StripePayoutsExport', () => {
|
|
19
|
-
const endpoint = new StripePayoutsExportEndpoint();
|
|
20
|
-
const statusEndpoint = new GetStripePayoutsExportStatusEndpoint();
|
|
21
|
-
|
|
22
|
-
let membershipOrganization: Organization;
|
|
23
|
-
let admin: User;
|
|
24
|
-
let adminToken: Token;
|
|
25
|
-
|
|
26
|
-
beforeAll(async () => {
|
|
27
|
-
membershipOrganization = await initMembershipOrganization();
|
|
28
|
-
membershipOrganization.meta.companies = [
|
|
29
|
-
Company.create({
|
|
30
|
-
name: 'Platform BV',
|
|
31
|
-
companyNumber: '0700000000',
|
|
32
|
-
VATNumber: 'BE0700000000',
|
|
33
|
-
}),
|
|
34
|
-
];
|
|
35
|
-
await membershipOrganization.save();
|
|
36
|
-
|
|
37
|
-
({ admin, adminToken } = await initPlatformAdmin());
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
afterEach(() => {
|
|
41
|
-
resetNock();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const post = async (organization: Organization, token: Token) => {
|
|
45
|
-
const request = Request.buildJson('POST', '/stripe/payouts', organization.getApiHost(), {
|
|
46
|
-
start: new Date(2026, 2, 1).getTime(),
|
|
47
|
-
end: new Date(2026, 3, 1).getTime() - 1000,
|
|
48
|
-
});
|
|
49
|
-
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
50
|
-
return await testServer.test(endpoint, request);
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const getStatus = async (organization: Organization, token: Token) => {
|
|
54
|
-
const request = Request.buildJson('GET', '/stripe/payouts/status', organization.getApiHost());
|
|
55
|
-
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
56
|
-
return await testServer.test(statusEndpoint, request);
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
test('A platform admin can export payout reports for the membership organization', async () => {
|
|
60
|
-
nock('https://api.stripe.com')
|
|
61
|
-
.get('/v1/payouts')
|
|
62
|
-
.query(true)
|
|
63
|
-
.reply(200, { object: 'list', data: [], has_more: false, url: '/v1/payouts' });
|
|
64
|
-
|
|
65
|
-
const response = await post(membershipOrganization, adminToken);
|
|
66
|
-
expect(response.status).toBe(200);
|
|
67
|
-
|
|
68
|
-
// Wait for the scheduled report to complete
|
|
69
|
-
await QueueHandler.awaitAll();
|
|
70
|
-
|
|
71
|
-
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
72
|
-
const reportEmail = emails.find(e => e.subject.startsWith('Stripe Uitbetalingen'));
|
|
73
|
-
expect(reportEmail).toBeDefined();
|
|
74
|
-
expect(reportEmail!.attachments).toHaveLength(1);
|
|
75
|
-
|
|
76
|
-
// The report is sent to the user that requested it
|
|
77
|
-
expect(reportEmail!.to).toContain(admin.email);
|
|
78
|
-
|
|
79
|
-
// The queue is empty again
|
|
80
|
-
const statusResponse = await getStatus(membershipOrganization, adminToken);
|
|
81
|
-
expect(statusResponse.body).toEqual([]);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('A user without platform full access cannot export payout reports', async () => {
|
|
85
|
-
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
86
|
-
const token = await TokenModel.createToken(user);
|
|
87
|
-
|
|
88
|
-
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
89
|
-
STExpect.simpleError({ code: 'permission_denied' }),
|
|
90
|
-
);
|
|
91
|
-
await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
|
|
92
|
-
STExpect.simpleError({ code: 'permission_denied' }),
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('Payout reports are not available for other organizations', async () => {
|
|
97
|
-
const otherOrganization = await new OrganizationFactory({}).create();
|
|
98
|
-
|
|
99
|
-
await expect(post(otherOrganization, adminToken)).rejects.toThrow(
|
|
100
|
-
STExpect.simpleError({ code: 'not_available' }),
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
});
|