@stamhoofd/backend 2.142.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 +19 -8
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +1 -1
- 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/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/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +206 -1
- package/src/helpers/StripeSettlementSync.ts +106 -61
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +26 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +20 -2
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.ts +26 -3
- 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
|
@@ -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
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AbortSignal } from '@stamhoofd/queues';
|
|
2
|
+
|
|
1
3
|
export type SettlementSyncSummary = {
|
|
2
4
|
feeMonths: number;
|
|
3
5
|
failedFeeMonths: number;
|
|
@@ -26,6 +28,12 @@ export type ProviderSyncRunOptions = {
|
|
|
26
28
|
* Fired after each unit of work (a month for Stripe, an account for Mollie).
|
|
27
29
|
*/
|
|
28
30
|
onProgress?: () => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stops the walk at the next safe point (a restart). Aborting is not a sync failure: it leaves
|
|
34
|
+
* everything it touched retryable instead of counting or reporting it.
|
|
35
|
+
*/
|
|
36
|
+
abort: AbortSignal;
|
|
29
37
|
};
|
|
30
38
|
|
|
31
39
|
/**
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Organization } from '@stamhoofd/models';
|
|
2
2
|
import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
3
3
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
4
|
+
import { AbortSignal } from '@stamhoofd/queues';
|
|
4
5
|
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
6
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
5
7
|
import { v4 as uuidv4 } from 'uuid';
|
|
6
8
|
import { vi } from 'vitest';
|
|
7
9
|
|
|
@@ -140,6 +142,57 @@ describe('Helper.SettlementSyncRunner', () => {
|
|
|
140
142
|
expect(mollieRow.syncedAt).not.toBeNull();
|
|
141
143
|
});
|
|
142
144
|
|
|
145
|
+
test('An aborted run walks nothing and does not report a failure', async () => {
|
|
146
|
+
const organization = await new OrganizationFactory({}).create();
|
|
147
|
+
const stripePayment = await createStripePayment(organization);
|
|
148
|
+
|
|
149
|
+
const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
|
|
150
|
+
stripeMocker.createBalanceTransaction({
|
|
151
|
+
type: 'charge',
|
|
152
|
+
amount: 10000,
|
|
153
|
+
created: new Date(2026, 0, 15),
|
|
154
|
+
payout: payout.id,
|
|
155
|
+
source: stripeMocker.createChargeObject({ metadata: { payment: stripePayment.id } }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const abort = new AbortSignal();
|
|
159
|
+
abort.abort();
|
|
160
|
+
|
|
161
|
+
// Throwing instead of returning a summary: a caller may not read an interrupted run as a
|
|
162
|
+
// completed one
|
|
163
|
+
await expect(new SettlementSyncRunner().run({
|
|
164
|
+
start: new Date(2026, 0, 1),
|
|
165
|
+
end: new Date(2026, 0, 31),
|
|
166
|
+
providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
|
|
167
|
+
abort,
|
|
168
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
|
|
169
|
+
|
|
170
|
+
expect(await Settlement.select().where('externalId', payout.id).first(false)).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('An aborted run walks nothing of a provider Stripe never reached', async () => {
|
|
174
|
+
const mollieOrganization = await new OrganizationFactory({}).create();
|
|
175
|
+
await mollieMocker.setupToken(mollieOrganization);
|
|
176
|
+
const { mockPayment } = await createMolliePayment(mollieOrganization);
|
|
177
|
+
const mollieSettlement = mollieMocker.createSettlement({
|
|
178
|
+
payments: [mockPayment],
|
|
179
|
+
value: '50.00',
|
|
180
|
+
settledAt: new Date(2026, 0, 20),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const abort = new AbortSignal();
|
|
184
|
+
abort.abort();
|
|
185
|
+
|
|
186
|
+
await expect(new SettlementSyncRunner().run({
|
|
187
|
+
start: new Date(2026, 0, 1),
|
|
188
|
+
end: new Date(2026, 0, 31),
|
|
189
|
+
providers: [PaymentProvider.Mollie],
|
|
190
|
+
abort,
|
|
191
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
|
|
192
|
+
|
|
193
|
+
expect(await Settlement.select().where('externalId', mollieSettlement.id).first(false)).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
143
196
|
test('The stripe retryUnsynced option flows through the run', async () => {
|
|
144
197
|
const organization = await new OrganizationFactory({}).create();
|
|
145
198
|
const settlement = await SettlementService.upsertSettlement({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AbortSignal } from '@stamhoofd/queues';
|
|
1
2
|
import { PaymentProvider } from '@stamhoofd/structures';
|
|
2
3
|
|
|
3
4
|
import { MollieSettlementSyncRunner } from './MollieSettlementSyncRunner.js';
|
|
@@ -19,11 +20,17 @@ export class SettlementSyncRunner {
|
|
|
19
20
|
*/
|
|
20
21
|
callback: ((summary: SettlementSyncSummary) => void) | null = null;
|
|
21
22
|
|
|
22
|
-
async run({ start = new Date(2025, 0, 1), end, providers, stripe }: {
|
|
23
|
+
async run({ start = new Date(2025, 0, 1), end, providers, stripe, abort = new AbortSignal() }: {
|
|
23
24
|
start?: Date;
|
|
24
25
|
end?: Date | null;
|
|
25
26
|
providers?: PaymentProvider[] | null;
|
|
26
27
|
stripe?: StripeSyncOptions;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stops the run at the next safe point, and throws what it was aborted with: a caller that
|
|
31
|
+
* treats a completed run as "done for today" may not mistake an interrupted one for it.
|
|
32
|
+
*/
|
|
33
|
+
abort?: AbortSignal;
|
|
27
34
|
} = {}): Promise<SettlementSyncSummary> {
|
|
28
35
|
const summary: SettlementSyncSummary = { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
|
|
29
36
|
const rangeEnd = end ?? new Date();
|
|
@@ -41,8 +48,12 @@ export class SettlementSyncRunner {
|
|
|
41
48
|
if (includeStripe && STAMHOOFD.STRIPE_SECRET_KEY) {
|
|
42
49
|
const runner = new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY, ...stripe });
|
|
43
50
|
try {
|
|
44
|
-
await runner.run({ start, end: rangeEnd, summary, onProgress });
|
|
51
|
+
await runner.run({ start, end: rangeEnd, summary, onProgress, abort });
|
|
45
52
|
} catch (e) {
|
|
53
|
+
// An interrupted provider stops the whole run: it didn't fail, and the
|
|
54
|
+
// providers after it would only be interrupted at their first step too
|
|
55
|
+
abort.throwIfAborted();
|
|
56
|
+
|
|
46
57
|
console.error('Stripe settlement sync failed', e);
|
|
47
58
|
summary.failed += 1;
|
|
48
59
|
}
|
|
@@ -51,13 +62,19 @@ export class SettlementSyncRunner {
|
|
|
51
62
|
if (includeMollie) {
|
|
52
63
|
const runner = new MollieSettlementSyncRunner();
|
|
53
64
|
try {
|
|
54
|
-
await runner.run({ start, end: rangeEnd, summary, onProgress });
|
|
65
|
+
await runner.run({ start, end: rangeEnd, summary, onProgress, abort });
|
|
55
66
|
} catch (e) {
|
|
67
|
+
abort.throwIfAborted();
|
|
68
|
+
|
|
56
69
|
console.error('Mollie settlement sync failed', e);
|
|
57
70
|
summary.failed += 1;
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
// A provider that ignores the signal (or a run without providers) may not hand back a
|
|
75
|
+
// summary that reads as a completed run
|
|
76
|
+
abort.throwIfAborted();
|
|
77
|
+
|
|
61
78
|
return summary;
|
|
62
79
|
});
|
|
63
80
|
}
|
|
@@ -2,17 +2,25 @@ import { EmailMocker } from '@stamhoofd/email';
|
|
|
2
2
|
import type { Organization, StripeAccount } from '@stamhoofd/models';
|
|
3
3
|
import { OrganizationFactory, Payment, Platform } from '@stamhoofd/models';
|
|
4
4
|
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
5
|
+
import { BalanceItem } from '@stamhoofd/models/models/BalanceItem.js';
|
|
6
|
+
import { BalanceItemPayment } from '@stamhoofd/models/models/BalanceItemPayment.js';
|
|
5
7
|
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
8
|
+
import type { Settlement as SettlementModel } from '@stamhoofd/models/models/Settlement.js';
|
|
6
9
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
7
10
|
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
8
|
-
import {
|
|
11
|
+
import { AbortSignal } from '@stamhoofd/queues';
|
|
12
|
+
import { BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
|
|
9
13
|
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
10
14
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
11
15
|
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
16
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
12
17
|
import { v4 as uuidv4 } from 'uuid';
|
|
18
|
+
import { vi } from 'vitest';
|
|
13
19
|
|
|
14
20
|
import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
|
|
15
21
|
import type { StripeObject } from '../../tests/helpers/StripeMocker.js';
|
|
22
|
+
import { ApplicationFeeService } from '../services/ApplicationFeeService.js';
|
|
23
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
16
24
|
import { StripeSettlementSync } from './StripeSettlementSync.js';
|
|
17
25
|
import { StripeSettlementSyncRunner } from './StripeSettlementSyncRunner.js';
|
|
18
26
|
import { WebmasterReport } from './WebmasterReport.js';
|
|
@@ -71,6 +79,54 @@ describe('StripeSettlementSync', () => {
|
|
|
71
79
|
return await Settlement.select().where('externalId', payout.id).first(true);
|
|
72
80
|
};
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A fee we already invoiced to the organization, like the ApplicationFeeInvoicer stores it: the
|
|
84
|
+
* membership organization bills it, the organization pays it. Every payout that contains such a
|
|
85
|
+
* fee needs a derived line on its fee payment.
|
|
86
|
+
*/
|
|
87
|
+
const createInvoicedFee = async (externalId: string, amount: number, { settlement = null as SettlementModel | null } = {}) => {
|
|
88
|
+
const payment = new Payment();
|
|
89
|
+
payment.organizationId = membershipOrganization.id;
|
|
90
|
+
payment.payingOrganizationId = organization.id;
|
|
91
|
+
payment.stripeAccountId = stripeAccount.id;
|
|
92
|
+
payment.method = PaymentMethod.AccountDeductions;
|
|
93
|
+
payment.provider = PaymentProvider.Stripe;
|
|
94
|
+
payment.status = PaymentStatus.Succeeded;
|
|
95
|
+
payment.price = amount;
|
|
96
|
+
payment.paidAt = created;
|
|
97
|
+
await payment.save();
|
|
98
|
+
|
|
99
|
+
const balanceItem = new BalanceItem();
|
|
100
|
+
balanceItem.type = BalanceItemType.ServiceFee;
|
|
101
|
+
balanceItem.organizationId = membershipOrganization.id;
|
|
102
|
+
balanceItem.payingOrganizationId = organization.id;
|
|
103
|
+
balanceItem.unitPrice = amount;
|
|
104
|
+
balanceItem.quantity = 1;
|
|
105
|
+
balanceItem.status = BalanceItemStatus.Hidden;
|
|
106
|
+
await balanceItem.save();
|
|
107
|
+
|
|
108
|
+
const balanceItemPayment = new BalanceItemPayment();
|
|
109
|
+
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
110
|
+
balanceItemPayment.paymentId = payment.id;
|
|
111
|
+
balanceItemPayment.organizationId = payment.organizationId;
|
|
112
|
+
balanceItemPayment.price = amount;
|
|
113
|
+
await balanceItemPayment.save();
|
|
114
|
+
|
|
115
|
+
const fee = new ApplicationFee();
|
|
116
|
+
fee.externalId = externalId;
|
|
117
|
+
fee.type = ApplicationFeeType.Service;
|
|
118
|
+
fee.amount = amount;
|
|
119
|
+
fee.organizationId = membershipOrganization.id;
|
|
120
|
+
fee.payingOrganizationId = organization.id;
|
|
121
|
+
fee.payingStripeAccountId = stripeAccount.id;
|
|
122
|
+
fee.balanceItemId = balanceItem.id;
|
|
123
|
+
fee.settlementId = settlement?.id ?? null;
|
|
124
|
+
fee.occurredAt = created;
|
|
125
|
+
await fee.save();
|
|
126
|
+
|
|
127
|
+
return { payment, fee };
|
|
128
|
+
};
|
|
129
|
+
|
|
74
130
|
test('a full destination-charge payout reconciles to zero', async () => {
|
|
75
131
|
const payment = await createPayment({ price: 100_00_00 });
|
|
76
132
|
const payout = stripeMocker.createPayout({ amount: 225, arrivalDate });
|
|
@@ -359,6 +415,85 @@ describe('StripeSettlementSync', () => {
|
|
|
359
415
|
expect(emails[0].html).toContain(second.id);
|
|
360
416
|
});
|
|
361
417
|
|
|
418
|
+
test('a walk that fails still updates the fee payments of what it stored', async () => {
|
|
419
|
+
const payerPayment = await createPayment();
|
|
420
|
+
const stripeFee = stripeMocker.createApplicationFee({
|
|
421
|
+
amount: 250,
|
|
422
|
+
account: stripeAccount.accountId,
|
|
423
|
+
originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payerPayment.id, serviceFee: '250' } }),
|
|
424
|
+
});
|
|
425
|
+
const { payment: feePayment } = await createInvoicedFee(stripeFee.id, 2_50_00);
|
|
426
|
+
|
|
427
|
+
const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
|
|
428
|
+
stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, payout: payout.id, source: stripeFee });
|
|
429
|
+
|
|
430
|
+
// Fails the walk after the fee was linked to the payout
|
|
431
|
+
stripeMocker.createBalanceTransaction({ type: 'issuing_authorization_hold', amount: 100, created, payout: payout.id, source: null });
|
|
432
|
+
|
|
433
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
|
|
434
|
+
|
|
435
|
+
// The walk linked the fee to this payout before it failed, so the fee payment gets its
|
|
436
|
+
// derived line: a fee may never sit in a payout that has no line for it
|
|
437
|
+
const settlement = await getSettlement(payout);
|
|
438
|
+
expect(settlement.syncedAt).toBeNull();
|
|
439
|
+
|
|
440
|
+
const derived = await PaymentSettlement.select().where('paymentId', feePayment.id).fetch();
|
|
441
|
+
expect(derived).toHaveLength(1);
|
|
442
|
+
expect(derived[0]).toMatchObject({
|
|
443
|
+
settlementId: settlement.id,
|
|
444
|
+
externalId: null,
|
|
445
|
+
amount: 2_50_00,
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('a fee walk updates the fee payments of the fees it stored before one failed', async () => {
|
|
450
|
+
const payerPayment = await createPayment();
|
|
451
|
+
const stripeFee = stripeMocker.createApplicationFee({
|
|
452
|
+
amount: 250,
|
|
453
|
+
account: stripeAccount.accountId,
|
|
454
|
+
// Both a service and a transfer part, so the fee is stored as two rows
|
|
455
|
+
originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payerPayment.id, serviceFee: '30' } }),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Already paid out and invoiced before this walk: the payout it sits in explains it through
|
|
459
|
+
// the derived line of its fee payment
|
|
460
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
461
|
+
provider: PaymentProvider.Stripe,
|
|
462
|
+
externalId: 'po_' + uuidv4(),
|
|
463
|
+
organizationId: membershipOrganization.id,
|
|
464
|
+
amount: 30_00,
|
|
465
|
+
settledAt: arrivalDate,
|
|
466
|
+
});
|
|
467
|
+
const { payment: feePayment } = await createInvoicedFee(stripeFee.id, 30_00, { settlement });
|
|
468
|
+
|
|
469
|
+
stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, source: stripeFee });
|
|
470
|
+
|
|
471
|
+
// The service fee is stored, the transfer fee of the same transaction fails after it
|
|
472
|
+
const upsertFee = ApplicationFeeService.upsertFee.bind(ApplicationFeeService);
|
|
473
|
+
const spy = vi.spyOn(ApplicationFeeService, 'upsertFee').mockImplementation(async (data) => {
|
|
474
|
+
if (data.type === ApplicationFeeType.Transfer) {
|
|
475
|
+
throw new Error('Storing the transfer fee failed');
|
|
476
|
+
}
|
|
477
|
+
return await upsertFee(data);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
await expect(createSync().syncFees({ start, end: arrivalDate })).rejects.toThrow(
|
|
482
|
+
STExpect.simpleError({ code: 'stripe_fee_sync_failed' }),
|
|
483
|
+
);
|
|
484
|
+
} finally {
|
|
485
|
+
spy.mockRestore();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const derived = await PaymentSettlement.select().where('paymentId', feePayment.id).fetch();
|
|
489
|
+
expect(derived).toHaveLength(1);
|
|
490
|
+
expect(derived[0]).toMatchObject({
|
|
491
|
+
settlementId: settlement.id,
|
|
492
|
+
externalId: null,
|
|
493
|
+
amount: 30_00,
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
362
497
|
test('a payout that failed after being paid keeps its status current', async () => {
|
|
363
498
|
const payment = await createPayment();
|
|
364
499
|
const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate, stripeAccount: stripeAccount.accountId });
|
|
@@ -994,4 +1129,74 @@ describe('StripeSettlementSync', () => {
|
|
|
994
1129
|
expect(settlement.syncedAt).toBeNull();
|
|
995
1130
|
});
|
|
996
1131
|
});
|
|
1132
|
+
|
|
1133
|
+
describe('Aborting', () => {
|
|
1134
|
+
/**
|
|
1135
|
+
* Aborts the walk while it stores its first row, so the payout is interrupted halfway.
|
|
1136
|
+
*/
|
|
1137
|
+
const abortDuringWalk = () => {
|
|
1138
|
+
const abort = new AbortSignal();
|
|
1139
|
+
const upsertCharge = SettlementService.upsertCharge.bind(SettlementService);
|
|
1140
|
+
|
|
1141
|
+
const spy = vi.spyOn(SettlementService, 'upsertCharge').mockImplementation(async (data) => {
|
|
1142
|
+
abort.abort();
|
|
1143
|
+
return await upsertCharge(data);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
return { abort, restore: () => spy.mockRestore() };
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
test('an interrupted walk gives up its synced state without counting a failure', async () => {
|
|
1150
|
+
const payout = stripeMocker.createPayout({ amount: 2000, arrivalDate });
|
|
1151
|
+
for (let i = 0; i < 2; i++) {
|
|
1152
|
+
stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: payout.id, source: null });
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
1156
|
+
expect((await getSettlement(payout)).syncedAt).not.toBeNull();
|
|
1157
|
+
|
|
1158
|
+
const { abort, restore } = abortDuringWalk();
|
|
1159
|
+
try {
|
|
1160
|
+
await WebmasterReport.group('Onderbroken synchronisatie', async () => {
|
|
1161
|
+
await expect(createSync().syncPayouts({ start, force: true, abort })).rejects.toThrow(
|
|
1162
|
+
STExpect.simpleError({ code: 'queue-aborted' }),
|
|
1163
|
+
);
|
|
1164
|
+
});
|
|
1165
|
+
} finally {
|
|
1166
|
+
restore();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Only part of the payout was walked, so it may not keep claiming it is synced. A
|
|
1170
|
+
// restart is not a failure of this payout: it doesn't count towards the retry cap
|
|
1171
|
+
const settlement = await getSettlement(payout);
|
|
1172
|
+
expect(settlement.syncedAt).toBeNull();
|
|
1173
|
+
expect(settlement.syncFailureCount).toBe(0);
|
|
1174
|
+
|
|
1175
|
+
const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Onderbroken synchronisatie'));
|
|
1176
|
+
expect(emails).toHaveLength(0);
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
test('the payouts after an interrupted one are left untouched', async () => {
|
|
1180
|
+
const interrupted = stripeMocker.createPayout({ amount: 2000, arrivalDate });
|
|
1181
|
+
for (let i = 0; i < 2; i++) {
|
|
1182
|
+
stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: interrupted.id, source: null });
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const untouched = stripeMocker.createPayout({ amount: 1000, arrivalDate });
|
|
1186
|
+
stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: untouched.id, source: null });
|
|
1187
|
+
|
|
1188
|
+
const { abort, restore } = abortDuringWalk();
|
|
1189
|
+
try {
|
|
1190
|
+
await expect(createSync().syncPayouts({ start, abort })).rejects.toThrow(
|
|
1191
|
+
STExpect.simpleError({ code: 'queue-aborted' }),
|
|
1192
|
+
);
|
|
1193
|
+
} finally {
|
|
1194
|
+
restore();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// The loop stopped at the payout it was walking: the other one was never started
|
|
1198
|
+
expect((await getSettlement(interrupted)).syncedAt).toBeNull();
|
|
1199
|
+
expect(await Settlement.select().where('externalId', untouched.id).count()).toBe(0);
|
|
1200
|
+
});
|
|
1201
|
+
});
|
|
997
1202
|
});
|