@stamhoofd/models 2.34.1 → 2.36.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 (36) hide show
  1. package/dist/src/migrations/1726054851-balance-item-price-pending.sql +2 -0
  2. package/dist/src/migrations/1726054852-cached-outstanding-balances.sql +13 -0
  3. package/dist/src/models/BalanceItem.d.ts +10 -1
  4. package/dist/src/models/BalanceItem.d.ts.map +1 -1
  5. package/dist/src/models/BalanceItem.js +77 -9
  6. package/dist/src/models/BalanceItem.js.map +1 -1
  7. package/dist/src/models/BalanceItemPayment.d.ts.map +1 -1
  8. package/dist/src/models/BalanceItemPayment.js +2 -6
  9. package/dist/src/models/BalanceItemPayment.js.map +1 -1
  10. package/dist/src/models/CachedOutstandingBalance.d.ts +35 -0
  11. package/dist/src/models/CachedOutstandingBalance.d.ts.map +1 -0
  12. package/dist/src/models/CachedOutstandingBalance.js +188 -0
  13. package/dist/src/models/CachedOutstandingBalance.js.map +1 -0
  14. package/dist/src/models/Member.d.ts +4 -0
  15. package/dist/src/models/Member.d.ts.map +1 -1
  16. package/dist/src/models/Member.js +8 -2
  17. package/dist/src/models/Member.js.map +1 -1
  18. package/dist/src/models/Organization.d.ts.map +1 -1
  19. package/dist/src/models/Organization.js.map +1 -1
  20. package/dist/src/models/Registration.d.ts.map +1 -1
  21. package/dist/src/models/Registration.js +2 -1
  22. package/dist/src/models/Registration.js.map +1 -1
  23. package/dist/src/models/index.d.ts +1 -0
  24. package/dist/src/models/index.d.ts.map +1 -1
  25. package/dist/src/models/index.js +1 -0
  26. package/dist/src/models/index.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/migrations/1726054851-balance-item-price-pending.sql +2 -0
  29. package/src/migrations/1726054852-cached-outstanding-balances.sql +13 -0
  30. package/src/models/BalanceItem.ts +95 -14
  31. package/src/models/BalanceItemPayment.ts +2 -9
  32. package/src/models/CachedOutstandingBalance.ts +239 -0
  33. package/src/models/Member.ts +9 -2
  34. package/src/models/Organization.ts +2 -2
  35. package/src/models/Registration.ts +2 -2
  36. package/src/models/index.ts +1 -0
