@stamhoofd/backend 2.62.0 → 2.64.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 (33) hide show
  1. package/index.ts +8 -6
  2. package/package.json +11 -11
  3. package/src/audit-logs/PaymentLogger.ts +1 -1
  4. package/src/crons/index.ts +1 -0
  5. package/src/crons/update-cached-balances.ts +39 -0
  6. package/src/email-recipient-loaders/receivable-balances.ts +5 -0
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +41 -16
  8. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +2 -0
  9. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +6 -3
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +85 -25
  11. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +15 -1
  12. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +89 -30
  13. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +62 -17
  14. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +52 -2
  15. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +8 -2
  16. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +10 -2
  17. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +19 -8
  18. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +10 -5
  19. package/src/helpers/AdminPermissionChecker.ts +3 -2
  20. package/src/helpers/AuthenticatedStructures.ts +127 -9
  21. package/src/helpers/MembershipCharger.ts +4 -0
  22. package/src/helpers/OrganizationCharger.ts +4 -0
  23. package/src/seeds/1733994455-balance-item-status-open.ts +30 -0
  24. package/src/seeds/1734596144-fill-previous-period-id.ts +55 -0
  25. package/src/seeds/1734700082-update-cached-outstanding-balance-from-items.ts +40 -0
  26. package/src/services/BalanceItemPaymentService.ts +8 -4
  27. package/src/services/BalanceItemService.ts +22 -3
  28. package/src/services/PaymentReallocationService.test.ts +746 -0
  29. package/src/services/PaymentReallocationService.ts +339 -0
  30. package/src/services/PaymentService.ts +13 -0
  31. package/src/services/PlatformMembershipService.ts +167 -137
  32. package/src/sql-filters/receivable-balances.ts +2 -1
  33. package/src/sql-sorters/receivable-balances.ts +3 -3
