@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.
Files changed (42) hide show
  1. package/package.json +17 -17
  2. package/src/crons/index.ts +1 -0
  3. package/src/crons/stripe-payout-reports.ts +69 -0
  4. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  6. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  12. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +1 -1
  14. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  16. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  17. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  18. package/src/helpers/AuthenticatedStructures.ts +2 -2
  19. package/src/helpers/CheckSettlements.test.ts +190 -0
  20. package/src/helpers/CheckSettlements.ts +73 -45
  21. package/src/helpers/MembershipCharger.ts +26 -1
  22. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  23. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  24. package/src/helpers/StripePayoutExportData.ts +195 -0
  25. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  26. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  27. package/src/helpers/StripePayoutReporter.ts +585 -0
  28. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  29. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  30. package/src/services/uitpas/UitpasService.ts +1 -1
  31. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  32. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  33. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  34. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  35. package/src/services/uitpas/registerTicketSales.ts +1 -1
  36. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  37. package/tests/e2e/documents.test.ts +3 -1
  38. package/tests/helpers/MollieMocker.ts +73 -0
  39. package/tests/vitest.global.setup.ts +1 -1
  40. package/tests/vitest.setup.ts +4 -2
  41. package/vitest.config.js +10 -0
  42. /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
- type MolliePaymentJSON = {
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, fromPaymentId?: string) {
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
- // Loop all payments of this settlement
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 molliePayments = request.data._embedded.payments as MolliePaymentJSON[];
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
- if (STAMHOOFD.environment === 'development') {
190
- console.log('Updated settlement of payment ' + payment.id);
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 updateSettlement(token, settlement, molliePayments[molliePayments.length - 1].id);
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
+ }