@@ -59,7 +59,7 @@ export class BalanceItemPayment extends Model {
59
59
  static payment = new ManyToOneRelation(Payment, "payment")
60
60
 
61
61
  async markPaid(this: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
62
- // Update cached amountPaid of the balance item
62
+ // Update cached amountPaid of the balance item (this will get overwritten later, but we need it to calculate the status)
63
63
  this.balanceItem.pricePaid += this.price
64
64
 
65
65
  // Update status
@@ -69,6 +69,7 @@ export class BalanceItemPayment extends Model {
69
69
 
70
70
  // Do logic of balance item
71
71
  if (this.balanceItem.status === BalanceItemStatus.Paid && old !== BalanceItemStatus.Paid) {
72
+ // Only call markPaid once (if it wasn't (partially) paid before)
72
73
  await this.balanceItem.markPaid(this.payment, organization)
73
74
  } else {
74
75
  await this.balanceItem.markUpdated(this.payment, organization)
@@ -79,14 +80,6 @@ export class BalanceItemPayment extends Model {
79
80
  * Call this once a earlier succeeded payment is no longer succeeded
80
81
  */
81
82
  async undoPaid(this: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
82
- // Update cached amountPaid of the balance item
83
- this.balanceItem.pricePaid -= this.price
84
-
85
- // Update status
86
- this.balanceItem.status = this.balanceItem.pricePaid >= this.balanceItem.price ? BalanceItemStatus.Paid : BalanceItemStatus.Pending;
87
-
88
- await this.balanceItem.save();
89
-
90
83
  await this.balanceItem.undoPaid(this.payment, organization)
91
84
  }
92
85
 
@@ -0,0 +1,239 @@
1
+ import { column, Model, SQLResultNamespacedRow } from '@simonbackx/simple-database';
2
+ import { SQL, SQLAlias, SQLCalculation, SQLMinusSign, SQLMultiplicationSign, SQLSelect, SQLSelectAs, SQLSum, SQLWhere } from '@stamhoofd/sql';
3
+ import { BalanceItemStatus } from '@stamhoofd/structures';
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import { BalanceItem } from './BalanceItem';
6
+
7
+
8
+ export type CachedOutstandingBalanceType = 'organization'|'member'|'user'
9
+
10
+ /**
11
+ * Keeps track of how much a member/user owes or needs to be reimbursed.
12
+ */
13
+ export class CachedOutstandingBalance extends Model {
14
+ static table = "cached_outstanding_balances"
15
+
16
+ @column({
17
+ primary: true, type: "string", beforeSave(value) {
18
+ return value ?? uuidv4();
19
+ }
20
+ })
21
+ id!: string;
22
+
23
+ @column({ type: "string" })
24
+ organizationId: string
25
+
26
+ @column({ type: "string" })
27
+ objectId: string
28
+
29
+ /**
30
+ * Defines which field to select
31
+ * organization: payingOrganizationId
32
+ * member: member id
33
+ * user: all balance items with that user id, but without a member id
34
+ */
35
+ @column({ type: "string" })
36
+ objectType: CachedOutstandingBalanceType
37
+
38
+ @column({ type: "integer" })
39
+ amount = 0
40
+
41
+ @column({ type: "integer" })
42
+ amountPending = 0
43
+
44
+ @column({
45
+ type: "datetime", beforeSave(old?: any) {
46
+ if (old !== undefined) {
47
+ return old;
48
+ }
49
+ const date = new Date()
50
+ date.setMilliseconds(0)
51
+ return date
52
+ }
53
+ })
54
+ createdAt: Date
55
+
56
+ @column({
57
+ type: "datetime", beforeSave() {
58
+ const date = new Date()
59
+ date.setMilliseconds(0)
60
+ return date
61
+ },
62
+ skipUpdate: true
63
+ })
64
+ updatedAt: Date
65
+
66
+ static async getForObjects(objectIds: string[], limitOrganizationId?: string|null): Promise<CachedOutstandingBalance[]> {
67
+ const query = this.select()
68
+ .where("objectId", objectIds);
69
+
70
+ if (limitOrganizationId) {
71
+ query.where("organizationId", limitOrganizationId);
72
+ }
73
+
74
+ return await query.fetch();
75
+ }
76
+
77
+ static async updateForObjects(organizationId: string, objectIds: string[], objectType: CachedOutstandingBalanceType) {
78
+ switch (objectType) {
79
+ case 'organization':
80
+ await this.updateForOrganizations(organizationId, objectIds);
81
+ break;
82
+ case 'member':
83
+ await this.updateForMembers(organizationId, objectIds);
84
+ break;
85
+ case 'user':
86
+ await this.updateForUsers(organizationId, objectIds);
87
+ break;
88
+ }
89
+ }
90
+
91
+ private static async fetchForObjects(organizationId: string, objectIds: string[], columnName: string, customWhere?: SQLWhere) {
92
+ const query = SQL.select(
93
+ SQL.column(columnName),
94
+ new SQLSelectAs(
95
+ new SQLCalculation(
96
+ new SQLSum(
97
+ new SQLCalculation(
98
+ SQL.column('unitPrice'),
99
+ new SQLMultiplicationSign(),
100
+ SQL.column('amount')
101
+ )
102
+ ),
103
+ new SQLMinusSign(),
104
+ new SQLSum(
105
+ SQL.column('pricePaid')
106
+ ),
107
+ ),
108
+ new SQLAlias('data__amount')
109
+ ),
110
+ new SQLSelectAs(
111
+ new SQLSum(
112
+ SQL.column('pricePending')
113
+ ),
114
+ new SQLAlias('data__amountPending')
115
+ )
116
+ )
117
+ .from(BalanceItem.table)
118
+ .where("organizationId", organizationId)
119
+ .where(columnName, objectIds)
120
+ .whereNot("status", BalanceItemStatus.Hidden)
121
+ .groupBy(SQL.column(columnName));
122
+
123
+ if (customWhere) {
124
+ query.where(customWhere);
125
+ }
126
+
127
+ const result = await query.fetch();
128
+
129
+ const results: [string, {amount: number, amountPending: number}][] = [];
130
+ for (const row of result) {
131
+ if (!row["data"]) {
132
+ throw new Error("Invalid data namespace");
133
+ }
134
+
135
+ if (!row[BalanceItem.table]) {
136
+ throw new Error("Invalid "+ BalanceItem.table +" namespace");
137
+ }
138
+
139
+ const objectId = row[BalanceItem.table][columnName];
140
+ const amount = row["data"]["amount"];
141
+ const amountPending = row["data"]["amountPending"];
142
+
143
+ if (typeof objectId !== "string") {
144
+ throw new Error("Invalid objectId");
145
+ }
146
+
147
+ if (typeof amount !== "number") {
148
+ throw new Error("Invalid amount");
149
+ }
150
+
151
+ if (typeof amountPending !== "number") {
152
+ throw new Error("Invalid amountPending");
153
+ }
154
+
155
+ results.push([objectId, {amount, amountPending}]);
156
+ }
157
+
158
+ return results;
159
+ }
160
+
161
+ private static async setForResults(organizationId: string, result: [string, {amount: number, amountPending: number}][], objectType: CachedOutstandingBalanceType) {
162
+ if (result.length === 0) {
163
+ return;
164
+ }
165
+ const query = SQL.insert(this.table)
166
+ .columns(
167
+ "id",
168
+ "organizationId",
169
+ "objectId",
170
+ "objectType",
171
+ "amount",
172
+ "amountPending",
173
+ "createdAt",
174
+ "updatedAt"
175
+ )
176
+ .values(...result.map(([objectId, {amount, amountPending}]) => {
177
+ return [
178
+ uuidv4(),
179
+ organizationId,
180
+ objectId,
181
+ objectType,
182
+ amount,
183
+ amountPending,
184
+ new Date(),
185
+ new Date()
186
+ ]
187
+ }))
188
+ .as('v')
189
+ .onDuplicateKeyUpdate(
190
+ SQL.assignment("amount", SQL.column("v", "amount")),
191
+ SQL.assignment("amountPending", SQL.column("v", "amountPending")),
192
+ SQL.assignment("updatedAt", SQL.column("v", "updatedAt"))
193
+ )
194
+
195
+ await query.insert()
196
+ }
197
+
198
+ static async updateForOrganizations(organizationId: string, organizationIds: string[]) {
199
+ if (organizationIds.length === 0) {
200
+ return;
201
+ }
202
+ const results = await this.fetchForObjects(organizationId, organizationIds, "payingOrganizationId");
203
+ await this.setForResults(organizationId, results, "organization");
204
+ }
205
+
206
+ static async updateForMembers(organizationId: string, memberIds: string[]) {
207
+ if (memberIds.length === 0) {
208
+ return;
209
+ }
210
+ const results = await this.fetchForObjects(organizationId, memberIds, "memberId");
211
+ await this.setForResults(organizationId, results, "member");
212
+ }
213
+
214
+ static async updateForUsers(organizationId: string, userIds: string[]) {
215
+ if (userIds.length === 0) {
216
+ return;
217
+ }
218
+ const results = await this.fetchForObjects(organizationId, userIds, "userId", SQL.where("memberId", null));
219
+ await this.setForResults(organizationId, results, "user");
220
+ }
221
+
222
+ /**
223
+ * Experimental: needs to move to library
224
+ */
225
+ static select() {
226
+ const transformer = (row: SQLResultNamespacedRow): CachedOutstandingBalance => {
227
+ const d = (this as typeof CachedOutstandingBalance & typeof Model).fromRow(row[this.table] as any) as CachedOutstandingBalance|undefined
228
+
229
+ if (!d) {
230
+ throw new Error("EmailTemplate not found")
231
+ }
232
+
233
+ return d;
234
+ }
235
+
236
+ const select = new SQLSelect(transformer, SQL.wildcard())
237
+ return select.from(SQL.table(this.table))
238
+ }
239
+ }
@@ -353,7 +353,7 @@ export class Member extends Model {
353
353
  /**
354
354
  * Fetch all members with their corresponding (valid) registrations or waiting lists and payments
355
355
  */
356
- static async getMembersWithRegistrationForUser(user: User): Promise<MemberWithRegistrations[]> {
356
+ static async getMemberIdsWithRegistrationForUser(user: User): Promise<string[]> {
357
357
  const query = SQL
358
358
  .select('id')
359
359
  .from(Member.table)
@@ -370,7 +370,14 @@ export class Member extends Model {
370
370
  )
371
371
 
372
372
  const data = await query.fetch()
373
- return this.getBlobByIds(...data.map((r) => r.members.id as string));
373
+ return data.map((r) => r.members.id as string)
374
+ }
375
+
376
+ /**
377
+ * Fetch all members with their corresponding (valid) registrations or waiting lists and payments
378
+ */
379
+ static async getMembersWithRegistrationForUser(user: User): Promise<MemberWithRegistrations[]> {
380
+ return this.getBlobByIds(...(await this.getMemberIdsWithRegistrationForUser(user)));
374
381
  }
375
382
 
376
383
  getStructureWithRegistrations(this: MemberWithRegistrations, forOrganization: null | boolean = null) {
@@ -3,7 +3,7 @@ import { DecodedRequest } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { I18n } from "@stamhoofd/backend-i18n";
5
5
  import { Email, EmailInterfaceRecipient } from "@stamhoofd/email";
6
- import { AccessRight, Address, Country, DNSRecordStatus, EmailTemplateType, OrganizationEmail, OrganizationMetaData, OrganizationPrivateMetaData, Organization as OrganizationStruct, PaymentMethod, PaymentProvider, PrivatePaymentConfiguration, Recipient, Replacement, STPackageType, TransferSettings } from "@stamhoofd/structures";
6
+ import { Address, Country, DNSRecordStatus, EmailTemplateType, OrganizationEmail, OrganizationMetaData, OrganizationPrivateMetaData, Organization as OrganizationStruct, PaymentMethod, PaymentProvider, PrivatePaymentConfiguration, Recipient, Replacement, STPackageType, TransferSettings } from "@stamhoofd/structures";
7
7
  import { AWSError } from 'aws-sdk';
8
8
  import SES from 'aws-sdk/clients/sesv2';
9
9
  import { PromiseResult } from 'aws-sdk/lib/request';
@@ -13,7 +13,7 @@ import { QueueHandler } from "@stamhoofd/queues";
13
13
  import { validateDNSRecords } from "../helpers/DNSValidator";
14
14
  import { getEmailBuilderForTemplate } from "../helpers/EmailBuilder";
15
15
  import { OrganizationServerMetaData } from '../structures/OrganizationServerMetaData';
16
- import { Group, OrganizationRegistrationPeriod, StripeAccount } from "./";
16
+ import { OrganizationRegistrationPeriod, StripeAccount } from "./";
17
17
 
18
18
  export class Organization extends Model {
19
19
  static table = "organizations";
@@ -1,5 +1,4 @@
1
1
  import { column, Database, ManyToOneRelation, Model } from '@simonbackx/simple-database';
2
- import { Email } from '@stamhoofd/email';
3
2
  import { EmailTemplateType, GroupPrice, PaymentMethod, PaymentMethodHelper, Recipient, RegisterItemOption, Registration as RegistrationStructure, Replacement, StockReservation } from '@stamhoofd/structures';
4
3
  import { Formatter } from '@stamhoofd/utility';
5
4
  import { v4 as uuidv4 } from "uuid";
@@ -396,6 +395,7 @@ export class Registration extends Model {
396
395
  type: EmailTemplateType.RegistrationTransferDetails,
397
396
  group
398
397
  },
398
+ type: "transactional",
399
399
  recipients
400
400
  })
401
401
  }
@@ -436,7 +436,7 @@ export class Registration extends Model {
436
436
 
437
437
  if (updated.shouldIncludeStock()) {
438
438
  const groupStockReservations: StockReservation[] = [
439
- // Group level stock reservatiosn (stored in the group)
439
+ // Group level stock reservations (stored in the group)
440
440
  StockReservation.create({
441
441
  objectId: updated.groupPrice.id,
442
442
  objectType: 'GroupPrice',
@@ -52,3 +52,4 @@ export * from "./MemberPlatformMembership"
52
52
  export * from "./Email"
53
53
  export * from "./EmailRecipient"
54
54
  export * from "./Event"
55
+ export * from "./CachedOutstandingBalance"