@stamhoofd/backend 2.134.0 → 2.136.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/package.json +18 -17
- package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
- package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
- package/src/crons/index.ts +1 -0
- package/src/email-recipient-loaders/orders.ts +7 -9
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
- package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
- package/src/excel-loaders/members.test.ts +59 -0
- package/src/excel-loaders/members.ts +17 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -0
- package/src/helpers/GlobalHelper.ts +1 -1
- package/src/helpers/MemberMerger.test.ts +70 -2
- package/src/helpers/MemberMerger.ts +43 -1
- package/src/helpers/MemberUserSyncer.ts +5 -0
- package/src/helpers/MembershipCharger.test.ts +110 -0
- package/src/helpers/MembershipCharger.ts +12 -33
- package/src/helpers/UitpasTokenRepository.ts +12 -11
- package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
- package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
- package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
- package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
- package/src/services/BalanceItemService.ts +1 -1
- package/src/services/PaymentService.ts +33 -8
- package/src/services/PlatformMembershipService.ts +2 -1
- package/src/services/RegistrationService.ts +5 -0
- package/src/services/uitpas/UitpasService.ts +3 -4
- package/src/sql-filters/members.ts +10 -0
- package/src/sql-filters/orders.ts +5 -0
- package/src/sql-sorters/members.ts +12 -1
- package/vitest.config.js +1 -0
|
@@ -2,16 +2,15 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
2
2
|
import { BalanceItem, Member, MemberPlatformMembership, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
3
3
|
import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
|
|
4
4
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
5
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
import { Formatter, sleep } from '@stamhoofd/utility';
|
|
6
6
|
|
|
7
7
|
export const MembershipCharger = {
|
|
8
8
|
async charge() {
|
|
9
9
|
console.log('Charging memberships...');
|
|
10
10
|
|
|
11
|
-
// Loop all
|
|
12
|
-
let lastId = '';
|
|
13
11
|
const platform = await Platform.getShared();
|
|
14
12
|
const chargeVia = platform.membershipOrganizationId;
|
|
13
|
+
const nextPeriodId = platform.nextPeriodId;
|
|
15
14
|
|
|
16
15
|
if (!chargeVia) {
|
|
17
16
|
throw new SimpleError({
|
|
@@ -46,27 +45,17 @@ export const MembershipCharger = {
|
|
|
46
45
|
|
|
47
46
|
let createdCount = 0;
|
|
48
47
|
let createdPrice = 0;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.where('id', SQLWhereSign.Greater, lastId)
|
|
54
|
-
.where('deletedAt', null)
|
|
55
|
-
.where('locked', false)
|
|
56
|
-
.where(SQL.where('trialUntil', null).or('trialUntil', SQLWhereSign.LessEqual, new Date()))
|
|
57
|
-
.limit(chunkSize)
|
|
58
|
-
.orderBy(
|
|
59
|
-
new SQLOrderBy({
|
|
60
|
-
column: SQL.column('id'),
|
|
61
|
-
direction: 'ASC',
|
|
62
|
-
}),
|
|
63
|
-
)
|
|
64
|
-
.fetch();
|
|
48
|
+
let q = MemberPlatformMembership.select()
|
|
49
|
+
.where('deletedAt', null)
|
|
50
|
+
.where('locked', false)
|
|
51
|
+
.where(SQL.where('trialUntil', null).or('trialUntil', SQLWhereSign.LessEqual, new Date()));
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
if (nextPeriodId) {
|
|
54
|
+
q = q.where('periodId', '!=', nextPeriodId);
|
|
55
|
+
}
|
|
69
56
|
|
|
57
|
+
// allBatched() paginates by id ASC internally, so we must not set a custom order by here.
|
|
58
|
+
for await (const memberships of q.limit(100).allBatched()) {
|
|
70
59
|
const memberIds = Formatter.uniqueArray(memberships.map(m => m.memberId));
|
|
71
60
|
const members = await Member.getByIDs(...memberIds);
|
|
72
61
|
const createdBalanceItems: BalanceItem[] = [];
|
|
@@ -153,17 +142,6 @@ export const MembershipCharger = {
|
|
|
153
142
|
createdCount += 1;
|
|
154
143
|
createdPrice += membership.price;
|
|
155
144
|
}
|
|
156
|
-
|
|
157
|
-
if (memberships.length < chunkSize) {
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const z = lastId;
|
|
162
|
-
lastId = memberships[memberships.length - 1].id;
|
|
163
|
-
|
|
164
|
-
if (lastId === z) {
|
|
165
|
-
throw new Error('Unexpected infinite loop found in MembershipCharger');
|
|
166
|
-
}
|
|
167
145
|
}
|
|
168
146
|
|
|
169
147
|
console.log('Charged ' + Formatter.integer(createdCount) + ' memberships, for a total value of ' + Formatter.price(createdPrice));
|
|
@@ -178,6 +156,7 @@ export const MembershipCharger = {
|
|
|
178
156
|
const chunkSize = 100;
|
|
179
157
|
|
|
180
158
|
while (true) {
|
|
159
|
+
await sleep(200);
|
|
181
160
|
const q = MemberPlatformMembership.select()
|
|
182
161
|
.where('id', SQLWhereSign.Greater, lastId)
|
|
183
162
|
.where('balanceItemId', null)
|
|
@@ -39,26 +39,27 @@ export class UitpasTokenRepository {
|
|
|
39
39
|
return await QueueHandler.schedule('uitpas/token-' + (organizationId ?? 'platform'), handler);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<
|
|
42
|
+
static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<void> {
|
|
43
43
|
if (!clientId || !clientSecret) { // empty strings
|
|
44
|
-
|
|
44
|
+
throw new SimpleError({
|
|
45
|
+
statusCode: 400,
|
|
46
|
+
code: 'invalid_uitpas_client_credentials',
|
|
47
|
+
message: `Empty UiTPAS client credentials`,
|
|
48
|
+
human: $t(`%ZdU`),
|
|
49
|
+
});
|
|
45
50
|
}
|
|
51
|
+
|
|
46
52
|
let model = new UitpasClientCredential();
|
|
47
53
|
model.organizationId = organizationId;
|
|
48
54
|
model.clientId = clientId;
|
|
49
55
|
model.clientSecret = clientSecret;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
await repo.getNewAccessToken();
|
|
54
|
-
} catch (e) {
|
|
55
|
-
return false; // not valid
|
|
56
|
-
}
|
|
56
|
+
await UitpasTokenRepository.handleInQueue(organizationId, async () => {
|
|
57
|
+
const repo = new UitpasTokenRepository(model);
|
|
58
|
+
await repo.getNewAccessToken();
|
|
57
59
|
// valid -> store
|
|
58
60
|
model = await UitpasTokenRepository.setModelInDb(organizationId, model);
|
|
59
61
|
repo.uitpasClientCredential = model; // update the uitpasClientCredential in the repo
|
|
60
|
-
|
|
61
|
-
return true;
|
|
62
|
+
UitpasTokenRepository.setRepoInMemory(organizationId, repo);
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
2
2
|
import type { PlatformMember } from '@stamhoofd/structures';
|
|
3
3
|
import {
|
|
4
|
+
EmergencyContact,
|
|
4
5
|
Group,
|
|
5
6
|
GroupCategory,
|
|
6
7
|
GroupCategorySettings,
|
|
@@ -166,4 +167,66 @@ describe('XlsxTransformerColumnHelper', () => {
|
|
|
166
167
|
expect(getColumn(ageGroupsCategory.id).getValue(member).value).toBe('Kapoenen, Welpen');
|
|
167
168
|
});
|
|
168
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('createColumnsForEmergencyContacts', () => {
|
|
172
|
+
function createMember(emergencyContacts: EmergencyContact[]) {
|
|
173
|
+
const organization = Organization.create({});
|
|
174
|
+
const blob = MembersBlob.create({
|
|
175
|
+
organizations: [organization],
|
|
176
|
+
members: [
|
|
177
|
+
MemberWithRegistrationsBlob.create({
|
|
178
|
+
details: MemberDetails.create({ firstName: 'John', lastName: 'Doe', emergencyContacts }),
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const family = PlatformFamily.create(blob, { platform: Platform.create({}), contextOrganization: organization });
|
|
184
|
+
return family.members[0];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getValue(member: PlatformMember, id: string) {
|
|
188
|
+
const columns = XlsxTransformerColumnHelper.createColumnsForEmergencyContacts();
|
|
189
|
+
const column = columns.find(c => 'id' in c && c.id === id) as XlsxTransformerConcreteColumn<PlatformMember> | undefined;
|
|
190
|
+
|
|
191
|
+
if (!column) {
|
|
192
|
+
throw new Error(`Column ${id} not found`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return column.getValue(member).value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it('returns the values of the first two emergency contacts', () => {
|
|
199
|
+
const member = createMember([
|
|
200
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
|
|
201
|
+
EmergencyContact.create({ name: 'Jan Janssens', title: 'Buur', phone: '0470 65 43 21' }),
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
expect(getValue(member, 'emergencyContact.0.name')).toBe('An Peeters');
|
|
205
|
+
expect(getValue(member, 'emergencyContact.0.title')).toBe('Oma');
|
|
206
|
+
expect(getValue(member, 'emergencyContact.0.phone')).toBe('0470 12 34 56');
|
|
207
|
+
|
|
208
|
+
expect(getValue(member, 'emergencyContact.1.name')).toBe('Jan Janssens');
|
|
209
|
+
expect(getValue(member, 'emergencyContact.1.title')).toBe('Buur');
|
|
210
|
+
expect(getValue(member, 'emergencyContact.1.phone')).toBe('0470 65 43 21');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('returns empty values when the member has fewer emergency contacts', () => {
|
|
214
|
+
const member = createMember([
|
|
215
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
expect(getValue(member, 'emergencyContact.1.name')).toBe('');
|
|
219
|
+
expect(getValue(member, 'emergencyContact.1.title')).toBe('');
|
|
220
|
+
expect(getValue(member, 'emergencyContact.1.phone')).toBe('');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('returns an empty value for a contact without a phone number', () => {
|
|
224
|
+
const member = createMember([
|
|
225
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: null }),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
expect(getValue(member, 'emergencyContact.0.name')).toBe('An Peeters');
|
|
229
|
+
expect(getValue(member, 'emergencyContact.0.phone')).toBe('');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
169
232
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
2
2
|
import { isXlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
3
|
-
import type { Address, GroupCategory, Parent, PlatformMember, RecordAnswer, RecordSettings } from '@stamhoofd/structures';
|
|
3
|
+
import type { Address, EmergencyContact, GroupCategory, Parent, PlatformMember, RecordAnswer, RecordSettings } from '@stamhoofd/structures';
|
|
4
4
|
import { CountryHelper, ParentTypeHelper, RecordCategory, RecordType } from '@stamhoofd/structures';
|
|
5
5
|
import { Formatter } from '@stamhoofd/utility';
|
|
6
6
|
|
|
@@ -24,6 +24,52 @@ export class XlsxTransformerColumnHelper {
|
|
|
24
24
|
];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Emergency contacts are an unbounded array, so only the first two get their own columns. The combined
|
|
29
|
+
* 'emergencyContacts' column in the members loader lists all of them.
|
|
30
|
+
*/
|
|
31
|
+
static createColumnsForEmergencyContacts(): XlsxTransformerColumn<PlatformMember>[] {
|
|
32
|
+
return [
|
|
33
|
+
...this.createColumnsForEmergencyContact(0),
|
|
34
|
+
...this.createColumnsForEmergencyContact(1),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static createColumnsForEmergencyContact(contactIndex: number): XlsxTransformerColumn<PlatformMember>[] {
|
|
39
|
+
const getContact = (member: PlatformMember): EmergencyContact | null | undefined => member.patchedMember.details.emergencyContacts[contactIndex];
|
|
40
|
+
|
|
41
|
+
const identifier = `Noodcontact ${contactIndex + 1}`;
|
|
42
|
+
const getId = (value: string) => `emergencyContact.${contactIndex}.${value}`;
|
|
43
|
+
const getName = (value: string) => `${identifier} - ${value}`;
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
id: getId('name'),
|
|
48
|
+
name: getName($t(`%1Os`)),
|
|
49
|
+
width: 20,
|
|
50
|
+
getValue: (member: PlatformMember) => ({
|
|
51
|
+
value: getContact(member)?.name ?? '',
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: getId('title'),
|
|
56
|
+
name: getName($t(`%f5`)),
|
|
57
|
+
width: 20,
|
|
58
|
+
getValue: (member: PlatformMember) => ({
|
|
59
|
+
value: getContact(member)?.title ?? '',
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: getId('phone'),
|
|
64
|
+
name: getName($t(`%wD`)),
|
|
65
|
+
width: 20,
|
|
66
|
+
getValue: (member: PlatformMember) => ({
|
|
67
|
+
value: getContact(member)?.phone ?? '',
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
27
73
|
static createColumnsForAddresses<T>({ limit, getAddresses, matchIdStart }: { limit: number; getAddresses: (object: T) => Address[]; matchIdStart: string }): XlsxTransformerColumn<T>[] {
|
|
28
74
|
const result: XlsxTransformerColumn<unknown>[] = [];
|
|
29
75
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Organization, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { QueryableModel } from '@stamhoofd/sql';
|
|
4
|
+
import { PaymentStatus } from '@stamhoofd/structures';
|
|
5
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
+
import { SeedTools } from '../helpers/SeedTools.js';
|
|
7
|
+
import { PaymentService } from '../services/PaymentService.js';
|
|
8
|
+
|
|
9
|
+
export default new Migration(async () => {
|
|
10
|
+
if (STAMHOOFD.environment === 'test') {
|
|
11
|
+
console.log('skipped in tests');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log('Start marking zero-price payments as succeeded.');
|
|
16
|
+
|
|
17
|
+
let succeeded = 0;
|
|
18
|
+
|
|
19
|
+
const result = await SeedTools.loopBatched({
|
|
20
|
+
// Keyset pagination on id: marking a payment as succeeded drops it from this filter,
|
|
21
|
+
// but rows are never skipped because each next batch is fetched by id > lastId.
|
|
22
|
+
query: Payment.select()
|
|
23
|
+
.where('price', 0)
|
|
24
|
+
.where('status', [PaymentStatus.Created, PaymentStatus.Pending]),
|
|
25
|
+
batchSize: 100,
|
|
26
|
+
batchAction: async (payments: Payment[]) => {
|
|
27
|
+
// Load the organizations of this batch in bulk
|
|
28
|
+
const organizationIds = Formatter.uniqueArray(
|
|
29
|
+
payments.map(p => p.organizationId).filter((id): id is string => id !== null),
|
|
30
|
+
);
|
|
31
|
+
const organizations = organizationIds.length ? await Organization.getByIDs(...organizationIds) : [];
|
|
32
|
+
|
|
33
|
+
// Load the attached balance item payments of this batch in bulk, and collect the ids of
|
|
34
|
+
// the payments that move money on at least one balance item. Those need the PaymentService
|
|
35
|
+
// side effects (marking balance items as paid, flushing caches, ...); the rest can be
|
|
36
|
+
// updated directly.
|
|
37
|
+
const { balanceItemPayments } = await Payment.loadBalanceItems(payments);
|
|
38
|
+
const paymentIdsWithNonZeroBalanceItemPayment = new Set(
|
|
39
|
+
balanceItemPayments.filter(bip => bip.price !== 0).map(bip => bip.paymentId),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
for (const payment of payments) {
|
|
43
|
+
// Only route through the PaymentService when the payment moves money on at least one
|
|
44
|
+
// balance item. When all attached balance item payments are zero (or there are none),
|
|
45
|
+
// update the status directly to avoid those side effects.
|
|
46
|
+
const hasNonZeroBalanceItemPayment = paymentIdsWithNonZeroBalanceItemPayment.has(payment.id);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (hasNonZeroBalanceItemPayment) {
|
|
50
|
+
if (!payment.organizationId) {
|
|
51
|
+
console.warn('Payment without organizationId, skipping', payment.id);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const organization = organizations.find(o => o.id === payment.organizationId);
|
|
56
|
+
if (!organization) {
|
|
57
|
+
console.warn('Organization not found for payment, skipping', payment.id, payment.organizationId);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Attribute the payment to its original date instead of the migration run date.
|
|
62
|
+
await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded, payment.createdAt);
|
|
63
|
+
} else {
|
|
64
|
+
// No side effects needed: update the status directly.
|
|
65
|
+
payment.status = PaymentStatus.Succeeded;
|
|
66
|
+
payment.paidAt = payment.createdAt;
|
|
67
|
+
await payment.save({
|
|
68
|
+
skipMarkSaved: true,
|
|
69
|
+
skipSendEvents: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
succeeded++;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Isolate failures per payment: one bad row should not abort (and wedge) the whole migration.
|
|
75
|
+
console.error('Failed to mark payment as succeeded, skipping', payment.id, e);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (QueryableModel.shutdownMigrations) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (QueryableModel.shutdownMigrations) {
|
|
84
|
+
throw new Error('Stopping migration gracefully');
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log(`Finished marking zero-price payments as succeeded: ${succeeded} of ${result.total} payments.`);
|
|
90
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { BalanceItem, Organization, Registration } from '@stamhoofd/models';
|
|
3
|
+
import { QueryableModel } from '@stamhoofd/sql';
|
|
4
|
+
import { BalanceItemStatus } from '@stamhoofd/structures';
|
|
5
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
+
import { SeedTools } from '../helpers/SeedTools.js';
|
|
7
|
+
import { BalanceItemService } from '../services/BalanceItemService.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registrations with a trial period that were made via the member portal were never activated: their
|
|
11
|
+
* balance items are only due after the trial, so they were not part of the payment, and nothing ever
|
|
12
|
+
* marked them valid. The result is a registration that exists in the database, but is invisible in the
|
|
13
|
+
* registration lists and the member portal, and a balance item that stays Hidden and is never billed.
|
|
14
|
+
*
|
|
15
|
+
* markPaid (without a payment) puts them in the exact same state as a registration made after the fix:
|
|
16
|
+
* it marks the balance item due (Hidden -> Due) and marks the registration valid without shortening the
|
|
17
|
+
* trial period.
|
|
18
|
+
*/
|
|
19
|
+
export async function fixInvisibleTrialRegistrations() {
|
|
20
|
+
let fixed = 0;
|
|
21
|
+
let skipped = 0;
|
|
22
|
+
let expired = 0;
|
|
23
|
+
|
|
24
|
+
const now = new Date();
|
|
25
|
+
|
|
26
|
+
const result = await SeedTools.loopBatched({
|
|
27
|
+
// Keyset pagination on id: activating a registration drops it from this filter, but rows are
|
|
28
|
+
// never skipped because each next batch is fetched by id > lastId.
|
|
29
|
+
//
|
|
30
|
+
// For now, only registrations whose trial is still running are activated. Activating one whose
|
|
31
|
+
// trial already ended would mark its balance item due right away (its dueAt is in the past), so
|
|
32
|
+
// the member would immediately owe the full price - as an overdue amount - for a trial they
|
|
33
|
+
// never got, since the registration was invisible to them the whole time. Most of the affected
|
|
34
|
+
// registrations are in that state, so this deliberately does not fix all of them yet: what
|
|
35
|
+
// should happen to them (bill them anyway, give them a new payment deadline, or don't charge
|
|
36
|
+
// them at all) is still to be decided. Leaving them untouched keeps that decision open, no data
|
|
37
|
+
// is lost and a later seed can still pick them up.
|
|
38
|
+
query: Registration.select()
|
|
39
|
+
.whereNot('trialUntil', null)
|
|
40
|
+
.where('registeredAt', null)
|
|
41
|
+
.where('deactivatedAt', null),
|
|
42
|
+
batchSize: 100,
|
|
43
|
+
batchAction: async (registrations: Registration[]) => {
|
|
44
|
+
const organizationIds = Formatter.uniqueArray(registrations.map(r => r.organizationId));
|
|
45
|
+
const organizations = organizationIds.length ? await Organization.getByIDs(...organizationIds) : [];
|
|
46
|
+
|
|
47
|
+
for (const registration of registrations) {
|
|
48
|
+
try {
|
|
49
|
+
const organization = organizations.find(o => o.id === registration.organizationId);
|
|
50
|
+
if (!organization) {
|
|
51
|
+
console.warn('Organization not found for registration, skipping', registration.id, registration.organizationId);
|
|
52
|
+
skipped++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The trial already ended. Activating it now would mark the balance item due right
|
|
57
|
+
// away (its dueAt is in the past), so the member would immediately owe the full
|
|
58
|
+
// price for a trial they never got. Leave it alone.
|
|
59
|
+
if (registration.trialUntil === null || registration.trialUntil <= now) {
|
|
60
|
+
expired++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The registration was invisible, so the member may have been registered for the same
|
|
65
|
+
// group again in the meantime. Activating this one would create a duplicate.
|
|
66
|
+
const activeDuplicates = await Registration.select()
|
|
67
|
+
.where('memberId', registration.memberId)
|
|
68
|
+
.where('groupId', registration.groupId)
|
|
69
|
+
.where('periodId', registration.periodId)
|
|
70
|
+
.whereNot('registeredAt', null)
|
|
71
|
+
.where('deactivatedAt', null)
|
|
72
|
+
.count();
|
|
73
|
+
|
|
74
|
+
if (activeDuplicates > 0) {
|
|
75
|
+
console.log('Member is already registered for this group again, skipping', registration.id);
|
|
76
|
+
skipped++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Do not send a registration confirmation email for a registration that was made a
|
|
81
|
+
// long time ago. The flag is only read when the registration is marked valid.
|
|
82
|
+
if (registration.sendConfirmationEmail) {
|
|
83
|
+
registration.sendConfirmationEmail = false;
|
|
84
|
+
await registration.save();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// All the balance items of a trial registration are deferred (Hidden + dueAt), so they
|
|
88
|
+
// all need to be marked due. The base and option items also mark the registration valid.
|
|
89
|
+
const balanceItems = await BalanceItem.select()
|
|
90
|
+
.where('registrationId', registration.id)
|
|
91
|
+
.where('status', BalanceItemStatus.Hidden)
|
|
92
|
+
.fetch();
|
|
93
|
+
|
|
94
|
+
for (const balanceItem of balanceItems) {
|
|
95
|
+
await BalanceItemService.markPaid(balanceItem, null, organization);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A trial registration always has at least one balance item, but don't leave the
|
|
99
|
+
// registration invisible if it somehow doesn't.
|
|
100
|
+
await registration.refresh();
|
|
101
|
+
if (registration.registeredAt === null) {
|
|
102
|
+
console.warn('Registration has no hidden balance items to activate it, skipping', registration.id);
|
|
103
|
+
skipped++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// markValid stamps registeredAt with the current date. Attribute the registration to
|
|
108
|
+
// the date it was really made instead of the date this migration runs.
|
|
109
|
+
registration.registeredAt = registration.createdAt;
|
|
110
|
+
await registration.save();
|
|
111
|
+
|
|
112
|
+
fixed++;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// Isolate failures per registration: one bad row should not abort (and wedge) the whole migration.
|
|
115
|
+
console.error('Failed to fix trial registration, skipping', registration.id, e);
|
|
116
|
+
skipped++;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (QueryableModel.shutdownMigrations) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (QueryableModel.shutdownMigrations) {
|
|
125
|
+
throw new Error('Stopping migration gracefully');
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
console.log(`Finished fixing invisible trial registrations: ${fixed} fixed, ${expired} left alone because their trial already ended, ${skipped} skipped of ${result.total} registrations.`);
|
|
131
|
+
|
|
132
|
+
return { fixed, skipped, expired };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default new Migration(async () => {
|
|
136
|
+
if (STAMHOOFD.environment === 'test') {
|
|
137
|
+
console.log('skipped in tests');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log('Start fixing invisible trial registrations.');
|
|
142
|
+
await fixInvisibleTrialRegistrations();
|
|
143
|
+
});
|
|
@@ -189,7 +189,7 @@ export class BalanceItemService {
|
|
|
189
189
|
userUpdateQueue.addItem(organizationId, userId);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
static
|
|
192
|
+
static scheduleMemberUpdate(organizationId: string, memberId: string) {
|
|
193
193
|
memberUpdateQueue.addItem(organizationId, memberId);
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -1208,16 +1208,38 @@ export class PaymentService {
|
|
|
1208
1208
|
privatePaymentConfiguration: PrivatePaymentConfiguration;
|
|
1209
1209
|
adminUserId?: string | null;
|
|
1210
1210
|
}) {
|
|
1211
|
-
|
|
1211
|
+
// Balance items that aren't due yet and that aren't being paid now (amount 0) are not part of
|
|
1212
|
+
// this payment: a registration in its trial period is the main example, nothing is due during
|
|
1213
|
+
// the trial. They still have to be marked due and valid, so the registration is activated and
|
|
1214
|
+
// the amount is billed once the trial ends. Marking them via markPaid means the 'paid' side
|
|
1215
|
+
// effects run exactly once (guarded by the paidAt column), also when they are really paid later.
|
|
1216
|
+
//
|
|
1217
|
+
// Items that the caller does pay (a non-zero amount) are always part of the payment, even when
|
|
1218
|
+
// they have a dueAt: balance items that are due soon are part of the outstanding balance and
|
|
1219
|
+
// can be paid before their due date (and are charged by direct debit).
|
|
1220
|
+
const notDueYetBalanceItems = [...balanceItems.keys()].filter(item => item.dueAt !== null && item.dueAt > new Date() && (balanceItems.get(item) ?? 0) === 0);
|
|
1221
|
+
const payableBalanceItems = new Map([...balanceItems].filter(([item]) => !notDueYetBalanceItems.includes(item)));
|
|
1222
|
+
|
|
1223
|
+
// Passing no payment keeps paid: false, so a trial period isn't shortened.
|
|
1224
|
+
const markNotDueYetValid = async () => {
|
|
1225
|
+
for (const balanceItem of notDueYetBalanceItems) {
|
|
1226
|
+
await BalanceItemService.markPaid(balanceItem, null, organization);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
if (payableBalanceItems.size === 0) {
|
|
1231
|
+
// Nothing to pay now, so no payment is created. The not-due-yet items still need to be
|
|
1232
|
+
// marked valid, otherwise their registration stays invisible.
|
|
1233
|
+
await markNotDueYetValid();
|
|
1212
1234
|
return null;
|
|
1213
1235
|
}
|
|
1214
1236
|
|
|
1215
1237
|
// Calculate total price to pay
|
|
1216
|
-
const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems, organization, members });
|
|
1238
|
+
const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems: payableBalanceItems, organization, members });
|
|
1217
1239
|
PaymentService.validateTotalPrice({ price, roundingAmount, checkout });
|
|
1218
1240
|
|
|
1219
1241
|
const { customer, prefix } = await this.validateCustomer({ user, checkout, payingOrganization });
|
|
1220
|
-
this.validateVATRates({ customer, sellingOrganization: organization, balanceItems });
|
|
1242
|
+
this.validateVATRates({ customer, sellingOrganization: organization, balanceItems: payableBalanceItems });
|
|
1221
1243
|
|
|
1222
1244
|
const { method, type, mandate } = await this.validatePaymentMethod({
|
|
1223
1245
|
method: checkout.paymentMethod ?? PaymentMethod.Unknown,
|
|
@@ -1226,7 +1248,7 @@ export class PaymentService {
|
|
|
1226
1248
|
customer,
|
|
1227
1249
|
price,
|
|
1228
1250
|
hasNegative,
|
|
1229
|
-
balanceItems,
|
|
1251
|
+
balanceItems: payableBalanceItems,
|
|
1230
1252
|
paymentConfiguration,
|
|
1231
1253
|
user,
|
|
1232
1254
|
payingOrganization: payingOrganization ?? null,
|
|
@@ -1283,7 +1305,7 @@ export class PaymentService {
|
|
|
1283
1305
|
|
|
1284
1306
|
payment.provider = provider;
|
|
1285
1307
|
payment.stripeAccountId = stripeAccount?.id ?? null;
|
|
1286
|
-
await ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...
|
|
1308
|
+
await ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...payableBalanceItems.entries()].map(([_, p]) => p));
|
|
1287
1309
|
await ServiceFeeHelper.setTransferFee({ payment, organization, stripeAccount });
|
|
1288
1310
|
|
|
1289
1311
|
// Add transfer description
|
|
@@ -1320,7 +1342,7 @@ export class PaymentService {
|
|
|
1320
1342
|
const description = organization.name + ' ' + payment.id;
|
|
1321
1343
|
|
|
1322
1344
|
try {
|
|
1323
|
-
for (const [balanceItem, price] of
|
|
1345
|
+
for (const [balanceItem, price] of payableBalanceItems) {
|
|
1324
1346
|
// Create one balance item payment to pay it in one payment
|
|
1325
1347
|
const balanceItemPayment = new BalanceItemPayment();
|
|
1326
1348
|
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
@@ -1332,7 +1354,7 @@ export class PaymentService {
|
|
|
1332
1354
|
}
|
|
1333
1355
|
|
|
1334
1356
|
// Update cached balance items pending amount (only created balance items, because those are involved in the payment)
|
|
1335
|
-
await BalanceItemService.updatePaidAndPending([...
|
|
1357
|
+
await BalanceItemService.updatePaidAndPending([...payableBalanceItems.keys()]);
|
|
1336
1358
|
|
|
1337
1359
|
// Update balance items
|
|
1338
1360
|
if (payment.method === PaymentMethod.Transfer) {
|
|
@@ -1445,6 +1467,9 @@ export class PaymentService {
|
|
|
1445
1467
|
throw e;
|
|
1446
1468
|
}
|
|
1447
1469
|
|
|
1470
|
+
// The items that aren't due yet are not paid by this payment, so they are marked valid separately
|
|
1471
|
+
await markNotDueYetValid();
|
|
1472
|
+
|
|
1448
1473
|
// TypeScript thinks status cannot change to Failed, but it can.
|
|
1449
1474
|
if (payment.status === PaymentStatus.Succeeded || (payment.status as PaymentStatus) === PaymentStatus.Failed) {
|
|
1450
1475
|
// force update
|
|
@@ -1454,7 +1479,7 @@ export class PaymentService {
|
|
|
1454
1479
|
} else if (payment.method === PaymentMethod.Transfer || payment.method === PaymentMethod.PointOfSale || payment.method === PaymentMethod.Unknown || (payment.method === PaymentMethod.DirectDebit && mandate)) {
|
|
1455
1480
|
// Mark valid (not same as paid) if needed
|
|
1456
1481
|
let hasBundleDiscount = false;
|
|
1457
|
-
for (const [balanceItem] of
|
|
1482
|
+
for (const [balanceItem] of payableBalanceItems) {
|
|
1458
1483
|
// Mark valid
|
|
1459
1484
|
await BalanceItemService.markPaid(balanceItem, payment, organization);
|
|
1460
1485
|
|
|
@@ -4,7 +4,7 @@ import { Group, Member, MemberPlatformMembership, Organization, Platform, Regist
|
|
|
4
4
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
5
|
import { SQL, SQLWhereSign } from '@stamhoofd/sql';
|
|
6
6
|
import { AuditLogSource, PlatformMembershipTypeBehaviour } from '@stamhoofd/structures';
|
|
7
|
-
import { Formatter, Sorter } from '@stamhoofd/utility';
|
|
7
|
+
import { Formatter, sleep, Sorter } from '@stamhoofd/utility';
|
|
8
8
|
import { AuditLogService } from './AuditLogService.js';
|
|
9
9
|
import { MemberNumberService } from './MemberNumberService.js';
|
|
10
10
|
|
|
@@ -50,6 +50,7 @@ export class PlatformMembershipService {
|
|
|
50
50
|
console.log('Starting updateAllMemberships');
|
|
51
51
|
await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
|
|
52
52
|
while (true) {
|
|
53
|
+
await sleep(200);
|
|
53
54
|
const rawMembers = await Member.where({
|
|
54
55
|
id: {
|
|
55
56
|
value: id,
|
|
@@ -70,6 +70,11 @@ export const RegistrationService = {
|
|
|
70
70
|
const registrationMemberRelation = new ManyToOneRelation(Member, 'member');
|
|
71
71
|
registrationMemberRelation.foreignKey = Member.registrations.foreignKey;
|
|
72
72
|
await Document.updateForRegistration(registration.setRelation(registrationMemberRelation, member));
|
|
73
|
+
|
|
74
|
+
// update lastRegisteredAt (should not be awaited)
|
|
75
|
+
member.tryUpdateLastRegisteredAt(registration)
|
|
76
|
+
// errors should never stop other logic, lastRegisteredAt is only used for sorting
|
|
77
|
+
.catch(console.error);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
// Update group occupancy
|
|
@@ -368,14 +368,13 @@ export class UitpasService {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
/**
|
|
371
|
-
* Store the uitpas client credentials if they are valid
|
|
371
|
+
* Store the uitpas client credentials if they are valid, throws otherwise
|
|
372
372
|
* @param organizationId null for platform
|
|
373
373
|
* @param clientId
|
|
374
374
|
* @param clientSecret
|
|
375
|
-
* @returns wether the credentials were valid and thus stored successfully
|
|
376
375
|
*/
|
|
377
|
-
static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<
|
|
378
|
-
|
|
376
|
+
static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<void> {
|
|
377
|
+
await UitpasTokenRepository.storeIfValid(organizationId, clientId, clientSecret);
|
|
379
378
|
}
|
|
380
379
|
|
|
381
380
|
static async clearClientCredentialsFor(organizationId: string | null) {
|