@stamhoofd/backend 2.64.0 → 2.65.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +10 -10
  2. package/src/audit-logs/EmailLogger.ts +7 -1
  3. package/src/audit-logs/ModelLogger.ts +17 -2
  4. package/src/crons/balance-emails.ts +232 -0
  5. package/src/crons/index.ts +1 -0
  6. package/src/email-recipient-loaders/members.ts +14 -4
  7. package/src/email-recipient-loaders/receivable-balances.ts +29 -15
  8. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +47 -12
  9. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -18
  10. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +15 -1
  11. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  12. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +0 -10
  14. package/src/helpers/EmailResumer.ts +1 -5
  15. package/src/helpers/MemberUserSyncer.ts +22 -1
  16. package/src/helpers/MembershipCharger.ts +1 -0
  17. package/src/helpers/TagHelper.ts +7 -14
  18. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +4 -14
  19. package/src/seeds/1729253172-update-orders.ts +7 -18
  20. package/src/seeds/{1726494420-update-cached-outstanding-balance-from-items.ts → 1735577912-update-cached-outstanding-balance-from-items.ts} +1 -14
  21. package/src/seeds/1736266448-recall-balance-item-price-paid.ts +70 -0
  22. package/src/services/BalanceItemPaymentService.ts +14 -2
  23. package/src/services/BalanceItemService.ts +41 -1
  24. package/src/services/PlatformMembershipService.ts +5 -5
  25. package/src/sql-filters/members.ts +1 -0
  26. package/src/sql-filters/receivable-balances.ts +15 -1
  27. package/src/sql-filters/shared/EmailRelationFilterCompilers.ts +19 -0
  28. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +0 -253
  29. package/src/helpers/ModelHelper.ts +0 -32
  30. package/src/seeds/1726055544-balance-item-paid.ts +0 -11
  31. package/src/seeds/1726055545-balance-item-pending.ts +0 -11
  32. package/src/seeds/1726494419-update-cached-outstanding-balance.ts +0 -53
  33. package/src/seeds/1728928973-balance-item-pending.ts +0 -11
  34. package/src/seeds/1734700082-update-cached-outstanding-balance-from-items.ts +0 -40
@@ -1,9 +1,10 @@
1
- import { Member, MemberResponsibilityRecord, MemberWithRegistrations, User } from '@stamhoofd/models';
1
+ import { CachedBalance, Member, MemberResponsibilityRecord, MemberWithRegistrations, User } from '@stamhoofd/models';
2
2
  import { SQL } from '@stamhoofd/sql';
3
3
  import { AuditLogSource, MemberDetails, Permissions, UserPermissions } from '@stamhoofd/structures';
4
4
  import crypto from 'crypto';
5
5
  import basex from 'base-x';
6
6
  import { AuditLogService } from '../services/AuditLogService';
7
+ import { Formatter } from '@stamhoofd/utility';
7
8
 
8
9
  const ALPHABET = '123456789ABCDEFGHJKMNPQRSTUVWXYZ'; // Note: we removed 0, O, I and l to make it easier for humans
9
10
  const customBase = basex(ALPHABET);
