@stamhoofd/backend 2.139.0 → 2.140.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.139.0",
3
+ "version": "2.140.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -65,20 +65,20 @@
65
65
  "@simonbackx/simple-errors": "1.5.0",
66
66
  "@simonbackx/simple-logging": "1.0.1",
67
67
  "@simplewebauthn/server": "13.3.2",
68
- "@stamhoofd/backend-env": "2.139.0",
69
- "@stamhoofd/backend-i18n": "2.139.0",
70
- "@stamhoofd/backend-middleware": "2.139.0",
71
- "@stamhoofd/crons": "2.139.0",
72
- "@stamhoofd/email": "2.139.0",
73
- "@stamhoofd/excel-writer": "2.139.0",
74
- "@stamhoofd/logging": "2.139.0",
75
- "@stamhoofd/models": "2.139.0",
76
- "@stamhoofd/object-differ": "2.139.0",
77
- "@stamhoofd/queues": "2.139.0",
78
- "@stamhoofd/sql": "2.139.0",
79
- "@stamhoofd/structures": "2.139.0",
80
- "@stamhoofd/types": "2.139.0",
81
- "@stamhoofd/utility": "2.139.0",
68
+ "@stamhoofd/backend-env": "2.140.0",
69
+ "@stamhoofd/backend-i18n": "2.140.0",
70
+ "@stamhoofd/backend-middleware": "2.140.0",
71
+ "@stamhoofd/crons": "2.140.0",
72
+ "@stamhoofd/email": "2.140.0",
73
+ "@stamhoofd/excel-writer": "2.140.0",
74
+ "@stamhoofd/logging": "2.140.0",
75
+ "@stamhoofd/models": "2.140.0",
76
+ "@stamhoofd/object-differ": "2.140.0",
77
+ "@stamhoofd/queues": "2.140.0",
78
+ "@stamhoofd/sql": "2.140.0",
79
+ "@stamhoofd/structures": "2.140.0",
80
+ "@stamhoofd/types": "2.140.0",
81
+ "@stamhoofd/utility": "2.140.0",
82
82
  "archiver": "7.0.1",
83
83
  "argon2": "0.44.0",
84
84
  "axios": "1.18.1",
@@ -102,7 +102,7 @@
102
102
  "uuid": "14.0.1"
103
103
  },
104
104
  "devDependencies": {
105
- "@stamhoofd/test-utils": "2.139.0",
105
+ "@stamhoofd/test-utils": "2.140.0",
106
106
  "@types/cookie": "0.6.0",
107
107
  "@types/luxon": "3.7.2",
108
108
  "@types/mailparser": "3.4.6",
@@ -117,5 +117,5 @@
117
117
  "publishConfig": {
118
118
  "access": "public"
119
119
  },
120
- "gitHead": "8c2cb66c10f96ca53aa07c20f410d6b8fa5a3291"
120
+ "gitHead": "532463b65f5fa152df23744b1f097ca48c2de3a1"
121
121
  }
@@ -1,7 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
4
- import { PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
4
+ import { NewUser, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
5
5
  import { TestUtils } from '@stamhoofd/test-utils';
6
6
  import crypto from 'crypto';
7
7
  import { authenticator } from 'otplib';
@@ -16,6 +16,7 @@ import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
16
16
  import { DeleteTOTPEndpoint } from './DeleteTOTPEndpoint.js';
17
17
  import { GetMFAChallengeEndpoint } from './GetMFAChallengeEndpoint.js';
18
18
  import { GetMFAStatusEndpoint } from './GetMFAStatusEndpoint.js';
19
+ import { PatchUserEndpoint } from './PatchUserEndpoint.js';
19
20
  import { RegisterPasskeyOptionsEndpoint } from './RegisterPasskeyOptionsEndpoint.js';
20
21
  import { SetupTOTPEndpoint } from './SetupTOTPEndpoint.js';
21
22
  import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
@@ -508,6 +509,80 @@ describe('MFA security', () => {
508
509
  });
509
510
  });
510
511
 
512
+ // -----------------------------------------------------------------------
513
+ // Absorbing someone else's account by taking over their email address
514
+ // -----------------------------------------------------------------------
515
+ describe('merging accounts', () => {
516
+ /**
517
+ * Verifying an email address that already belongs to another account merges that
518
+ * account into yours. Reading the victim's mailbox is a single primary credential,
519
+ * exactly the one a second factor exists to back up, so it must not be enough to
520
+ * pull an account that has a second factor (and its permissions) into an account
521
+ * that does not.
522
+ */
523
+ async function requestEmailChange(user: User, token: Token, newEmail: string, organization: Organization): Promise<EmailVerificationCode> {
524
+ const request = Request.patch({
525
+ path: '/user/' + user.id,
526
+ host: organization.getApiHost(),
527
+ headers: { authorization: 'Bearer ' + token.accessToken },
528
+ body: NewUser.patch({ id: user.id, email: newEmail }),
529
+ });
530
+
531
+ const err = await captureError(testServer.test(new PatchUserEndpoint(), request));
532
+ expect(err.code).toBe('verify_email');
533
+
534
+ const verificationToken = (err.meta as { token: string }).token;
535
+ const code = await EmailVerificationCode.select().where('token', verificationToken).first(true);
536
+ expect(code.email).toBe(newEmail);
537
+ return code;
538
+ }
539
+
540
+ test('taking over the email address of an account with a second factor is refused', async () => {
541
+ const organization = await new OrganizationFactory({}).create();
542
+ const victim = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
543
+ const victimTotp = await addConfirmedTOTP(victim);
544
+
545
+ const attacker = await new UserFactory({ organization, password }).create();
546
+ const attackerToken = await freshToken(attacker);
547
+
548
+ const code = await requestEmailChange(attacker, attackerToken, victim.email, organization);
549
+
550
+ const err = await captureError(testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), attackerToken)));
551
+ expect(err.code).toBe('email_in_use');
552
+
553
+ // The victim still owns their account, their permissions and their factor.
554
+ const storedVictim = await User.getByID(victim.id);
555
+ expect(storedVictim).toBeDefined();
556
+ expect(storedVictim!.email).toBe(victim.email);
557
+ expect(await MFATOTP.getByID(victimTotp.id)).toBeDefined();
558
+
559
+ const storedAttacker = await User.getByID(attacker.id);
560
+ expect(storedAttacker!.email).toBe(attacker.email);
561
+ expect(storedAttacker!.permissions).toBeNull();
562
+ });
563
+
564
+ test('an account without a second factor can still be merged', async () => {
565
+ // Merging is a real feature for people who signed up twice. Without a factor,
566
+ // whoever reads the mailbox could take that account over with a password reset
567
+ // anyway, so nothing is bypassed here.
568
+ const organization = await new OrganizationFactory({}).create();
569
+ const other = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
570
+
571
+ const user = await new UserFactory({ organization, password }).create();
572
+ const token = await freshToken(user);
573
+
574
+ const code = await requestEmailChange(user, token, other.email, organization);
575
+
576
+ const response = await testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), token));
577
+ expect(response.body).toBeInstanceOf(TokenStruct);
578
+
579
+ expect(await User.getByID(other.id)).toBeUndefined();
580
+ const stored = await User.getByID(user.id);
581
+ expect(stored!.email).toBe(other.email);
582
+ expect(stored!.permissions).not.toBeNull();
583
+ });
584
+ });
585
+
511
586
  // -----------------------------------------------------------------------
512
587
  // Changing the factors of an account ends the sessions the user is not on
513
588
  // -----------------------------------------------------------------------
@@ -82,6 +82,20 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
82
82
  const other = await User.getForAuthentication(user.organizationId, code.email, { allowWithoutAccount: true });
83
83
 
