@stamhoofd/backend 2.133.0 → 2.135.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.
Files changed (35) hide show
  1. package/package.json +18 -18
  2. package/src/crons/invoices.ts +5 -3
  3. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  4. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  5. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +224 -2
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +190 -2
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +72 -14
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  10. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +183 -0
  11. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  12. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +158 -0
  14. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  15. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  16. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  17. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  18. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +70 -0
  19. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  20. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  21. package/src/helpers/GlobalHelper.ts +1 -1
  22. package/src/helpers/MembershipCharger.ts +2 -1
  23. package/src/helpers/StripeInvoicer.ts +13 -2
  24. package/src/helpers/UitpasTokenRepository.ts +12 -11
  25. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  26. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  27. package/src/services/PaymentService.ts +33 -8
  28. package/src/services/PlatformMembershipService.ts +2 -1
  29. package/src/services/RegistrationService.ts +5 -0
  30. package/src/services/uitpas/UitpasService.ts +3 -4
  31. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  32. package/src/sql-filters/balance-item-payments.ts +4 -56
  33. package/src/sql-filters/balance-items.ts +69 -10
  34. package/src/sql-filters/members.ts +5 -0
  35. package/src/sql-sorters/members.ts +12 -1
@@ -6,8 +6,10 @@ import type { Organization, StripeAccount, User } from '@stamhoofd/models';
6
6
  import { Order, OrganizationFactory, Payment, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
7
7
  import type { OrderResponse } from '@stamhoofd/structures';
8
8
  import { Address, Cart, CartItem, CartItemOption, Customer, Option, OptionMenu, OrderData, PaymentConfiguration, PaymentMethod, PermissionLevel, Permissions, PrivateOrder, PrivatePaymentConfiguration, Product, ProductPrice, ProductType, SeatingPlan, SeatingPlanRow, SeatingPlanSeat, SeatingPlanSection, TransferSettings, WebshopAuthType, WebshopDeliveryMethod, WebshopMetaData, WebshopOnSiteMethod, WebshopPrivateMetaData, WebshopTakeoutMethod, WebshopTimeSlot } from '@stamhoofd/structures';
9
- import { STExpect } from '@stamhoofd/test-utils';
9
+ import { I18n } from '@stamhoofd/backend-i18n';
10
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
10
11
  import { Country } from '@stamhoofd/types/Country';
12
+ import { Language } from '@stamhoofd/types/Language';
11
13
  import sinon from 'sinon';
12
14
  import { v4 as uuidv4 } from 'uuid';
13
15
 
@@ -573,4 +575,69 @@ describe('Endpoint.PlaceOrderEndpoint', () => {
573
575
  expect(payment.customer!.equals(orderModel.data.customer.toPaymentCustomer())).toBe(true);
574
576
  });
575
577
  });
