@stamhoofd/models 2.120.5 → 2.121.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 (90) hide show
  1. package/dist/factories/RegistrationInvitationFactory.d.ts +15 -0
  2. package/dist/factories/RegistrationInvitationFactory.d.ts.map +1 -0
  3. package/dist/factories/RegistrationInvitationFactory.js +18 -0
  4. package/dist/factories/RegistrationInvitationFactory.js.map +1 -0
  5. package/dist/factories/index.d.ts +1 -0
  6. package/dist/factories/index.d.ts.map +1 -1
  7. package/dist/factories/index.js +1 -0
  8. package/dist/factories/index.js.map +1 -1
  9. package/dist/helpers/Handlebars.d.ts.map +1 -1
  10. package/dist/helpers/Handlebars.js +3 -0
  11. package/dist/helpers/Handlebars.js.map +1 -1
  12. package/dist/helpers/InvoiceCounter.d.ts +24 -0
  13. package/dist/helpers/InvoiceCounter.d.ts.map +1 -0
  14. package/dist/helpers/InvoiceCounter.js +133 -0
  15. package/dist/helpers/InvoiceCounter.js.map +1 -0
  16. package/dist/migrations/1763216320-bigint-balance-item-payments.sql +2 -0
  17. package/dist/migrations/1763216320-bigint-balance-items.sql +5 -0
  18. package/dist/migrations/1763216320-bigint-orders.sql +2 -0
  19. package/dist/migrations/1763216320-bigint-payments.sql +2 -0
  20. package/dist/migrations/1763216332-bigint-balance-item-price-total.sql +2 -0
  21. package/dist/migrations/1776873089-create-registration-invitations-table.sql +13 -0
  22. package/dist/migrations/1778657958-payments-create-mandate.sql +2 -0
  23. package/dist/migrations/1778657959-payments-mandate-id.sql +2 -0
  24. package/dist/migrations/1778796615-payments-reversing-payment-id.sql +3 -0
  25. package/dist/migrations/1778950642-price-invoiced.sql +2 -0
  26. package/dist/models/BalanceItem.d.ts +9 -1
  27. package/dist/models/BalanceItem.d.ts.map +1 -1
  28. package/dist/models/BalanceItem.js +52 -5
  29. package/dist/models/BalanceItem.js.map +1 -1
  30. package/dist/models/Group.d.ts +4 -0
  31. package/dist/models/Group.d.ts.map +1 -1
  32. package/dist/models/Group.js +17 -0
  33. package/dist/models/Group.js.map +1 -1
  34. package/dist/models/Invoice.d.ts.map +1 -1
  35. package/dist/models/Invoice.js +0 -8
  36. package/dist/models/Invoice.js.map +1 -1
  37. package/dist/models/MollieToken.d.ts +4 -8
  38. package/dist/models/MollieToken.d.ts.map +1 -1
  39. package/dist/models/MollieToken.js +37 -90
  40. package/dist/models/MollieToken.js.map +1 -1
  41. package/dist/models/Organization.d.ts +11 -2
  42. package/dist/models/Organization.d.ts.map +1 -1
  43. package/dist/models/Organization.js +27 -3
  44. package/dist/models/Organization.js.map +1 -1
  45. package/dist/models/Payment.d.ts +14 -1
  46. package/dist/models/Payment.d.ts.map +1 -1
  47. package/dist/models/Payment.js +23 -1
  48. package/dist/models/Payment.js.map +1 -1
  49. package/dist/models/Registration.d.ts +1 -0
  50. package/dist/models/Registration.d.ts.map +1 -1
  51. package/dist/models/Registration.js +1 -0
  52. package/dist/models/Registration.js.map +1 -1
  53. package/dist/models/RegistrationInvitation.d.ts +14 -0
  54. package/dist/models/RegistrationInvitation.d.ts.map +1 -0
  55. package/dist/models/RegistrationInvitation.js +45 -0
  56. package/dist/models/RegistrationInvitation.js.map +1 -0
  57. package/dist/models/User.d.ts +1 -1
  58. package/dist/models/User.d.ts.map +1 -1
  59. package/dist/models/User.js +1 -1
  60. package/dist/models/User.js.map +1 -1
  61. package/dist/models/index.d.ts +1 -0
  62. package/dist/models/index.d.ts.map +1 -1
  63. package/dist/models/index.js +1 -0
  64. package/dist/models/index.js.map +1 -1
  65. package/package.json +11 -2
  66. package/src/factories/RegistrationInvitationFactory.ts +24 -0
  67. package/src/factories/index.ts +1 -0
  68. package/src/helpers/Handlebars.ts +4 -0
  69. package/src/helpers/InvoiceCounter.test.ts +220 -0
  70. package/src/helpers/InvoiceCounter.ts +162 -0
  71. package/src/migrations/1763216320-bigint-balance-item-payments.sql +2 -0
  72. package/src/migrations/1763216320-bigint-balance-items.sql +5 -0
  73. package/src/migrations/1763216320-bigint-orders.sql +2 -0
  74. package/src/migrations/1763216320-bigint-payments.sql +2 -0
  75. package/src/migrations/1763216332-bigint-balance-item-price-total.sql +2 -0
  76. package/src/migrations/1776873089-create-registration-invitations-table.sql +13 -0
  77. package/src/migrations/1778657958-payments-create-mandate.sql +2 -0
  78. package/src/migrations/1778657959-payments-mandate-id.sql +2 -0
  79. package/src/migrations/1778796615-payments-reversing-payment-id.sql +3 -0
  80. package/src/migrations/1778950642-price-invoiced.sql +2 -0
  81. package/src/models/BalanceItem.ts +59 -5
  82. package/src/models/Group.ts +24 -3
  83. package/src/models/Invoice.ts +1 -9
  84. package/src/models/MollieToken.ts +42 -102
  85. package/src/models/Organization.ts +31 -3
  86. package/src/models/Payment.ts +21 -2
  87. package/src/models/Registration.ts +3 -2
  88. package/src/models/RegistrationInvitation.ts +40 -0
  89. package/src/models/User.ts +6 -7
  90. package/src/models/index.ts +1 -0
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { InvoiceCounter } from './InvoiceCounter.js';
3
+ import { OrganizationInvoiceSettings } from '@stamhoofd/structures/OrganizationInvoiceSettings.js';
4
+ import { Invoice } from '../models/Invoice.js';
5
+ import { OrganizationFactory } from '../factories/OrganizationFactory.js';
6
+
7
+ // Minimal settings factory
8
+ function makeSettings(overrides: Partial<OrganizationInvoiceSettings> = {}): OrganizationInvoiceSettings {
9
+ return OrganizationInvoiceSettings.create({
10
+ resetMonth: null,
11
+ prefixYear: false,
12
+ ...overrides,
13
+ })
14
+ }
15
+
16
+ // ─────────────────────────────────────────────
17
+ // parseNumber
18
+ // ─────────────────────────────────────────────
19
+ describe('InvoiceCounter.parseNumber', () => {
20
+ describe('without year prefix', () => {
21
+ const settings = makeSettings({ prefixYear: false });
22
+
23
+ it('parses ABC-123 → 123', () => {
24
+ expect(InvoiceCounter.parseNumber(settings, 'ABC-123')).toBe(123);
25
+ });
26
+
27
+ it('parses XXX0001 → 1', () => {
28
+ expect(InvoiceCounter.parseNumber(settings, 'XXX0001')).toBe(1);
29
+ });
30
+
31
+ it('parses 05STA0001 → 1', () => {
32
+ expect(InvoiceCounter.parseNumber(settings, '05STA0001')).toBe(1);
33
+ });
34
+
35
+ it('parses 1234-0011 → 11', () => {
36
+ expect(InvoiceCounter.parseNumber(settings, '1234-0011')).toBe(11);
37
+ });
38
+ });
39
+
40
+ describe('with year prefix (prefixYear: true)', () => {
41
+ const settings = makeSettings({ prefixYear: true });
42
+
43
+ it('parses 2012001584 → 1584', () => {
44
+ expect(InvoiceCounter.parseNumber(settings, '2012001584')).toBe(1584);
45
+ });
46
+
47
+ it('parses XXX-2012001584 → 1584', () => {
48
+ expect(InvoiceCounter.parseNumber(settings, 'XXX-2012001584')).toBe(1584);
49
+ });
50
+ });
51
+ });
52
+
53
+ describe('InvoiceCounter.formatNumber', () => {
54
+ const date2025 = new Date('2025-06-15T12:00:00Z');
55
+
56
+ it('formats 123 with fixed prefix ABC → "ABC000123"', () => {
57
+ const settings = makeSettings({ fixedPrefix: 'ABC' });
58
+ expect(InvoiceCounter.formatNumber(settings, 123, date2025)).toBe('ABC000123');
59
+ });
60
+
61
+ it('formats 123 with fixed prefix ABC1 → "ABC1-000123"', () => {
62
+ const settings = makeSettings({ fixedPrefix: 'ABC1' });
63
+ expect(InvoiceCounter.formatNumber(settings, 123, date2025)).toBe('ABC1-000123');
64
+ });
65
+
66
+ it('formats 1 with year prefix → "2025000001"', () => {
67
+ const settings = makeSettings({ prefixYear: true });
68
+ expect(InvoiceCounter.formatNumber(settings, 1, date2025)).toBe('2025000001');
69
+ });
70
+
71
+ it('formats 11 with fixed prefix "111-" and no duplicate dash → "111-000011"', () => {
72
+ const settings = makeSettings({ fixedPrefix: '111-' });
73
+ expect(InvoiceCounter.formatNumber(settings, 11, date2025)).toBe('111-000011');
74
+ });
75
+
76
+ it('formats 11 with fixed prefix "test-" and no duplicate dash → "test-000011"', () => {
77
+ const settings = makeSettings({ fixedPrefix: 'test-' });
78
+ expect(InvoiceCounter.formatNumber(settings, 11, date2025)).toBe('test-000011');
79
+ });
80
+
81
+ it('formats 54 with year prefix + fixed prefix → "ABC202500054"', () => {
82
+ // year is prepended first, then fixedPrefix wraps around
83
+ const settings = makeSettings({ prefixYear: true, fixedPrefix: 'ABC' });
84
+ expect(InvoiceCounter.formatNumber(settings, 54, date2025)).toBe('ABC2025000054');
85
+ });
86
+ });
87
+
88
+ describe('InvoiceCounter.shouldStartNewSeries', () => {
89
+ it('returns false when resetMonth is null', () => {
90
+ const settings = makeSettings({ resetMonth: null });
91
+ const last = new Date('2024-12-01');
92
+ const now = new Date('2025-06-01');
93
+ expect(InvoiceCounter.shouldStartNewSeries(settings, last, now)).toBe(false);
94
+ });
95
+
96
+ it('returns true when crossing the reset month boundary', () => {
97
+ // reset on January (month 1)
98
+ const settings = makeSettings({ resetMonth: 1 });
99
+ const last = new Date('2024-06-15');
100
+ const now = new Date('2025-01-02');
101
+ expect(InvoiceCounter.shouldStartNewSeries(settings, last, now)).toBe(true);
102
+ });
103
+
104
+ it('returns false when still within the same series period', () => {
105
+ const settings = makeSettings({ resetMonth: 1 });
106
+ const last = new Date('2025-01-05');
107
+ const now = new Date('2025-06-01');
108
+ expect(InvoiceCounter.shouldStartNewSeries(settings, last, now)).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('InvoiceCounter.assignNextNumber', () => {
113
+ beforeEach(() => {
114
+ InvoiceCounter.clearAll();
115
+ vitest.useFakeTimers({ toFake: ['Date'] });
116
+ });
117
+
118
+ afterEach(() => {
119
+ vitest.useRealTimers();
120
+ });
121
+
122
+ it('assigns 000001 to the first invoice for an org (no cache, no DB)', async () => {
123
+ const org = await new OrganizationFactory({}).create();
124
+ const settings = makeSettings({});
125
+
126
+ const invoice = new Invoice();
127
+ invoice.organizationId = org.id;
128
+ await InvoiceCounter.assignNextNumber(invoice, settings);
129
+
130
+ expect(invoice.number).toBe('000001');
131
+ expect(invoice.invoicedAt).not.toBeNull();
132
+ });
133
+
134
+ it('increments from cache when called twice without a reset boundary', async () => {
135
+ const org = await new OrganizationFactory({}).create();
136
+ const settings = makeSettings({});
137
+
138
+ const invoice1 = new Invoice();
139
+ invoice1.organizationId = org.id;
140
+ await InvoiceCounter.assignNextNumber(invoice1, settings);
141
+ expect(invoice1.number).toBe('000001');
142
+
143
+ const invoice2 = new Invoice();
144
+ invoice2.organizationId = org.id;
145
+ await InvoiceCounter.assignNextNumber(invoice2, settings);
146
+ expect(invoice2.number).toBe('000002');
147
+ });
148
+
149
+ it('continues from DB when cache is absent but a numbered invoice exists', async () => {
150
+ const org = await new OrganizationFactory({}).create();
151
+ const settings = makeSettings({});
152
+
153
+ const existing = new Invoice();
154
+ existing.organizationId = org.id;
155
+ existing.number = '000042';
156
+ existing.invoicedAt = new Date();
157
+ await existing.save();
158
+
159
+ InvoiceCounter.clearAll();
160
+
161
+ const invoice = new Invoice();
162
+ invoice.organizationId = org.id;
163
+ await InvoiceCounter.assignNextNumber(invoice, settings);
164
+
165
+ expect(invoice.number).toBe('000043');
166
+ });
167
+
168
+ it('starts a new series when the reset boundary is crossed (cache hit)', async () => {
169
+ const org = await new OrganizationFactory({}).create();
170
+ const settings = makeSettings({ resetMonth: 1, prefixYear: true });
171
+
172
+ vitest.setSystemTime(new Date('2024-06-15T12:00:00Z'));
173
+ const invoice1 = new Invoice();
174
+ invoice1.organizationId = org.id;
175
+ await InvoiceCounter.assignNextNumber(invoice1, settings);
176
+ expect(invoice1.number).toBe('2024000001');
177
+
178
+ // Advance past the January 2025 reset boundary
179
+ vitest.setSystemTime(new Date('2025-02-01T12:00:00Z'));
180
+ const invoice2 = new Invoice();
181
+ invoice2.organizationId = org.id;
182
+ await InvoiceCounter.assignNextNumber(invoice2, settings);
183
+
184
+ expect(invoice2.number).toBe('2025000001');
185
+ });
186
+
187
+ it('falls back to 1 when the DB invoice number cannot be parsed', async () => {
188
+ const org = await new OrganizationFactory({}).create();
189
+ const settings = makeSettings({});
190
+
191
+ const existing = new Invoice();
192
+ existing.organizationId = org.id;
193
+ existing.number = 'INVALID';
194
+ existing.invoicedAt = new Date();
195
+ await existing.save();
196
+
197
+ const invoice = new Invoice();
198
+ invoice.organizationId = org.id;
199
+ await InvoiceCounter.assignNextNumber(invoice, settings);
200
+
201
+ expect(invoice.number).toBe('000001');
202
+ });
203
+
204
+ it('reads from DB after resetNumbers clears the cache', async () => {
205
+ const org = await new OrganizationFactory({}).create();
206
+ const settings = makeSettings({});
207
+
208
+ const invoice1 = new Invoice();
209
+ invoice1.organizationId = org.id;
210
+ await InvoiceCounter.assignNextNumber(invoice1, settings);
211
+ expect(invoice1.number).toBe('000001');
212
+
213
+ await InvoiceCounter.resetNumbers(org.id);
214
+
215
+ const invoice2 = new Invoice();
216
+ invoice2.organizationId = org.id;
217
+ await InvoiceCounter.assignNextNumber(invoice2, settings);
218
+ expect(invoice2.number).toBe('000002');
219
+ });
220
+ });
@@ -0,0 +1,162 @@
1
+ import { QueueHandler } from '@stamhoofd/queues';
2
+
3
+ import type { OrganizationInvoiceSettings } from '@stamhoofd/structures/OrganizationInvoiceSettings.js';
4
+ import { Formatter } from '@stamhoofd/utility';
5
+ import type { DateTime } from 'luxon';
6
+ import { Invoice } from '../models/Invoice.js';
7
+
8
+ export class InvoiceCounter {
9
+ static numberCache: Map<string, {lastNumber: number, date: Date}> = new Map();
10
+
11
+
12
+ private static getNextResetDate(last: DateTime, resetMonth: number): DateTime {
13
+ const candidate = last.set({ month: resetMonth, day: 1 }).startOf('day');
14
+ return candidate > last ? candidate : candidate.plus({ years: 1 }).startOf('day');
15
+ }
16
+
17
+ static shouldStartNewSeries(settings: OrganizationInvoiceSettings, lastInvoiceDate: Date, invoiceDate: Date) {
18
+ if (settings.resetMonth === null) {
19
+ return false;
20
+ }
21
+
22
+ const last = Formatter.luxon(lastInvoiceDate);
23
+ const current = Formatter.luxon(invoiceDate);
24
+
25
+ return current >= this.getNextResetDate(last, settings.resetMonth)
26
+ }
27
+
28
+ /**
29
+ * XXX-123 -> 123
30
+ * YYY-0001 -> 1
31
+ *
32
+ * if prefix is enabled:
33
+ * 2025001 -> 1
34
+ * STA-2025001 -> 1
35
+ */
36
+ static parseNumber(settings: OrganizationInvoiceSettings, str: string) {
37
+ str = str.replace(/\D+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
38
+ // Remove prefixes and possibly year
39
+ const splitted = str.split('-');
40
+
41
+ let last = splitted[splitted.length - 1];
42
+
43
+ const stripYear = settings.prefixYear
44
+
45
+ if (last.length) {
46
+ if (stripYear) {
47
+ if (last.length <= 4) {
48
+ console.error('Could not parse invoice number from string ' + str + ' (could not trim year prefix)')
49
+ return null;
50
+ }
51
+ last = last.substring(4)
52
+ }
53
+
54
+ const int = parseInt(last);
55
+ if (int !== 0 && !isNaN(int) && isFinite(int)) {
56
+ return int;
57
+ }
58
+ }
59
+
60
+ console.error('Could not parse invoice number from string ' + str)
61
+ return null;
62
+ }
63
+
64
+ static formatNumber(settings: OrganizationInvoiceSettings, int: number, invoicedAt: Date) {
65
+ let str = int.toFixed(0).padStart(settings.padZeroLength, '0')
66
+
67
+ if (settings.prefixYear) {
68
+ const year = Formatter.luxon(invoicedAt).year
69
+ str = year + str;
70
+ }
71
+
72
+ if (settings.fixedPrefix) {
73
+ if (!settings.fixedPrefix.match(/\D$/)) {
74
+ // Need seperation character
75
+ str = settings.fixedPrefix.replace(/-$/, '') + '-' + str;
76
+ } else {
77
+ str = settings.fixedPrefix + str;
78
+ }
79
+ }
80
+
81
+ return str;
82
+ }
83
+
84
+ static async assignNextNumber(invoice: Invoice, settings: OrganizationInvoiceSettings): Promise<void> {
85
+ const organizationId = invoice.organizationId
86
+ return await QueueHandler.schedule('invoice/numbers-' + organizationId, async () => {
87
+ // Invoice date should be inside the queue to ensure it is chronologically generated
88
+ const invoicedAt = new Date();
89
+
90
+ const c = this.numberCache.get(organizationId);
91
+ if (c !== undefined) {
92
+ const lastNumber = c.lastNumber
93
+
94
+ // check date
95
+ if (!this.shouldStartNewSeries(settings, c.date, invoicedAt)) {
96
+ // Set and save.
97
+ // we do this here because it assures we'll not increase the next number if the save fails
98
+ invoice.number = this.formatNumber(settings, lastNumber + 1, invoicedAt);
99
+ invoice.invoicedAt = invoicedAt;
100
+ await invoice.save()
101
+
102
+ // If save succeeds, increase cache:
103
+ this.numberCache.set(organizationId, {
104
+ lastNumber: lastNumber + 1,
105
+ date: new Date(invoicedAt)
106
+ });
107
+ return
108
+ }
109
+ }
110
+
111
+ const lastInvoice = await Invoice.select()
112
+ .where('organizationId', organizationId)
113
+ .where('number', '!=', null)
114
+ .where('invoicedAt', '!=', null)
115
+ .orderBy('invoicedAt', 'DESC')
116
+ .first(false);
117
+
118
+ if (lastInvoice && lastInvoice.number && lastInvoice.invoicedAt) {
119
+ // check date
120
+ if (!this.shouldStartNewSeries(settings, lastInvoice.invoicedAt, invoicedAt)) {
121
+ const lastNumber = this.parseNumber(settings, lastInvoice.number)
122
+
123
+ if (lastNumber) {
124
+ invoice.number = this.formatNumber(settings, lastNumber + 1, invoicedAt);
125
+ invoice.invoicedAt = invoicedAt;
126
+ await invoice.save()
127
+
128
+ this.numberCache.set(organizationId, {
129
+ lastNumber: lastNumber + 1,
130
+ date: new Date(invoicedAt)
131
+ });
132
+ return;
133
+ }
134
+ }
135
+ }
136
+
137
+ // Start new
138
+ invoice.number = this.formatNumber(settings, 1, invoicedAt);
139
+ invoice.invoicedAt = invoicedAt;
140
+ await invoice.save()
141
+
142
+ this.numberCache.set(organizationId, {
143
+ lastNumber: 1,
144
+ date: new Date(invoicedAt)
145
+ });
146
+ return;
147
+ });
148
+ }
149
+
150
+ static async resetNumbers(organizationId: string): Promise<void> {
151
+ // Prevent race conditions: create a queue
152
+ // The queue can only run one at a time for the same webshop (so multiple webshops at the same time are allowed)
153
+ return await QueueHandler.schedule('invoice/numbers-' + organizationId, async () => {
154
+ this.numberCache.delete(organizationId);
155
+ return Promise.resolve();
156
+ });
157
+ }
158
+
159
+ static clearAll() {
160
+ this.numberCache.clear();
161
+ }
162
+ }
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `balance_item_payments`
2
+ CHANGE `price` `price` bigint NOT NULL DEFAULT '0';
@@ -0,0 +1,5 @@
1
+ ALTER TABLE `balance_items`
2
+ CHANGE `unitPrice` `unitPrice` bigint NOT NULL DEFAULT '0',
3
+ CHANGE `pricePaid` `pricePaid` bigint NOT NULL DEFAULT '0',
4
+ CHANGE `pricePending` `pricePending` bigint NOT NULL DEFAULT '0',
5
+ CHANGE `priceOpen` `priceOpen` bigint NOT NULL DEFAULT '0';
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `webshop_orders`
2
+ CHANGE `totalPrice` `totalPrice` bigint NULL;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `payments`
2
+ CHANGE `price` `price` bigint NOT NULL DEFAULT '0';
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `balance_items`
2
+ CHANGE `priceTotal` `priceTotal` bigint NOT NULL DEFAULT '0';
@@ -0,0 +1,13 @@
1
+ CREATE TABLE `registration_invitations` (
2
+ `id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
3
+ `memberId` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
4
+ `groupId` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
5
+ `organizationId` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
6
+ `createdAt` datetime NOT NULL,
7
+ PRIMARY KEY (`id`),
8
+ UNIQUE KEY `uk_member_group` (`memberId`, `groupId`),
9
+ KEY `idx_memberId` (`memberId`) USING BTREE,
10
+ CONSTRAINT `registration_invitations_ibfk_1` FOREIGN KEY (`memberId`) REFERENCES `members` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
11
+ CONSTRAINT `registration_invitations_ibfk_2` FOREIGN KEY (`groupId`) REFERENCES `groups` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
12
+ CONSTRAINT `registration_invitations_ibfk_3` FOREIGN KEY (`organizationId`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE
13
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `payments`
2
+ ADD COLUMN `createMandate` json NULL AFTER `stripeAccountId`;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `payments`
2
+ ADD COLUMN `mandateId` varchar(36) NULL AFTER `stripeAccountId`;
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `payments`
2
+ ADD COLUMN `reversingPaymentId` varchar(36) NULL AFTER `invoiceId`,
3
+ ADD FOREIGN KEY (`reversingPaymentId`) REFERENCES `payments` (`id`) ON UPDATE CASCADE ON DELETE SET NULL;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `balance_items`
2
+ ADD COLUMN `priceInvoiced` int NOT NULL DEFAULT '0' AFTER `priceOpen`;
@@ -186,6 +186,12 @@ export class BalanceItem extends QueryableModel {
186
186
  })
187
187
  priceOpen = 0;
188
188
 
189
+ /**
190
+ * Cached value, for optimizations
191
+ */
192
+ @column({ type: 'integer' })
193
+ priceInvoiced = 0;
194
+
189
195
  /**
190
196
  * todo: deprecate ('pending' and 'paid') + 'hidden' status and replace with 'due' + 'hidden'
191
197
  * -> maybe add 'due' (due if dueAt is null or <= now), 'hidden' (never due), 'future' (= not due until dueAt - but not possible to pay earlier)
@@ -544,6 +550,49 @@ export class BalanceItem extends QueryableModel {
544
550
  await Database.update(query, params);
545
551
  }
546
552
 
553
+ /**
554
+ * Update the outstanding balance of multiple members in one go (or all members)
555
+ */
556
+ static async updateInvoiced(balanceItemIds: string[] | 'all') {
557
+ if (balanceItemIds !== 'all' && balanceItemIds.length === 0) {
558
+ return 0;
559
+ }
560
+
561
+ const params: any[] = [];
562
+ let firstWhere = '';
563
+ let secondWhere = '';
564
+
565
+ if (balanceItemIds !== 'all') {
566
+ firstWhere = ` AND balanceItemId IN (?)`;
567
+ params.push(balanceItemIds);
568
+ params.push(balanceItemIds);
569
+
570
+ secondWhere = `WHERE balance_items.id IN (?)`;
571
+ params.push(balanceItemIds);
572
+ }
573
+
574
+ // Note: this query only works in MySQL because of the SET assignment behaviour allowing to reference newly set values
575
+ const query = `
576
+ UPDATE
577
+ balance_items
578
+ LEFT JOIN (
579
+ SELECT
580
+ balanceItemId,
581
+ sum(invoiced_balance_items.balanceInvoicedAmount) AS invoiced
582
+ FROM
583
+ invoiced_balance_items
584
+ LEFT JOIN invoices ON invoices.id = invoiced_balance_items.invoiceId
585
+ WHERE
586
+ invoices.number is not null${firstWhere}
587
+ GROUP BY
588
+ balanceItemId
589
+ ) invoiced ON invoiced.balanceItemId = balance_items.id
590
+ SET balance_items.priceInvoiced = coalesce(invoiced.invoiced, 0)
591
+ ${secondWhere}`;
592
+
593
+ return (await Database.update(query, params))[0].affectedRows;
594
+ }
595
+
547
596
  static async loadPayments(items: (BalanceItem | { id: string })[]) {
548
597
  if (items.length == 0) {
549
598
  return { balanceItemPayments: [], payments: [] };
@@ -664,10 +713,15 @@ export class BalanceItem extends QueryableModel {
664
713
  return balanceItems;
665
714
  }
666
715
 
667
- static async balanceItemsForOrganization(organizationId: string): Promise<BalanceItem[]> {
668
- return await BalanceItem.select()
669
- .where('payingOrganizationId', organizationId)
670
- .whereNot('status', BalanceItemStatus.Hidden)
671
- .fetch();
716
+ static async balanceItemsForOrganization(payingOrganizationId: string, organizationId?: string): Promise<BalanceItem[]> {
717
+ const base = BalanceItem.select()
718
+ .where('payingOrganizationId', payingOrganizationId)
719
+ .whereNot('status', BalanceItemStatus.Hidden);
720
+
721
+ if (organizationId) {
722
+ base.where('organizationId', organizationId)
723
+ }
724
+
725
+ return await base.fetch();
672
726
  }
673
727
  }
@@ -1,12 +1,12 @@
1
- import { column, Database, ManyToOneRelation } from '@simonbackx/simple-database';
2
- import type { GroupCategory} from '@stamhoofd/structures';
1
+ import { column, Database } from '@simonbackx/simple-database';
2
+ import type { GroupCategory } from '@stamhoofd/structures';
3
3
  import { GroupPrivateSettings, GroupSettings, GroupStatus, Group as GroupStruct, GroupType, StockReservation } from '@stamhoofd/structures';
4
4
  import { v4 as uuidv4 } from 'uuid';
5
5
 
6
6
  import { ArrayDecoder } from '@simonbackx/simple-encoding';
7
7
  import { QueueHandler } from '@stamhoofd/queues';
8
8
  import { QueryableModel } from '@stamhoofd/sql';
9
- import type {OrganizationRegistrationPeriod} from './OrganizationRegistrationPeriod.js';
9
+ import type { OrganizationRegistrationPeriod } from './OrganizationRegistrationPeriod.js';
10
10
  import { Registration } from './Registration.js';
11
11
 
12
12
  if (Registration === undefined) {
@@ -90,6 +90,27 @@ export class Group extends QueryableModel {
90
90
  @column({ type: 'json', decoder: new ArrayDecoder(StockReservation) })
91
91
  stockReservations: StockReservation[] = [];
92
92
 
93
+ /**
94
+ * No registrations and waiting list registrations are possible if closed
95
+ */
96
+ get closed() {
97
+ if (this.status !== GroupStatus.Open) {
98
+ return true;
99
+ }
100
+
101
+ if (this.settings.notYetOpen) {
102
+ // Start date or pre registration date are in the future
103
+ return true;
104
+ }
105
+
106
+ const now = new Date();
107
+ if (this.settings.registrationEndDate && this.settings.registrationEndDate < now) {
108
+ return true;
109
+ }
110
+
111
+ return false;
112
+ }
113
+
93
114
  static async getAll(organizationId: string, periodId: string | null, active = true, types: GroupType[] = [GroupType.Membership]): Promise<Group[]> {
94
115
  const query = Group.select()
95
116
  .where('organizationId', organizationId);
@@ -1,5 +1,5 @@
1
1
  import { column } from '@simonbackx/simple-database';
2
- import { Company, File, Invoice as InvoiceStruct, PaymentCustomer, VATSubtotal } from '@stamhoofd/structures';
2
+ import { Company, File, PaymentCustomer, VATSubtotal } from '@stamhoofd/structures';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
5
  import { ArrayDecoder } from '@simonbackx/simple-encoding';
@@ -109,14 +109,6 @@ export class Invoice extends QueryableModel {
109
109
  @column({
110
110
  type: 'datetime',
111
111
  nullable: true,
112
- beforeSave(old?: any) {
113
- if (old !== undefined || !this.number) {
114
- return old;
115
- }
116
- const date = new Date();
117
- date.setMilliseconds(0);
118
- return date;
119
- },
120
112
  })
121
113
  invoicedAt: Date | null = null;
122
114