@stamhoofd/backend 2.129.3 → 2.131.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/admin/organizations/GetOrganizationsEndpoint.test.ts +324 -2
- 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 +44 -3
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +61 -2
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +14 -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 +5 -7
- 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/migrations/1783342463-remove-platform-uitpas-flag.ts +25 -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/searchUitpasEvents.ts +22 -18
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/src/sql-filters/organizations.ts +52 -0
- package/tests/e2e/documents.test.ts +3 -1
- 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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { AutoEncoder, DateDecoder, field, IntegerDecoder } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Platform } from '@stamhoofd/models';
|
|
7
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
8
|
+
|
|
9
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
10
|
+
import { StripePayoutReporter } from '../../../../helpers/StripePayoutReporter.js';
|
|
11
|
+
|
|
12
|
+
export class PayoutExportStatus extends AutoEncoder {
|
|
13
|
+
@field({ decoder: DateDecoder })
|
|
14
|
+
start: Date;
|
|
15
|
+
|
|
16
|
+
@field({ decoder: DateDecoder })
|
|
17
|
+
end: Date;
|
|
18
|
+
|
|
19
|
+
@field({ decoder: IntegerDecoder })
|
|
20
|
+
count = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Params = Record<string, never>;
|
|
24
|
+
class Body extends AutoEncoder {
|
|
25
|
+
@field({ decoder: DateDecoder })
|
|
26
|
+
start: Date;
|
|
27
|
+
|
|
28
|
+
@field({ decoder: DateDecoder })
|
|
29
|
+
end: Date;
|
|
30
|
+
}
|
|
31
|
+
type Query = undefined;
|
|
32
|
+
type ResponseBody = undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Manually build a Stripe payout report for a given period and email it (to check whether
|
|
36
|
+
* everything we charged via Stripe application fees was invoiced correctly).
|
|
37
|
+
*/
|
|
38
|
+
export class StripePayoutsExportEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
39
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
40
|
+
|
|
41
|
+
static queue: PayoutExportStatus[] = [];
|
|
42
|
+
|
|
43
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
44
|
+
if (request.method !== 'POST') {
|
|
45
|
+
return [false];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const params = Endpoint.parseParameters(request.url, '/stripe/payouts', {});
|
|
49
|
+
|
|
50
|
+
if (params) {
|
|
51
|
+
return [true, params as Params];
|
|
52
|
+
}
|
|
53
|
+
return [false];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async authenticate() {
|
|
57
|
+
const organization = await Context.setOrganizationScope();
|
|
58
|
+
const { user } = await Context.authenticate();
|
|
59
|
+
|
|
60
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
61
|
+
throw Context.auth.error();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const platform = await Platform.getShared();
|
|
65
|
+
if (!platform.membershipOrganizationId || platform.membershipOrganizationId !== organization.id) {
|
|
66
|
+
throw new SimpleError({
|
|
67
|
+
code: 'not_available',
|
|
68
|
+
message: 'Stripe payout exports are only available for the platform membership organization',
|
|
69
|
+
statusCode: 400,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { organization, user };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
77
|
+
const { organization, user } = await StripePayoutsExportEndpoint.authenticate();
|
|
78
|
+
|
|
79
|
+
if (!STAMHOOFD.STRIPE_SECRET_KEY) {
|
|
80
|
+
throw new SimpleError({
|
|
81
|
+
code: 'not_configured',
|
|
82
|
+
message: 'Stripe is not configured',
|
|
83
|
+
statusCode: 400,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const secretKey = STAMHOOFD.STRIPE_SECRET_KEY;
|
|
87
|
+
|
|
88
|
+
const start = request.body.start;
|
|
89
|
+
const end = request.body.end;
|
|
90
|
+
|
|
91
|
+
const item = PayoutExportStatus.create({
|
|
92
|
+
start,
|
|
93
|
+
end,
|
|
94
|
+
count: 0,
|
|
95
|
+
});
|
|
96
|
+
StripePayoutsExportEndpoint.queue.push(item);
|
|
97
|
+
|
|
98
|
+
// Schedule
|
|
99
|
+
QueueHandler.schedule('stripe-payout-export', async () => {
|
|
100
|
+
try {
|
|
101
|
+
const reporter = new StripePayoutReporter({
|
|
102
|
+
secretKey,
|
|
103
|
+
sellingOrganization: organization,
|
|
104
|
+
});
|
|
105
|
+
let count = 0;
|
|
106
|
+
reporter.callback = () => {
|
|
107
|
+
count++;
|
|
108
|
+
item.count = count;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await reporter.build(start, end);
|
|
112
|
+
|
|
113
|
+
// Send the report to the user that requested it
|
|
114
|
+
await reporter.sendEmail({
|
|
115
|
+
to: [{ email: user.email, name: user.name }],
|
|
116
|
+
});
|
|
117
|
+
} finally {
|
|
118
|
+
// Remove from queue
|
|
119
|
+
StripePayoutsExportEndpoint.queue.splice(StripePayoutsExportEndpoint.queue.indexOf(item), 1);
|
|
120
|
+
}
|
|
121
|
+
}).catch(console.error);
|
|
122
|
+
|
|
123
|
+
return new Response(undefined);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -514,6 +514,9 @@ export class AuthenticatedStructures {
|
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
for (const member of members) {
|
|
517
|
+
if (member.organizationId) {
|
|
518
|
+
organizationIds.push(member.organizationId);
|
|
519
|
+
}
|
|
517
520
|
for (const registration of member.registrations) {
|
|
518
521
|
organizationIds.push(registration.organizationId);
|
|
519
522
|
}
|
|
@@ -620,14 +623,8 @@ export class AuthenticatedStructures {
|
|
|
620
623
|
group: Group;
|
|
621
624
|
})[] = [];
|
|
622
625
|
const userManager = Context.auth.isUserManager(member);
|
|
626
|
+
|
|
623
627
|
for (const registration of member.registrations) {
|
|
624
|
-
if (includeContextOrganization || registration.organizationId !== Context.auth.organization?.id) {
|
|
625
|
-
const found = organizations.get(registration.id);
|
|
626
|
-
if (!found) {
|
|
627
|
-
const organization = await Context.auth.getOrganization(registration.organizationId);
|
|
628
|
-
organizations.set(organization.id, organization);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
628
|
if (organizations.get(registration.organizationId)?.active || (Context.auth.organization && Context.auth.organization.active && registration.organizationId === Context.auth.organization.id) || await Context.auth.hasFullAccess(registration.organizationId)) {
|
|
632
629
|
if (
|
|
633
630
|
!!options?.forAdminCartCalculation
|
|
@@ -639,6 +636,7 @@ export class AuthenticatedStructures {
|
|
|
639
636
|
}
|
|
640
637
|
}
|
|
641
638
|
}
|
|
639
|
+
|
|
642
640
|
member.registrations = filtered;
|
|
643
641
|
const balancesPermission = (!!options?.forAdminCartCalculation) || await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id);
|
|
644
642
|
|
|
@@ -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
|
+
}
|