@@ -1,6 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { AuditLog, BalanceItem, CachedBalance, Document, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
- import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, Document as DocumentStruct, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
2
+ import { AuditLog, BalanceItem, CachedBalance, Document, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, Document as DocumentStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { Context } from './Context';
@@ -40,8 +40,6 @@ export class AuthenticatedStructures {
40
40
 
41
41
  const includeSettlements = checkPermissions && !!Context.user && !!Context.user.permissions;
42
42
 
43
- console.log('includeSettlements', includeSettlements);
44
-
45
43
  const { payingOrganizations } = await Payment.loadPayingOrganizations(payments);
46
44
 
47
45
  return Payment.getGeneralStructureFromRelations({
@@ -325,6 +323,9 @@ export class AuthenticatedStructures {
325
323
  }
326
324
  const organizations = new Map<string, Organization>();
327
325
 
326
+ const registrationIds = Formatter.uniqueArray(members.flatMap(m => m.registrations.map(r => r.id)));
327
+ const balances = await CachedBalance.getForObjects(registrationIds, Context.organization?.id ?? null);
328
+
328
329
  if (includeUser) {
329
330
  for (const organizationId of includeUser.permissions?.organizationPermissions.keys() ?? []) {
330
331
  if (includeContextOrganization || organizationId !== Context.auth.organization?.id) {
@@ -367,7 +368,25 @@ export class AuthenticatedStructures {
367
368
  }
368
369
  }
369
370
  member.registrations = member.registrations.filter(r => (Context.auth.organization && Context.auth.organization.active && r.organizationId === Context.auth.organization.id) || (organizations.get(r.organizationId)?.active ?? false));
370
- const blob = member.getStructureWithRegistrations();
371
+ const balancesPermission = await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read);
372
+
373
+ const blob = MemberWithRegistrationsBlob.create({
374
+ ...member,
375
+ registrations: member.registrations.map((r) => {
376
+ const base = r.getStructure();
377
+
378
+ base.balances = balancesPermission
379
+ ? (balances.filter(b => r.id === b.objectId).map((b) => {
380
+ return GenericBalance.create(b);
381
+ }))
382
+ : [];
383
+
384
+ return base;
385
+ }),
386
+ details: member.details,
387
+ users: member.users.map(u => u.getStructure()),
388
+ });
389
+
371
390
  memberBlobs.push(
372
391
  await Context.auth.filterMemberData(member, blob),
373
392
  );
@@ -570,12 +589,23 @@ export class AuthenticatedStructures {
570
589
 
571
590
  const organizationStructs = await this.organizations(organizations);
572
591
 
592
+ const registrationIds = Formatter.uniqueArray([
593
+ ...balances.filter(b => b.objectType === ReceivableBalanceType.registration).map(b => b.objectId),
594
+ ]);
595
+ const registrations = await Registration.getByIDs(...registrationIds);
596
+
573
597
  const memberIds = Formatter.uniqueArray([
574
598
  ...balances.filter(b => b.objectType === ReceivableBalanceType.member).map(b => b.objectId),
575
599
  ...responsibilities.map(r => r.memberId),
600
+ ...registrations.map(r => r.memberId),
576
601
  ]);
577
602
  const members = memberIds.length > 0 ? await Member.getByIDs(...memberIds) : [];
578
603
 
604
+ const userIds = Formatter.uniqueArray([
605
+ ...balances.filter(b => b.objectType === ReceivableBalanceType.user).map(b => b.objectId),
606
+ ]);
607
+ const users = userIds.length > 0 ? await User.getByIDs(...userIds) : [];
608
+
579
609
  const result: ReceivableBalanceStruct[] = [];
580
610
  for (const balance of balances) {
581
611
  let object = ReceivableBalanceObject.create({
@@ -584,7 +614,7 @@ export class AuthenticatedStructures {
584
614
  });
585
615
 
586
616
  if (balance.objectType === ReceivableBalanceType.organization) {
587
- const organization = organizationStructs.find(o => o.id == balance.objectId) ?? null;
617
+ const organization = organizationStructs.find(o => o.id === balance.objectId) ?? null;
588
618
  if (organization) {
589
619
  const theseResponsibilities = responsibilities.filter(r => r.organizationId === organization.id);
590
620
  const thisMembers = members.flatMap((m) => {
@@ -605,6 +635,7 @@ export class AuthenticatedStructures {
605
635
  lastName: member.lastName ?? '',
606
636
  emails: member.details.getMemberEmails(),
607
637
  meta: {
638
+ type: 'organization',
608
639
  responsibilityIds: responsibilities.map(r => r.responsibilityId),
609
640
  url: organization.dashboardUrl + '/boekhouding/openstaand/' + (Context.organization?.uri ?? ''),
610
641
  },
@@ -615,14 +646,101 @@ export class AuthenticatedStructures {
615
646
  else if (balance.objectType === ReceivableBalanceType.member) {
616
647
  const member = members.find(m => m.id === balance.objectId) ?? null;
617
648
  if (member) {
649
+ const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + Context.organization.getHost() : '';
618
650
  object = ReceivableBalanceObject.create({
619
651
  id: balance.objectId,
620
652
  name: member.details.name,
653
+ contacts: [
654
+ ...(member.details.getMemberEmails().length
655
+ ? [
656
+ ReceivableBalanceObjectContact.create({
657
+ firstName: member.details.firstName ?? '',
658
+ lastName: member.details.lastName ?? '',
659
+ emails: member.details.getMemberEmails(),
660
+ meta: {
661
+ type: 'member',
662
+ responsibilityIds: [],
663
+ url,
664
+ },
665
+ }),
666
+ ]
667
+ : []),
668
+
669
+ ...(member.details.parentsHaveAccess
670
+ ? member.details.parents.filter(p => !!p.email).map(p => ReceivableBalanceObjectContact.create({
671
+ firstName: p.firstName ?? '',
672
+ lastName: p.lastName ?? '',
673
+ emails: [p.email!],
674
+ meta: {
675
+ type: 'parent',
676
+ responsibilityIds: [],
677
+ url,
678
+ },
679
+ }))
680
+ : []),
681
+ ],
682
+ });
683
+ }
684
+ }
685
+ else if (balance.objectType === ReceivableBalanceType.registration) {
686
+ const registration = registrations.find(r => r.id === balance.objectId) ?? null;
687
+ if (!registration) {
688
+ continue;
689
+ }
690
+ const member = members.find(m => m.id === registration.memberId) ?? null;
691
+ if (member) {
692
+ const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + Context.organization.getHost() : '';
693
+ object = ReceivableBalanceObject.create({
694
+ id: balance.objectId,
695
+ name: member.details.name,
696
+ contacts: [
697
+ ...(member.details.getMemberEmails().length
698
+ ? [
699
+ ReceivableBalanceObjectContact.create({
700
+ firstName: member.details.firstName ?? '',
701
+ lastName: member.details.lastName ?? '',
702
+ emails: member.details.getMemberEmails(),
703
+ meta: {
704
+ type: 'member',
705
+ responsibilityIds: [],
706
+ url,
707
+ },
708
+ }),
709
+ ]
710
+ : []),
711
+
712
+ ...(member.details.parentsHaveAccess
713
+ ? member.details.parents.filter(p => !!p.email).map(p => ReceivableBalanceObjectContact.create({
714
+ firstName: p.firstName ?? '',
715
+ lastName: p.lastName ?? '',
716
+ emails: [p.email!],
717
+ meta: {
718
+ type: 'parent',
719
+ responsibilityIds: [],
720
+ url,
721
+ },
722
+ }))
723
+ : []),
724
+ ],
725
+ });
726
+ }
727
+ }
728
+ else if (balance.objectType === ReceivableBalanceType.user) {
729
+ const user = users.find(m => m.id === balance.objectId) ?? null;
730
+ if (user) {
731
+ const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + Context.organization.getHost() : '';
732
+ object = ReceivableBalanceObject.create({
733
+ id: balance.objectId,
734
+ name: user.name || user.email,
621
735
  contacts: [
622
736
  ReceivableBalanceObjectContact.create({
623
- firstName: member.details.firstName ?? '',
624
- lastName: member.details.lastName ?? '',
625
- emails: member.details.getMemberEmails(),
737
+ firstName: user.firstName ?? '',
738
+ lastName: user.lastName ?? '',
739
+ emails: [user.email],
740
+ meta: {
741
+ responsibilityIds: [],
742
+ url,
743
+ },
626
744
  }),
627
745
  ],
628
746
  });
@@ -3,6 +3,7 @@ import { BalanceItem, Member, MemberPlatformMembership, Platform } from '@stamho
3
3
  import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
4
4
  import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
+ import { BalanceItemService } from '../services/BalanceItemService';
6
7
 
7
8
  export const MembershipCharger = {
8
9
  async charge() {
@@ -122,6 +123,9 @@ export const MembershipCharger = {
122
123
 
123
124
  await BalanceItem.updateOutstanding(createdBalanceItems);
124
125
 
126
+ // Reallocate
127
+ await BalanceItemService.reallocate(createdBalanceItems, chargeVia);
128
+
125
129
  if (memberships.length < chunkSize) {
126
130
  break;
127
131
  }
@@ -1,6 +1,7 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { BalanceItem, Platform } from '@stamhoofd/models';
3
3
  import { BalanceItemType, Organization as OrganizationStruct } from '@stamhoofd/structures';
4
+ import { BalanceItemService } from '../services/BalanceItemService';
4
5
 
5
6
  export class OrganizationCharger {
6
7
  static async chargeFromPlatform(args: { organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string }) {
@@ -30,6 +31,9 @@ export class OrganizationCharger {
30
31
 
31
32
  await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
32
33
  await BalanceItem.updateOutstanding(balanceItems);
34
+
35
+ // Reallocate
36
+ await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
33
37
  }
34
38
 
35
39
  private static createBalanceItem({ price, amount, description, chargingOrganizationId, organizationBeingCharged }: { price: number; amount?: number; description: string; chargingOrganizationId: string; organizationBeingCharged: OrganizationStruct }): BalanceItem {
@@ -0,0 +1,30 @@
1
+ import { Database, Migration } from '@simonbackx/simple-database';
2
+ import { BalanceItemStatus } from '@stamhoofd/structures';
3
+
4
+ export default new Migration(async () => {
5
+ if (STAMHOOFD.environment === 'test') {
6
+ console.log('skipped in tests');
7
+ return;
8
+ }
9
+
10
+ const query = `
11
+ UPDATE
12
+ balance_items
13
+ SET status = ?
14
+ WHERE status IN (?)`;
15
+ await Database.update(query, [
16
+ BalanceItemStatus.Due,
17
+ ['Paid', 'Pending'],
18
+ ]);
19
+
20
+ const q2 = `
21
+ UPDATE
22
+ balance_items
23
+ SET status = ?,
24
+ amount = coalesce(nullif(ROUND(coalesce(pricePaid / nullif(unitPrice, 0), 0)), 0), 1)
25
+ WHERE amount = 0 AND status = ?`;
26
+ await Database.update(q2, [
27
+ BalanceItemStatus.Canceled,
28
+ BalanceItemStatus.Due,
29
+ ]);
30
+ });
@@ -0,0 +1,55 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { logger } from '@simonbackx/simple-logging';
3
+ import { Platform, RegistrationPeriod } from '@stamhoofd/models';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment == 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ process.stdout.write('\n');
12
+ let c = 0;
13
+ let id: string = '';
14
+
15
+ await logger.setContext({ tags: ['seed'] }, async () => {
16
+ while (true) {
17
+ const items = await RegistrationPeriod.where({
18
+ id: {
19
+ value: id,
20
+ sign: '>',
21
+ },
22
+ }, { limit: 1000, sort: ['id'] });
23
+
24
+ if (items.length === 0) {
25
+ break;
26
+ }
27
+
28
+ process.stdout.write('.');
29
+
30
+ for (const item of items) {
31
+ await item.setPreviousPeriodId();
32
+ if (await item.save()) {
33
+ c += 1;
34
+ }
35
+ }
36
+
37
+ if (items.length < 1000) {
38
+ break;
39
+ }
40
+ id = items[items.length - 1].id;
41
+ }
42
+ });
43
+
44
+ console.log('Updated ' + c + ' registration periods');
45
+
46
+ // Now update platform
47
+ const platform = await Platform.getShared();
48
+ await platform.setPreviousPeriodId();
49
+ await platform.save();
50
+
51
+ console.log('Updated platform');
52
+
53
+ // Do something here
54
+ return Promise.resolve();
55
+ });
@@ -0,0 +1,40 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { logger } from '@simonbackx/simple-logging';
3
+ import { BalanceItem } from '@stamhoofd/models';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment == 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ process.stdout.write('\n');
12
+ let c = 0;
13
+ let id: string = '';
14
+
15
+ 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
+
24
+ await BalanceItem.updateOutstanding(items);
25
+
26
+ c += items.length;
27
+ process.stdout.write('.');
28
+
29
+ if (items.length < 1000) {
30
+ break;
31
+ }
32
+ id = items[items.length - 1].id;
33
+ }
34
+ });
35
+
36
+ console.log('Updated outstanding balance for ' + c + ' items');
37
+
38
+ // Do something here
39
+ return Promise.resolve();
40
+ });
@@ -7,16 +7,20 @@ 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;
11
+
10
12
  // Update cached amountPaid of the balance item (balanceItemPayment will get overwritten later, but we need it to calculate the status)
11
13
  balanceItemPayment.balanceItem.pricePaid += balanceItemPayment.price;
12
14
 
13
- // Update status
14
- const old = balanceItemPayment.balanceItem.status;
15
- balanceItemPayment.balanceItem.updateStatus();
15
+ if (balanceItemPayment.balanceItem.status === BalanceItemStatus.Hidden && balanceItemPayment.balanceItem.pricePaid !== 0) {
16
+ balanceItemPayment.balanceItem.status = BalanceItemStatus.Due;
17
+ }
18
+
16
19
  await balanceItemPayment.balanceItem.save();
20
+ const isPaid = balanceItemPayment.balanceItem.priceOpen === 0;
17
21
 
18
22
  // Do logic of balance item
19
- if (balanceItemPayment.balanceItem.status === BalanceItemStatus.Paid && old !== BalanceItemStatus.Paid && balanceItemPayment.price >= 0) {
23
+ if (isPaid && !wasPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
20
24
  // Only call markPaid once (if it wasn't (partially) paid before)
21
25
  await BalanceItemService.markPaid(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
22
26
  }
@@ -1,7 +1,9 @@
1
1
  import { BalanceItem, Order, Organization, Payment, Webshop } from '@stamhoofd/models';
2
- import { AuditLogSource, BalanceItemStatus, OrderStatus } from '@stamhoofd/structures';
3
- import { RegistrationService } from './RegistrationService';
2
+ import { AuditLogSource, BalanceItemStatus, OrderStatus, ReceivableBalanceType } from '@stamhoofd/structures';
4
3
  import { AuditLogService } from './AuditLogService';
4
+ import { RegistrationService } from './RegistrationService';
5
+ import { PaymentReallocationService } from './PaymentReallocationService';
6
+ import { Formatter } from '@stamhoofd/utility';
5
7
 
6
8
  export const BalanceItemService = {
7
9
  async markPaid(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
@@ -45,6 +47,24 @@ export const BalanceItemService = {
45
47
  }
46
48
  },
47
49
 
50
+ async reallocate(balanceItems: BalanceItem[], organizationId: string) {
51
+ const memberIds = Formatter.uniqueArray(balanceItems.map(b => b.memberId).filter(b => b !== null));
52
+ const payingOrganizationIds = Formatter.uniqueArray(balanceItems.map(b => b.payingOrganizationId).filter(b => b !== null));
53
+ const userIds = Formatter.uniqueArray(balanceItems.map(b => b.userId).filter(b => b !== null));
54
+
55
+ for (const memberId of memberIds) {
56
+ await PaymentReallocationService.reallocate(organizationId, memberId, ReceivableBalanceType.member);
57
+ }
58
+
59
+ for (const payingOrganizationId of payingOrganizationIds) {
60
+ await PaymentReallocationService.reallocate(organizationId, payingOrganizationId, ReceivableBalanceType.organization);
61
+ }
62
+
63
+ for (const userId of userIds) {
64
+ await PaymentReallocationService.reallocate(organizationId, userId, ReceivableBalanceType.user);
65
+ }
66
+ },
67
+
48
68
  async markUpdated(balanceItem: BalanceItem, payment: Payment, organization: Organization) {
49
69
  // For orders: mark order as changed (so they are refetched in front ends)
50
70
  if (balanceItem.orderId) {
@@ -93,5 +113,4 @@ export const BalanceItemService = {
93
113
  }
94
114
  }
95
115
  },
96
-
97
116
  };