@stamhoofd/models 2.120.6 → 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.
- package/dist/factories/RegistrationInvitationFactory.d.ts +15 -0
- package/dist/factories/RegistrationInvitationFactory.d.ts.map +1 -0
- package/dist/factories/RegistrationInvitationFactory.js +18 -0
- package/dist/factories/RegistrationInvitationFactory.js.map +1 -0
- package/dist/factories/index.d.ts +1 -0
- package/dist/factories/index.d.ts.map +1 -1
- package/dist/factories/index.js +1 -0
- package/dist/factories/index.js.map +1 -1
- package/dist/helpers/Handlebars.d.ts.map +1 -1
- package/dist/helpers/Handlebars.js +3 -0
- package/dist/helpers/Handlebars.js.map +1 -1
- package/dist/helpers/InvoiceCounter.d.ts +24 -0
- package/dist/helpers/InvoiceCounter.d.ts.map +1 -0
- package/dist/helpers/InvoiceCounter.js +133 -0
- package/dist/helpers/InvoiceCounter.js.map +1 -0
- package/dist/migrations/1763216320-bigint-balance-item-payments.sql +2 -0
- package/dist/migrations/1763216320-bigint-balance-items.sql +5 -0
- package/dist/migrations/1763216320-bigint-orders.sql +2 -0
- package/dist/migrations/1763216320-bigint-payments.sql +2 -0
- package/dist/migrations/1763216332-bigint-balance-item-price-total.sql +2 -0
- package/dist/migrations/1776873089-create-registration-invitations-table.sql +13 -0
- package/dist/migrations/1778657958-payments-create-mandate.sql +2 -0
- package/dist/migrations/1778657959-payments-mandate-id.sql +2 -0
- package/dist/migrations/1778796615-payments-reversing-payment-id.sql +3 -0
- package/dist/migrations/1778950642-price-invoiced.sql +2 -0
- package/dist/models/BalanceItem.d.ts +9 -1
- package/dist/models/BalanceItem.d.ts.map +1 -1
- package/dist/models/BalanceItem.js +52 -5
- package/dist/models/BalanceItem.js.map +1 -1
- package/dist/models/Group.d.ts +4 -0
- package/dist/models/Group.d.ts.map +1 -1
- package/dist/models/Group.js +17 -0
- package/dist/models/Group.js.map +1 -1
- package/dist/models/Invoice.d.ts.map +1 -1
- package/dist/models/Invoice.js +0 -8
- package/dist/models/Invoice.js.map +1 -1
- package/dist/models/MollieToken.d.ts +4 -8
- package/dist/models/MollieToken.d.ts.map +1 -1
- package/dist/models/MollieToken.js +37 -90
- package/dist/models/MollieToken.js.map +1 -1
- package/dist/models/Organization.d.ts +11 -2
- package/dist/models/Organization.d.ts.map +1 -1
- package/dist/models/Organization.js +27 -3
- package/dist/models/Organization.js.map +1 -1
- package/dist/models/Payment.d.ts +14 -1
- package/dist/models/Payment.d.ts.map +1 -1
- package/dist/models/Payment.js +23 -1
- package/dist/models/Payment.js.map +1 -1
- package/dist/models/Registration.d.ts +1 -0
- package/dist/models/Registration.d.ts.map +1 -1
- package/dist/models/Registration.js +1 -0
- package/dist/models/Registration.js.map +1 -1
- package/dist/models/RegistrationInvitation.d.ts +14 -0
- package/dist/models/RegistrationInvitation.d.ts.map +1 -0
- package/dist/models/RegistrationInvitation.js +45 -0
- package/dist/models/RegistrationInvitation.js.map +1 -0
- package/dist/models/User.d.ts +1 -1
- package/dist/models/User.d.ts.map +1 -1
- package/dist/models/User.js +1 -1
- package/dist/models/User.js.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +1 -0
- package/dist/models/index.js.map +1 -1
- package/package.json +11 -2
- package/src/factories/RegistrationInvitationFactory.ts +24 -0
- package/src/factories/index.ts +1 -0
- package/src/helpers/Handlebars.ts +4 -0
- package/src/helpers/InvoiceCounter.test.ts +220 -0
- package/src/helpers/InvoiceCounter.ts +162 -0
- package/src/migrations/1763216320-bigint-balance-item-payments.sql +2 -0
- package/src/migrations/1763216320-bigint-balance-items.sql +5 -0
- package/src/migrations/1763216320-bigint-orders.sql +2 -0
- package/src/migrations/1763216320-bigint-payments.sql +2 -0
- package/src/migrations/1763216332-bigint-balance-item-price-total.sql +2 -0
- package/src/migrations/1776873089-create-registration-invitations-table.sql +13 -0
- package/src/migrations/1778657958-payments-create-mandate.sql +2 -0
- package/src/migrations/1778657959-payments-mandate-id.sql +2 -0
- package/src/migrations/1778796615-payments-reversing-payment-id.sql +3 -0
- package/src/migrations/1778950642-price-invoiced.sql +2 -0
- package/src/models/BalanceItem.ts +59 -5
- package/src/models/Group.ts +24 -3
- package/src/models/Invoice.ts +1 -9
- package/src/models/MollieToken.ts +42 -102
- package/src/models/Organization.ts +31 -3
- package/src/models/Payment.ts +21 -2
- package/src/models/Registration.ts +3 -2
- package/src/models/RegistrationInvitation.ts +40 -0
- package/src/models/User.ts +6 -7
- 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,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,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;
|
|
@@ -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(
|
|
668
|
-
|
|
669
|
-
.where('payingOrganizationId',
|
|
670
|
-
.whereNot('status', BalanceItemStatus.Hidden)
|
|
671
|
-
|
|
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
|
}
|
package/src/models/Group.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { column, 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);
|
package/src/models/Invoice.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { column } from '@simonbackx/simple-database';
|
|
2
|
-
import { Company, File,
|
|
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
|
|