578
+
579
+ describe('Order language', () => {
580
+ function buildFreeOrderData() {
581
+ return OrderData.create({
582
+ paymentMethod: PaymentMethod.Unknown,
583
+ checkoutMethod: onSiteMethod,
584
+ timeSlot: slot4,
585
+ cart: Cart.create({
586
+ items: [
587
+ CartItem.create({
588
+ product,
589
+ productPrice: freeProductPrice,
590
+ amount: 1,
591
+ }),
592
+ ],
593
+ }),
594
+ customer,
595
+ });
596
+ }
597
+
598
+ test('Stores the request language on a placed order', async () => {
599
+ const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), buildFreeOrderData());
600
+ r.headers['accept-language'] = 'en';
601
+
602
+ const response = await testServer.test(endpoint, r);
603
+ expect(response.body.order.consumerLanguage).toEqual('en');
604
+
605
+ // Persisted in the dedicated column
606
+ const orderModel = (await Order.getByID(response.body.order.id))!;
607
+ expect(orderModel.consumerLanguage).toEqual('en');
608
+ });
609
+
610
+ test('Falls back to the webshop default language when the request language is unknown', async () => {
611
+ webshop.meta.defaultLanguage = Language.French;
612
+ await webshop.save();
613
+
614
+ // The struct default is Dutch, so the server-side fallback to the webshop default is observable.
615
+ const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), buildFreeOrderData());
616
+
617
+ const response = await testServer.test(endpoint, r);
618
+ expect(response.body.order.consumerLanguage).toEqual(Language.French);
619
+
620
+ const orderModel = (await Order.getByID(response.body.order.id))!;
621
+ expect(orderModel.consumerLanguage).toEqual(Language.French);
622
+ });
623
+
624
+ test('runWithLocale overrides the global language used for $t evaluation', async () => {
625
+ // Allow French to be a valid locale for this test (test env has no locales configured).
626
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
627
+
628
+ const i18n = new I18n(Language.French, Country.Belgium);
629
+
630
+ expect(I18n.override).toBeNull();
631
+ expect((global as any).$getLanguage()).toEqual(Language.Dutch);
632
+
633
+ await I18n.runWithLocale(i18n, async () => {
634
+ expect(I18n.override).toBe(i18n);
635
+ expect((global as any).$getLanguage()).toEqual(Language.French);
636
+ });
637
+
638
+ // Restored afterwards
639
+ expect(I18n.override).toBeNull();
640
+ expect((global as any).$getLanguage()).toEqual(Language.Dutch);
641
+ });
642
+ });
576
643
  });
@@ -3,6 +3,7 @@ import type { Decoder } from '@simonbackx/simple-encoding';
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { I18n } from '@stamhoofd/backend-i18n';
6
7
  import { Email } from '@stamhoofd/email';
7
8
  import { BalanceItem, BalanceItemPayment, MolliePayment, Order, PayconiqPayment, Payment, RateLimiter, Webshop, WebshopDiscountCode } from '@stamhoofd/models';
8
9
  import { QueueHandler } from '@stamhoofd/queues';
@@ -132,6 +133,13 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
132
133
 
133
134
  const order = new Order().setRelation(Order.webshop, webshop);
134
135
  order.data = request.body; // TODO: validate
136
+
137
+ // Store the language the customer used while placing the order, so future emails to
138
+ // this order are rendered in the same language. Fall back to the webshop's default
139
+ // language when it can't be determined from the request.
140
+ const consumerLanguage = I18n.getLanguageFromRequest(request) ?? webshop.meta.defaultLanguage;
141
+ order.consumerLanguage = consumerLanguage;
142
+
135
143
  order.organizationId = organization.id;
136
144
  order.createdAt = new Date();
137
145
  order.createdAt.setMilliseconds(0);