84
84
  if (other) {
85
+ // Merging absorbs the other account (its permissions included) and then
86
+ // deletes it, second factor and all. Reading the mailbox is exactly the
87
+ // single credential a second factor exists to back up, so an account that
88
+ // has one may not be taken over this way: nothing in this request proves
89
+ // the caller can pass it.
90
+ if (await TwoFactorHelper.userHasFactors(other.id)) {
91
+ throw new SimpleError({
92
+ code: 'email_in_use',
93
+ message: 'This e-mail is already in use by an account with two-factor authentication',
94
+ human: $t('%Zis'),
95
+ statusCode: 400,
96
+ });
97
+ }
98
+
85
99
  // Delete the other user, but merge data
86
100
  await user.merge(other);
87
101
  if (user.organizationId) {
@@ -317,17 +317,17 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
317
317
  expect(response.status).toBe(200);
318
318
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
319
319
  { name: '1234567.0312.01', price: 40_00 },
320
- { name: $t('In verwerking'), price: 30_00 },
320
+ { name: $t('%1OL'), price: 30_00 },
321
321
  // Only what was tried sits under the failed payment, the rest was never attempted
322
- { name: $t('Mislukte betaling'), price: 20_00 },
323
- { name: $t('Openstaand na mislukte poging'), price: 10_00 },
322
+ { name: $t('%ZjW'), price: 20_00 },
323
+ { name: $t('%ZjC'), price: 10_00 },
324
324
  ]);
325
325
 
326
326
  // Every part of what was charged ends up in exactly one row
327
327
  expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
328
328
 
329
329
  // Running the rows through the database gives back the balance items they were added up from
330
- for (const name of [$t('Mislukte betaling'), $t('Openstaand na mislukte poging')]) {
330
+ for (const name of [$t('%ZjW'), $t('%ZjC')]) {
331
331
  const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
332
332
  const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
333
333
  expect(exported.body.balanceItemCount).toBe(1);
@@ -348,19 +348,19 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
348
348
  expect(response.status).toBe(200);
349
349
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
350
350
  { name: '1234567.0312.01', price: 50_00 },
351
- { name: $t('Terug te betalen'), price: -50_00 },
352
- { name: $t('Openstaand'), price: 25_00 },
351
+ { name: $t('%10b'), price: -50_00 },
352
+ { name: $t('%1Ni'), price: 25_00 },
353
353
  ]);
354
354
 
355
355
  // A canceled item is not charged anymore, so it doesn't add to what is open
356
356
  expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
357
357
 
358
358
  // Each row selects exactly the balance items it was added up from
359
- const refund = response.body.bySettlement.find(g => g.name.toString() === $t('Terug te betalen'))!;
359
+ const refund = response.body.bySettlement.find(g => g.name.toString() === $t('%10b'))!;
360
360
  const refunded = await getBreakdown({ organization, user, filter: refund.selection!.listFilter });
361
361
  expect(refunded.body.balanceItemCount).toBe(1);
362
362
 
363
- const open = response.body.bySettlement.find(g => g.name.toString() === $t('Openstaand'))!;
363
+ const open = response.body.bySettlement.find(g => g.name.toString() === $t('%1Ni'))!;
364
364
  const stillOpen = await getBreakdown({ organization, user, filter: open.selection!.listFilter });
365
365
  expect(stillOpen.body.balanceItemCount).toBe(1);
366
366
  expect(stillOpen.body.price).toBe(25_00);
@@ -381,13 +381,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
381
381
 
382
382
  expect(response.status).toBe(200);
383
383
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
384
- { name: $t('Mislukte betaling'), price: 100_00 },
384
+ { name: $t('%ZjW'), price: 100_00 },
385
385
  { name: '1234567.0312.01', price: 40_00 },
386
- { name: $t('Openstaand'), price: 25_00 },
386
+ { name: $t('%1Ni'), price: 25_00 },
387
387
  ]);
388
388
 
389
389
  // Both rows select exactly the balance items they were added up from
390
- for (const [name, count] of [[$t('Mislukte betaling'), 1], [$t('Openstaand'), 1]] as [string, number][]) {
390
+ for (const [name, count] of [[$t('%ZjW'), 1], [$t('%1Ni'), 1]] as [string, number][]) {
391
391
  const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
392
392
  const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
393
393
  expect(exported.body.balanceItemCount).toBe(count);
@@ -405,7 +405,7 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
405
405
  await payItem(organization, processing, { price: 25_00, status: PaymentStatus.Pending });
406
406
 
407
407
  const all = await getBreakdown({ organization, user });
408
- const row = all.body.bySettlement.find(g => g.name.toString() === $t('In verwerking'))!;
408
+ const row = all.body.bySettlement.find(g => g.name.toString() === $t('%1OL'))!;
409
409
 
410
410
  const narrowed = await getBreakdown({
411
411
  organization,
@@ -367,7 +367,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
367
367
 
368
368
  expect(response.status).toBe(200);
369
369
  expect(response.body.byArticle).toHaveLength(1);
370
- expect(response.body.byArticle[0].name.toString()).toBe($t('Deels betaalde bestelling'));
370
+ expect(response.body.byArticle[0].name.toString()).toBe($t('%Zjb'));
371
371
  expect(response.body.byArticle[0].price).toBe(1_00);
372
372
  });
373
373
 
@@ -391,7 +391,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
391
391
  const response = await getBreakdown({ organization, user });
392
392
 
393
393
  expect(response.status).toBe(200);
394
- expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('Gewijzigde bestelling')]);
394
+ expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('%Zik')]);
395
395
  });
396
396
  });
397
397
 
@@ -652,8 +652,8 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
652
652
  expect(response.status).toBe(200);
653
653
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price, count: g.count }))).toEqual([
654
654
  { name: '1234567.0312.01', price: 50_00, count: 2 },
655
- { name: $t('Nog niet uitbetaald'), price: 30_00, count: 1 },
656
- { name: $t('Niet online betaald'), price: 20_00, count: 1 },
655
+ { name: $t('%Zjn'), price: 30_00, count: 1 },
656
+ { name: $t('%ZjN'), price: 20_00, count: 1 },
657
657
  ]);
658
658
  });
659
659
 
@@ -717,7 +717,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
717
717
  await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
718
718
 
719
719
  const all = await getBreakdown({ organization, user });
720
- const pending = all.body.bySettlement.find(g => g.name.toString() === $t('Nog niet uitbetaald'))!;
720
+ const pending = all.body.bySettlement.find(g => g.name.toString() === $t('%Zjn'))!;
721
721
 
722
722
  const exported = await getBreakdown({ organization, user, filter: pending.selection!.filter });
723
723
  expect(exported.body.price).toBe(30_00);
@@ -735,7 +735,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
735
735
  await createPayment(organization, { items: [[item, 10_00]], iban: 'BE68539007547034' });
736
736
 
737
737
  const all = await getBreakdown({ organization, user });
738
- const offline = all.body.bySettlement.find(g => g.name.toString() === $t('Niet online betaald'))!;
738
+ const offline = all.body.bySettlement.find(g => g.name.toString() === $t('%ZjN'))!;
739
739
 
740
740
  const exported = await getBreakdown({ organization, user, filter: offline.selection!.filter });
741
741
  expect(exported.body.price).toBe(30_00);
@@ -756,9 +756,9 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
756
756
  const all = await getBreakdown({ organization, user });
757
757
 
758
758
  expect(all.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
759
- { name: $t('Nog niet uitbetaald'), price: 40_00 },
759
+ { name: $t('%Zjn'), price: 40_00 },
760
760
  { name: getPaymentProviderName(PaymentProvider.Buckaroo), price: 25_00 },
761
- { name: $t('Niet online betaald'), price: 20_00 },
761
+ { name: $t('%ZjN'), price: 20_00 },
762
762
  { name: getPaymentProviderName(PaymentProvider.Payconiq), price: 15_00 },
763
763
  ]);