@@ -185,6 +186,9 @@ export class MemberUserSyncerStatic {
185
186
  console.log('Removing access for ' + user.id + ' to member ' + member.id);
186
187
  await Member.users.reverse('members').unlink(user, member);
187
188
 
189
+ // Update balance of this user, as it could have changed
190
+ await this.updateUserBalance(user.id, member.id);
191
+
188
192
  if (user.memberId === member.id) {
189
193
  user.memberId = null;
190
194
  }
@@ -305,6 +309,23 @@ export class MemberUserSyncerStatic {
305
309
  if (!member.users.find(u => u.id === user.id)) {
306
310
  await Member.users.reverse('members').link(user, [member]);
307
311
  member.users.push(user);
312
+
313
+ // Update balance of this user, as it could have changed
314
+ await this.updateUserBalance(user.id, member.id);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Update the balance after making a change in linked member/users
320
+ */
321
+ async updateUserBalance(userId: string, memberId: string) {
322
+ // Update balance of this user, as it could have changed
323
+ const memberBalances = await CachedBalance.getForObjects([memberId]);
324
+ if (memberBalances.length > 0) {
325
+ const organizationIds = Formatter.uniqueArray(memberBalances.map(b => b.organizationId));
326
+ for (const organizationId of organizationIds) {
327
+ await CachedBalance.updateForUsers(organizationId, [userId]);
328
+ }
308
329
  }
309
330
  }
310
331
  }
@@ -36,6 +36,7 @@ export const MembershipCharger = {
36
36
  .where('balanceItemId', null)
37
37
  .where('deletedAt', null)
38
38
  .whereNot('organizationId', chargeVia)
39
+ .where(SQL.where('trialUntil', null).or('trialUntil', SQLWhereSign.LessEqual, new Date()))
39
40
  .limit(chunkSize)
40
41
  .orderBy(
41
42
  new SQLOrderBy({
@@ -1,7 +1,6 @@
1
1
  import { Organization, Platform } from '@stamhoofd/models';
2
2
  import { QueueHandler } from '@stamhoofd/queues';
3
3
  import { AuditLogSource, OrganizationTag, TagHelper as SharedTagHelper } from '@stamhoofd/structures';
4
- import { ModelHelper } from './ModelHelper';
5
4
  import { AuditLogService } from '../services/AuditLogService';
6
5
 
7
6
  export class TagHelper extends SharedTagHelper {
@@ -14,17 +13,15 @@ export class TagHelper extends SharedTagHelper {
14
13
  let platform = await Platform.getShared();
15
14
 
16
15
  const tagCounts = new Map<string, number>();
17
- await this.loopOrganizations(async (organizations) => {
18
- for (const organization of organizations) {
19
- organization.meta.tags = this.getAllTagsFromHierarchy(organization.meta.tags, platform.config.tags);
20
16
 
21
- for (const tag of organization.meta.tags) {
22
- tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
23
- }
24
- }
17
+ for await (const organization of Organization.select().all()) {
18
+ organization.meta.tags = this.getAllTagsFromHierarchy(organization.meta.tags, platform.config.tags);
25
19
 
26
- await Promise.all(organizations.map(organization => organization.save()));
27
- });
20
+ for (const tag of organization.meta.tags) {
21
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
22
+ }
23
+ await organization.save();
24
+ }
28
25
 
29
26
  // Reload platform to avoid race conditions
30
27
  platform = await Platform.getShared();
@@ -39,10 +36,6 @@ export class TagHelper extends SharedTagHelper {
39
36
  });
40
37
  }
41
38
 
42
- private static async loopOrganizations(onBatchReceived: (batch: Organization[]) => Promise<void>) {
43
- await ModelHelper.loop(Organization, 'id', onBatchReceived, { limit: 10 });
44
- }
45
-
46
39
  /**
47
40
  * Removes child tag ids that do not exist and sorts the tags.
48
41
  * @param platformTags
@@ -2,6 +2,9 @@ import { Migration } from '@simonbackx/simple-database';
2
2
  import { logger } from '@simonbackx/simple-logging';
3
3
  import { BalanceItem } from '@stamhoofd/models';
4
4
 
5
+ /**
6
+ * This migration is required to keep '1733994455-balance-item-status-open' working
7
+ */
5
8
  export default new Migration(async () => {
6
9
  if (STAMHOOFD.environment == 'test') {
7
10
  console.log('skipped in tests');
@@ -10,26 +13,13 @@ export default new Migration(async () => {
10
13
 
11
14
  process.stdout.write('\n');
12
15
  let c = 0;
13
- let id: string = '';
14
16
 
15
17
  await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
16
- while (true) {
17
- const items = await BalanceItem.where({
18
- id: {
19
- value: id,
20
- sign: '>',
21
- },
22
- }, { limit: 1000, sort: ['id'] });
23
-
18
+ for await (const items of BalanceItem.select().limit(1000).allBatched()) {
24
19
  await BalanceItem.updateOutstanding(items);
25
20
 
26
21
  c += items.length;
27
22
  process.stdout.write('.');
28
-
29
- if (items.length < 1000) {
30
- break;
31
- }
32
- id = items[items.length - 1].id;
33
23
  }
34
24
  });
35
25
 
@@ -1,7 +1,5 @@
1
1
  import { Migration } from '@simonbackx/simple-database';
2
2
  import { Order } from '@stamhoofd/models';
3
- import { sleep } from '@stamhoofd/utility';
4
- import { ModelHelper } from '../helpers/ModelHelper';
5
3
 
6
4
  export default new Migration(async () => {
7
5
  if (STAMHOOFD.environment === 'test') {
@@ -11,22 +9,13 @@ export default new Migration(async () => {
11
9
 
12
10
  console.log('Start saving orders.');
13
11
 
14
- const limit = 100;
15
- let count = limit;
12
+ const batchSize = 100;
13
+ let count = 0;
16
14
 
17
- await ModelHelper.loop(Order, 'id', async (batch: Order[]) => {
18
- console.log('Saving orders...', `(${count})`);
19
-
20
- // save all orders to update the new columns
21
- for (const order of batch) {
22
- await order.save();
23
- }
24
-
25
- count += limit;
26
- },
27
- { limit });
28
-
29
- await sleep(1000);
15
+ for await (const order of Order.select().limit(batchSize).all()) {
16
+ await order.save();
17
+ count += 1;
18
+ }
30
19
 
31
- console.log('Finished saving orders.');
20
+ console.log('Finished saving ' + count + ' orders.');
32
21
  });
@@ -10,26 +10,13 @@ export default new Migration(async () => {
10
10
 
11
11
  process.stdout.write('\n');
12
12
  let c = 0;
13
- let id: string = '';
14
13
 
15
14
  await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
16
- while (true) {
17
- const items = await BalanceItem.where({
18
- id: {
19
- value: id,
20
- sign: '>',
21
- },
22
- }, { limit: 1000, sort: ['id'] });
23
-
15
+ for await (const items of BalanceItem.select().limit(1000).allBatched()) {
24
16
  await BalanceItem.updateOutstanding(items);
25
17
 
26
18
  c += items.length;
27
19
  process.stdout.write('.');
28
-
29
- if (items.length < 1000) {
30
- break;
31
- }
32
- id = items[items.length - 1].id;
33
20
  }
34
21
  });
35
22
 
@@ -0,0 +1,70 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { logger } from '@simonbackx/simple-logging';
3
+ import { BalanceItem, BalanceItemPayment, Organization, Payment } from '@stamhoofd/models';
4
+ import { QueueHandler } from '@stamhoofd/queues';
5
+ import { AuditLogSource, PaymentStatus } from '@stamhoofd/structures';
6
+ import { AuditLogService } from '../services/AuditLogService';
7
+ import { BalanceItemPaymentService } from '../services/BalanceItemPaymentService';
8
+
9
+ export default new Migration(async () => {
10
+ if (STAMHOOFD.environment == 'test') {
11
+ console.log('skipped in tests');
12
+ return;
13
+ }
14
+
15
+ process.stdout.write('\n');
16
+ let c = 0;
17
+
18
+ await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
19
+ const q = Payment.select()
20
+ .where('status', PaymentStatus.Succeeded)
21
+ .where('createdAt', '>=', new Date('2024-12-12'))
22
+ .limit(100);
23
+ for await (const payment of q.all()) {
24
+ await fix(payment);
25
+
26
+ c += 1;
27
+
28
+ if (c % 1000 === 0) {
29
+ process.stdout.write('.');
30
+ }
31
+ }
32
+ });
33
+
34
+ console.log('Updated ' + c + ' payments');
35
+
36
+ // Do something here
37
+ return Promise.resolve();
38
+ });
39
+
40
+ async function fix(payment: Payment) {
41
+ if (payment.status !== PaymentStatus.Succeeded) {
42
+ return;
43
+ }
44
+
45
+ if (!payment.organizationId) {
46
+ return;
47
+ }
48
+
49
+ const organization = await Organization.getByID(payment.organizationId);
50
+
51
+ if (!organization) {
52
+ return;
53
+ }
54
+
55
+ await AuditLogService.setContext({ fallbackUserId: payment.payingUserId, source: AuditLogSource.Payment, fallbackOrganizationId: payment.organizationId }, async () => {
56
+ // Prevent concurrency issues
57
+ await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
58
+ const unloaded = (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment));
59
+ const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
60
+ unloaded,
61
+ );
62
+
63
+ for (const balanceItemPayment of balanceItemPayments) {
64
+ await BalanceItemPaymentService.markPaidRepeated(balanceItemPayment, organization);
65
+ }
66
+
67
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
68
+ });
69
+ });
70
+ }
@@ -7,7 +7,7 @@ type Loaded<T> = (T) extends ManyToOneRelation<infer Key, infer Model> ? Record<
7
7
 
8
8
  export const BalanceItemPaymentService = {
9
9
  async markPaid(balanceItemPayment: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
10
- const wasPaid = balanceItemPayment.balanceItem.priceOpen === 0;
10
+ const wasPaid = balanceItemPayment.balanceItem.isPaid;
11
11
 
12
12
  // Update cached amountPaid of the balance item (balanceItemPayment will get overwritten later, but we need it to calculate the status)
13
13
  balanceItemPayment.balanceItem.pricePaid += balanceItemPayment.price;
@@ -17,7 +17,7 @@ export const BalanceItemPaymentService = {
17
17
  }
18
18
 
19
19
  await balanceItemPayment.balanceItem.save();
20
- const isPaid = balanceItemPayment.balanceItem.priceOpen === 0;
20
+ const isPaid = balanceItemPayment.balanceItem.isPaid;
21
21
 
22
22
  // Do logic of balance item
23
23
  if (isPaid && !wasPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
@@ -29,6 +29,18 @@ export const BalanceItemPaymentService = {
29
29
  }
30
30
  },
31
31
 
32
+ /**
33
+ * Safe method to correct balance items that missed a markPaid call, but avoid double marking an order as valid.
34
+ */
35
+ async markPaidRepeated(balanceItemPayment: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
36
+ const isPaid = balanceItemPayment.balanceItem.isPaid;
37
+
38
+ // Do logic of balance item
39
+ if (isPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
40
+ await BalanceItemService.markPaidRepeated(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
41
+ }
42
+ },
43
+
32
44
  /**
33
45
  * Call balanceItemPayment once a earlier succeeded payment is no longer succeeded
34
46
  */
@@ -6,7 +6,43 @@ import { PaymentReallocationService } from './PaymentReallocationService';
6
6
  import { Formatter } from '@stamhoofd/utility';
7
7
 
8
8
  export const BalanceItemService = {
9
- async markPaid(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
9
+ /**
10
+ * Safe method to correct balance items that missed a markPaid call, but avoid double marking an order as valid.
11
+ */
12
+ async markPaidRepeated(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
13
+ if (balanceItem.pricePaid <= 0) {
14
+ return;
15
+ }
16
+
17
+ await this.markDue(balanceItem);
18
+
19
+ // Registrations are safe to mark valid multiple times
20
+ if (balanceItem.registrationId) {
21
+ await RegistrationService.markValid(balanceItem.registrationId);
22
+ }
23
+
24
+ // Orders aren't safe to mark paid twice - so only mark paid if not yet valid
25
+ // The only downside of this is that we won't send a paid email for transfer orders
26
+ // we should fix that in the future by introducing a paidAt timestamp for orders
27
+ if (balanceItem.orderId) {
28
+ const order = await Order.getByID(balanceItem.orderId);
29
+ if (order && !order.validAt) {
30
+ await order.markPaid(payment, organization);
31
+
32
+ // Save number in balance description
33
+ if (order.number !== null) {
34
+ const webshop = await Webshop.getByID(order.webshopId);
35
+
36
+ if (webshop) {
37
+ balanceItem.description = order.generateBalanceDescription(webshop);
38
+ await balanceItem.save();
39
+ }
40
+ }
41
+ }
42
+ }
43
+ },
44
+
45
+ async markDue(balanceItem: BalanceItem) {
10
46
  if (balanceItem.status === BalanceItemStatus.Hidden) {
11
47
  await BalanceItem.reactivateItems([balanceItem]);
12
48
  }
@@ -18,6 +54,10 @@ export const BalanceItemService = {
18
54
  await BalanceItem.reactivateItems([depending]);
19
55
  }
20
56
  }
57
+ },
58
+
59
+ async markPaid(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
60
+ await this.markDue(balanceItem);
21
61
 
22
62
  // It is possible this balance item was earlier paid
23
63
  // and later the regigstration / order has been canceled and it became a negative balance item - which as some point has been reembursed and marked as 'paid'
@@ -225,12 +225,12 @@ export class PlatformMembershipService {
225
225
 
226
226
  const shouldApplyReducedPrice = me.details.shouldApplyReducedPrice;
227
227
 
228
- const cheapestMembership = defaultMembershipsWithOrganization.sort(({ membership: a, registration: ar, organization: ao }, { membership: b, registration: br, organization: bo }) => {
229
- const tagIdsA = ao?.meta.tags ?? [];
230
- const tagIdsB = bo?.meta.tags ?? [];
231
- const diff = a.getPrice(period.id, ar.startDate ?? ar.registeredAt ?? now, tagIdsA, shouldApplyReducedPrice)! - b.getPrice(period.id, ar.startDate ?? ar.registeredAt ?? now, tagIdsB, shouldApplyReducedPrice)!;
228
+ const cheapestMembership = defaultMembershipsWithOrganization.sort((a, b) => {
229
+ const tagIdsA = a.organization?.meta.tags ?? [];
230
+ const tagIdsB = b.organization?.meta.tags ?? [];
231
+ const diff = a.membership.getPrice(period.id, a.registration.startDate ?? a.registration.registeredAt ?? now, tagIdsA, shouldApplyReducedPrice)! - b.membership.getPrice(period.id, a.registration.startDate ?? a.registration.registeredAt ?? now, tagIdsB, shouldApplyReducedPrice)!;
232
232
  if (diff === 0) {
233
- return Sorter.byDateValue(br.createdAt, ar.createdAt);
233
+ return Sorter.byDateValue(b.registration.startDate ?? b.registration.createdAt, a.registration.startDate ?? a.registration.createdAt);
234
234
  }
235
235
  return diff;
236
236
  })[0];
@@ -242,6 +242,7 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
242
242
  startDate: createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'startDate')),
243
243
  endDate: createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'endDate')),
244
244
  expireDate: createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'expireDate')),
245
+ trialUntil: createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'trialUntil')),
245
246
  },
246
247
  ),
247
248
 
@@ -1,4 +1,5 @@
1
- import { SQLFilterDefinitions, baseSQLFilterCompilers, createSQLColumnFilterCompiler } from '@stamhoofd/sql';
1
+ import { SQL, SQLFilterDefinitions, baseSQLFilterCompilers, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler } from '@stamhoofd/sql';
2
+ import { EmailRelationFilterCompilers } from './shared/EmailRelationFilterCompilers';
2
3
 
3
4
  /**
4
5
  * Defines how to filter cached balance items in the database from StamhoofdFilter objects
@@ -11,4 +12,17 @@ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
11
12
  amountOpen: createSQLColumnFilterCompiler('amountOpen'),
12
13
  amountPending: createSQLColumnFilterCompiler('amountPending'),
13
14
  nextDueAt: createSQLColumnFilterCompiler('nextDueAt'),
15
+ lastReminderEmail: createSQLColumnFilterCompiler('lastReminderEmail'),
16
+ reminderEmailCount: createSQLColumnFilterCompiler('reminderEmailCount'),
17
+ reminderAmountIncreased: createSQLExpressionFilterCompiler(
18
+ SQL.if(
19
+ SQL.column('amountOpen'),
20
+ '>',
21
+ SQL.column('lastReminderAmountOpen'),
22
+ ).then(1).else(0),
23
+ { isJSONValue: false, isJSONObject: false },
24
+ ),
25
+
26
+ // Allowed to filter by recent emails
27
+ ...EmailRelationFilterCompilers,
14
28
  };
@@ -0,0 +1,19 @@
1
+ import { createSQLRelationFilterCompiler, SQL, SQLParentNamespace, baseSQLFilterCompilers, createSQLColumnFilterCompiler } from '@stamhoofd/sql';
2
+
3
+ export const EmailRelationFilterCompilers = {
4
+ emails: createSQLRelationFilterCompiler(
5
+ SQL.select()
6
+ .from(
7
+ SQL.table('email_recipients'),
8
+ )
9
+ .where(
10
+ SQL.column(SQLParentNamespace, 'id'),
11
+ SQL.column('objectId'),
12
+ ),
13
+ {
14
+ ...baseSQLFilterCompilers,
15
+ emailType: createSQLColumnFilterCompiler('emailType'),
16
+ sentAt: createSQLColumnFilterCompiler('sentAt'),
17
+ },
18
+ ),
19
+ };