@stamhoofd/backend 2.141.0 → 2.143.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/boot.ts +31 -16
- package/src/crons/settlement-sync.test.ts +59 -1
- package/src/crons/settlement-sync.ts +20 -9
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
- package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
- package/src/helpers/MollieSettlementSync.test.ts +43 -0
- package/src/helpers/MollieSettlementSync.ts +29 -4
- package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
- package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
- package/src/helpers/SettlementExporter.test.ts +22 -0
- package/src/helpers/SettlementExporter.ts +13 -3
- package/src/helpers/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +375 -1
- package/src/helpers/StripeSettlementSync.ts +264 -113
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +47 -11
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.test.ts +82 -9
- package/src/services/SettlementService.ts +72 -6
- package/src/services/WebshopCrowdfundingService.test.ts +433 -0
- package/src/services/WebshopCrowdfundingService.ts +98 -0
- package/tests/filters/orders.test.ts +24 -1
- package/tests/vitest.setup.ts +5 -2
package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts
CHANGED
|
@@ -35,11 +35,15 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
38
|
-
const organization = await Context.
|
|
38
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
39
39
|
await Context.authenticate();
|
|
40
40
|
|
|
41
41
|
// Fast throw first (more in depth checking for patches later)
|
|
42
|
-
if (
|
|
42
|
+
if (organization) {
|
|
43
|
+
if (!await Context.auth.hasSomeAccess(organization.id)) {
|
|
44
|
+
throw Context.auth.error();
|
|
45
|
+
}
|
|
46
|
+
} else if (!Context.auth.hasSomePlatformAccess()) {
|
|
43
47
|
throw Context.auth.error();
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -48,11 +52,11 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
48
52
|
|
|
49
53
|
const puts = request.body.getPuts();
|
|
50
54
|
for (const { put } of puts) {
|
|
51
|
-
await this.checkCanCreateRegistrationInvitation(put, organization
|
|
55
|
+
const group = await this.checkCanCreateRegistrationInvitation(put, organization?.id ?? null);
|
|
52
56
|
|
|
53
57
|
const invitation = new RegistrationInvitation();
|
|
54
58
|
invitation.id = put.id;
|
|
55
|
-
invitation.organizationId =
|
|
59
|
+
invitation.organizationId = group.organizationId;
|
|
56
60
|
invitation.groupId = put.groupId;
|
|
57
61
|
invitation.memberId = put.memberId;
|
|
58
62
|
|
|
@@ -125,12 +129,13 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
125
129
|
/**
|
|
126
130
|
* Will throw if not allowed to invite.
|
|
127
131
|
* @param invitation
|
|
128
|
-
* @param organizationId
|
|
132
|
+
* @param organizationId organization scope of the request; when set, the group must belong to it (platform admins can invite without a scope)
|
|
133
|
+
* @returns the group to invite for
|
|
129
134
|
*/
|
|
130
|
-
private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string) {
|
|
135
|
+
private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string | null): Promise<Group> {
|
|
131
136
|
const group = await Group.getByID(invitation.groupId);
|
|
132
137
|
|
|
133
|
-
if (!group || group.organizationId !== organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
|
|
138
|
+
if (!group || (organizationId !== null && group.organizationId !== organizationId) || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
|
|
134
139
|
throw Context.auth.error($t(`%1ST`));
|
|
135
140
|
}
|
|
136
141
|
|
|
@@ -147,7 +152,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
147
152
|
|
|
148
153
|
if (!member
|
|
149
154
|
// in userMode 'organization' we can only invite members from the same organization
|
|
150
|
-
|| (STAMHOOFD.userMode === 'organization' && member.organizationId !== organizationId)
|
|
155
|
+
|| (STAMHOOFD.userMode === 'organization' && member.organizationId !== group.organizationId)
|
|
151
156
|
// read access is suficient
|
|
152
157
|
|| !await Context.auth.canAccessMember(member, PermissionLevel.Read)
|
|
153
158
|
) {
|
|
@@ -163,5 +168,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
163
168
|
human: $t('%1S2'),
|
|
164
169
|
});
|
|
165
170
|
}
|
|
171
|
+
|
|
172
|
+
return group;
|
|
166
173
|
}
|
|
167
174
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
3
|
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
3
4
|
import { OrganizationFactory, Payment, Token as TokenModel, UserFactory } from '@stamhoofd/models';
|
|
4
5
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import type { AbortSignal } from '@stamhoofd/queues';
|
|
5
7
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
8
|
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
7
9
|
import { STExpect } from '@stamhoofd/test-utils';
|
|
10
|
+
import { vi } from 'vitest';
|
|
8
11
|
|
|
9
12
|
import { StripeMocker } from '../../../../../tests/helpers/StripeMocker.js';
|
|
13
|
+
import { SettlementSyncRunner } from '../../../../helpers/SettlementSyncRunner.js';
|
|
10
14
|
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
11
15
|
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
12
16
|
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
@@ -88,6 +92,86 @@ describe('Endpoint.SettlementsSync', () => {
|
|
|
88
92
|
expect(statusResponse.body).toEqual([]);
|
|
89
93
|
});
|
|
90
94
|
|
|
95
|
+
const shutdown = () => {
|
|
96
|
+
QueueHandler.abortAll(new SimpleError({
|
|
97
|
+
code: 'SHUTDOWN',
|
|
98
|
+
message: 'Shutting down',
|
|
99
|
+
statusCode: 503,
|
|
100
|
+
}));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
test('A sync that is canceled by a shutdown walks nothing and leaves no status behind', async () => {
|
|
104
|
+
const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
|
|
105
|
+
stripeMocker.createBalanceTransaction({
|
|
106
|
+
type: 'stripe_fee',
|
|
107
|
+
amount: 10000,
|
|
108
|
+
created: new Date(2026, 0, 15),
|
|
109
|
+
payout: payout.id,
|
|
110
|
+
source: null,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Occupy the queue, so the sync is still waiting its turn when the shutdown aborts it
|
|
114
|
+
let release = () => {};
|
|
115
|
+
const occupied = QueueHandler.schedule('settlement-sync', async () => {
|
|
116
|
+
await new Promise<void>((resolve) => {
|
|
117
|
+
release = resolve;
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await post(membershipOrganization, adminToken);
|
|
123
|
+
expect(response.status).toBe(200);
|
|
124
|
+
expect((await getStatus(membershipOrganization, adminToken)).body).toHaveLength(1);
|
|
125
|
+
|
|
126
|
+
shutdown();
|
|
127
|
+
} finally {
|
|
128
|
+
release();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await occupied;
|
|
132
|
+
await QueueHandler.awaitAll();
|
|
133
|
+
|
|
134
|
+
expect(await Settlement.select().where('externalId', payout.id).count()).toBe(0);
|
|
135
|
+
|
|
136
|
+
// The status list may not keep a sync that never ran
|
|
137
|
+
expect((await getStatus(membershipOrganization, adminToken)).body).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('A shutdown interrupts a sync that is already walking', async () => {
|
|
141
|
+
let running: AbortSignal | null = null;
|
|
142
|
+
let stopWalking = () => {};
|
|
143
|
+
|
|
144
|
+
const spy = vi.spyOn(SettlementSyncRunner.prototype, 'run').mockImplementation(async ({ abort } = {}) => {
|
|
145
|
+
running = abort ?? null;
|
|
146
|
+
|
|
147
|
+
// Walk until the shutdown asks us to stop
|
|
148
|
+
await new Promise<void>((resolve) => {
|
|
149
|
+
stopWalking = resolve;
|
|
150
|
+
abort?.on('abort', () => resolve());
|
|
151
|
+
});
|
|
152
|
+
abort?.throwIfAborted();
|
|
153
|
+
|
|
154
|
+
return { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const response = await post(membershipOrganization, adminToken);
|
|
159
|
+
expect(response.status).toBe(200);
|
|
160
|
+
await vi.waitFor(() => expect(running).not.toBeNull());
|
|
161
|
+
|
|
162
|
+
shutdown();
|
|
163
|
+
await QueueHandler.awaitAll();
|
|
164
|
+
} finally {
|
|
165
|
+
// A failed assertion may not leave the walk (and the queue behind it) parked forever
|
|
166
|
+
stopWalking();
|
|
167
|
+
spy.mockRestore();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The signal of the queue job reaches the runner, so the walk stops at its next safe point
|
|
171
|
+
expect(running!.isAborted).toBe(true);
|
|
172
|
+
expect((await getStatus(membershipOrganization, adminToken)).body).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
91
175
|
test('A user without platform full access cannot run the sync', async () => {
|
|
92
176
|
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
93
177
|
const token = await TokenModel.createToken(user);
|
|
@@ -86,17 +86,18 @@ export class SettlementsSyncEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
86
86
|
});
|
|
87
87
|
SettlementsSyncEndpoint.queue.push(item);
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
// A shutdown aborts the queue: the run stops at its next safe point instead of holding up
|
|
90
|
+
// the restart, and the status list is cleaned up whether the run finished, was aborted, or
|
|
91
|
+
// was canceled before it started
|
|
92
|
+
QueueHandler.schedule('settlement-sync', async ({ abort }) => {
|
|
93
|
+
const runner = new SettlementSyncRunner();
|
|
94
|
+
runner.callback = (summary) => {
|
|
95
|
+
item.count = summary.synced + summary.skipped + summary.failed;
|
|
96
|
+
item.failed = summary.failed + summary.failedFeeMonths;
|
|
97
|
+
};
|
|
98
|
+
await runner.run({ start, end, providers, stripe: { force }, abort });
|
|
99
|
+
}).finally(() => {
|
|
100
|
+
SettlementsSyncEndpoint.queue = SettlementsSyncEndpoint.queue.filter(queued => queued !== item);
|
|
100
101
|
}).catch(console.error);
|
|
101
102
|
|
|
102
103
|
return new Response(undefined);
|
|
@@ -9,6 +9,7 @@ import { Formatter, isReservedWebshopPathSegment } from '@stamhoofd/utility';
|
|
|
9
9
|
|
|
10
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
11
|
import { RecordAnswerHelper } from '../../../../helpers/RecordAnswerHelper.js';
|
|
12
|
+
import { WebshopCrowdfundingService } from '../../../../services/WebshopCrowdfundingService.js';
|
|
12
13
|
|
|
13
14
|
type Params = { id: string };
|
|
14
15
|
type Query = undefined;
|
|
@@ -226,6 +227,11 @@ export class PatchWebshopEndpoint extends Endpoint<Params, Query, Body, Response
|
|
|
226
227
|
throw e;
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
if (request.body.meta?.crowdfunding !== undefined) {
|
|
231
|
+
// A changed crowdfunding configuration (e.g. just enabled) requires a recalculation of the cached amounts
|
|
232
|
+
await WebshopCrowdfundingService.updateWebshop(webshop);
|
|
233
|
+
}
|
|
234
|
+
|
|
229
235
|
return new Response(PrivateWebshop.create(webshop));
|
|
230
236
|
});
|
|
231
237
|
}
|
|
@@ -10,6 +10,7 @@ import { AuditLogSource, BalanceItemRelation, BalanceItemRelationType, BalanceIt
|
|
|
10
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
11
|
import { ServiceFeeHelper } from '../../../../helpers/ServiceFeeHelper.js';
|
|
12
12
|
import { AuditLogService } from '../../../../services/AuditLogService.js';
|
|
13
|
+
import { BalanceItemService } from '../../../../services/BalanceItemService.js';
|
|
13
14
|
import { OrderService } from '../../../../services/OrderService.js';
|
|
14
15
|
import { PaymentService } from '../../../../services/PaymentService.js';
|
|
15
16
|
import { shouldReserveUitpasNumbers, UitpasService } from '../../../../services/uitpas/UitpasService.js';
|
|
@@ -232,6 +233,9 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
232
233
|
|
|
233
234
|
balanceItem.description = order.generateBalanceDescription(webshop);
|
|
234
235
|
await balanceItem.save();
|
|
236
|
+
|
|
237
|
+
// Update the cached pricePending of the balance item, so the unresolved payment is visible
|
|
238
|
+
await BalanceItemService.updatePaidAndPending([balanceItem]);
|
|
235
239
|
}
|
|
236
240
|
} catch (e) {
|
|
237
241
|
await order.deleteOrderBecauseOfCreationError();
|
|
@@ -15,6 +15,7 @@ import { Context } from '../../../helpers/Context.js';
|
|
|
15
15
|
import { ServiceFeeHelper } from '../../../helpers/ServiceFeeHelper.js';
|
|
16
16
|
import { StripeHelper } from '../../../helpers/StripeHelper.js';
|
|
17
17
|
import { AuditLogService } from '../../../services/AuditLogService.js';
|
|
18
|
+
import { BalanceItemService } from '../../../services/BalanceItemService.js';
|
|
18
19
|
import { MollieService } from '../../../services/MollieService.js';
|
|
19
20
|
import { OrderService } from '../../../services/OrderService.js';
|
|
20
21
|
import { PaymentService } from '../../../services/PaymentService.js';
|
|
@@ -356,6 +357,9 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
// Update the cached pricePending of the balance item, so the unresolved payment is visible
|
|
361
|
+
await BalanceItemService.updatePaidAndPending([balanceItem]);
|
|
362
|
+
|
|
359
363
|
return new Response(OrderResponse.create({
|
|
360
364
|
paymentUrl: paymentUrl,
|
|
361
365
|
paymentQRCode,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
1
2
|
import type { StripeAccount } from '@stamhoofd/models';
|
|
2
3
|
import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
3
4
|
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
@@ -15,6 +16,7 @@ import { initMembershipOrganization } from '../../tests/init/initMembershipOrgan
|
|
|
15
16
|
import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
|
|
16
17
|
import { SettlementService } from '../services/SettlementService.js';
|
|
17
18
|
import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
|
|
19
|
+
import { WebmasterReport } from './WebmasterReport.js';
|
|
18
20
|
|
|
19
21
|
describe('ApplicationFeeInvoicer', () => {
|
|
20
22
|
const stripeMocker = new StripeMocker();
|
|
@@ -262,8 +264,18 @@ describe('ApplicationFeeInvoicer', () => {
|
|
|
262
264
|
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
263
265
|
await createFee(organization, broken, { amount: 40_00 });
|
|
264
266
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
+
// A month the legacy invoicer billed may never be billed again: that group throws
|
|
268
|
+
const legacy = new Payment();
|
|
269
|
+
legacy.organizationId = membershipOrganization.id;
|
|
270
|
+
legacy.payingOrganizationId = organization.id;
|
|
271
|
+
legacy.stripeAccountId = broken.id;
|
|
272
|
+
legacy.method = PaymentMethod.AccountDeductions;
|
|
273
|
+
legacy.provider = PaymentProvider.Stripe;
|
|
274
|
+
legacy.status = PaymentStatus.Succeeded;
|
|
275
|
+
legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
|
|
276
|
+
legacy.price = 40_00;
|
|
277
|
+
legacy.paidAt = occurredAt;
|
|
278
|
+
await legacy.save();
|
|
267
279
|
|
|
268
280
|
await invoiceMonth();
|
|
269
281
|
|
|
@@ -272,14 +284,63 @@ describe('ApplicationFeeInvoicer', () => {
|
|
|
272
284
|
expect(payments[0].price).toBe(30_00);
|
|
273
285
|
});
|
|
274
286
|
|
|
287
|
+
test('fees without the Stripe account they were deducted from are skipped, not retried forever', async () => {
|
|
288
|
+
const { organization, stripeAccount } = await init();
|
|
289
|
+
const removed = await stripeMocker.createStripeAccount(organization.id);
|
|
290
|
+
|
|
291
|
+
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
292
|
+
const orphaned = await createFee(organization, removed, { amount: 40_00 });
|
|
293
|
+
|
|
294
|
+
// Deleting the account row clears payingStripeAccountId: that fee can no longer be checked
|
|
295
|
+
// against what the legacy invoicer billed per account, so it is not billed at all
|
|
296
|
+
await removed.delete();
|
|
297
|
+
|
|
298
|
+
await WebmasterReport.group('Overslaan applicatiekosten', async () => {
|
|
299
|
+
await invoiceMonth();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const payments = await getFeePayments(organization);
|
|
303
|
+
expect(payments).toHaveLength(1);
|
|
304
|
+
expect(payments[0].price).toBe(30_00);
|
|
305
|
+
expect((await ApplicationFee.getByID(orphaned.id))!.balanceItemId).toBeNull();
|
|
306
|
+
|
|
307
|
+
// Skipping is silent here: the sync that stored the fee already reported it, and this run
|
|
308
|
+
// repeats every night
|
|
309
|
+
const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Overslaan applicatiekosten'));
|
|
310
|
+
expect(emails).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
275
313
|
test('charges of a billed fee keep pointing at the deduction row', async () => {
|
|
276
314
|
const { organization, stripeAccount } = await init();
|
|
277
315
|
const fee = await createFee(organization, stripeAccount);
|
|
278
316
|
|
|
279
317
|
await invoiceMonth();
|
|
280
318
|
|
|
281
|
-
const charge = await SettlementCharge.getByID(fee.settlementChargeId);
|
|
319
|
+
const charge = await SettlementCharge.getByID(fee.settlementChargeId!);
|
|
282
320
|
expect(charge).toBeDefined();
|
|
283
321
|
expect(charge!.amount).toBe(-30_00);
|
|
284
322
|
});
|
|
323
|
+
|
|
324
|
+
test('fees of a deleted organization are never billed, and do not block the other accounts', async () => {
|
|
325
|
+
const { organization, stripeAccount } = await init();
|
|
326
|
+
const { organization: other, stripeAccount: otherAccount } = await init();
|
|
327
|
+
|
|
328
|
+
const orphaned = await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
329
|
+
await createFee(other, otherAccount, { amount: 40_00 });
|
|
330
|
+
|
|
331
|
+
// Takes the Stripe account and the deduction charge with it, but not our income
|
|
332
|
+
await organization.delete();
|
|
333
|
+
|
|
334
|
+
await invoiceMonth();
|
|
335
|
+
|
|
336
|
+
const payments = await getFeePayments(other);
|
|
337
|
+
expect(payments).toHaveLength(1);
|
|
338
|
+
expect(payments[0].price).toBe(40_00);
|
|
339
|
+
|
|
340
|
+
const stored = await ApplicationFee.getByID(orphaned.id);
|
|
341
|
+
expect(stored).toBeDefined();
|
|
342
|
+
expect(stored!.payingOrganizationId).toBeNull();
|
|
343
|
+
expect(stored!.settlementChargeId).toBeNull();
|
|
344
|
+
expect(stored!.balanceItemId).toBeNull();
|
|
345
|
+
});
|
|
285
346
|
});
|
|
@@ -84,9 +84,7 @@ export class ApplicationFeeInvoicer {
|
|
|
84
84
|
const snapshot = truncateToSecond(new Date());
|
|
85
85
|
|
|
86
86
|
const currentPeriodStart = SettlementService.getPeriodStart(new Date());
|
|
87
|
-
const oldest = await
|
|
88
|
-
.where('organizationId', sellingOrganization.id)
|
|
89
|
-
.where('balanceItemId', null)
|
|
87
|
+
const oldest = await this.#selectBillableFees(sellingOrganization)
|
|
90
88
|
.where('occurredAt', '<', currentPeriodStart)
|
|
91
89
|
.where('createdAt', '<', snapshot)
|
|
92
90
|
.orderBy('occurredAt', 'ASC')
|
|
@@ -149,15 +147,16 @@ export class ApplicationFeeInvoicer {
|
|
|
149
147
|
const reference = ApplicationFeeInvoicer.reference(periodStart);
|
|
150
148
|
|
|
151
149
|
await QueueHandler.schedule(reference, async () => {
|
|
152
|
-
const totalsPerAccount = new Map<string
|
|
150
|
+
const totalsPerAccount = new Map<string, AccountTotals>();
|
|
153
151
|
|
|
152
|
+
// Both ids are non-null: #selectUninvoicedFees only returns fees that can be billed
|
|
154
153
|
for await (const fee of this.#selectUninvoicedFees(sellingOrganization, periodStart, nextPeriodStart, snapshot).all()) {
|
|
155
|
-
const totals = totalsPerAccount.get(fee.payingStripeAccountId) ?? {
|
|
156
|
-
payingOrganizationId: fee.payingOrganizationId
|
|
154
|
+
const totals = totalsPerAccount.get(fee.payingStripeAccountId!) ?? {
|
|
155
|
+
payingOrganizationId: fee.payingOrganizationId!,
|
|
157
156
|
amountPerType: new Map<ApplicationFeeType, number>(),
|
|
158
157
|
};
|
|
159
158
|
totals.amountPerType.set(fee.type, (totals.amountPerType.get(fee.type) ?? 0) + fee.amount);
|
|
160
|
-
totalsPerAccount.set(fee.payingStripeAccountId
|
|
159
|
+
totalsPerAccount.set(fee.payingStripeAccountId!, totals);
|
|
161
160
|
}
|
|
162
161
|
|
|
163
162
|
for (const [payingStripeAccountId, totals] of totalsPerAccount) {
|
|
@@ -171,19 +170,32 @@ export class ApplicationFeeInvoicer {
|
|
|
171
170
|
});
|
|
172
171
|
}
|
|
173
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A fee this invoicer cannot bill is left out everywhere, or every run would walk its month at
|
|
175
|
+
* Stripe and report it again: without a paying organization there is nobody left to bill, and
|
|
176
|
+
* without its Stripe account a month cannot be checked against what the legacy invoicer billed
|
|
177
|
+
* per account — billing it anyway risks charging it twice. Both are reported by the sync that
|
|
178
|
+
* stored them (StripeSettlementSync.reportUnattributedFee).
|
|
179
|
+
*/
|
|
174
180
|
#selectUninvoicedFees(sellingOrganization: Organization, periodStart: Date, nextPeriodStart: Date, snapshot: Date) {
|
|
175
|
-
return
|
|
176
|
-
.where('organizationId', sellingOrganization.id)
|
|
177
|
-
.where('balanceItemId', null)
|
|
181
|
+
return this.#selectBillableFees(sellingOrganization)
|
|
178
182
|
.where('occurredAt', '>=', periodStart)
|
|
179
183
|
.where('occurredAt', '<', nextPeriodStart)
|
|
180
184
|
.where('createdAt', '<', snapshot)
|
|
181
185
|
.limit(FEE_BATCH_SIZE);
|
|
182
186
|
}
|
|
183
187
|
|
|
188
|
+
#selectBillableFees(sellingOrganization: Organization) {
|
|
189
|
+
return ApplicationFee.select()
|
|
190
|
+
.where('organizationId', sellingOrganization.id)
|
|
191
|
+
.where('balanceItemId', null)
|
|
192
|
+
.where('payingOrganizationId', '!=', null)
|
|
193
|
+
.where('payingStripeAccountId', '!=', null);
|
|
194
|
+
}
|
|
195
|
+
|
|
184
196
|
async #invoiceGroup({ sellingOrganization, payingStripeAccountId, totals, periodStart, nextPeriodStart, snapshot }: {
|
|
185
197
|
sellingOrganization: Organization;
|
|
186
|
-
payingStripeAccountId: string
|
|
198
|
+
payingStripeAccountId: string;
|
|
187
199
|
totals: AccountTotals;
|
|
188
200
|
periodStart: Date;
|
|
189
201
|
nextPeriodStart: Date;
|
|
@@ -194,13 +206,6 @@ export class ApplicationFeeInvoicer {
|
|
|
194
206
|
return;
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
if (!payingStripeAccountId) {
|
|
198
|
-
throw new SimpleError({
|
|
199
|
-
code: 'missing_stripe_account',
|
|
200
|
-
message: 'Uninvoiced application fees without a Stripe account',
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
209
|
const stripeAccount = await StripeAccount.getByID(payingStripeAccountId);
|
|
205
210
|
if (!stripeAccount) {
|
|
206
211
|
throw new SimpleError({
|
|
@@ -3,10 +3,14 @@ import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
|
3
3
|
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
4
4
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
5
5
|
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
6
|
+
import { AbortSignal } from '@stamhoofd/queues';
|
|
6
7
|
import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
|
|
7
8
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
9
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
10
|
+
import { vi } from 'vitest';
|
|
8
11
|
import type { MollieMockPayment, MollieMockRefund } from '../../tests/helpers/MollieMocker.js';
|
|
9
12
|
import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
|
|
13
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
10
14
|
import { MollieSettlementSync } from './MollieSettlementSync.js';
|
|
11
15
|
import type { SettlementSyncSummary } from './ProviderSettlementSyncRunner.js';
|
|
12
16
|
|
|
@@ -340,6 +344,45 @@ describe('Helper.MollieSettlementSync', () => {
|
|
|
340
344
|
expect(summary.failed).toBe(0);
|
|
341
345
|
});
|
|
342
346
|
|
|
347
|
+
test('An interrupted walk gives up its synced state without counting a failure', async () => {
|
|
348
|
+
const { token, mockPayment, mockRefund } = await init();
|
|
349
|
+
|
|
350
|
+
const settlement = mollieMocker.createSettlement({ payments: [mockPayment], refunds: [mockRefund], value: '30.00', settledAt: new Date(2026, 2, 3) });
|
|
351
|
+
|
|
352
|
+
await runCron(token);
|
|
353
|
+
expect((await Settlement.select().where('externalId', settlement.id).first(true)).syncedAt).not.toBeNull();
|
|
354
|
+
|
|
355
|
+
// Older, so the newest-first walk only reaches it after the one it is interrupted in
|
|
356
|
+
const untouched = mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00', settledAt: new Date(2026, 2, 2) });
|
|
357
|
+
|
|
358
|
+
// Abort while the walk stores its first entry, so the settlement is only walked halfway
|
|
359
|
+
const abort = new AbortSignal();
|
|
360
|
+
const upsertPaymentLine = SettlementService.upsertPaymentLine.bind(SettlementService);
|
|
361
|
+
const spy = vi.spyOn(SettlementService, 'upsertPaymentLine').mockImplementation(async (row, data) => {
|
|
362
|
+
abort.abort();
|
|
363
|
+
return await upsertPaymentLine(row, data);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const summary: SettlementSyncSummary = { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
|
|
367
|
+
try {
|
|
368
|
+
await expect(new MollieSettlementSync({ token }).syncSettlements({
|
|
369
|
+
start: new Date(2020, 0, 1),
|
|
370
|
+
summary,
|
|
371
|
+
abort,
|
|
372
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
|
|
373
|
+
} finally {
|
|
374
|
+
spy.mockRestore();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const row = await Settlement.select().where('externalId', settlement.id).first(true);
|
|
378
|
+
expect(row.syncedAt).toBeNull();
|
|
379
|
+
expect(row.syncFailureCount).toBe(0);
|
|
380
|
+
expect(summary).toMatchObject({ synced: 0, failed: 0 });
|
|
381
|
+
|
|
382
|
+
// The walk stopped at the settlement it was in: the next one was never started
|
|
383
|
+
expect(await Settlement.select().where('externalId', untouched.id).count()).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
|
|
343
386
|
test('An unlinked refund entry in a settlement is skipped without affecting the known refund', async () => {
|
|
344
387
|
const { token, refundPayment, mockPayment, mockRefund } = await init();
|
|
345
388
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MollieToken } from '@stamhoofd/models';
|
|
2
2
|
import { MolliePayment, Payment } from '@stamhoofd/models';
|
|
3
3
|
import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
4
|
+
import type { AbortSignal } from '@stamhoofd/queues';
|
|
4
5
|
import { PaymentProvider } from '@stamhoofd/structures';
|
|
5
6
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
6
7
|
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
@@ -47,6 +48,11 @@ type MollieSettlement = {
|
|
|
47
48
|
type SettlementSyncState = {
|
|
48
49
|
settlementRow: Settlement;
|
|
49
50
|
reported: ReportedRows;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stops the walk at the next entry (a restart).
|
|
54
|
+
*/
|
|
55
|
+
abort: AbortSignal;
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -89,14 +95,17 @@ export class MollieSettlementSync {
|
|
|
89
95
|
/**
|
|
90
96
|
* Walk the settlements newest first, until they settle before `start`.
|
|
91
97
|
*/
|
|
92
|
-
async syncSettlements({ start, end = new Date(), summary }: {
|
|
98
|
+
async syncSettlements({ start, end = new Date(), summary, abort }: {
|
|
93
99
|
start: Date;
|
|
94
100
|
end?: Date;
|
|
95
101
|
summary?: SettlementSyncSummary;
|
|
102
|
+
abort?: AbortSignal;
|
|
96
103
|
}): Promise<void> {
|
|
97
104
|
let url: string | null = 'https://api.mollie.com/v2/settlements?limit=250';
|
|
98
105
|
|
|
99
106
|
while (url) {
|
|
107
|
+
abort?.throwIfAborted();
|
|
108
|
+
|
|
100
109
|
const request = await this.#get(url);
|
|
101
110
|
|
|
102
111
|
if (request.status !== 200) {
|
|
@@ -112,6 +121,8 @@ export class MollieSettlementSync {
|
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
for (const settlement of settlements) {
|
|
124
|
+
abort?.throwIfAborted();
|
|
125
|
+
|
|
115
126
|
if (settlement.settledAt === null) {
|
|
116
127
|
// Skip: this is the open settlement
|
|
117
128
|
continue;
|
|
@@ -134,11 +145,14 @@ export class MollieSettlementSync {
|
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
try {
|
|
137
|
-
await SettlementService.lock(PaymentProvider.Mollie, settlement.id,
|
|
148
|
+
await SettlementService.lock(PaymentProvider.Mollie, settlement.id, signal => this.#syncSettlement(settlement, signal), { abort });
|
|
138
149
|
if (summary) {
|
|
139
150
|
summary.synced += 1;
|
|
140
151
|
}
|
|
141
152
|
} catch (e) {
|
|
153
|
+
// An interrupted settlement is not a failing settlement
|
|
154
|
+
abort?.throwIfAborted();
|
|
155
|
+
|
|
142
156
|
console.error('Sync of Mollie settlement ' + settlement.id + ' failed', e);
|
|
143
157
|
if (summary) {
|
|
144
158
|
summary.failed += 1;
|
|
@@ -163,7 +177,7 @@ export class MollieSettlementSync {
|
|
|
163
177
|
});
|
|
164
178
|
}
|
|
165
179
|
|
|
166
|
-
async #syncSettlement(settlement: MollieSettlement) {
|
|
180
|
+
async #syncSettlement(settlement: MollieSettlement, abort: AbortSignal) {
|
|
167
181
|
const settlementRow = await SettlementService.upsertSettlement({
|
|
168
182
|
provider: PaymentProvider.Mollie,
|
|
169
183
|
externalId: settlement.id,
|
|
@@ -178,6 +192,7 @@ export class MollieSettlementSync {
|
|
|
178
192
|
const state: SettlementSyncState = {
|
|
179
193
|
settlementRow,
|
|
180
194
|
reported: new ReportedRows(),
|
|
195
|
+
abort,
|
|
181
196
|
};
|
|
182
197
|
|
|
183
198
|
try {
|
|
@@ -200,7 +215,13 @@ export class MollieSettlementSync {
|
|
|
200
215
|
transactionCount: state.reported.paymentLineExternalIds.size + state.reported.chargeExternalIds.size,
|
|
201
216
|
});
|
|
202
217
|
} catch (e) {
|
|
203
|
-
|
|
218
|
+
// A walk that was interrupted stored only part of the settlement: it has to be walked
|
|
219
|
+
// again, but it didn't fail
|
|
220
|
+
if (abort.isAborted) {
|
|
221
|
+
await SettlementService.markSyncInterrupted(settlementRow);
|
|
222
|
+
} else {
|
|
223
|
+
await SettlementService.markSyncFailed(settlementRow);
|
|
224
|
+
}
|
|
204
225
|
throw e;
|
|
205
226
|
}
|
|
206
227
|
}
|
|
@@ -265,6 +286,10 @@ export class MollieSettlementSync {
|
|
|
265
286
|
const entries = request.data._embedded[resource] as MollieSettlementEntryJSON[];
|
|
266
287
|
|
|
267
288
|
for (const entry of entries) {
|
|
289
|
+
// Between two entries is a safe point to stop: the settlement only claims to be
|
|
290
|
+
// synced after the sweep, so an interrupted walk is re-walked from the start
|
|
291
|
+
state.abort.throwIfAborted();
|
|
292
|
+
|
|
268
293
|
await this.#applySettlementToPayment(settlement, entry.id, state);
|
|
269
294
|
}
|
|
270
295
|
|
|
@@ -10,7 +10,7 @@ import type { ProviderSettlementSyncRunner, ProviderSyncRunOptions } from './Pro
|
|
|
10
10
|
* (STAMHOOFD.MOLLIE_ORGANIZATION_TOKEN for the platform, a MollieToken row per organization).
|
|
11
11
|
*/
|
|
12
12
|
export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner {
|
|
13
|
-
async run({ start, end, summary, onProgress }: ProviderSyncRunOptions): Promise<void> {
|
|
13
|
+
async run({ start, end, summary, onProgress, abort }: ProviderSyncRunOptions): Promise<void> {
|
|
14
14
|
const accessToken = STAMHOOFD.MOLLIE_ORGANIZATION_TOKEN;
|
|
15
15
|
if (!accessToken) {
|
|
16
16
|
console.error('Missing mollie organization token');
|
|
@@ -24,8 +24,11 @@ export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner
|
|
|
24
24
|
// A plain access token without a refresh flow: the future expiry keeps
|
|
25
25
|
// refreshIfNeeded from attempting one
|
|
26
26
|
token.expiresOn = new Date(new Date().getTime() + 24 * 60 * 60 * 1000);
|
|
27
|
-
await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary });
|
|
27
|
+
await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary, abort });
|
|
28
28
|
} catch (e) {
|
|
29
|
+
// An interrupted account is not a failing account
|
|
30
|
+
abort.throwIfAborted();
|
|
31
|
+
|
|
29
32
|
console.error(e);
|
|
30
33
|
summary.failed += 1;
|
|
31
34
|
}
|
|
@@ -34,6 +37,8 @@ export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner
|
|
|
34
37
|
|
|
35
38
|
const mollieTokens = await MollieToken.all();
|
|
36
39
|
for (const token of mollieTokens) {
|
|
40
|
+
abort.throwIfAborted();
|
|
41
|
+
|
|
37
42
|
// Tokens created before the settlements permission was added to the OAuth scope
|
|
38
43
|
// cannot read settlements
|
|
39
44
|
if (token.createdAt < new Date(2021, 8 /* september! */, 8)) {
|
|
@@ -42,8 +47,10 @@ export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner
|
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
try {
|
|
45
|
-
await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary });
|
|
50
|
+
await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary, abort });
|
|
46
51
|
} catch (e) {
|
|
52
|
+
abort.throwIfAborted();
|
|
53
|
+
|
|
47
54
|
console.error(e);
|
|
48
55
|
summary.failed += 1;
|
|
49
56
|
}
|