@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,109 @@
|
|
|
1
|
+
// Nightly settlement sync for all providers. Everything is an upsert and synced payouts are
|
|
2
|
+
// skipped, so the windowed re-walk is cheap.
|
|
3
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
4
|
+
import { Email } from '@stamhoofd/email';
|
|
5
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
7
|
+
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
8
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
9
|
+
|
|
10
|
+
import { SettlementSyncRunner } from '../helpers/SettlementSyncRunner.js';
|
|
11
|
+
import { isApplicationFeeInvoicingEnabled } from './stripe-invoices.js';
|
|
12
|
+
|
|
13
|
+
registerCron('settlement-sync', syncSettlements);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many days of settlements the nightly run re-walks.
|
|
17
|
+
*/
|
|
18
|
+
const SYNC_WINDOW_DAYS = 30;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fees received in a platform payout are billed by the monthly invoicer, so pendingFees taking a
|
|
22
|
+
* while to reach 0 is normal. Older than this it means the invoicer failed or skipped something.
|
|
23
|
+
*/
|
|
24
|
+
const MAXIMUM_PENDING_FEES_AGE_MS = 31 * 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
let lastSettlementSync: Date | null = null;
|
|
27
|
+
|
|
28
|
+
async function syncSettlements() {
|
|
29
|
+
if (STAMHOOFD.environment !== 'production') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Between 5 and 6 AM Brussels time.
|
|
34
|
+
if (Formatter.luxon().hour !== 5) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wait for the next day before doing a new sync
|
|
39
|
+
const today = new Date();
|
|
40
|
+
if (lastSettlementSync && Formatter.dateIso(lastSettlementSync) === Formatter.dateIso(today)) {
|
|
41
|
+
console.log('Settlement sync done for this day');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('Syncing settlements...');
|
|
46
|
+
|
|
47
|
+
const start = new Date(today.getTime() - SYNC_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
48
|
+
|
|
49
|
+
const runner = new SettlementSyncRunner();
|
|
50
|
+
await runner.run({
|
|
51
|
+
start,
|
|
52
|
+
providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
|
|
53
|
+
stripe: { retryUnsynced: true },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await reportProblemSettlements();
|
|
57
|
+
|
|
58
|
+
lastSettlementSync = new Date();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every unsynced settlement or non-zero delta is a real question to answer, not noise.
|
|
63
|
+
*
|
|
64
|
+
* Exported for tests only.
|
|
65
|
+
*/
|
|
66
|
+
export async function reportProblemSettlements() {
|
|
67
|
+
// Only payouts that actually arrived: money that failed or was canceled holds no transactions
|
|
68
|
+
// to reconcile
|
|
69
|
+
const unexplained = await Settlement.select()
|
|
70
|
+
.where('status', SettlementStatus.Paid)
|
|
71
|
+
.where('unexplainedAmount', '!=', 0)
|
|
72
|
+
.limit(50)
|
|
73
|
+
.fetch();
|
|
74
|
+
|
|
75
|
+
const unsynced = await Settlement.select()
|
|
76
|
+
.where('status', SettlementStatus.Paid)
|
|
77
|
+
.where('syncedAt', null)
|
|
78
|
+
.limit(50)
|
|
79
|
+
.fetch();
|
|
80
|
+
|
|
81
|
+
// Fees only stop being pending once the invoicer bills them: while it is off, every platform
|
|
82
|
+
// payout would be reported forever
|
|
83
|
+
const stalePendingFees = isApplicationFeeInvoicingEnabled()
|
|
84
|
+
? await Settlement.select()
|
|
85
|
+
.where('status', SettlementStatus.Paid)
|
|
86
|
+
.where('pendingFees', '!=', 0)
|
|
87
|
+
.where('settledAt', '<', new Date(Date.now() - MAXIMUM_PENDING_FEES_AGE_MS))
|
|
88
|
+
.limit(50)
|
|
89
|
+
.fetch()
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
if (unexplained.length === 0 && unsynced.length === 0 && stalePendingFees.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
|
|
97
|
+
|
|
98
|
+
const describe = (settlement: Settlement) => {
|
|
99
|
+
return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
Email.sendWebmaster({
|
|
103
|
+
subject: 'Uitbetalingen met problemen: ' + total,
|
|
104
|
+
html: 'Deze uitbetalingen kloppen niet of konden niet gesynchroniseerd worden: <br><br>'
|
|
105
|
+
+ 'Onverklaard verschil:<br>' + (unexplained.map(describe).join('<br>') || 'geen') + '<br><br>'
|
|
106
|
+
+ 'Niet gesynchroniseerd:<br>' + (unsynced.map(describe).join('<br>') || 'geen') + '<br><br>'
|
|
107
|
+
+ 'Kosten te lang niet gefactureerd:<br>' + (stalePendingFees.map(describe).join('<br>') || 'geen'),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
3
|
-
import { StripeInvoicer } from '../helpers/StripeInvoicer.js';
|
|
4
2
|
import { Organization, Platform } from '@stamhoofd/models';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
|
|
5
|
+
import { ApplicationFeeInvoicer } from '../helpers/ApplicationFeeInvoicer.js';
|
|
5
6
|
|
|
6
7
|
registerCron('stripe-invoices', createStripeInvoices);
|
|
7
8
|
|
|
8
9
|
let lastStripeInvoice: Date | null = null;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether application fees are billed automatically. The sync stores what we received either way,
|
|
13
|
+
* so anything that reports on uninvoiced fees has to ask this first: while it is off, fees staying
|
|
14
|
+
* uninvoiced is the expected state, not a problem.
|
|
15
|
+
*
|
|
16
|
+
* The settlements feature is not released yet: allow production at go-live.
|
|
17
|
+
*/
|
|
18
|
+
export function isApplicationFeeInvoicingEnabled(): boolean {
|
|
19
|
+
return (STAMHOOFD.environment as string) === 'development'
|
|
20
|
+
&& STAMHOOFD.userMode !== 'platform'
|
|
21
|
+
&& STAMHOOFD.STRIPE_CONNECT_METHOD !== 'standard';
|
|
22
|
+
}
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
async function createStripeInvoices() {
|
|
25
|
+
if (!isApplicationFeeInvoicingEnabled()) {
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -41,9 +47,9 @@ async function createStripeInvoices() {
|
|
|
41
47
|
|
|
42
48
|
const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
|
|
43
49
|
|
|
44
|
-
const invoicer = new
|
|
50
|
+
const invoicer = new ApplicationFeeInvoicer({
|
|
45
51
|
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
46
52
|
});
|
|
47
|
-
await invoicer.
|
|
53
|
+
await invoicer.generateInvoices(membershipOrganization);
|
|
48
54
|
lastStripeInvoice = new Date();
|
|
49
55
|
}
|
package/src/crons.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { registerCron } from '@stamhoofd/crons';
|
|
|
3
3
|
import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
|
|
4
4
|
import { SQL } from '@stamhoofd/sql';
|
|
5
5
|
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
6
|
-
import { checkSettlements } from './helpers/CheckSettlements.js';
|
|
7
6
|
import { OrganizationDNSService } from './services/OrganizationDNSService.js';
|
|
8
7
|
import { PaymentService } from './services/PaymentService.js';
|
|
9
8
|
import { RegistrationService } from './services/RegistrationService.js';
|
|
@@ -193,7 +192,6 @@ async function checkOldPayments() {
|
|
|
193
192
|
// 5 days - 10 days
|
|
194
193
|
async function checkOldDirectDebitPayments() {
|
|
195
194
|
let timeout = 60 * 1000 * 60 * 24 * 5;
|
|
196
|
-
const timeout2 = 60 * 1000 * 60 * 24 * 10;
|
|
197
195
|
|
|
198
196
|
if (STAMHOOFD.environment === 'development') {
|
|
199
197
|
// For Mollie, webhooks won't work, so we poll in the backend
|
|
@@ -207,8 +205,7 @@ async function checkOldDirectDebitPayments() {
|
|
|
207
205
|
PaymentMethod.DirectDebit,
|
|
208
206
|
])
|
|
209
207
|
.and('status', [PaymentStatus.Created, PaymentStatus.Pending])
|
|
210
|
-
.and('createdAt', '<', new Date(new Date().getTime() - timeout))
|
|
211
|
-
.and('createdAt', '>', new Date(new Date().getTime() - timeout2)),
|
|
208
|
+
.and('createdAt', '<', new Date(new Date().getTime() - timeout)),
|
|
212
209
|
)
|
|
213
210
|
.orderBy('createdAt', 'ASC')
|
|
214
211
|
.limit(500)
|
|
@@ -357,7 +354,6 @@ async function checkReservedUntil() {
|
|
|
357
354
|
}
|
|
358
355
|
}
|
|
359
356
|
|
|
360
|
-
registerCron('checkSettlements', checkSettlements);
|
|
361
357
|
registerCron('checkExpirationEmails', checkExpirationEmails);
|
|
362
358
|
registerCron('checkReservedUntil', checkReservedUntil);
|
|
363
359
|
registerCron('checkDNS', checkDNS);
|
package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Request } from '@simonbackx/simple-endpoints';
|
|
|
2
2
|
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
3
|
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice,
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, TranslatedString } from '@stamhoofd/structures';
|
|
6
6
|
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
7
|
import type { BalanceItemBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
8
|
import { BreakdownAmountType, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
@@ -254,13 +254,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
describe('Grouping by payout', () => {
|
|
257
|
-
const march =
|
|
258
|
-
const april =
|
|
257
|
+
const march = SettlementReference.create({ id: 'stl_march', reference: '1234567.0312.01', settledAt: new Date(2026, 2, 12), amount: 0 });
|
|
258
|
+
const april = SettlementReference.create({ id: 'stl_april', reference: '1234567.0409.01', settledAt: new Date(2026, 3, 9), amount: 0 });
|
|
259
259
|
|
|
260
260
|
/**
|
|
261
261
|
* Pays a part of a balance item, the way a payment provider settles it afterwards.
|
|
262
262
|
*/
|
|
263
|
-
const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?:
|
|
263
|
+
const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?: SettlementReference; status?: PaymentStatus; method?: PaymentMethod }) => {
|
|
264
264
|
const payment = new Payment();
|
|
265
265
|
payment.organizationId = organization.id;
|
|
266
266
|
payment.method = options.method ?? PaymentMethod.Bancontact;
|
|
@@ -7,8 +7,8 @@ import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
|
7
7
|
import { PermissionLevel } from '@stamhoofd/structures';
|
|
8
8
|
|
|
9
9
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
10
|
-
import { checkMollieSettlementsFor } from '../../../../helpers/CheckSettlements.js';
|
|
11
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
|
+
import { MollieSettlementSync } from '../../../../helpers/MollieSettlementSync.js';
|
|
12
12
|
import { MollieService } from '../../../../services/MollieService.js';
|
|
13
13
|
|
|
14
14
|
type Params = Record<string, never>;
|
|
@@ -53,7 +53,7 @@ export class ConnectMollieEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
53
53
|
await service.setupOnboarding();
|
|
54
54
|
|
|
55
55
|
// Check settlements after linking (shouldn't block)
|
|
56
|
-
|
|
56
|
+
new MollieSettlementSync({ token: mollieToken }).syncSettlements({ start: organization.createdAt }).catch(console.error);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
return new Response(await AuthenticatedStructures.organization(organization));
|
|
@@ -667,7 +667,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
667
667
|
* A custom PEPPOL endpoint id may only be set by full platform admins for now.
|
|
668
668
|
*/
|
|
669
669
|
private requirePeppolPermission() {
|
|
670
|
-
if (!Context.auth.hasPlatformFullAccess()) {
|
|
670
|
+
if (!Context.auth.hasPlatformFullAccess() && STAMHOOFD.userMode === 'platform') {
|
|
671
671
|
throw new SimpleError({
|
|
672
672
|
code: 'permission_denied',
|
|
673
673
|
message: 'Only platform admins can set a custom PEPPOL endpoint id',
|
|
@@ -2,7 +2,7 @@ import { Request } from '@simonbackx/simple-endpoints';
|
|
|
2
2
|
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
3
|
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, StripeAccount, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice,
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, StripeBusinessProfile, StripeMetaData, TransferSettings, TranslatedString } from '@stamhoofd/structures';
|
|
6
6
|
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
7
|
import type { PaymentBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
8
|
import { BreakdownGraphUnit, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
@@ -142,7 +142,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
142
142
|
iban?: string;
|
|
143
143
|
transferDescription?: string;
|
|
144
144
|
transferFee?: number;
|
|
145
|
-
settlement?:
|
|
145
|
+
settlement?: SettlementReference;
|
|
146
146
|
/**
|
|
147
147
|
* What this payment rounded away, because a payment goes to the cent while what was charged
|
|
148
148
|
* goes to four digits after the comma.
|
|
@@ -631,7 +631,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
631
631
|
|
|
632
632
|
describe('Grouping by payout', () => {
|
|
633
633
|
const createSettlement = (reference: string, settledAt: Date) => {
|
|
634
|
-
return
|
|
634
|
+
return SettlementReference.create({ id: 'stl_' + reference, reference, settledAt, amount: 0 });
|
|
635
635
|
};
|
|
636
636
|
|
|
637
637
|
test('per payout, with what is not online and what still has to be paid out apart', async () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import type { Organization, User } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, StripeAccount, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { PaginatedResponse, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
7
|
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { SettlementService } from '../../../../services/SettlementService.js';
|
|
7
9
|
import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
|
|
8
10
|
|
|
9
11
|
// These tests exercise the balance-item filters reused inside the payments query (balanceItemPayments ->
|
|
@@ -155,4 +157,150 @@ describe('Endpoint.GetPaymentsEndpoint', () => {
|
|
|
155
157
|
expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
|
|
156
158
|
});
|
|
157
159
|
});
|
|
160
|
+
|
|
161
|
+
describe('Settlements of a payment', () => {
|
|
162
|
+
const createSettledPayment = async (organization: Organization) => {
|
|
163
|
+
const payment = new Payment();
|
|
164
|
+
payment.method = PaymentMethod.Bancontact;
|
|
165
|
+
payment.provider = PaymentProvider.Stripe;
|
|
166
|
+
payment.status = PaymentStatus.Succeeded;
|
|
167
|
+
payment.organizationId = organization.id;
|
|
168
|
+
payment.price = 50_00_00;
|
|
169
|
+
payment.paidAt = new Date();
|
|
170
|
+
await payment.save();
|
|
171
|
+
|
|
172
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
173
|
+
provider: PaymentProvider.Stripe,
|
|
174
|
+
externalId: 'po_' + payment.id,
|
|
175
|
+
organizationId: organization.id,
|
|
176
|
+
reference: 'STRIPE PAYOUT',
|
|
177
|
+
amount: 49_00_00,
|
|
178
|
+
settledAt: new Date(2026, 0, 15),
|
|
179
|
+
});
|
|
180
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
181
|
+
paymentId: payment.id,
|
|
182
|
+
amount: 50_00_00,
|
|
183
|
+
externalId: 'txn_' + payment.id,
|
|
184
|
+
occurredAt: new Date(2026, 0, 14),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { payment, settlement };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
test('the m2m settlement filter selects only payments in a matching payout', async () => {
|
|
191
|
+
const organization = await new OrganizationFactory({}).create();
|
|
192
|
+
const user = await createFinanceUser(organization);
|
|
193
|
+
|
|
194
|
+
const { payment, settlement } = await createSettledPayment(organization);
|
|
195
|
+
|
|
196
|
+
// Negative control: a payment without settlement rows
|
|
197
|
+
const other = new Payment();
|
|
198
|
+
other.method = PaymentMethod.Bancontact;
|
|
199
|
+
other.provider = PaymentProvider.Stripe;
|
|
200
|
+
other.status = PaymentStatus.Succeeded;
|
|
201
|
+
other.organizationId = organization.id;
|
|
202
|
+
other.price = 10_00_00;
|
|
203
|
+
await other.save();
|
|
204
|
+
|
|
205
|
+
const response = await getPayments({
|
|
206
|
+
filter: {
|
|
207
|
+
settlements: {
|
|
208
|
+
$elemMatch: {
|
|
209
|
+
settlement: {
|
|
210
|
+
externalId: settlement.externalId,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
organization,
|
|
216
|
+
user,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(response.status).toBe(200);
|
|
220
|
+
expect(response.body.results.map(r => r.id)).toEqual([payment.id]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an admin of the organization receives the settlements of a payment', async () => {
|
|
224
|
+
const organization = await new OrganizationFactory({}).create();
|
|
225
|
+
const user = await createFinanceUser(organization);
|
|
226
|
+
|
|
227
|
+
const { payment, settlement } = await createSettledPayment(organization);
|
|
228
|
+
|
|
229
|
+
const response = await getPayments({
|
|
230
|
+
filter: { id: payment.id },
|
|
231
|
+
organization,
|
|
232
|
+
user,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(response.status).toBe(200);
|
|
236
|
+
expect(response.body.results).toHaveLength(1);
|
|
237
|
+
|
|
238
|
+
const result = response.body.results[0];
|
|
239
|
+
expect(result.settlements).toHaveLength(1);
|
|
240
|
+
expect(result.settlements[0]).toMatchObject({
|
|
241
|
+
paymentId: payment.id,
|
|
242
|
+
amount: 50_00_00,
|
|
243
|
+
externalId: 'txn_' + payment.id,
|
|
244
|
+
});
|
|
245
|
+
expect(result.settlements[0].settlement).toMatchObject({
|
|
246
|
+
externalId: settlement.externalId,
|
|
247
|
+
reference: 'STRIPE PAYOUT',
|
|
248
|
+
amount: 49_00_00,
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('the platform payout of a destination charge is never returned to the organization', async () => {
|
|
253
|
+
const organization = await new OrganizationFactory({}).create();
|
|
254
|
+
const user = await createFinanceUser(organization);
|
|
255
|
+
|
|
256
|
+
const stripeAccount = new StripeAccount();
|
|
257
|
+
stripeAccount.organizationId = organization.id;
|
|
258
|
+
stripeAccount.accountId = 'acct_' + uuidv4();
|
|
259
|
+
await stripeAccount.save();
|
|
260
|
+
|
|
261
|
+
const payment = new Payment();
|
|
262
|
+
payment.method = PaymentMethod.Bancontact;
|
|
263
|
+
payment.provider = PaymentProvider.Stripe;
|
|
264
|
+
payment.status = PaymentStatus.Succeeded;
|
|
265
|
+
payment.organizationId = organization.id;
|
|
266
|
+
payment.stripeAccountId = stripeAccount.id;
|
|
267
|
+
payment.price = 50_00_00;
|
|
268
|
+
payment.paidAt = new Date();
|
|
269
|
+
await payment.save();
|
|
270
|
+
|
|
271
|
+
const organizationPayout = await SettlementService.upsertSettlement({
|
|
272
|
+
provider: PaymentProvider.Stripe,
|
|
273
|
+
externalId: 'po_org_' + payment.id,
|
|
274
|
+
stripeAccountId: stripeAccount.id,
|
|
275
|
+
organizationId: organization.id,
|
|
276
|
+
amount: 49_00_00,
|
|
277
|
+
settledAt: new Date(2026, 0, 15),
|
|
278
|
+
});
|
|
279
|
+
await SettlementService.upsertPaymentLine(organizationPayout, {
|
|
280
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_org_' + payment.id, occurredAt: new Date(2026, 0, 14),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// The same gross charge also sits in our platform payout, owned by the membership
|
|
284
|
+
// organization
|
|
285
|
+
const membershipOrganization = await new OrganizationFactory({}).create();
|
|
286
|
+
const platformPayout = await SettlementService.upsertSettlement({
|
|
287
|
+
provider: PaymentProvider.Stripe,
|
|
288
|
+
externalId: 'po_platform_' + payment.id,
|
|
289
|
+
stripeAccountId: null,
|
|
290
|
+
organizationId: membershipOrganization.id,
|
|
291
|
+
amount: 1234_00_00,
|
|
292
|
+
settledAt: new Date(2026, 0, 12),
|
|
293
|
+
});
|
|
294
|
+
await SettlementService.upsertPaymentLine(platformPayout, {
|
|
295
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_platform_' + payment.id, occurredAt: new Date(2026, 0, 11),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const response = await getPayments({ filter: { id: payment.id }, organization, user });
|
|
299
|
+
|
|
300
|
+
expect(response.status).toBe(200);
|
|
301
|
+
const result = response.body.results[0];
|
|
302
|
+
expect(result.settlements).toHaveLength(1);
|
|
303
|
+
expect(result.settlements[0].settlement.externalId).toBe(organizationPayout.externalId);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
158
306
|
});
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { SettlementsSyncStatus } from '@stamhoofd/structures/settlements/SettlementsSyncStatus.js';
|
|
5
|
+
|
|
6
|
+
import { SettlementsSyncEndpoint } from './SettlementsSyncEndpoint.js';
|
|
5
7
|
|
|
6
8
|
type Params = Record<string, never>;
|
|
7
9
|
type Body = undefined;
|
|
8
10
|
type Query = undefined;
|
|
9
|
-
type ResponseBody =
|
|
11
|
+
type ResponseBody = SettlementsSyncStatus[];
|
|
10
12
|
|
|
11
|
-
export class
|
|
13
|
+
export class GetSettlementsSyncStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
12
14
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
13
15
|
if (request.method !== 'GET') {
|
|
14
16
|
return [false];
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
const params = Endpoint.parseParameters(request.url, '/
|
|
19
|
+
const params = Endpoint.parseParameters(request.url, '/settlements/sync/status', {});
|
|
18
20
|
|
|
19
21
|
if (params) {
|
|
20
22
|
return [true, params as Params];
|
|
@@ -23,10 +25,10 @@ export class GetStripePayoutsExportStatusEndpoint extends Endpoint<Params, Query
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
26
|
-
await
|
|
28
|
+
await SettlementsSyncEndpoint.authenticate();
|
|
27
29
|
|
|
28
|
-
return new Response(
|
|
29
|
-
return
|
|
30
|
+
return new Response(SettlementsSyncEndpoint.queue.map((item) => {
|
|
31
|
+
return SettlementsSyncStatus.create(item);
|
|
30
32
|
}));
|
|
31
33
|
}
|
|
32
34
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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, Payment, Token as TokenModel, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
7
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
8
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
9
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
10
|
+
|
|
11
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
12
|
+
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
13
|
+
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
14
|
+
import { SettlementService } from '../../../../services/SettlementService.js';
|
|
15
|
+
import { SettlementsExportEndpoint } from './SettlementsExportEndpoint.js';
|
|
16
|
+
|
|
17
|
+
describe('Endpoint.SettlementsExport', () => {
|
|
18
|
+
const endpoint = new SettlementsExportEndpoint();
|
|
19
|
+
|
|
20
|
+
let membershipOrganization: Organization;
|
|
21
|
+
let admin: User;
|
|
22
|
+
let adminToken: Token;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
membershipOrganization = await initMembershipOrganization();
|
|
26
|
+
membershipOrganization.privateMeta.featureFlags = ['settlements'];
|
|
27
|
+
await membershipOrganization.save();
|
|
28
|
+
|
|
29
|
+
({ admin, adminToken } = await initPlatformAdmin());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const createFinanceAdmin = async (organization: Organization) => {
|
|
33
|
+
const user = await new UserFactory({
|
|
34
|
+
organization,
|
|
35
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
36
|
+
}).create();
|
|
37
|
+
return { user, token: await TokenModel.createToken(user) };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const post = async (organization: Organization, token: Token, { start = new Date(2026, 0, 1), end = new Date(2026, 1, 1) } = {}) => {
|
|
41
|
+
const request = Request.buildJson('POST', '/settlements/export', organization.getApiHost(), {
|
|
42
|
+
start: start.getTime(),
|
|
43
|
+
end: end.getTime(),
|
|
44
|
+
});
|
|
45
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
46
|
+
return await testServer.test(endpoint, request);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const createSettledPayment = async () => {
|
|
50
|
+
const organization = await new OrganizationFactory({}).create();
|
|
51
|
+
|
|
52
|
+
const payment = new Payment();
|
|
53
|
+
payment.organizationId = organization.id;
|
|
54
|
+
payment.method = PaymentMethod.Bancontact;
|
|
55
|
+
payment.provider = PaymentProvider.Mollie;
|
|
56
|
+
payment.status = PaymentStatus.Succeeded;
|
|
57
|
+
payment.price = 50_00_00;
|
|
58
|
+
payment.paidAt = new Date(2026, 0, 10);
|
|
59
|
+
await payment.save();
|
|
60
|
+
|
|
61
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
62
|
+
provider: PaymentProvider.Mollie,
|
|
63
|
+
externalId: 'stl_' + uuidv4(),
|
|
64
|
+
organizationId: organization.id,
|
|
65
|
+
reference: '1234567.2601.01',
|
|
66
|
+
amount: 49_63_70,
|
|
67
|
+
settledAt: new Date(2026, 0, 15),
|
|
68
|
+
});
|
|
69
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
70
|
+
paymentId: payment.id,
|
|
71
|
+
amount: 50_00_00,
|
|
72
|
+
externalId: 'tr_' + uuidv4(),
|
|
73
|
+
occurredAt: new Date(2026, 0, 15),
|
|
74
|
+
});
|
|
75
|
+
await SettlementService.upsertCharge({
|
|
76
|
+
type: SettlementChargeType.ProviderTransactionFee,
|
|
77
|
+
externalId: settlement.externalId + ':cost:0',
|
|
78
|
+
amount: -30_00,
|
|
79
|
+
settlementId: settlement.id,
|
|
80
|
+
organizationId: settlement.organizationId,
|
|
81
|
+
providerInvoiceId: 'inv_123',
|
|
82
|
+
description: 'Transactiekosten',
|
|
83
|
+
occurredAt: new Date(2026, 0, 15),
|
|
84
|
+
});
|
|
85
|
+
await SettlementService.upsertCharge({
|
|
86
|
+
type: SettlementChargeType.Tax,
|
|
87
|
+
externalId: settlement.externalId + ':cost:0:tax',
|
|
88
|
+
amount: -6_30,
|
|
89
|
+
settlementId: settlement.id,
|
|
90
|
+
organizationId: settlement.organizationId,
|
|
91
|
+
providerInvoiceId: 'inv_123',
|
|
92
|
+
description: 'BTW op transactiekosten',
|
|
93
|
+
occurredAt: new Date(2026, 0, 15),
|
|
94
|
+
});
|
|
95
|
+
await SettlementService.finishSync(settlement, { transactionCount: 3 });
|
|
96
|
+
|
|
97
|
+
return { organization, payment, settlement };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
test('A platform admin receives the report by email', async () => {
|
|
101
|
+
await createSettledPayment();
|
|
102
|
+
|
|
103
|
+
const response = await post(membershipOrganization, adminToken);
|
|
104
|
+
expect(response.status).toBe(200);
|
|
105
|
+
|
|
106
|
+
await QueueHandler.awaitAll();
|
|
107
|
+
|
|
108
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
109
|
+
const reportEmail = emails.find(e => e.subject.startsWith('Uitbetalingen export'));
|
|
110
|
+
expect(reportEmail).toBeDefined();
|
|
111
|
+
expect(reportEmail!.attachments).toHaveLength(1);
|
|
112
|
+
expect(reportEmail!.attachments![0].filename).toContain('.xlsx');
|
|
113
|
+
expect(reportEmail!.to).toContain(admin.email);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('A user without finance access cannot export settlements', async () => {
|
|
117
|
+
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
118
|
+
const token = await TokenModel.createToken(user);
|
|
119
|
+
|
|
120
|
+
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
121
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('A finance admin exports the settlements of their own organization', async () => {
|
|
126
|
+
const { organization } = await createSettledPayment();
|
|
127
|
+
organization.privateMeta.featureFlags = ['settlements'];
|
|
128
|
+
await organization.save();
|
|
129
|
+
|
|
130
|
+
const { user, token } = await createFinanceAdmin(organization);
|
|
131
|
+
|
|
132
|
+
const response = await post(organization, token);
|
|
133
|
+
expect(response.status).toBe(200);
|
|
134
|
+
|
|
135
|
+
await QueueHandler.awaitAll();
|
|
136
|
+
|
|
137
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
138
|
+
const reportEmail = emails.filter(e => e.subject.startsWith('Uitbetalingen export')).at(-1);
|
|
139
|
+
expect(reportEmail).toBeDefined();
|
|
140
|
+
expect(reportEmail!.to).toContain(user.email);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Without the feature flag the export is not available', async () => {
|
|
144
|
+
const organization = await new OrganizationFactory({}).create();
|
|
145
|
+
const { token } = await createFinanceAdmin(organization);
|
|
146
|
+
|
|
147
|
+
await expect(post(organization, token)).rejects.toThrow(
|
|
148
|
+
STExpect.simpleError({ code: 'not_available' }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('An unbounded range is rejected', async () => {
|
|
153
|
+
await expect(post(membershipOrganization, adminToken, { start: new Date(2020, 0, 1), end: new Date(2026, 0, 1) })).rejects.toThrow(
|
|
154
|
+
STExpect.simpleError({ code: 'range_too_large' }),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|