764
764
 
@@ -812,7 +812,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
812
812
 
813
813
  expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
814
814
  { name: 'Kapoenen', price: 14_6652 },
815
- { name: $t('Afronding'), price: 48 },
815
+ { name: $t('%1b6'), price: 48 },
816
816
  ]);
817
817
 
818
818
  // Everything the payment is worth is accounted for
@@ -870,12 +870,12 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
870
870
 
871
871
  expect(response.status).toBe(200);
872
872
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
873
- { name: $t('Niet online betaald'), price: 40_00 },
873
+ { name: $t('%ZjN'), price: 40_00 },
874
874
  { name: PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), price: 10_00 },
875
875
  ]);
876
876
 
877
877
  // Running the rows through the database keeps them apart
878
- for (const [name, price] of [[$t('Niet online betaald'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
878
+ for (const [name, price] of [[$t('%ZjN'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
879
879
  const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
880
880
  const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
881
881
  expect(exported.body.price).toBe(price);
@@ -212,7 +212,7 @@ export class TwoFactorHelper {
212
212
  throw new SimpleError({
213
213
  code: 'require_email_confirmation',
214
214
  message: 'Email confirmation required before two-factor authentication setup',
215
- human: $t('Je account is beheerder en moet beveiligd worden met tweestapsverificatie, maar je logde al meer dan {days} dagen niet meer in. Bevestig eerst je e-mailadres: we stuurden je een e-mail met een link waarmee je opnieuw toegang krijgt en tweestapsverificatie kan instellen. Kreeg je geen e-mail? Dan kan je ook een nieuwe link aanvragen via wachtwoord vergeten.', { days: INACTIVE_ADMIN_ENROLLMENT_DAYS.toString() }),
215
+ human: $t('%Zjm', { days: INACTIVE_ADMIN_ENROLLMENT_DAYS.toString() }),
216
216
  statusCode: 403,
217
217
  });
218
218
  }
@@ -52,7 +52,7 @@ export async function streamForBreakdown<T>(options: {
52
52
  throw new SimpleError({
53
53
  code: 'breakdown_pending',
54
54
  message: 'A breakdown is already running for this user',
55
- human: $t('Er worden al statistieken berekend, probeer het zo opnieuw.'),
55
+ human: $t('%Zj3'),
56
56
  statusCode: 429,
57
57
  });
58
58
  }
@@ -96,7 +96,7 @@ function assertBreakdownSize(count: number) {
96
96
  throw new SimpleError({
97
97
  code: 'too_many_objects',
98
98
  message: 'Too many objects to break down',
99
- human: $t('Deze selectie bevat meer dan {limit} items, dat zijn er te veel om statistieken van te maken. Kies een kortere periode of verfijn je selectie.', {
99
+ human: $t('%Ziw', {
100
100
  limit: Formatter.integer(MAX_BREAKDOWN_OBJECTS),
101
101
  }),
102
102
  statusCode: 400,
@@ -1,7 +1,10 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { Document, DocumentTemplateFactory, Organization } from '@stamhoofd/models';
3
- import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
3
+ import { render } from '@stamhoofd/models/helpers/Handlebars.js';
4
+ import type { RecordAnswer } from '@stamhoofd/structures';
5
+ import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordCheckboxAnswer, RecordDateAnswer, RecordPriceAnswer, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
4
6
  import { Country } from '@stamhoofd/types/Country';
7
+ import { Formatter } from '@stamhoofd/utility';
5
8
  import { DocumentRenderService } from './DocumentRenderService.js';
6
9
 
7
10
  function createImage(id: string) {
@@ -66,6 +69,49 @@ function createInvalidFieldAnswers() {
66
69
 
67
70
  const xmlExport = '<documents>{{#each documents}}<document>{{{this.number}}}</document>{{/each}}</documents>';
68
71
 
72
+ /**
73
+ * The day price row of the participation template (templates/participation.html in the dashboard).
74
+ */
75
+ const dayPriceTemplate = '{{#if (and registration.showDayPrice (coalesce registration.price registration.priceOriginal 0)) }}'
76
+ + 'Bedrag per dag: {{ formatPrice (div (coalesce registration.price registration.priceOriginal 0) (coalesce registration.days (days registration.startDate registration.endDate))) round=true }}'
77
+ + '{{/if}}';
78
+
79
+ function createParticipationDocument(answers: { showDayPrice: boolean; price?: number | null; priceOriginal?: number | null; startDate?: Date; endDate?: Date }) {
80
+ const settingsFor = (id: string, type: RecordType) => RecordSettings.create({ id, type });
81
+
82
+ const fieldAnswers = new Map<string, RecordAnswer>([
83
+ ['registration.showDayPrice', RecordCheckboxAnswer.create({
84
+ settings: settingsFor('registration.showDayPrice', RecordType.Checkbox),
85
+ selected: answers.showDayPrice,
86
+ })],
87
+ ['registration.price', RecordPriceAnswer.create({
88
+ settings: settingsFor('registration.price', RecordType.Price),
89
+ value: answers.price ?? null,
90
+ })],
91
+ ['registration.priceOriginal', RecordPriceAnswer.create({
92
+ settings: settingsFor('registration.priceOriginal', RecordType.Price),
93
+ value: answers.priceOriginal ?? null,
94
+ })],
95
+ ['registration.startDate', RecordDateAnswer.create({
96
+ settings: settingsFor('registration.startDate', RecordType.Date),
97
+ dateValue: answers.startDate ?? new Date(2024, 6, 1),
98
+ })],
99
+ ['registration.endDate', RecordDateAnswer.create({
100
+ settings: settingsFor('registration.endDate', RecordType.Date),
101
+ dateValue: answers.endDate ?? new Date(2024, 6, 7),
102
+ })],
103
+ ]);
104
+
105
+ const document = createDocument();
106
+ document.data.fieldAnswers = fieldAnswers;
107
+ return document;
108
+ }
109
+
110
+ async function renderDayPrice(answers: Parameters<typeof createParticipationDocument>[0]) {
111
+ const context = DocumentRenderService.buildDocumentContext(createParticipationDocument(answers), createOrganization(), createPlatform({}));
112
+ return await render(dayPriceTemplate, context);
113
+ }
114
+
69
115
  /**
70
116
  * Builds a locked template: a locked template makes buildAll return the documents unchanged, so the
71
117
  * numbers set up here are exactly what the renumbering logic gets to see.
@@ -150,6 +196,31 @@ describe('DocumentRenderService', () => {
150
196
  });
151
197
  });
152
198
 
199
+ describe('day price', () => {
200
+ // 1 July until 7 July are 7 days, so €25 is rounded to €3,57 per day
201
+ const dayPriceOf25 = 'Bedrag per dag: ' + Formatter.price(3_5700);
202
+
203
+ test('It divides the price by the amount of days, both start and end date included', async () => {
204
+ await expect(renderDayPrice({ showDayPrice: true, price: 25_0000 })).resolves.toBe(dayPriceOf25);
205
+ });
206
+
207
+ test('It uses the original price when the price itself is hidden on the document', async () => {
208
+ await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: 25_0000 })).resolves.toBe(dayPriceOf25);
209
+ });
210
+
211
+ test('It prefers the price of the document over the original price', async () => {
212
+ await expect(renderDayPrice({ showDayPrice: true, price: 14_0000, priceOriginal: 25_0000 })).resolves.toBe('Bedrag per dag: ' + Formatter.price(2_0000));
213
+ });
214
+
215
+ test('It is left out when the checkbox is not selected', async () => {
216
+ await expect(renderDayPrice({ showDayPrice: false, price: 25_0000 })).resolves.toBe('');
217
+ });
218
+
219
+ test('It is left out when no price is known', async () => {
220
+ await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: null })).resolves.toBe('');
221
+ });
222
+ });
223
+
153
224
  describe('getRenderedHtml', () => {
154
225
  test('It returns null and logs when building the context fails', async () => {
155
226
  const template = await new DocumentTemplateFactory({ groups: [] }).create();
@@ -517,6 +517,14 @@ export class PaymentService {
517
517
  }
518
518
  }
519
519
 
520
+ // DirectDebit expires after 3 weeks
521
+ if ((status === PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method === PaymentMethod.DirectDebit) {
522
+ // If payment is not succeeded after one day, mark as failed
523
+ if (payment.createdAt < new Date(new Date().getTime() - 60 * 1000 * 60 * 24 * 7 * 3)) {
524
+ return true;
525
+ }
526
+ }
527
+
520
528
  if (STAMHOOFD.environment === 'development') {
521
529
  // In development, we expire all direct debits and other paymetns after 1 hour, because they need manual changes
522
530
  // otherwise they will remain stuck in the dev environment, poluting the UI
@@ -30,13 +30,17 @@ export const orderFilterCompilers: SQLFilterDefinitions = {
30
30
  nullable: false,
31
31
  }),
32
32
  timeSlotEndTime: createColumnFilter({
33
+ // Stored as a number (minutes since midnight), so it must be typed as a JSON number to allow
34
+ // numeric comparisons and to match the in-memory filter.
33
35
  expression: SQL.jsonExtract(SQL.column('data'), '$.value.timeSlot.endTime'),
34
- type: SQLValueType.JSONString,
36
+ type: SQLValueType.JSONNumber,
35
37
  nullable: true,
36
38
  }),
37
39
  timeSlotStartTime: createColumnFilter({
40
+ // Stored as a number (minutes since midnight), so it must be typed as a JSON number to allow
41
+ // numeric comparisons and to match the in-memory filter.
38
42
  expression: SQL.jsonExtract(SQL.column('data'), '$.value.timeSlot.startTime'),
39
- type: SQLValueType.JSONString,
43
+ type: SQLValueType.JSONNumber,
40
44
  nullable: true,
41
45
  }),
42
46
  createdAt: createColumnFilter({
@@ -0,0 +1,635 @@
1
+ import { Database } from '@simonbackx/simple-database';
2
+ import type { Organization, Webshop } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemPayment, Order, OrganizationFactory, Payment, WebshopFactory } from '@stamhoofd/models';
4
+ import { compileToSQLFilter, SQL } from '@stamhoofd/sql';
5
+ import type { StamhoofdFilter } from '@stamhoofd/structures';
6
+ import { Address, BalanceItemType, Cart, CartItem, CartItemPrice, CheckoutMethodType, compileToInMemoryFilter, Customer, DiscountCode, OrderData, OrderStatus, PaymentMethod, PaymentStatus, privateOrderWithTicketsFilterCompilers, Product, ProductPrice, RecordCheckboxAnswer, RecordChoice, RecordChooseOneAnswer, RecordDateAnswer, RecordIntegerAnswer, RecordMultipleChoiceAnswer, RecordSettings, RecordTextAnswer, RecordType, WebshopTakeoutMethod, WebshopTimeSlot } from '@stamhoofd/structures';
7
+ import { Country } from '@stamhoofd/types/Country';
8
+
9
+ import { orderFilterCompilers } from '../../src/sql-filters/orders.js';
10
+
11
+ /**
12
+ * These tests pin the in-memory order filters (used in the dashboard on locally cached orders) to the
13
+ * backend SQL order filters (used by GetWebshopOrdersEndpoint). For every filter, the same StamhoofdFilter
14
+ * is run through both engines against the exact same set of orders, and both must return the same order ids.
15
+ *
16
+ * The in-memory engine runs on the decoded PrivateOrder structures (privateOrderWithTicketsFilterCompilers).
17
+ * The SQL engine runs a `SELECT * FROM webshop_orders WHERE <filter>` (orderFilterCompilers), the same way
18
+ * the endpoint builds its query.
19
+ *
20
+ * Filters that only exist in one engine are intentionally not covered here (no counterpart to compare to):
21
+ * - in-memory only: location, openBalance, ticketScanStatus, ticketScannedAt, ticketCount
22
+ * - SQL only: organizationId, updatedAt, paymentMethod
23
+ */
24
+ describe('Order filters (in-memory vs backend SQL parity)', () => {
25
+ let organization: Organization;
26
+ let webshop: Webshop;
27
+ let nextNumber = 1;
28
+
29
+ beforeAll(async () => {
30
+ organization = await new OrganizationFactory({}).create();
31
+ webshop = await new WebshopFactory({ organizationId: organization.id }).create();
32
+ });
33
+
34
+ beforeEach(async () => {
35
+ // Start every test from an empty order set so the in-memory universe and the SQL universe are identical.
36
+ await Database.delete('DELETE FROM `balance_item_payments`');
37
+ await Database.delete('DELETE FROM `payments`');
38
+ await Database.delete('DELETE FROM `balance_items`');
39
+ await Database.delete('DELETE FROM `webshop_orders`');
40
+ nextNumber = 1;
41
+ });
42
+
43
+ // --- order creation helpers ------------------------------------------------------------------------
44
+
45
+ const address = () => Address.create({
46
+ street: 'Demostraat',
47
+ number: '15',
48
+ postalCode: '9000',
49
+ city: 'Gent',
50
+ country: Country.Belgium,
51
+ });
52
+
53
+ /** A cart item with a fixed, deterministic price so totalPrice/amount are predictable. */
54
+ function cartItem(options: { productId?: string; productPriceId?: string; amount?: number; unitPrice?: number } = {}) {
55
+ const amount = options.amount ?? 1;
56
+ const unitPrice = options.unitPrice ?? 0;
57
+ return CartItem.create({
58
+ product: Product.create({ id: options.productId ?? 'product-default' }),
59
+ productPrice: ProductPrice.create({ id: options.productPriceId ?? 'price-default' }),
60
+ amount,
61
+ calculatedPrices: [CartItemPrice.create({ price: unitPrice * amount })],
62
+ });
63
+ }
64
+
65
+ function orderData(options: {
66
+ firstName?: string;
67
+ lastName?: string;
68
+ email?: string;
69
+ phone?: string;
70
+ timeSlot?: WebshopTimeSlot | null;
71
+ checkoutMethod?: WebshopTakeoutMethod | null;
72
+ discountCodes?: DiscountCode[];
73
+ items?: CartItem[];
74
+ recordAnswers?: Map<string, RecordCheckboxAnswer | RecordTextAnswer | RecordChooseOneAnswer | RecordMultipleChoiceAnswer | RecordDateAnswer | RecordIntegerAnswer>;
75
+ } = {}): OrderData {
76
+ return OrderData.create({
77
+ customer: Customer.create({
78
+ firstName: options.firstName ?? 'John',
79
+ lastName: options.lastName ?? 'Doe',
80
+ email: options.email ?? 'john@example.com',
81
+ phone: options.phone ?? '+32412345678',
82
+ }),
83
+ timeSlot: options.timeSlot ?? null,
84
+ checkoutMethod: options.checkoutMethod ?? null,
85
+ discountCodes: options.discountCodes ?? [],
86
+ cart: Cart.create({ items: options.items ?? [] }),
87
+ recordAnswers: options.recordAnswers ?? new Map(),
88
+ });
89
+ }
90
+
91
+ async function createOrder(options: {
92
+ data?: OrderData;
93
+ status?: OrderStatus;
94
+ number?: number | null;
95
+ validAt?: Date | null;
96
+ createdAt?: Date;
97
+ } = {}): Promise<Order> {
98
+ const order = new Order();
99
+ order.organizationId = organization.id;
100
+ order.webshopId = webshop.id;
101
+ order.data = options.data ?? orderData();
102
+ order.number = options.number !== undefined ? options.number : nextNumber++;
103
+ order.status = options.status ?? OrderStatus.Created;
104
+ order.validAt = options.validAt !== undefined ? options.validAt : new Date();
105
+ if (options.createdAt) {
106
+ order.createdAt = options.createdAt;
107
+ }
108
+ await order.save();
109
+ return order;
110
+ }
111
+
112
+ /** Attaches a balance item to an order (needed for amountToPay and to hang payments off). */
113
+ async function addBalanceItem(order: Order, options: { unitPrice: number; amount?: number; pricePaid?: number }): Promise<BalanceItem> {
114
+ const balanceItem = new BalanceItem();
115
+ balanceItem.organizationId = organization.id;
116
+ balanceItem.orderId = order.id;
117
+ balanceItem.type = BalanceItemType.Order;
118
+ balanceItem.amount = options.amount ?? 1;
119
+ balanceItem.unitPrice = options.unitPrice;
120
+ balanceItem.pricePaid = options.pricePaid ?? 0;
121
+ await balanceItem.save();
122
+ return balanceItem;
123
+ }
124
+
125
+ async function addPayment(balanceItem: BalanceItem, options: {
126
+ method: PaymentMethod;
127
+ price: number;
128
+ status?: PaymentStatus;
129
+ paidAt?: Date | null;
130
+ transferDescription?: string | null;
131
+ }): Promise<Payment> {
132
+ const payment = new Payment();
133
+ payment.method = options.method;
134
+ payment.status = options.status ?? PaymentStatus.Succeeded;
135
+ payment.organizationId = organization.id;
136
+ payment.price = options.price;
137
+ payment.paidAt = options.paidAt ?? null;
138
+ payment.transferDescription = options.transferDescription ?? null;
139
+ await payment.save();
140
+
141
+ const balanceItemPayment = new BalanceItemPayment();
142
+ balanceItemPayment.balanceItemId = balanceItem.id;
143
+ balanceItemPayment.paymentId = payment.id;
144
+ balanceItemPayment.price = options.price;
145
+ balanceItemPayment.organizationId = organization.id;
146
+ await balanceItemPayment.save();
147
+
148
+ return payment;
149
+ }
150
+
151
+ // --- parity harness --------------------------------------------------------------------------------
152
+
153
+ async function runInMemory(filter: StamhoofdFilter): Promise<string[]> {
154
+ const orders = await Order.where({ organizationId: organization.id });
155
+ const structures = await Order.getPrivateStructures(orders);
156
+ const runner = compileToInMemoryFilter(filter, privateOrderWithTicketsFilterCompilers);
157
+ return structures.filter(s => s.number !== null && runner(s)).map(s => s.id).sort();
158
+ }
159
+
160
+ async function runSQL(filter: StamhoofdFilter): Promise<string[]> {
161
+ const query = SQL
162
+ .select(SQL.wildcard(Order.table))
163
+ .from(SQL.table(Order.table))
164
+ // Same base filter as GetWebshopOrdersEndpoint.buildQuery
165
+ .where(await compileToSQLFilter({ organizationId: organization.id, number: { $neq: null } }, orderFilterCompilers))
166
+ .where(await compileToSQLFilter(filter, orderFilterCompilers));
167
+
168
+ const rows = await query.fetch();
169
+ return Order.fromRows(rows, Order.table).map(o => o.id).sort();
170
+ }
171
+
172
+ /**
173
+ * Asserts that both engines return exactly the expected orders. Comparing to an explicit expected set
174
+ * (instead of only comparing the engines to each other) also guards against both engines being wrong
175
+ * in the same way, or a filter accidentally matching everything / nothing.
176
+ */
177
+ async function expectFilter(filter: StamhoofdFilter, expected: Order[]) {
178
+ const expectedIds = [...new Set(expected.map(o => o.id))].sort();
179
+ const inMemoryIds = await runInMemory(filter);
180
+ const sqlIds = await runSQL(filter);
181
+
182
+ expect(inMemoryIds, 'in-memory result should match expected').toEqual(expectedIds);
183
+ expect(sqlIds, 'backend SQL result should match expected').toEqual(expectedIds);
184
+ }
185
+
186
+ // --- simple columns --------------------------------------------------------------------------------
187
+
188
+ describe('id', () => {
189
+ it('$eq matches a single order', async () => {
190
+ const a = await createOrder();
191
+ const b = await createOrder();
192
+ await expectFilter({ id: { $eq: a.id } }, [a]);
193
+ await expectFilter({ id: { $eq: b.id } }, [b]);
194
+ });
195
+
196
+ it('$in matches multiple orders', async () => {
197
+ const a = await createOrder();
198
+ const b = await createOrder();
199
+ const c = await createOrder();
200
+ await expectFilter({ id: { $in: [a.id, c.id] } }, [a, c]);
201
+ });
202
+
203
+ it('$neq excludes a single order', async () => {
204
+ const a = await createOrder();
205
+ const b = await createOrder();
206
+ await expectFilter({ id: { $neq: a.id } }, [b]);
207
+ });
208
+ });
209
+
210
+ describe('webshopId', () => {
211
+ it('$eq matches orders of the webshop', async () => {
212
+ const a = await createOrder();
213
+ const b = await createOrder();
214
+ await expectFilter({ webshopId: { $eq: webshop.id } }, [a, b]);
215
+ await expectFilter({ webshopId: { $eq: 'does-not-exist' } }, []);
216
+ });
217
+ });
218
+
219
+ describe('status', () => {
220
+ it('$eq / $in / $neq on the enum', async () => {
221
+ const created = await createOrder({ status: OrderStatus.Created });
222
+ const prepared = await createOrder({ status: OrderStatus.Prepared });
223
+ const completed = await createOrder({ status: OrderStatus.Completed });
224
+
225
+ await expectFilter({ status: { $eq: OrderStatus.Created } }, [created]);
226
+ await expectFilter({ status: { $in: [OrderStatus.Created, OrderStatus.Completed] } }, [created, completed]);
227
+ await expectFilter({ status: { $neq: OrderStatus.Prepared } }, [created, completed]);
228
+ });
229
+ });
230
+
231
+ describe('number', () => {
232
+ it('numeric comparisons', async () => {
233
+ const a = await createOrder({ number: 10 });
234
+ const b = await createOrder({ number: 20 });
235
+ const c = await createOrder({ number: 30 });
236
+
237
+ await expectFilter({ number: { $eq: 20 } }, [b]);
238
+ await expectFilter({ number: { $gt: 15 } }, [b, c]);
239
+ await expectFilter({ number: { $lt: 25 } }, [a, b]);
240
+ await expectFilter({ number: { $in: [10, 30] } }, [a, c]);
241
+ });
242
+ });
243
+
244
+ describe('createdAt', () => {
245
+ it('date comparisons', async () => {
246
+ const older = await createOrder({ createdAt: new Date('2023-01-01T12:00:00Z') });
247
+ const newer = await createOrder({ createdAt: new Date('2024-01-01T12:00:00Z') });
248
+
249
+ await expectFilter({ createdAt: { $lt: new Date('2023-06-01T00:00:00Z') } }, [older]);
250
+ await expectFilter({ createdAt: { $gt: new Date('2023-06-01T00:00:00Z') } }, [newer]);
251
+ });
252
+ });
253
+
254
+ describe('validAt', () => {
255
+ it('$eq null / $neq null', async () => {
256
+ const valid = await createOrder({ validAt: new Date('2024-01-01T12:00:00Z') });
257
+ const invalid = await createOrder({ validAt: null });
258
+
259
+ await expectFilter({ validAt: { $eq: null } }, [invalid]);
260
+ await expectFilter({ validAt: { $neq: null } }, [valid]);
261
+ });
262
+
263
+ it('date comparison', async () => {
264
+ const older = await createOrder({ validAt: new Date('2023-01-01T12:00:00Z') });
265
+ const newer = await createOrder({ validAt: new Date('2024-01-01T12:00:00Z') });
266
+
267
+ await expectFilter({ validAt: { $gt: new Date('2023-06-01T00:00:00Z') } }, [newer]);
268
+ await expectFilter({ validAt: { $lt: new Date('2023-06-01T00:00:00Z') } }, [older]);
269
+ });
270
+ });
271
+
272
+ // --- customer JSON ---------------------------------------------------------------------------------
273
+
274
+ describe('name', () => {
275
+ it('$eq and $contains (case-insensitive)', async () => {
276
+ const john = await createOrder({ data: orderData({ firstName: 'John', lastName: 'Doe' }) });
277
+ const jane = await createOrder({ data: orderData({ firstName: 'Jane', lastName: 'Roe' }) });
278
+
279
+ await expectFilter({ name: { $eq: 'John Doe' } }, [john]);
280
+ await expectFilter({ name: { $eq: 'john doe' } }, [john]);
281
+ await expectFilter({ name: { $contains: 'oe' } }, [john, jane]);
282
+ await expectFilter({ name: { $contains: 'jane' } }, [jane]);
283
+ });
284
+ });
285
+
286
+ describe('email', () => {
287
+ it('$eq and $contains', async () => {
288
+ const a = await createOrder({ data: orderData({ email: 'alice@example.com' }) });
289
+ const b = await createOrder({ data: orderData({ email: 'bob@other.org' }) });
290
+
291
+ await expectFilter({ email: { $eq: 'alice@example.com' } }, [a]);
292
+ await expectFilter({ email: { $contains: 'example.com' } }, [a]);
293
+ await expectFilter({ email: { $contains: '@' } }, [a, b]);
294
+ });
295
+ });
296
+
297
+ describe('phone', () => {
298
+ it('$contains and $eq on a set phone number', async () => {
299
+ const a = await createOrder({ data: orderData({ phone: '+32412345678' }) });
300
+ const b = await createOrder({ data: orderData({ phone: '+32498765432' }) });
301
+
302
+ await expectFilter({ phone: { $eq: '+32412345678' } }, [a]);
303
+ await expectFilter({ phone: { $contains: '4123' } }, [a]);
304
+ await expectFilter({ phone: { $contains: '3' } }, [a, b]);
305
+ });
306
+ });
307
+
308
+ // --- cached columns --------------------------------------------------------------------------------
309
+
310
+ describe('totalPrice', () => {
311
+ it('numeric comparisons', async () => {
312
+ const cheap = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 5_00, amount: 1 })] }) });
313
+ const expensive = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 50_00, amount: 1 })] }) });
314
+
315
+ await expectFilter({ totalPrice: { $eq: 5_00 } }, [cheap]);
316
+ await expectFilter({ totalPrice: { $gt: 10_00 } }, [expensive]);
317
+ await expectFilter({ totalPrice: { $lt: 10_00 } }, [cheap]);
318
+ });
319
+ });
320
+
321
+ describe('amount', () => {
322
+ it('numeric comparisons', async () => {
323
+ const few = await createOrder({ data: orderData({ items: [cartItem({ amount: 1, unitPrice: 1_00 })] }) });
324
+ const many = await createOrder({ data: orderData({ items: [cartItem({ amount: 5, unitPrice: 1_00 })] }) });
325
+
326
+ await expectFilter({ amount: { $eq: 1 } }, [few]);
327
+ await expectFilter({ amount: { $gt: 3 } }, [many]);
328
+ await expectFilter({ amount: { $lte: 1 } }, [few]);
329
+ });
330
+ });
331
+
332
+ // --- time slot -------------------------------------------------------------------------------------
333
+
334
+ describe('time slot', () => {
335
+ const slot = (options: { date: Date; startTime?: number; endTime?: number }) => WebshopTimeSlot.create({
336
+ date: options.date,
337
+ startTime: options.startTime ?? 12 * 60,
338
+ endTime: options.endTime ?? 14 * 60,
339
+ });
340
+
341
+ it('timeSlotDate $eq null matches orders without a time slot', async () => {
342
+ const withSlot = await createOrder({ data: orderData({ timeSlot: slot({ date: new Date('2024-05-10T00:00:00Z') }) }) });
343
+ const without = await createOrder({ data: orderData({ timeSlot: null }) });
344
+
345
+ await expectFilter({ timeSlotDate: { $eq: null } }, [without]);
346
+ await expectFilter({ timeSlotDate: { $neq: null } }, [withSlot]);
347
+ });
348
+
349
+ // timeSlotStartTime / timeSlotEndTime hold numbers (minutes since midnight). Numeric comparisons
350
+ // must work in both engines, and a missing time slot must behave like SQL NULL: matched by $eq null
351
+ // and by $lt (NULL is the smallest), not matched by $gt or $neq null.
352
+ it('timeSlotStartTime / timeSlotEndTime comparisons and null parity', async () => {
353
+ const morning = await createOrder({ data: orderData({ timeSlot: slot({ date: new Date('2024-05-10T00:00:00Z'), startTime: 9 * 60, endTime: 11 * 60 }) }) });
354
+ const evening = await createOrder({ data: orderData({ timeSlot: slot({ date: new Date('2024-05-10T00:00:00Z'), startTime: 18 * 60, endTime: 20 * 60 }) }) });
355
+ const without = await createOrder({ data: orderData({ timeSlot: null }) });
356
+
357
+ await expectFilter({ timeSlotStartTime: { $eq: null } }, [without]);
358
+ await expectFilter({ timeSlotStartTime: { $neq: null } }, [morning, evening]);
359
+ await expectFilter({ timeSlotStartTime: { $gt: 12 * 60 } }, [evening]);
360
+ await expectFilter({ timeSlotStartTime: { $lt: 12 * 60 } }, [morning, without]);
361
+ await expectFilter({ timeSlotEndTime: { $gt: 12 * 60 } }, [evening]);
362
+ await expectFilter({ timeSlotEndTime: { $lt: 12 * 60 } }, [morning, without]);
363
+ });
364
+ });
365
+
366
+ // --- checkout method -------------------------------------------------------------------------------
367
+
368
+ describe('checkout method', () => {
369
+ const takeout = (id: string, name = 'Pickup point') => WebshopTakeoutMethod.create({
370
+ id,
371
+ name,
372
+ address: address(),
373
+ });
374
+
375
+ it('checkoutMethod (type) and checkoutMethodId, incl null parity', async () => {
376
+ const a = await createOrder({ data: orderData({ checkoutMethod: takeout('method-a') }) });
377
+ const b = await createOrder({ data: orderData({ checkoutMethod: takeout('method-b') }) });
378
+ const without = await createOrder({ data: orderData({ checkoutMethod: null }) });
379
+
380
+ await expectFilter({ checkoutMethodId: { $eq: 'method-a' } }, [a]);
381
+ await expectFilter({ checkoutMethodId: { $eq: null } }, [without]);
382
+ await expectFilter({ checkoutMethod: { $in: [CheckoutMethodType.Takeout] } }, [a, b]);
383
+ await expectFilter({ checkoutMethod: { $eq: null } }, [without]);
384
+ });
385
+ });
386
+
387
+ // --- discount codes --------------------------------------------------------------------------------
388
+
389
+ describe('discountCodes.code', () => {
390
+ // Codes are stored with mixed case on purpose: both $eq and $in on a JSON array must be
391
+ // case-insensitive, exactly like the in-memory engine.
392
+ it('$eq and $in match the codes in the array (case-insensitive)', async () => {
393
+ const summer = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'SUMMER' })] }) });
394
+ const summerLower = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'summer' })] }) });
395
+ const summerCombination = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'suMMER' })] }) });
396
+
397
+ const winter = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'WINTER' })] }) });
398
+
399
+ const none = await createOrder({ data: orderData({ discountCodes: [] }) });
400
+
401
+ await expectFilter({ discountCodes: { code: { $eq: 'summer' } } }, [summer, summerLower, summerCombination]);
402
+ await expectFilter({ discountCodes: { code: { $in: ['summer', 'winter'] } } }, [summer, summerLower, summerCombination, winter]);
403
+ await expectFilter({ discountCodes: { code: { $in: ['SUMMER', 'WINTER'] } } }, [summer, summerLower, summerCombination, winter]);
404
+ await expectFilter({ discountCodes: { code: { $in: ['suMmer', 'winTEr'] } } }, [summer, summerLower, summerCombination, winter]);
405
+ expect(none.id).toBeDefined();
406
+ });
407
+ });
408
+
409
+ // --- cart items ($elemMatch) -----------------------------------------------------------------------
410
+
411
+ describe('items', () => {
412
+ it('$elemMatch on product / productPrice / amount', async () => {
413
+ const apple = await createOrder({ data: orderData({ items: [cartItem({ productId: 'apple', productPriceId: 'apple-price', amount: 2, unitPrice: 1_00 })] }) });
414
+ const pear = await createOrder({ data: orderData({ items: [cartItem({ productId: 'pear', productPriceId: 'pear-price', amount: 5, unitPrice: 1_00 })] }) });
415
+
416
+ await expectFilter({ items: { $elemMatch: { product: { id: { $eq: 'apple' } } } } }, [apple]);
417
+ await expectFilter({ items: { $elemMatch: { productPrice: { id: { $eq: 'pear-price' } } } } }, [pear]);
418
+ await expectFilter({ items: { $elemMatch: { amount: { $gt: 3 } } } }, [pear]);
419
+ await expectFilter({ items: { $elemMatch: { product: { id: { $in: ['apple', 'pear'] } } } } }, [apple, pear]);
420
+ });
421
+ });
422
+
423
+ // --- record answers --------------------------------------------------------------------------------
424
+
425
+ describe('record answers', () => {
426
+ const RID = 'record-id-1';
427
+
428
+ const checkbox = (selected: boolean) => new Map([[RID, RecordCheckboxAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Checkbox }), selected })]]);
429
+ const text = (value: string) => new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value })]]);
430
+ const chooseOne = (choiceId: string | null) => new Map([[RID, RecordChooseOneAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.ChooseOne }), selectedChoice: choiceId === null ? null : RecordChoice.create({ id: choiceId }) })]]);
431
+ const multipleChoice = (choiceIds: string[]) => new Map([[RID, RecordMultipleChoiceAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.MultipleChoice }), selectedChoices: choiceIds.map(id => RecordChoice.create({ id })) })]]);
432
+ const date = (dateValue: Date | null) => new Map([[RID, RecordDateAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Date }), dateValue })]]);
433
+ const integer = (value: number | null) => new Map([[RID, RecordIntegerAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Integer }), value })]]);
434
+
435
+ it('checkbox: selected', async () => {
436
+ const checked = await createOrder({ data: orderData({ recordAnswers: checkbox(true) }) });
437
+ const unchecked = await createOrder({ data: orderData({ recordAnswers: checkbox(false) }) });
438
+
439
+ await expectFilter({ recordAnswers: { [RID]: { selected: { $eq: true } } } }, [checked]);
440
+ await expectFilter({ recordAnswers: { [RID]: { selected: { $eq: false } } } }, [unchecked]);
441
+ });
442
+
443
+ it('text value: $contains and $eq', async () => {
444
+ const hello = await createOrder({ data: orderData({ recordAnswers: text('say hello world') }) });
445
+ const bye = await createOrder({ data: orderData({ recordAnswers: text('goodbye') }) });
446
+
447
+ await expectFilter({ recordAnswers: { [RID]: { value: { $contains: 'hello' } } } }, [hello]);
448
+ await expectFilter({ recordAnswers: { [RID]: { value: { $eq: 'goodbye' } } } }, [bye]);
449
+ });
450
+
451
+ it('single choice: selectedChoice.id', async () => {
452
+ const a = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-a') }) });
453
+ const b = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-b') }) });
454
+
455
+ await expectFilter({ recordAnswers: { [RID]: { selectedChoice: { id: { $eq: 'choice-a' } } } } }, [a]);
456
+ await expectFilter({ recordAnswers: { [RID]: { selectedChoice: { id: { $in: ['choice-a', 'choice-b'] } } } } }, [a, b]);
457
+ });
458
+
459
+ it('multiple choice: selectedChoices.id', async () => {
460
+ const ab = await createOrder({ data: orderData({ recordAnswers: multipleChoice(['choice-a', 'choice-b']) }) });
461
+ const c = await createOrder({ data: orderData({ recordAnswers: multipleChoice(['choice-c']) }) });
462
+
463
+ await expectFilter({ recordAnswers: { [RID]: { selectedChoices: { id: { $in: ['choice-a'] } } } } }, [ab]);
464
+ await expectFilter({ recordAnswers: { [RID]: { selectedChoices: { id: { $in: ['choice-c'] } } } } }, [c]);
465
+ });
466
+
467
+ it('date value: comparisons and $eq null', async () => {
468
+ const stored = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-10T14:30:00Z')) }) });
469
+ const later = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-25T14:30:00Z')) }) });
470
+ const nullDate = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
471
+
472
+ await expectFilter({ recordAnswers: { [RID]: { dateValue: { $gt: new Date('2023-06-20T00:00:00Z') } } } }, [later]);
473
+ await expectFilter({ recordAnswers: { [RID]: { dateValue: { $eq: null } } } }, [nullDate]);
474
+ expect(stored.id).toBeDefined();
475
+ });
476
+
477
+ // An unanswered record question (missing map key) or a null answer value must behave in memory exactly like the SQL NULL the JSON extraction yields.
478
+ describe('missing / null answer parity (the SQL NULL behaviour)', () => {
479
+ it('$eq null matches both a null value and an unanswered question', async () => {
480
+ const nullValue = await createOrder({ data: orderData({ recordAnswers: text('') }) });
481
+ // Overwrite with an actual null text value
482
+ const nullAnswer = await createOrder({ data: orderData({ recordAnswers: new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value: null })]]) }) });
483
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
484
+ const answered = await createOrder({ data: orderData({ recordAnswers: text('something') }) });
485
+
486
+ await expectFilter({ recordAnswers: { [RID]: { value: { $eq: null } } } }, [nullAnswer, unanswered]);
487
+ expect(nullValue.id).toBeDefined();
488
+ expect(answered.id).toBeDefined();
489
+ });
490
+
491
+ it('$lt matches smaller values, null and unanswered (null is the smallest)', async () => {
492
+ const small = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-01-01T00:00:00Z')) }) });
493
+ const big = await createOrder({ data: orderData({ recordAnswers: date(new Date('2025-01-01T00:00:00Z')) }) });
494
+ const nullAnswer = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
495
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
496
+
497
+ await expectFilter({ recordAnswers: { [RID]: { dateValue: { $lt: new Date('2024-01-01T00:00:00Z') } } } }, [small, nullAnswer, unanswered]);
498
+ expect(big.id).toBeDefined();
499
+ });
500
+
501
+ it('$contains never matches a null value or an unanswered question', async () => {
502
+ const match = await createOrder({ data: orderData({ recordAnswers: text('hello world') }) });
503
+ const nullAnswer = await createOrder({ data: orderData({ recordAnswers: new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value: null })]]) }) });
504
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
505
+
506
+ await expectFilter({ recordAnswers: { [RID]: { value: { $contains: 'hello' } } } }, [match]);
507
+ expect(nullAnswer.id).toBeDefined();
508
+ expect(unanswered.id).toBeDefined();
509
+ });
510
+
511
+ it('number value $lt matches smaller values, null and unanswered', async () => {
512
+ const small = await createOrder({ data: orderData({ recordAnswers: integer(5) }) });
513
+ const big = await createOrder({ data: orderData({ recordAnswers: integer(15) }) });
514
+ const nullValue = await createOrder({ data: orderData({ recordAnswers: integer(null) }) });
515
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
516
+
517
+ await expectFilter({ recordAnswers: { [RID]: { value: { $lt: 10 } } } }, [small, nullValue, unanswered]);
518
+ expect(big.id).toBeDefined();
519
+ });
520
+
521
+ it('date value $gt matches later values but not null or unanswered', async () => {
522
+ const later = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-10T00:00:00Z')) }) });
523
+ const earlier = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-01T00:00:00Z')) }) });
524
+ const nullValue = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
525
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
526
+
527
+ await expectFilter({ recordAnswers: { [RID]: { dateValue: { $gt: new Date('2023-06-05T00:00:00Z') } } } }, [later]);
528
+ expect(earlier.id).toBeDefined();
529
+ expect(nullValue.id).toBeDefined();
530
+ expect(unanswered.id).toBeDefined();
531
+ });
532
+
533
+ it('date value $eq null matches null and unanswered', async () => {
534
+ const stored = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-10T00:00:00Z')) }) });
535
+ const nullValue = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
536
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
537
+
538
+ await expectFilter({ recordAnswers: { [RID]: { dateValue: { $eq: null } } } }, [nullValue, unanswered]);
539
+ expect(stored.id).toBeDefined();
540
+ });
541
+
542
+ it('checkbox selected: $in [null, true] matches unanswered, $in [true] does not', async () => {
543
+ const checked = await createOrder({ data: orderData({ recordAnswers: checkbox(true) }) });
544
+ const unchecked = await createOrder({ data: orderData({ recordAnswers: checkbox(false) }) });
545
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
546
+
547
+ await expectFilter({ recordAnswers: { [RID]: { selected: { $in: [null, true] } } } }, [checked, unanswered]);
548
+ await expectFilter({ recordAnswers: { [RID]: { selected: { $in: [true] } } } }, [checked]);
549
+ expect(unchecked.id).toBeDefined();
550
+ });
551
+
552
+ it('single choice: $in [null, choice] matches a null choice and unanswered', async () => {
553
+ const a = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-a') }) });
554
+ const b = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-b') }) });
555
+ const nullChoice = await createOrder({ data: orderData({ recordAnswers: chooseOne(null) }) });
556
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
557
+
558
+ await expectFilter({ recordAnswers: { [RID]: { selectedChoice: { id: { $in: [null, 'choice-a'] } } } } }, [a, nullChoice, unanswered]);
559
+ expect(b.id).toBeDefined();
560
+ });
561
+ });
562
+
563
+ // Negated filters on an empty record answer (null value, or a question that was never answered).
564
+ // The dashboard builds "not equal to x" and "does not contain x" as $not { ... }. A missing or null
565
+ // answer is not equal to and does not contain any concrete value, so it must match — in both engines.
566
+ describe('negated filters on missing or null values', () => {
567
+ const nullText = () => new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value: null })]]);
568
+
569
+ it('$not { $eq } (NotEquals) matches null and unanswered in both engines', async () => {
570
+ const x = await createOrder({ data: orderData({ recordAnswers: text('x') }) });
571
+ const y = await createOrder({ data: orderData({ recordAnswers: text('y') }) });
572
+ const nullValue = await createOrder({ data: orderData({ recordAnswers: nullText() }) });
573
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
574
+
575
+ await expectFilter({ recordAnswers: { [RID]: { $not: { value: { $eq: 'x' } } } } }, [y, nullValue, unanswered]);
576
+ expect(x.id).toBeDefined();
577
+ });
578
+
579
+ it('$not { $contains } (NotContains) matches null and unanswered in both engines', async () => {
580
+ const withX = await createOrder({ data: orderData({ recordAnswers: text('fox') }) });
581
+ const withoutX = await createOrder({ data: orderData({ recordAnswers: text('dog') }) });
582
+ const nullValue = await createOrder({ data: orderData({ recordAnswers: nullText() }) });
583
+ const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
584
+
585
+ await expectFilter({ recordAnswers: { [RID]: { $not: { value: { $contains: 'x' } } } } }, [withoutX, nullValue, unanswered]);
586
+ expect(withX.id).toBeDefined();
587
+ });
588
+ });
589
+ });
590
+
591
+ // --- payments ($elemMatch) -------------------------------------------------------------------------
592
+
593
+ describe('payments', () => {
594
+ it('$elemMatch on method / price / transferDescription, ignoring failed payments', async () => {
595
+ const transferOrder = await createOrder();
596
+ const transferItem = await addBalanceItem(transferOrder, { unitPrice: 10_00, pricePaid: 10_00 });
597
+ await addPayment(transferItem, { method: PaymentMethod.Transfer, price: 10_00, transferDescription: '+++123/456/789+++' });
598
+
599
+ const cardOrder = await createOrder();
600
+ const cardItem = await addBalanceItem(cardOrder, { unitPrice: 25_00, pricePaid: 25_00 });
601
+ await addPayment(cardItem, { method: PaymentMethod.CreditCard, price: 25_00 });
602
+
603
+ // Order whose only payment failed: both engines must ignore the failed payment.
604
+ const failedOrder = await createOrder();
605
+ const failedItem = await addBalanceItem(failedOrder, { unitPrice: 5_00, pricePaid: 0 });
606
+ await addPayment(failedItem, { method: PaymentMethod.Transfer, price: 5_00, status: PaymentStatus.Failed });
607
+
608
+ await expectFilter({ payments: { $elemMatch: { method: { $eq: PaymentMethod.Transfer } } } }, [transferOrder]);
609
+ await expectFilter({ payments: { $elemMatch: { price: { $gt: 20_00 } } } }, [cardOrder]);
610
+ await expectFilter({ payments: { $elemMatch: { transferDescription: { $contains: '123' } } } }, [transferOrder]);
611
+ });
612
+ });
613
+
614
+ // --- amountToPay -----------------------------------------------------------------------------------
615
+
616
+ describe('amountToPay', () => {
617
+ // amountToPay = totalPrice - sum(balance item pricePaid). Every order here has a balance item so the
618
+ // SQL SUM is not NULL (an order with no balance items would make amountToPay NULL in SQL but
619
+ // totalPrice in memory, which is a separate, documented divergence).
620
+ it('numeric comparisons', async () => {
621
+ const fullyPaid = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 10_00, amount: 1 })] }) });
622
+ await addBalanceItem(fullyPaid, { unitPrice: 10_00, pricePaid: 10_00 });
623
+
624
+ const partlyPaid = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 10_00, amount: 1 })] }) });
625
+ await addBalanceItem(partlyPaid, { unitPrice: 10_00, pricePaid: 4_00 });
626
+
627
+ const unpaid = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 10_00, amount: 1 })] }) });
628
+ await addBalanceItem(unpaid, { unitPrice: 10_00, pricePaid: 0 });
629
+
630
+ await expectFilter({ amountToPay: { $eq: 0 } }, [fullyPaid]);
631
+ await expectFilter({ amountToPay: { $gt: 0 } }, [partlyPaid, unpaid]);
632
+ await expectFilter({ amountToPay: { $eq: 10_00 } }, [unpaid]);
633
+ });
634
+ });
635
+ });