@stamhoofd/backend 2.130.0 → 2.132.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 +17 -17
- package/src/crons/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
- package/src/helpers/AuthenticatedStructures.ts +2 -2
- package/src/helpers/CheckSettlements.test.ts +190 -0
- package/src/helpers/CheckSettlements.ts +73 -45
- package/src/helpers/MembershipCharger.ts +26 -1
- package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
- package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
- package/src/helpers/StripePayoutExportData.ts +195 -0
- package/src/helpers/StripePayoutExportExcel.ts +280 -0
- package/src/helpers/StripePayoutReporter.test.ts +419 -0
- package/src/helpers/StripePayoutReporter.ts +585 -0
- package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
- package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
- package/src/services/uitpas/UitpasService.ts +1 -1
- package/src/services/uitpas/cancelTicketSales.ts +1 -1
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
- package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
- package/src/services/uitpas/registerTicketSales.ts +1 -1
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/tests/e2e/documents.test.ts +3 -1
- package/tests/helpers/MollieMocker.ts +73 -0
- package/tests/vitest.global.setup.ts +1 -1
- package/tests/vitest.setup.ts +4 -2
- package/vitest.config.js +10 -0
- /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
|
@@ -16,7 +16,11 @@ type MollieSettlement = {
|
|
|
16
16
|
};
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Both payments (tr_...) and refunds (re_...) settled in a settlement are matched to a local
|
|
21
|
+
* payment through the mollieId of their MolliePayment link, so we only need their id here.
|
|
22
|
+
*/
|
|
23
|
+
type MollieSettlementEntryJSON = {
|
|
20
24
|
id: string;
|
|
21
25
|
};
|
|
22
26
|
|
|
@@ -146,64 +150,88 @@ export async function checkMollieSettlementsFor(token: string, checkAll = false)
|
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
async function updateSettlement(token: string, settlement: MollieSettlement
|
|
153
|
+
async function updateSettlement(token: string, settlement: MollieSettlement) {
|
|
154
|
+
// Regular payments settled in this settlement
|
|
155
|
+
await updateSettlementResource(token, settlement, 'payments');
|
|
156
|
+
|
|
157
|
+
// Refunds settled in this settlement. These are negative entries linked to a refund payment
|
|
158
|
+
// (created by the mollie-refunds cron), so we can set their settlement metadata too.
|
|
159
|
+
await updateSettlementResource(token, settlement, 'refunds');
|
|
160
|
+
|
|
161
|
+
// Chargebacks settled in this settlement. Like refunds, these are negative entries linked to a
|
|
162
|
+
// chargeback payment (created by the mollie-chargebacks cron).
|
|
163
|
+
await updateSettlementResource(token, settlement, 'chargebacks');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Loop over all entries (payments, refunds or chargebacks) that are part of a settlement and set the
|
|
168
|
+
* settlement metadata on the matching local payment. Every resource exposes its entries under
|
|
169
|
+
* `_embedded[resource]` and uses the same pagination, so they share this logic.
|
|
170
|
+
*/
|
|
171
|
+
async function updateSettlementResource(token: string, settlement: MollieSettlement, resource: 'payments' | 'refunds' | 'chargebacks', fromId?: string) {
|
|
150
172
|
const limit = 250;
|
|
151
173
|
|
|
152
|
-
|
|
153
|
-
const request = await axios.get('https://api.mollie.com/v2/settlements/' + settlement.id + '/payments?limit=' + limit + (fromPaymentId ? ('&from=' + encodeURIComponent(fromPaymentId)) : ''), {
|
|
174
|
+
const request = await axios.get('https://api.mollie.com/v2/settlements/' + settlement.id + '/' + resource + '?limit=' + limit + (fromId ? ('&from=' + encodeURIComponent(fromId)) : ''), {
|
|
154
175
|
headers: {
|
|
155
176
|
Authorization: 'Bearer ' + token,
|
|
156
177
|
},
|
|
157
178
|
});
|
|
158
179
|
|
|
159
180
|
if (request.status === 200) {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
for (const mollie of molliePayments) {
|
|
163
|
-
// Search payment
|
|
164
|
-
const mps = await MolliePayment.where({ mollieId: mollie.id });
|
|
165
|
-
if (mps.length == 1) {
|
|
166
|
-
const mp = mps[0];
|
|
167
|
-
const payment = await Payment.getByID(mp.paymentId);
|
|
168
|
-
if (payment) {
|
|
169
|
-
payment.settlement = Settlement.create({
|
|
170
|
-
id: settlement.id,
|
|
171
|
-
reference: settlement.reference,
|
|
172
|
-
settledAt: new Date(settlement.settledAt),
|
|
173
|
-
amount: Math.round(parseFloat(settlement.amount.value) * 100) * 100,
|
|
174
|
-
});
|
|
175
|
-
const saved = await payment.save();
|
|
176
|
-
|
|
177
|
-
if (saved) {
|
|
178
|
-
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
179
|
-
const order = await Order.getForPayment(null, payment.id);
|
|
180
|
-
if (order) {
|
|
181
|
-
order.updatedAt = new Date();
|
|
182
|
-
order.forceSaveProperty('updatedAt');
|
|
183
|
-
await order.save();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// TODO: Mark registrations as 'saved'
|
|
187
|
-
}
|
|
181
|
+
const entries = request.data._embedded[resource] as MollieSettlementEntryJSON[];
|
|
188
182
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
console.log(payment.settlement);
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
console.log('Missing payment ' + mp.paymentId);
|
|
195
|
-
}
|
|
196
|
-
} else {
|
|
197
|
-
// Probably a payment in a different system/platform
|
|
198
|
-
// console.log("No mollie payment found for id "+mollie.id)
|
|
199
|
-
}
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
await applySettlementToPayment(settlement, entry.id);
|
|
200
185
|
}
|
|
201
186
|
|
|
202
187
|
// Check next page
|
|
203
|
-
if (request.data._links.next) {
|
|
204
|
-
await
|
|
188
|
+
if (request.data._links.next && entries.length > 0) {
|
|
189
|
+
await updateSettlementResource(token, settlement, resource, entries[entries.length - 1].id);
|
|
205
190
|
}
|
|
206
191
|
} else {
|
|
207
192
|
console.error(request.data);
|
|
208
193
|
}
|
|
209
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Find the local payment linked to a Mollie payment or refund id and store the settlement metadata.
|
|
198
|
+
*/
|
|
199
|
+
async function applySettlementToPayment(settlement: MollieSettlement, mollieId: string) {
|
|
200
|
+
// Search payment
|
|
201
|
+
const mps = await MolliePayment.where({ mollieId });
|
|
202
|
+
if (mps.length === 1) {
|
|
203
|
+
const mp = mps[0];
|
|
204
|
+
const payment = await Payment.getByID(mp.paymentId);
|
|
205
|
+
if (payment) {
|
|
206
|
+
payment.settlement = Settlement.create({
|
|
207
|
+
id: settlement.id,
|
|
208
|
+
reference: settlement.reference,
|
|
209
|
+
settledAt: new Date(settlement.settledAt),
|
|
210
|
+
amount: Math.round(parseFloat(settlement.amount.value) * 100) * 100,
|
|
211
|
+
});
|
|
212
|
+
const saved = await payment.save();
|
|
213
|
+
|
|
214
|
+
if (saved) {
|
|
215
|
+
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
216
|
+
const order = await Order.getForPayment(null, payment.id);
|
|
217
|
+
if (order) {
|
|
218
|
+
order.updatedAt = new Date();
|
|
219
|
+
order.forceSaveProperty('updatedAt');
|
|
220
|
+
await order.save();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// TODO: Mark registrations as 'saved'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (STAMHOOFD.environment === 'development') {
|
|
227
|
+
console.log('Updated settlement of payment ' + payment.id);
|
|
228
|
+
console.log(payment.settlement);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
console.log('Missing payment ' + mp.paymentId);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
// Probably a payment in a different system/platform
|
|
235
|
+
// console.log("No mollie payment found for id "+mollieId)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
-
import { BalanceItem, Member, MemberPlatformMembership, Platform } from '@stamhoofd/models';
|
|
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
5
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -25,6 +25,25 @@ export const MembershipCharger = {
|
|
|
25
25
|
return platform.config.membershipTypes.find(t => t.id === id);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Cache the registration period relation per periodId, so we only load each period once.
|
|
29
|
+
const periodRelationCache = new Map<string, BalanceItemRelation | null>();
|
|
30
|
+
async function getPeriodRelation(periodId: string): Promise<BalanceItemRelation | null> {
|
|
31
|
+
if (periodRelationCache.has(periodId)) {
|
|
32
|
+
return periodRelationCache.get(periodId)!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const period = await RegistrationPeriod.getByID(periodId);
|
|
36
|
+
const relation = period
|
|
37
|
+
? BalanceItemRelation.create({
|
|
38
|
+
id: period.id,
|
|
39
|
+
name: new TranslatedString(period.getBaseStructure().name),
|
|
40
|
+
})
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
periodRelationCache.set(periodId, relation);
|
|
44
|
+
return relation;
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
let createdCount = 0;
|
|
29
48
|
let createdPrice = 0;
|
|
30
49
|
const chunkSize = 100;
|
|
@@ -114,6 +133,12 @@ export const MembershipCharger = {
|
|
|
114
133
|
],
|
|
115
134
|
]);
|
|
116
135
|
|
|
136
|
+
// Add the registration period (working year) relation so it is visible in the item description.
|
|
137
|
+
const periodRelation = await getPeriodRelation(membership.periodId);
|
|
138
|
+
if (periodRelation) {
|
|
139
|
+
balanceItem.relations.set(BalanceItemRelationType.RegistrationPeriod, periodRelation);
|
|
140
|
+
}
|
|
141
|
+
|
|
117
142
|
balanceItem.type = BalanceItemType.PlatformMembership;
|
|
118
143
|
balanceItem.organizationId = chargeVia;
|
|
119
144
|
balanceItem.payingOrganizationId = membership.organizationId;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { RegistrationPeriod } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItem, BalanceItemFactory, GroupFactory, MemberFactory, MemberPlatformMembership, OrganizationFactory, OrganizationRegistrationPeriodFactory, RegistrationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import { backfillRegistrationPeriodRelations } from './RegistrationPeriodRelationBackfiller.js';
|
|
7
|
+
|
|
8
|
+
describe('helper.RegistrationPeriodRelationBackfiller', () => {
|
|
9
|
+
let period: RegistrationPeriod;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
period = await new RegistrationPeriodFactory({
|
|
13
|
+
startDate: new Date(2024, 0, 1),
|
|
14
|
+
endDate: new Date(2025, 11, 31),
|
|
15
|
+
}).create();
|
|
16
|
+
|
|
17
|
+
// A deterministic name so we can assert on it.
|
|
18
|
+
period.customName = 'Werkjaar 2024-2025';
|
|
19
|
+
await period.save();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('fills the registration period relation for registration balance items', async () => {
|
|
27
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
28
|
+
await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
|
|
29
|
+
|
|
30
|
+
const member = await new MemberFactory({ organization }).create();
|
|
31
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
32
|
+
const registration = await new RegistrationFactory({ member, group }).create();
|
|
33
|
+
|
|
34
|
+
const balanceItem = await new BalanceItemFactory({
|
|
35
|
+
organizationId: organization.id,
|
|
36
|
+
memberId: member.id,
|
|
37
|
+
registrationId: registration.id,
|
|
38
|
+
type: BalanceItemType.Registration,
|
|
39
|
+
amount: 1,
|
|
40
|
+
unitPrice: 25_00,
|
|
41
|
+
}).create();
|
|
42
|
+
|
|
43
|
+
expect(balanceItem.relations.has(BalanceItemRelationType.RegistrationPeriod)).toBe(false);
|
|
44
|
+
|
|
45
|
+
await backfillRegistrationPeriodRelations();
|
|
46
|
+
|
|
47
|
+
const updated = await BalanceItem.getByID(balanceItem.id);
|
|
48
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)).toMatchObject({ id: period.id });
|
|
49
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)?.name.toString()).toBe('Werkjaar 2024-2025');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('fills the registration period relation for platform membership balance items', async () => {
|
|
53
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
54
|
+
const member = await new MemberFactory({ organization }).create();
|
|
55
|
+
|
|
56
|
+
const balanceItem = await new BalanceItemFactory({
|
|
57
|
+
organizationId: organization.id,
|
|
58
|
+
memberId: member.id,
|
|
59
|
+
type: BalanceItemType.PlatformMembership,
|
|
60
|
+
amount: 1,
|
|
61
|
+
unitPrice: 15_00,
|
|
62
|
+
}).create();
|
|
63
|
+
|
|
64
|
+
const membership = new MemberPlatformMembership();
|
|
65
|
+
membership.memberId = member.id;
|
|
66
|
+
membership.membershipTypeId = uuidv4();
|
|
67
|
+
membership.organizationId = organization.id;
|
|
68
|
+
membership.periodId = period.id;
|
|
69
|
+
membership.startDate = new Date(2024, 0, 1);
|
|
70
|
+
membership.endDate = new Date(2025, 11, 31);
|
|
71
|
+
membership.balanceItemId = balanceItem.id;
|
|
72
|
+
await membership.save();
|
|
73
|
+
|
|
74
|
+
await backfillRegistrationPeriodRelations();
|
|
75
|
+
|
|
76
|
+
const updated = await BalanceItem.getByID(balanceItem.id);
|
|
77
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)).toMatchObject({ id: period.id });
|
|
78
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)?.name.toString()).toBe('Werkjaar 2024-2025');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not overwrite an existing registration period relation', async () => {
|
|
82
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
83
|
+
await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
|
|
84
|
+
|
|
85
|
+
const member = await new MemberFactory({ organization }).create();
|
|
86
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
87
|
+
const registration = await new RegistrationFactory({ member, group }).create();
|
|
88
|
+
|
|
89
|
+
const existingRelations = new Map([
|
|
90
|
+
[
|
|
91
|
+
BalanceItemRelationType.RegistrationPeriod,
|
|
92
|
+
BalanceItemRelation.create({ id: period.id, name: new TranslatedString('Custom period name') }),
|
|
93
|
+
],
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const balanceItem = await new BalanceItemFactory({
|
|
97
|
+
organizationId: organization.id,
|
|
98
|
+
memberId: member.id,
|
|
99
|
+
registrationId: registration.id,
|
|
100
|
+
type: BalanceItemType.Registration,
|
|
101
|
+
amount: 1,
|
|
102
|
+
unitPrice: 25_00,
|
|
103
|
+
relations: existingRelations,
|
|
104
|
+
}).create();
|
|
105
|
+
|
|
106
|
+
await backfillRegistrationPeriodRelations();
|
|
107
|
+
|
|
108
|
+
const updated = await BalanceItem.getByID(balanceItem.id);
|
|
109
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)?.name.toString()).toBe('Custom period name');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { BalanceItem, MemberPlatformMembership, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
import { SeedTools } from './SeedTools.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backfills the `RegistrationPeriod` relation on existing balance items.
|
|
8
|
+
*
|
|
9
|
+
* The relation contains the name of the registration period (working year) connected to a registration
|
|
10
|
+
* (or its options / bundle discount) and to a platform membership. It was added after these balance items
|
|
11
|
+
* were created, so we reconstruct it from the linked registration or membership.
|
|
12
|
+
*
|
|
13
|
+
* Only balance items that don't have the relation yet are touched, so the migration is idempotent and never
|
|
14
|
+
* overwrites relations that were already set by the current flows.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Loads the registration period relation once per periodId.
|
|
19
|
+
*/
|
|
20
|
+
class PeriodRelationCache {
|
|
21
|
+
private cache = new Map<string, BalanceItemRelation | null>();
|
|
22
|
+
|
|
23
|
+
async get(periodId: string): Promise<BalanceItemRelation | null> {
|
|
24
|
+
if (this.cache.has(periodId)) {
|
|
25
|
+
return this.cache.get(periodId)!;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const period = await RegistrationPeriod.getByID(periodId);
|
|
29
|
+
const relation = period
|
|
30
|
+
? BalanceItemRelation.create({
|
|
31
|
+
id: period.id,
|
|
32
|
+
name: new TranslatedString(period.getBaseStructure().name),
|
|
33
|
+
})
|
|
34
|
+
: null;
|
|
35
|
+
|
|
36
|
+
this.cache.set(periodId, relation);
|
|
37
|
+
return relation;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function backfillRegistrationBalanceItems(cache: PeriodRelationCache): Promise<number> {
|
|
42
|
+
let updated = 0;
|
|
43
|
+
|
|
44
|
+
await SeedTools.loopBatched({
|
|
45
|
+
batchSize: 100,
|
|
46
|
+
query: BalanceItem.select().whereNot('registrationId', null),
|
|
47
|
+
batchAction: async (items: BalanceItem[]) => {
|
|
48
|
+
const registrationIds = Formatter.uniqueArray(items.map(i => i.registrationId!).filter(id => !!id));
|
|
49
|
+
const registrations = await Registration.getByIDs(...registrationIds);
|
|
50
|
+
const registrationMap = new Map(registrations.map(r => [r.id, r]));
|
|
51
|
+
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
// Never overwrite a relation that is already set.
|
|
54
|
+
if (item.relations.has(BalanceItemRelationType.RegistrationPeriod)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const registration = registrationMap.get(item.registrationId!);
|
|
59
|
+
if (!registration) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const relation = await cache.get(registration.periodId);
|
|
64
|
+
if (!relation) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
item.relations.set(BalanceItemRelationType.RegistrationPeriod, relation);
|
|
69
|
+
await item.save();
|
|
70
|
+
updated += 1;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return updated;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function backfillPlatformMembershipBalanceItems(cache: PeriodRelationCache): Promise<number> {
|
|
79
|
+
let updated = 0;
|
|
80
|
+
|
|
81
|
+
await SeedTools.loopBatched({
|
|
82
|
+
batchSize: 100,
|
|
83
|
+
query: BalanceItem.select().where('type', BalanceItemType.PlatformMembership),
|
|
84
|
+
batchAction: async (items: BalanceItem[]) => {
|
|
85
|
+
const balanceItemIds = items.map(i => i.id);
|
|
86
|
+
const memberships = await MemberPlatformMembership.select()
|
|
87
|
+
.where('balanceItemId', balanceItemIds)
|
|
88
|
+
.limit(balanceItemIds.length)
|
|
89
|
+
.fetch();
|
|
90
|
+
const membershipByBalanceItemId = new Map(memberships.filter(m => m.balanceItemId).map(m => [m.balanceItemId!, m]));
|
|
91
|
+
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
if (item.relations.has(BalanceItemRelationType.RegistrationPeriod)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const membership = membershipByBalanceItemId.get(item.id);
|
|
98
|
+
if (!membership) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const relation = await cache.get(membership.periodId);
|
|
103
|
+
if (!relation) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
item.relations.set(BalanceItemRelationType.RegistrationPeriod, relation);
|
|
108
|
+
await item.save();
|
|
109
|
+
updated += 1;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function backfillRegistrationPeriodRelations(): Promise<{ registrations: number; memberships: number }> {
|
|
118
|
+
const cache = new PeriodRelationCache();
|
|
119
|
+
const registrations = await backfillRegistrationBalanceItems(cache);
|
|
120
|
+
const memberships = await backfillPlatformMembershipBalanceItems(cache);
|
|
121
|
+
|
|
122
|
+
console.log(`[RegistrationPeriodRelationBackfiller] Updated ${registrations} registration balance items and ${memberships} platform membership balance items`);
|
|
123
|
+
|
|
124
|
+
return { registrations, memberships };
|
|
125
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { Invoice, Payment } from '@stamhoofd/models';
|
|
2
|
+
|
|
3
|
+
export enum StripePayoutItemType {
|
|
4
|
+
Invoice = 'Invoice',
|
|
5
|
+
StripeFees = 'StripeFees',
|
|
6
|
+
StripeReserved = 'StripeReserved',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class StripePayoutItemData {
|
|
10
|
+
name = '';
|
|
11
|
+
type = StripePayoutItemType.Invoice;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In platform price units (1/100 cent)
|
|
15
|
+
*/
|
|
16
|
+
amount = 0;
|
|
17
|
+
description = '';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stripe account id + payment reference: only set for Invoice items, used to group the same
|
|
21
|
+
* account/month combination across multiple payouts.
|
|
22
|
+
*/
|
|
23
|
+
accountId: string | null = null;
|
|
24
|
+
reference: string | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Payments created in the database for these application fees (can be uninvoiced)
|
|
28
|
+
*/
|
|
29
|
+
payments: Payment[] = [];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Invoices connected to those payments (only invoices with a number)
|
|
33
|
+
*/
|
|
34
|
+
invoices: Invoice[] = [];
|
|
35
|
+
|
|
36
|
+
constructor(data: Partial<StripePayoutItemData>) {
|
|
37
|
+
Object.assign(this, data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get paymentsTotal() {
|
|
41
|
+
return this.payments.reduce((total, payment) => total + payment.price, 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get hasUninvoicedPayments() {
|
|
45
|
+
return this.payments.some(p => p.invoiceId === null);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class StripePayoutData {
|
|
50
|
+
id: string;
|
|
51
|
+
amount: number; // in platform price units (1/100 cent)
|
|
52
|
+
arrivalDate: Date;
|
|
53
|
+
statementDescriptor: string;
|
|
54
|
+
|
|
55
|
+
constructor(data: { id: string; amount: number; arrivalDate: Date; statementDescriptor: string }) {
|
|
56
|
+
this.id = data.id;
|
|
57
|
+
this.amount = data.amount;
|
|
58
|
+
this.arrivalDate = data.arrivalDate;
|
|
59
|
+
this.statementDescriptor = data.statementDescriptor;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class StripePayoutBreakdownData {
|
|
64
|
+
payout: StripePayoutData;
|
|
65
|
+
items: StripePayoutItemData[] = [];
|
|
66
|
+
|
|
67
|
+
constructor(data: { payout: StripePayoutData; items?: StripePayoutItemData[] }) {
|
|
68
|
+
this.payout = data.payout;
|
|
69
|
+
this.items = data.items ?? [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether the payout amount matches the sum of the items
|
|
74
|
+
*/
|
|
75
|
+
get isValid() {
|
|
76
|
+
return this.payout.amount === this.items.reduce((total, item) => total + item.amount, 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
isComplete(payoutExport: StripePayoutExportData) {
|
|
80
|
+
for (const item of this.items) {
|
|
81
|
+
if (item.type !== StripePayoutItemType.Invoice) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!payoutExport.isItemComplete(item)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class StripePayoutExportData {
|
|
94
|
+
/**
|
|
95
|
+
* All fetched payouts (we need to fetch more payouts than requested in order to complete all information,
|
|
96
|
+
* because the fees of a given month might have been paid out in other payouts than the requested ones)
|
|
97
|
+
*/
|
|
98
|
+
payouts: StripePayoutBreakdownData[] = [];
|
|
99
|
+
|
|
100
|
+
start: Date;
|
|
101
|
+
end: Date;
|
|
102
|
+
|
|
103
|
+
constructor(data: { start: Date; end: Date }) {
|
|
104
|
+
this.start = data.start;
|
|
105
|
+
this.end = data.end;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get includedPayouts() {
|
|
109
|
+
return this.payouts.filter(p => p.payout.arrivalDate >= this.start && p.payout.arrivalDate <= this.end);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* All payouts for which all application fees have a matching payment that has been invoiced
|
|
114
|
+
*/
|
|
115
|
+
get completePayouts() {
|
|
116
|
+
return this.includedPayouts.filter(p => p.isComplete(this));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An item is complete if all application fees for the account + month are covered by
|
|
121
|
+
* created payments, and all of those payments have been invoiced.
|
|
122
|
+
*/
|
|
123
|
+
isItemComplete(item: StripePayoutItemData) {
|
|
124
|
+
if (item.type !== StripePayoutItemType.Invoice) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (item.payments.length === 0) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (item.hasUninvoicedPayments || item.invoices.length === 0) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The sum of the application fees across all fetched payouts should equal the total amount
|
|
137
|
+
// of the created payments for this account + month
|
|
138
|
+
const totalAcrossPayouts = this.payouts
|
|
139
|
+
.flatMap(p => p.items)
|
|
140
|
+
.filter(i => i.accountId === item.accountId && i.reference === item.reference)
|
|
141
|
+
.reduce((total, i) => total + i.amount, 0);
|
|
142
|
+
|
|
143
|
+
return totalAcrossPayouts === item.paymentsTotal;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get totalPaidOut() {
|
|
147
|
+
return this.completePayouts.reduce((total, payout) => total + payout.payout.amount, 0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get totalStripeFees() {
|
|
151
|
+
return this.completePayouts.reduce((total, payout) => total + payout.items.filter(i => i.type === StripePayoutItemType.StripeFees).reduce((total, item) => total - item.amount, 0), 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get totalStripeReserved() {
|
|
155
|
+
return this.completePayouts.reduce((total, payout) => total + payout.items.filter(i => i.type === StripePayoutItemType.StripeReserved).reduce((total, item) => total - item.amount, 0), 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get totalInvoices() {
|
|
159
|
+
return this.completePayouts.reduce((total, payout) => total + payout.items.filter(i => i.type === StripePayoutItemType.Invoice).reduce((total, item) => total + item.amount, 0), 0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get totalVAT() {
|
|
163
|
+
let VAT = 0;
|
|
164
|
+
for (const payout of this.completePayouts) {
|
|
165
|
+
for (const item of payout.items) {
|
|
166
|
+
if (item.type !== StripePayoutItemType.Invoice) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const invoiceVAT = item.invoices.reduce((total, invoice) => total + invoice.VATTotalAmount, 0);
|
|
171
|
+
const invoiceTotal = item.invoices.reduce((total, invoice) => total + invoice.totalWithVAT, 0);
|
|
172
|
+
|
|
173
|
+
if (invoiceTotal === 0) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Estimate applicable VAT based on the VAT rate of the connected invoices
|
|
178
|
+
// (an invoice can contain more than only these fees, so we apply the rate, not the absolute amount)
|
|
179
|
+
VAT += invoiceVAT / invoiceTotal * item.amount;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return Math.round(VAT);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get net() {
|
|
186
|
+
return this.totalPaidOut - this.totalVAT;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get isValid() {
|
|
190
|
+
return this.totalPaidOut === this.totalInvoices - this.totalStripeFees - this.totalStripeReserved
|
|
191
|
+
// Aggregate totals can mask payout-level mismatches that cancel each other out
|
|
192
|
+
&& this.includedPayouts.every(p => p.isValid)
|
|
193
|
+
&& this.completePayouts.length === this.includedPayouts.length;
|
|
194
|
+
}
|
|
195
|
+
}
|