@stamhoofd/backend 2.142.0 → 2.143.1

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.
Files changed (29) hide show
  1. package/package.json +17 -17
  2. package/src/boot.ts +32 -16
  3. package/src/crons/settlement-sync.test.ts +59 -1
  4. package/src/crons/settlement-sync.ts +19 -8
  5. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +1 -1
  6. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
  7. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
  8. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
  9. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  10. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
  11. package/src/helpers/MollieSettlementSync.test.ts +43 -0
  12. package/src/helpers/MollieSettlementSync.ts +29 -4
  13. package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
  14. package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
  15. package/src/helpers/SettlementSyncRunner.test.ts +53 -0
  16. package/src/helpers/SettlementSyncRunner.ts +20 -3
  17. package/src/helpers/StripeSettlementSync.test.ts +206 -1
  18. package/src/helpers/StripeSettlementSync.ts +106 -61
  19. package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
  20. package/src/helpers/StripeSettlementSyncRunner.ts +26 -9
  21. package/src/helpers/waitUntilDeadline.test.ts +48 -0
  22. package/src/helpers/waitUntilDeadline.ts +28 -0
  23. package/src/services/ApplicationFeeService.ts +20 -2
  24. package/src/services/BalanceItemService.ts +5 -0
  25. package/src/services/SettlementService.ts +26 -3
  26. package/src/services/WebshopCrowdfundingService.test.ts +433 -0
  27. package/src/services/WebshopCrowdfundingService.ts +98 -0
  28. package/tests/filters/orders.test.ts +24 -1
  29. 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 { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
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
  });