@@ -17,7 +17,7 @@ export class GlobalHelper {
17
17
 
18
18
  static loadGlobalTranslateFunction() {
19
19
  function getI18n() {
20
- return ContextInstance.optional?.i18n ?? new I18n(I18n.defaultLanguage, STAMHOOFD.fixedCountry ?? I18n.defaultCountry);
20
+ return I18n.override ?? ContextInstance.optional?.i18n ?? new I18n(I18n.defaultLanguage, STAMHOOFD.fixedCountry ?? I18n.defaultCountry);
21
21
  }
22
22
  (global as any).$t = (key: string, replace?: Record<string, string>) => getI18n().$t(key, replace);
23
23
  (global as any).$getLanguage = () => getI18n().language;
@@ -2,7 +2,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { BalanceItem, Member, MemberPlatformMembership, Platform, RegistrationPeriod } from '@stamhoofd/models';
3
3
  import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
4
4
  import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
5
- import { Formatter } from '@stamhoofd/utility';
5
+ import { Formatter, sleep } from '@stamhoofd/utility';
6
6
 
7
7
  export const MembershipCharger = {
8
8
  async charge() {
@@ -178,6 +178,7 @@ export const MembershipCharger = {
178
178
  const chunkSize = 100;
179
179
 
180
180
  while (true) {
181
+ await sleep(200);
181
182
  const q = MemberPlatformMembership.select()
182
183
  .where('id', SQLWhereSign.Greater, lastId)
183
184
  .where('balanceItemId', null)
@@ -235,7 +235,7 @@ export class StripeReportInvoicer {
235
235
  sellingOrganization,
236
236
  type: 'services',
237
237
  });
238
- item.VATIncluded = true;
238
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
239
239
  item.quantity = 1;
240
240
  item.unitPrice = applicationFee.serviceFee;
241
241
  item.createdAt = new Date();
@@ -265,7 +265,7 @@ export class StripeReportInvoicer {
265
265
  sellingOrganization,
266
266
  type: 'services',
267
267
  });
268
- item.VATIncluded = true;
268
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
269
269
  item.quantity = 1;
270
270
  item.unitPrice = applicationFee.transferFee;
271
271
  item.createdAt = new Date();
@@ -276,6 +276,17 @@ export class StripeReportInvoicer {
276
276
  balanceItems.push(item);
277
277
  }
278
278
 
279
+ let total = 0;
280
+ for (const balanceItem of balanceItems) {
281
+ total += balanceItem.priceWithVAT;
282
+ }
283
+ if (total !== applicationFee.amount) {
284
+ throw new SimpleError({
285
+ code: 'price_mismatched',
286
+ message: 'The charged amount does not match the total application fee for the payment',
287
+ });
288
+ }
289
+
279
290
  const systemUser = await User.getSystem();
280
291
 
281
292
  // Done validation
@@ -39,26 +39,27 @@ export class UitpasTokenRepository {
39
39
  return await QueueHandler.schedule('uitpas/token-' + (organizationId ?? 'platform'), handler);
40
40
  }
41
41
 
42
- static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<boolean> {
42
+ static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<void> {
43
43
  if (!clientId || !clientSecret) { // empty strings
44
- return false; // not valid
44
+ throw new SimpleError({
45
+ statusCode: 400,
46
+ code: 'invalid_uitpas_client_credentials',
47
+ message: `Empty UiTPAS client credentials`,
48
+ human: $t(`%ZdU`),
49
+ });
45
50
  }
51
+
46
52
  let model = new UitpasClientCredential();
47
53
  model.organizationId = organizationId;
48
54
  model.clientId = clientId;
49
55
  model.clientSecret = clientSecret;
50
- return await UitpasTokenRepository.handleInQueue(organizationId, async () => {
51
- let repo = new UitpasTokenRepository(model);
52
- try {
53
- await repo.getNewAccessToken();
54
- } catch (e) {
55
- return false; // not valid
56
- }
56
+ await UitpasTokenRepository.handleInQueue(organizationId, async () => {
57
+ const repo = new UitpasTokenRepository(model);
58
+ await repo.getNewAccessToken();
57
59
  // valid -> store
58
60
  model = await UitpasTokenRepository.setModelInDb(organizationId, model);
59
61
  repo.uitpasClientCredential = model; // update the uitpasClientCredential in the repo
60
- repo = UitpasTokenRepository.setRepoInMemory(organizationId, repo);
61
- return true;
62
+ UitpasTokenRepository.setRepoInMemory(organizationId, repo);
62
63
  });
63
64
  }
64
65
 
@@ -0,0 +1,6 @@
1
+ UPDATE `members` m
2
+ SET m.lastRegisteredAt = (
3
+ SELECT MAX(r.registeredAt)
4
+ FROM registrations r
5
+ WHERE r.memberId = m.id
6
+ );
@@ -0,0 +1,90 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Organization, Payment } from '@stamhoofd/models';
3
+ import { QueryableModel } from '@stamhoofd/sql';
4
+ import { PaymentStatus } from '@stamhoofd/structures';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+ import { SeedTools } from '../helpers/SeedTools.js';
7
+ import { PaymentService } from '../services/PaymentService.js';
8
+
9
+ export default new Migration(async () => {
10
+ if (STAMHOOFD.environment === 'test') {
11
+ console.log('skipped in tests');
12
+ return;
13
+ }
14
+
15
+ console.log('Start marking zero-price payments as succeeded.');
16
+
17
+ let succeeded = 0;
18
+
19
+ const result = await SeedTools.loopBatched({
20
+ // Keyset pagination on id: marking a payment as succeeded drops it from this filter,
21
+ // but rows are never skipped because each next batch is fetched by id > lastId.
22
+ query: Payment.select()
23
+ .where('price', 0)
24
+ .where('status', [PaymentStatus.Created, PaymentStatus.Pending]),
25
+ batchSize: 100,
26
+ batchAction: async (payments: Payment[]) => {
27
+ // Load the organizations of this batch in bulk
28
+ const organizationIds = Formatter.uniqueArray(
29
+ payments.map(p => p.organizationId).filter((id): id is string => id !== null),
30
+ );
31
+ const organizations = organizationIds.length ? await Organization.getByIDs(...organizationIds) : [];
32
+
33
+ // Load the attached balance item payments of this batch in bulk, and collect the ids of
34
+ // the payments that move money on at least one balance item. Those need the PaymentService
35
+ // side effects (marking balance items as paid, flushing caches, ...); the rest can be
36
+ // updated directly.
37
+ const { balanceItemPayments } = await Payment.loadBalanceItems(payments);
38
+ const paymentIdsWithNonZeroBalanceItemPayment = new Set(
39
+ balanceItemPayments.filter(bip => bip.price !== 0).map(bip => bip.paymentId),
40
+ );
41
+
42
+ for (const payment of payments) {
43
+ // Only route through the PaymentService when the payment moves money on at least one
44
+ // balance item. When all attached balance item payments are zero (or there are none),
45
+ // update the status directly to avoid those side effects.
46
+ const hasNonZeroBalanceItemPayment = paymentIdsWithNonZeroBalanceItemPayment.has(payment.id);
47
+
48
+ try {
49
+ if (hasNonZeroBalanceItemPayment) {
50
+ if (!payment.organizationId) {
51
+ console.warn('Payment without organizationId, skipping', payment.id);
52
+ continue;
53
+ }
54
+
55
+ const organization = organizations.find(o => o.id === payment.organizationId);
56
+ if (!organization) {
57
+ console.warn('Organization not found for payment, skipping', payment.id, payment.organizationId);
58
+ continue;
59
+ }
60
+
61
+ // Attribute the payment to its original date instead of the migration run date.
62
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded, payment.createdAt);
63
+ } else {
64
+ // No side effects needed: update the status directly.
65
+ payment.status = PaymentStatus.Succeeded;
66
+ payment.paidAt = payment.createdAt;
67
+ await payment.save({
68
+ skipMarkSaved: true,
69
+ skipSendEvents: true,
70
+ });
71
+ }
72
+ succeeded++;
73
+ } catch (e) {
74
+ // Isolate failures per payment: one bad row should not abort (and wedge) the whole migration.
75
+ console.error('Failed to mark payment as succeeded, skipping', payment.id, e);
76
+ }
77
+
78
+ if (QueryableModel.shutdownMigrations) {
79
+ break;
80
+ }
81
+ }
82
+
83
+ if (QueryableModel.shutdownMigrations) {
84
+ throw new Error('Stopping migration gracefully');
85
+ }
86
+ },
87
+ });
88
+
89
+ console.log(`Finished marking zero-price payments as succeeded: ${succeeded} of ${result.total} payments.`);
90
+ });
@@ -1208,16 +1208,38 @@ export class PaymentService {
1208
1208
  privatePaymentConfiguration: PrivatePaymentConfiguration;
1209
1209
  adminUserId?: string | null;
1210
1210
  }) {
1211
- if (balanceItems.size === 0) {
1211
+ // Balance items that aren't due yet and that aren't being paid now (amount 0) are not part of
1212
+ // this payment: a registration in its trial period is the main example, nothing is due during
1213
+ // the trial. They still have to be marked due and valid, so the registration is activated and
1214
+ // the amount is billed once the trial ends. Marking them via markPaid means the 'paid' side
1215
+ // effects run exactly once (guarded by the paidAt column), also when they are really paid later.
1216
+ //
1217
+ // Items that the caller does pay (a non-zero amount) are always part of the payment, even when
1218
+ // they have a dueAt: balance items that are due soon are part of the outstanding balance and
1219
+ // can be paid before their due date (and are charged by direct debit).
1220
+ const notDueYetBalanceItems = [...balanceItems.keys()].filter(item => item.dueAt !== null && item.dueAt > new Date() && (balanceItems.get(item) ?? 0) === 0);
1221
+ const payableBalanceItems = new Map([...balanceItems].filter(([item]) => !notDueYetBalanceItems.includes(item)));
1222
+
1223
+ // Passing no payment keeps paid: false, so a trial period isn't shortened.
1224
+ const markNotDueYetValid = async () => {
1225
+ for (const balanceItem of notDueYetBalanceItems) {
1226
+ await BalanceItemService.markPaid(balanceItem, null, organization);
1227
+ }
1228
+ };
1229
+
1230
+ if (payableBalanceItems.size === 0) {
1231
+ // Nothing to pay now, so no payment is created. The not-due-yet items still need to be
1232
+ // marked valid, otherwise their registration stays invisible.
1233
+ await markNotDueYetValid();
1212
1234
  return null;
1213
1235
  }
1214
1236
 
1215
1237
  // Calculate total price to pay
1216
- const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems, organization, members });
1238
+ const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems: payableBalanceItems, organization, members });
1217
1239
  PaymentService.validateTotalPrice({ price, roundingAmount, checkout });
1218
1240
 
1219
1241
  const { customer, prefix } = await this.validateCustomer({ user, checkout, payingOrganization });
1220
- this.validateVATRates({ customer, sellingOrganization: organization, balanceItems });
1242
+ this.validateVATRates({ customer, sellingOrganization: organization, balanceItems: payableBalanceItems });
1221
1243
 
1222
1244
  const { method, type, mandate } = await this.validatePaymentMethod({
1223
1245
  method: checkout.paymentMethod ?? PaymentMethod.Unknown,
@@ -1226,7 +1248,7 @@ export class PaymentService {
1226
1248
  customer,
1227
1249
  price,
1228
1250
  hasNegative,
1229
- balanceItems,
1251
+ balanceItems: payableBalanceItems,
1230
1252
  paymentConfiguration,
1231
1253
  user,
1232
1254
  payingOrganization: payingOrganization ?? null,
@@ -1283,7 +1305,7 @@ export class PaymentService {
1283
1305
 
1284
1306
  payment.provider = provider;
1285
1307
  payment.stripeAccountId = stripeAccount?.id ?? null;
1286
- await ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...balanceItems.entries()].map(([_, p]) => p));
1308
+ await ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...payableBalanceItems.entries()].map(([_, p]) => p));
1287
1309
  await ServiceFeeHelper.setTransferFee({ payment, organization, stripeAccount });
1288
1310
 
1289
1311
  // Add transfer description
@@ -1320,7 +1342,7 @@ export class PaymentService {
1320
1342
  const description = organization.name + ' ' + payment.id;
1321
1343
 
1322
1344
  try {
1323
- for (const [balanceItem, price] of balanceItems) {
1345
+ for (const [balanceItem, price] of payableBalanceItems) {
1324
1346
  // Create one balance item payment to pay it in one payment
1325
1347
  const balanceItemPayment = new BalanceItemPayment();
1326
1348
  balanceItemPayment.balanceItemId = balanceItem.id;
@@ -1332,7 +1354,7 @@ export class PaymentService {
1332
1354
  }
1333
1355
 
1334
1356
  // Update cached balance items pending amount (only created balance items, because those are involved in the payment)
1335
- await BalanceItemService.updatePaidAndPending([...balanceItems.keys()]);
1357
+ await BalanceItemService.updatePaidAndPending([...payableBalanceItems.keys()]);
1336
1358
 
1337
1359
  // Update balance items
1338
1360
  if (payment.method === PaymentMethod.Transfer) {
@@ -1445,6 +1467,9 @@ export class PaymentService {
1445
1467
  throw e;
1446
1468
  }
1447
1469
 
1470
+ // The items that aren't due yet are not paid by this payment, so they are marked valid separately
1471
+ await markNotDueYetValid();
1472
+
1448
1473
  // TypeScript thinks status cannot change to Failed, but it can.
1449
1474
  if (payment.status === PaymentStatus.Succeeded || (payment.status as PaymentStatus) === PaymentStatus.Failed) {
1450
1475
  // force update
@@ -1454,7 +1479,7 @@ export class PaymentService {
1454
1479
  } else if (payment.method === PaymentMethod.Transfer || payment.method === PaymentMethod.PointOfSale || payment.method === PaymentMethod.Unknown || (payment.method === PaymentMethod.DirectDebit && mandate)) {
1455
1480
  // Mark valid (not same as paid) if needed
1456
1481
  let hasBundleDiscount = false;
1457
- for (const [balanceItem] of balanceItems) {
1482
+ for (const [balanceItem] of payableBalanceItems) {
1458
1483
  // Mark valid
1459
1484
  await BalanceItemService.markPaid(balanceItem, payment, organization);
1460
1485
 
@@ -4,7 +4,7 @@ import { Group, Member, MemberPlatformMembership, Organization, Platform, Regist
4
4
  import { QueueHandler } from '@stamhoofd/queues';
5
5
  import { SQL, SQLWhereSign } from '@stamhoofd/sql';
6
6
  import { AuditLogSource, PlatformMembershipTypeBehaviour } from '@stamhoofd/structures';
7
- import { Formatter, Sorter } from '@stamhoofd/utility';
7
+ import { Formatter, sleep, Sorter } from '@stamhoofd/utility';
8
8
  import { AuditLogService } from './AuditLogService.js';
9
9
  import { MemberNumberService } from './MemberNumberService.js';
10
10
 
@@ -50,6 +50,7 @@ export class PlatformMembershipService {
50
50
  console.log('Starting updateAllMemberships');
51
51
  await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
52
52
  while (true) {
53
+ await sleep(200);
53
54
  const rawMembers = await Member.where({
54
55
  id: {
55
56
  value: id,
@@ -70,6 +70,11 @@ export const RegistrationService = {
70
70
  const registrationMemberRelation = new ManyToOneRelation(Member, 'member');
71
71
  registrationMemberRelation.foreignKey = Member.registrations.foreignKey;
72
72
  await Document.updateForRegistration(registration.setRelation(registrationMemberRelation, member));
73
+
74
+ // update lastRegisteredAt (should not be awaited)
75
+ member.tryUpdateLastRegisteredAt(registration)
76
+ // errors should never stop other logic, lastRegisteredAt is only used for sorting
77
+ .catch(console.error);
73
78
  }
74
79
 
75
80
  // Update group occupancy
@@ -368,14 +368,13 @@ export class UitpasService {
368
368
  }
369
369
 
370
370
  /**
371
- * Store the uitpas client credentials if they are valid
371
+ * Store the uitpas client credentials if they are valid, throws otherwise
372
372
  * @param organizationId null for platform
373
373
  * @param clientId
374
374
  * @param clientSecret
375
- * @returns wether the credentials were valid and thus stored successfully
376
375
  */
377
- static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<boolean> {
378
- return await UitpasTokenRepository.storeIfValid(organizationId, clientId, clientSecret);
376
+ static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<void> {
377
+ await UitpasTokenRepository.storeIfValid(organizationId, clientId, clientSecret);
379
378
  }
380
379
 
381
380
  static async clearClientCredentialsFor(organizationId: string | null) {
@@ -74,7 +74,7 @@ export async function checkPermissionsFor(access_token: string, organizationId:
74
74
  });
75
75
  });
76
76
  assertIsPermissionsResponse(json);
77
- const neededPermissions = organizationId
77
+ const neededPermissions = !organizationId
78
78
  ? [{
79
79
  permission: 'PASSES_READ',
80
80
  human: 'Basis UiTPAS informatie ophalen met UiTPAS nummer',
@@ -1,5 +1,6 @@
1
1
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
- import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+ import { balanceItemFilterCompilers } from './balance-items.js';
3
4
 
4
5
  export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
5
6
  ...baseSQLFilterCompilers,
@@ -13,59 +14,6 @@ export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
13
14
  type: SQLValueType.Number,
14
15
  nullable: false,
15
16
  }),
16
- balanceItem: {
17
- ...baseSQLFilterCompilers,
18
- id: createColumnFilter({
19
- expression: SQL.column('balance_items', 'id'),
20
- type: SQLValueType.String,
21
- nullable: false,
22
- }),
23
- description: createColumnFilter({
24
- expression: SQL.column('balance_items', 'description'),
25
- type: SQLValueType.String,
26
- nullable: false,
27
- }),
28
- payingOrganizationId: createColumnFilter({
29
- expression: SQL.column('balance_items', 'payingOrganizationId'),
30
- type: SQLValueType.String,
31
- nullable: true,
32
- }),
33
- type: createColumnFilter({
34
- expression: SQL.column('balance_items', 'type'),
35
- type: SQLValueType.String,
36
- nullable: false,
37
- }),
38
- registration: createExistsFilter(
39
- SQL.select()
40
- .from(SQL.table('registrations'))
41
- .where(
42
- SQL.column('registrations', 'id'),
43
- SQL.column('balance_items', 'registrationId'),
44
- ),
45
- {
46
- ...baseSQLFilterCompilers,
47
- groupId: createColumnFilter({
48
- expression: SQL.column('registrations', 'groupId'),
49
- type: SQLValueType.String,
50
- nullable: false,
51
- }),
52
- },
53
- ),
54
- order: createExistsFilter(
55
- SQL.select()
56
- .from(SQL.table('webshop_orders'))
57
- .where(
58
- SQL.column('webshop_orders', 'id'),
59
- SQL.column('balance_items', 'orderId'),
60
- ),
61
- {
62
- ...baseSQLFilterCompilers,
63
- webshopId: createColumnFilter({
64
- expression: SQL.column('webshop_orders', 'webshopId'),
65
- type: SQLValueType.String,
66
- nullable: false,
67
- }),
68
- },
69
- ),
70
- },
17
+ // Reuse the shared balance item filters (type, webshop, group, membership type, ...)
18
+ balanceItem: balanceItemFilterCompilers,
71
19
  };
@@ -1,56 +1,115 @@
1
1
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
- import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+ import { BalanceItemRelationType } from '@stamhoofd/structures';
4
+
5
+ /**
6
+ * Filters on the relations of a balance item (registration group, webshop order and membership type).
7
+ *
8
+ * Grouped separately from the plain column filters because these are JOIN/JSON based rather than simple
9
+ * columns. They are spread into balanceItemFilterCompilers below, which is the single set consumed both
10
+ * by the direct balance items endpoint and by the balance items nested inside payments (see
11
+ * balance-item-payments.ts) — so both automatically expose these relation filters.
12
+ *
13
+ * All expressions are qualified with the balance_items table so they keep working when balance_items
14
+ * is joined into another query (e.g. via balance_item_payments).
15
+ */
16
+ export const balanceItemRelationFilterCompilers: SQLFilterDefinitions = {
17
+ registration: createExistsFilter(
18
+ SQL.select()
19
+ .from(SQL.table('registrations'))
20
+ .where(
21
+ SQL.column('registrations', 'id'),
22
+ SQL.column('balance_items', 'registrationId'),
23
+ ),
24
+ {
25
+ ...baseSQLFilterCompilers,
26
+ groupId: createColumnFilter({
27
+ expression: SQL.column('registrations', 'groupId'),
28
+ type: SQLValueType.String,
29
+ nullable: false,
30
+ }),
31
+ },
32
+ ),
33
+ order: createExistsFilter(
34
+ SQL.select()
35
+ .from(SQL.table('webshop_orders'))
36
+ .where(
37
+ SQL.column('webshop_orders', 'id'),
38
+ SQL.column('balance_items', 'orderId'),
39
+ ),
40
+ {
41
+ ...baseSQLFilterCompilers,
42
+ webshopId: createColumnFilter({
43
+ expression: SQL.column('webshop_orders', 'webshopId'),
44
+ type: SQLValueType.String,
45
+ nullable: false,
46
+ }),
47
+ },
48
+ ),
49
+ // The membership type is only stored inside the relations JSON, so we read its id from there.
50
+ membershipType: createColumnFilter({
51
+ expression: SQL.jsonExtract(SQL.column('balance_items', 'relations'), `$.value.${BalanceItemRelationType.MembershipType}.id`),
52
+ type: SQLValueType.JSONString,
53
+ nullable: true,
54
+ }),
55
+ };
3
56
 
4
57
  /**
5
58
  * Defines how to filter balance items in the database from StamhoofdFilter objects
6
59
  */
7
60
  export const balanceItemFilterCompilers: SQLFilterDefinitions = {
8
61
  ...baseSQLFilterCompilers,
62
+ ...balanceItemRelationFilterCompilers,
9
63
  id: createColumnFilter({
10
- expression: SQL.column('id'),
64
+ expression: SQL.column('balance_items', 'id'),
11
65
  type: SQLValueType.String,
12
66
  nullable: false,
13
67
  }),
14
68
  organizationId: createColumnFilter({
15
- expression: SQL.column('organizationId'),
69
+ expression: SQL.column('balance_items', 'organizationId'),
16
70
  type: SQLValueType.String,
17
71
  nullable: false,
18
72
  }),
73
+ payingOrganizationId: createColumnFilter({
74
+ expression: SQL.column('balance_items', 'payingOrganizationId'),
75
+ type: SQLValueType.String,
76
+ nullable: true,
77
+ }),
19
78
  type: createColumnFilter({
20
- expression: SQL.column('type'),
79
+ expression: SQL.column('balance_items', 'type'),
21
80
  type: SQLValueType.String,
22
81
  nullable: false,
23
82
  }),
24
83
  status: createColumnFilter({
25
- expression: SQL.column('status'),
84
+ expression: SQL.column('balance_items', 'status'),
26
85
  type: SQLValueType.String,
27
86
  nullable: false,
28
87
  }),
29
88
  createdAt: createColumnFilter({
30
- expression: SQL.column('createdAt'),
89
+ expression: SQL.column('balance_items', 'createdAt'),
31
90
  type: SQLValueType.Datetime,
32
91
  nullable: false,
33
92
  }),
34
93
  updatedAt: createColumnFilter({
35
- expression: SQL.column('updatedAt'),
94
+ expression: SQL.column('balance_items', 'updatedAt'),
36
95
  type: SQLValueType.Datetime,
37
96
  nullable: false,
38
97
  }),
39
98
 
40
99
  description: createColumnFilter({
41
- expression: SQL.column('description'),
100
+ expression: SQL.column('balance_items', 'description'),
42
101
  type: SQLValueType.String,
43
102
  nullable: false,
44
103
  }),
45
104
 
46
105
  priceWithVAT: createColumnFilter({
47
- expression: SQL.column('priceTotal'),
106
+ expression: SQL.column('balance_items', 'priceTotal'),
48
107
  type: SQLValueType.Number,
49
108
  nullable: false,
50
109
  }),
51
110
 
52
111
  priceOpen: createColumnFilter({
53
- expression: SQL.column('priceOpen'),
112
+ expression: SQL.column('balance_items', 'priceOpen'),
54
113
  type: SQLValueType.Number,
55
114
  nullable: false,
56
115
  }),