@stamhoofd/backend 2.140.0 → 2.142.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 (65) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  12. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  13. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  14. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  15. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  16. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  17. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  18. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  19. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  22. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  23. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  24. package/src/excel-loaders/payments.ts +18 -1
  25. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  26. package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
  27. package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
  28. package/src/helpers/AuthenticatedStructures.ts +14 -1
  29. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  30. package/src/helpers/MollieSettlementSync.ts +323 -0
  31. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  32. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  33. package/src/helpers/SettlementExporter.test.ts +388 -0
  34. package/src/helpers/SettlementExporter.ts +593 -0
  35. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  36. package/src/helpers/SettlementSyncRunner.ts +64 -0
  37. package/src/helpers/StripeHelper.ts +2 -1
  38. package/src/helpers/StripeSettlementSync.test.ts +997 -0
  39. package/src/helpers/StripeSettlementSync.ts +1053 -0
  40. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  41. package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
  42. package/src/helpers/WebmasterReport.test.ts +109 -0
  43. package/src/helpers/WebmasterReport.ts +115 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  45. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +301 -0
  48. package/src/services/InvoiceService.ts +18 -1
  49. package/src/services/SettlementService.test.ts +632 -0
  50. package/src/services/SettlementService.ts +650 -0
  51. package/src/sql-filters/payment-settlement.test.ts +2 -2
  52. package/src/sql-filters/payments.ts +73 -0
  53. package/tests/helpers/MollieMocker.ts +64 -6
  54. package/tests/helpers/StripeMocker.ts +209 -17
  55. package/src/crons/stripe-payout-reports.ts +0 -69
  56. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  57. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  58. package/src/helpers/CheckSettlements.test.ts +0 -190
  59. package/src/helpers/CheckSettlements.ts +0 -237
  60. package/src/helpers/StripeInvoicer.ts +0 -419
  61. package/src/helpers/StripePayoutChecker.ts +0 -193
  62. package/src/helpers/StripePayoutExportData.ts +0 -195
  63. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  64. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  65. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,104 @@
1
+ import type { Decoder } from '@simonbackx/simple-encoding';
2
+ import { ArrayDecoder, AutoEncoder, BooleanDecoder, DateDecoder, EnumDecoder, field } 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
+ import { PaymentProvider } from '@stamhoofd/structures';
9
+ import { SettlementsSyncStatus } from '@stamhoofd/structures/settlements/SettlementsSyncStatus.js';
10
+
11
+ import { Context } from '../../../../helpers/Context.js';
12
+ import { SettlementSyncRunner } from '../../../../helpers/SettlementSyncRunner.js';
13
+
14
+ type Params = Record<string, never>;
15
+ class Body extends AutoEncoder {
16
+ @field({ decoder: DateDecoder, optional: true })
17
+ start: Date = new Date(2025, 0, 1);
18
+
19
+ @field({ decoder: DateDecoder, nullable: true, optional: true })
20
+ end: Date | null = null;
21
+
22
+ @field({ decoder: new ArrayDecoder(new EnumDecoder(PaymentProvider)), nullable: true, optional: true })
23
+ providers: PaymentProvider[] | null = null;
24
+
25
+ @field({ decoder: BooleanDecoder, optional: true })
26
+ force = false;
27
+ }
28
+ type Query = undefined;
29
+ type ResponseBody = undefined;
30
+
31
+ /**
32
+ * Manually run the settlement sync (backfill) over a period. Everything is an upsert and
33
+ * already-synced settlements are skipped unless force, so re-running is cheap.
34
+ */
35
+ export class SettlementsSyncEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
36
+ bodyDecoder = Body as Decoder<Body>;
37
+
38
+ static queue: SettlementsSyncStatus[] = [];
39
+
40
+ protected doesMatch(request: Request): [true, Params] | [false] {
41
+ if (request.method !== 'POST') {
42
+ return [false];
43
+ }
44
+
45
+ const params = Endpoint.parseParameters(request.url, '/settlements/sync', {});
46
+
47
+ if (params) {
48
+ return [true, params as Params];
49
+ }
50
+ return [false];
51
+ }
52
+
53
+ /**
54
+ * A sync covers the provider accounts of the whole platform: only platform admins, and only
55
+ * scoped to the platform membership organization (the owner of the platform's own payouts).
56
+ */
57
+ static async authenticate() {
58
+ const organization = await Context.setOrganizationScope();
59
+ const { user } = await Context.authenticate();
60
+
61
+ if (!Context.auth.hasPlatformFullAccess()) {
62
+ throw Context.auth.error();
63
+ }
64
+
65
+ const platform = await Platform.getShared();
66
+ if (!platform.membershipOrganizationId || platform.membershipOrganizationId !== organization.id) {
67
+ throw new SimpleError({
68
+ code: 'not_available',
69
+ message: 'Settlement syncs are only available for the platform membership organization',
70
+ statusCode: 400,
71
+ });
72
+ }
73
+
74
+ return { organization, user };
75
+ }
76
+
77
+ async handle(request: DecodedRequest<Params, Query, Body>) {
78
+ await SettlementsSyncEndpoint.authenticate();
79
+
80
+ const { start, end, providers, force } = request.body;
81
+
82
+ const item = SettlementsSyncStatus.create({
83
+ start,
84
+ end,
85
+ force,
86
+ });
87
+ SettlementsSyncEndpoint.queue.push(item);
88
+
89
+ QueueHandler.schedule('settlement-sync', async () => {
90
+ try {
91
+ const runner = new SettlementSyncRunner();
92
+ runner.callback = (summary) => {
93
+ item.count = summary.synced + summary.skipped + summary.failed;
94
+ item.failed = summary.failed + summary.failedFeeMonths;
95
+ };
96
+ await runner.run({ start, end, providers, stripe: { force } });
97
+ } finally {
98
+ SettlementsSyncEndpoint.queue.splice(SettlementsSyncEndpoint.queue.indexOf(item), 1);
99
+ }
100
+ }).catch(console.error);
101
+
102
+ return new Response(undefined);
103
+ }
104
+ }
@@ -3,7 +3,7 @@ import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stam
3
3
  import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
4
4
  import { Order, StripeAccount } from '@stamhoofd/models';
5
5
  import type { OrderData } from '@stamhoofd/structures';
6
- import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
6
+ import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
  import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
9
9
  import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
@@ -594,6 +594,23 @@ function getSettlementColumns(): XlsxTransformerColumn<PaymentGeneral>[] {
594
594
  };
595
595
  },
596
596
  },
597
+ {
598
+ id: 'settlement.check',
599
+ name: $t('%ZkA'),
600
+ width: 24,
601
+ getValue: (object: PaymentGeneralWithStripeAccount) => {
602
+ // Only meaningful for fee payments: their payout lines are derived from the
603
+ // application fees they bill, so a matching sum means completely paid out. Other
604
+ // methods either don't pay out or always match
605
+ if (object.method !== PaymentMethod.AccountDeductions) {
606
+ return { value: '' };
607
+ }
608
+ const settled = object.settlements.reduce((total, line) => total + line.amount, 0);
609
+ return {
610
+ value: settled === object.price ? '✓' : $t('%Zk0'),
611
+ };
612
+ },
613
+ },
597
614
  ];
598
615
  }
599
616
 
@@ -0,0 +1,66 @@
1
+ import type Stripe from 'stripe';
2
+
3
+ /**
4
+ * The service/transfer split of one or more application fees. `fromStripe` is the single source of
5
+ * the split rule: the serviceFee metadata on the originating charge (in cents) is the service part,
6
+ * the remainder of the fee is the transfer part.
7
+ */
8
+ export class ApplicationFeeDetails {
9
+ transferFee = 0;
10
+ serviceFee = 0;
11
+ count = 0;
12
+ minimumDate: Date | null = null;
13
+ maximumDate: Date | null = null;
14
+
15
+ constructor(details: { count?: number; transferFee: number; serviceFee: number; minimumDate: Date | null; maximumDate: Date | null }) {
16
+ this.count = details.count ?? 0;
17
+ this.transferFee = details.transferFee;
18
+ this.serviceFee = details.serviceFee;
19
+ this.minimumDate = details.minimumDate;
20
+ this.maximumDate = details.maximumDate;
21
+ }
22
+
23
+ get amount() {
24
+ return this.transferFee + this.serviceFee;
25
+ }
26
+
27
+ static fromStripe(transaction: Pick<Stripe.BalanceTransaction, 'source' | 'amount' | 'created'>) {
28
+ const source = transaction.source as Stripe.ApplicationFee;
29
+
30
+ // Only a destination charge has an originating transaction on our own account. A direct
31
+ // charge (Standard accounts) keeps it on the connected account, where this walk can't read
32
+ // the metadata that splits the fee
33
+ const originatingTransaction = source.originating_transaction;
34
+ if (!originatingTransaction || typeof originatingTransaction === 'string') {
35
+ throw new Error('Application fee ' + source.id + ' has no expanded originating transaction, which is not supported for direct charges');
36
+ }
37
+
38
+ const metadata = (originatingTransaction as Stripe.Charge).metadata;
39
+
40
+ const serviceFeeStr = metadata.serviceFee as unknown;
41
+ if (serviceFeeStr === undefined || typeof serviceFeeStr !== 'string') {
42
+ throw new Error('Missing serviceFee metadata');
43
+ }
44
+
45
+ const parsed = parseInt(serviceFeeStr);
46
+ if (isNaN(parsed) || !isFinite(parsed)) {
47
+ throw new Error('Invalid serviceFee metadata');
48
+ }
49
+ const serviceFee = parsed * 100; // in cents
50
+ const transferFee = transaction.amount * 100 - serviceFee;
51
+
52
+ // Both parts are billed as their own balance item, so a negative one would invent a credit
53
+ // out of wrong metadata instead of failing
54
+ if (serviceFee < 0 || transferFee < 0) {
55
+ throw new Error('Application fee of ' + transaction.amount * 100 + ' does not cover its serviceFee of ' + serviceFee);
56
+ }
57
+
58
+ return new ApplicationFeeDetails({
59
+ count: 1,
60
+ serviceFee,
61
+ transferFee,
62
+ minimumDate: new Date(transaction.created * 1000),
63
+ maximumDate: new Date(transaction.created * 1000),
64
+ });
65
+ }
66
+ }
@@ -0,0 +1,346 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import type { StripeAccount } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
4
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
5
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
6
+ import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
7
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
8
+ import { Address, BalanceItemType, Company, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
9
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
10
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
11
+ import { Country } from '@stamhoofd/types/Country';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+
14
+ import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
15
+ import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
16
+ import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
17
+ import { SettlementService } from '../services/SettlementService.js';
18
+ import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
19
+ import { WebmasterReport } from './WebmasterReport.js';
20
+
21
+ describe('ApplicationFeeInvoicer', () => {
22
+ const stripeMocker = new StripeMocker();
23
+ let membershipOrganization: Organization;
24
+
25
+ // A month in the past that no other test writes fee rows in
26
+ const month = new Date(2024, 6, 1);
27
+ const occurredAt = new Date(2024, 6, 10);
28
+ const reference = 'application-fees-2024-07-01';
29
+
30
+ const belgianAddress = () => Address.create({
31
+ street: 'Teststraat',
32
+ number: '1',
33
+ postalCode: '9000',
34
+ city: 'Gent',
35
+ country: Country.Belgium,
36
+ });
37
+
38
+ beforeAll(async () => {
39
+ stripeMocker.start();
40
+
41
+ membershipOrganization = await initMembershipOrganization();
42
+ membershipOrganization.meta.companies = [
43
+ Company.create({
44
+ name: 'Platform BV',
45
+ companyNumber: '0700000000',
46
+ VATNumber: 'BE0700000000',
47
+ address: belgianAddress(),
48
+ }),
49
+ ];
50
+ await membershipOrganization.save();
51
+ });
52
+
53
+ afterAll(() => {
54
+ stripeMocker.stop();
55
+ });
56
+
57
+ beforeEach(async () => {
58
+ stripeMocker.clear();
59
+ ApplicationFeeService.resetWarnings();
60
+
61
+ // The test database persists across runs: leftover fees of this month would be billed again
62
+ await ApplicationFee.delete()
63
+ .where('occurredAt', '>=', month)
64
+ .where('occurredAt', '<', new Date(2024, 7, 1));
65
+ });
66
+
67
+ const init = async () => {
68
+ const organization = await new OrganizationFactory({}).create();
69
+ organization.meta.companies = [
70
+ Company.create({
71
+ name: 'Testvereniging VZW ' + organization.id,
72
+ companyNumber: '0500000000',
73
+ VATNumber: 'BE0500000000',
74
+ address: belgianAddress(),
75
+ }),
76
+ ];
77
+ await organization.save();
78
+
79
+ const stripeAccount = await stripeMocker.createStripeAccount(organization.id);
80
+ return { organization, stripeAccount };
81
+ };
82
+
83
+ const createFee = async (organization: Organization, stripeAccount: StripeAccount, { type = ApplicationFeeType.Service, amount = 30_00, settlement = null as Settlement | null, when = occurredAt } = {}) => {
84
+ const externalId = 'fee_' + uuidv4();
85
+ const charge = await SettlementService.upsertCharge({
86
+ type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
87
+ externalId: externalId + ':' + type,
88
+ amount: -amount,
89
+ applicationFeeId: externalId,
90
+ organizationId: organization.id,
91
+ stripeAccountId: stripeAccount.id,
92
+ occurredAt: when,
93
+ });
94
+
95
+ return await ApplicationFeeService.upsertFee({
96
+ externalId,
97
+ type,
98
+ amount,
99
+ organizationId: membershipOrganization.id,
100
+ payingOrganizationId: organization.id,
101
+ payingStripeAccountId: stripeAccount.id,
102
+ settlementChargeId: charge.id,
103
+ settlementId: settlement?.id ?? null,
104
+ occurredAt: when,
105
+ });
106
+ };
107
+
108
+ const getFeePayments = async (organization: Organization) => {
109
+ return await Payment.select()
110
+ .where('payingOrganizationId', organization.id)
111
+ .where('reference', reference)
112
+ .where('method', PaymentMethod.AccountDeductions)
113
+ .fetch();
114
+ };
115
+
116
+ const createInvoicer = () => new ApplicationFeeInvoicer({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
117
+
118
+ /**
119
+ * Bills the month as if the run started a second from now: fees stored in the current second
120
+ * are deliberately left for the next run, and a test stores them right before billing.
121
+ */
122
+ const invoiceMonth = async () => {
123
+ await createInvoicer().generateInvoicesForMonth(membershipOrganization, month, new Date(Date.now() + 1000));
124
+ };
125
+
126
+ test('a month is billed per paying account, and every fee carries its balance item', async () => {
127
+ const { organization, stripeAccount } = await init();
128
+ const serviceFee = await createFee(organization, stripeAccount, { type: ApplicationFeeType.Service, amount: 30_00 });
129
+ const transferFee = await createFee(organization, stripeAccount, { type: ApplicationFeeType.Transfer, amount: 2_20_00 });
130
+
131
+ await invoiceMonth();
132
+
133
+ const payments = await getFeePayments(organization);
134
+ expect(payments).toHaveLength(1);
135
+ expect(payments[0]).toMatchObject({
136
+ price: 2_50_00,
137
+ status: PaymentStatus.Succeeded,
138
+ provider: PaymentProvider.Stripe,
139
+ organizationId: membershipOrganization.id,
140
+ stripeAccountId: stripeAccount.id,
141
+ });
142
+
143
+ const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payments[0].id).fetch();
144
+ const balanceItems = await BalanceItem.select().where('id', balanceItemPayments.map(b => b.balanceItemId)).fetch();
145
+ const serviceItem = balanceItems.find(i => i.type === BalanceItemType.ServiceFee)!;
146
+ const transferItem = balanceItems.find(i => i.type === BalanceItemType.TransferFee)!;
147
+
148
+ expect(serviceItem.unitPrice).toBe(30_00);
149
+ expect(transferItem.unitPrice).toBe(2_20_00);
150
+ expect(serviceItem.organizationId).toBe(membershipOrganization.id);
151
+ expect(serviceItem.payingOrganizationId).toBe(organization.id);
152
+
153
+ expect((await ApplicationFee.getByID(serviceFee.id))!.balanceItemId).toBe(serviceItem.id);
154
+ expect((await ApplicationFee.getByID(transferFee.id))!.balanceItemId).toBe(transferItem.id);
155
+ });
156
+
157
+ test('fees of different accounts are billed separately', async () => {
158
+ const { organization, stripeAccount } = await init();
159
+ const second = await stripeMocker.createStripeAccount(organization.id);
160
+
161
+ await createFee(organization, stripeAccount, { amount: 30_00 });
162
+ await createFee(organization, second, { amount: 40_00 });
163
+
164
+ await invoiceMonth();
165
+
166
+ const payments = await getFeePayments(organization);
167
+ expect(payments).toHaveLength(2);
168
+ expect(payments.map(p => p.price).sort((a, b) => a - b)).toEqual([30_00, 40_00]);
169
+ expect(payments.map(p => p.stripeAccountId).sort()).toEqual([stripeAccount.id, second.id].sort());
170
+ });
171
+
172
+ test('re-running bills nothing more', async () => {
173
+ const { organization, stripeAccount } = await init();
174
+ await createFee(organization, stripeAccount);
175
+
176
+ await invoiceMonth();
177
+ await invoiceMonth();
178
+
179
+ expect(await getFeePayments(organization)).toHaveLength(1);
180
+ });
181
+
182
+ test('a fee that arrives after its month was billed lands in an extra payment', async () => {
183
+ const { organization, stripeAccount } = await init();
184
+ await createFee(organization, stripeAccount, { amount: 30_00 });
185
+
186
+ await invoiceMonth();
187
+
188
+ const late = await createFee(organization, stripeAccount, { amount: 5_00 });
189
+ await invoiceMonth();
190
+
191
+ const payments = await getFeePayments(organization);
192
+ expect(payments).toHaveLength(2);
193
+ expect(payments.map(p => p.price).sort((a, b) => a - b)).toEqual([5_00, 30_00]);
194
+ expect((await ApplicationFee.getByID(late.id))!.balanceItemId).not.toBeNull();
195
+ });
196
+
197
+ test('the fee payment is settled by the payouts that contained its fees', async () => {
198
+ const { organization, stripeAccount } = await init();
199
+ const payout = await SettlementService.upsertSettlement({
200
+ provider: PaymentProvider.Stripe,
201
+ externalId: 'po_' + uuidv4(),
202
+ stripeAccountId: null,
203
+ organizationId: membershipOrganization.id,
204
+ amount: 30_00,
205
+ settledAt: new Date(2024, 6, 20),
206
+ });
207
+ await createFee(organization, stripeAccount, { amount: 30_00, settlement: payout });
208
+
209
+ await invoiceMonth();
210
+
211
+ const payment = (await getFeePayments(organization))[0];
212
+ const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
213
+ expect(lines).toHaveLength(1);
214
+ expect(lines[0]).toMatchObject({ settlementId: payout.id, amount: 30_00 });
215
+
216
+ // Completely paid out: the lines add up to the payment
217
+ expect(lines.reduce((total, line) => total + line.amount, 0)).toBe(payment.price);
218
+ });
219
+
220
+ test('a month the legacy invoicer billed is never billed again', async () => {
221
+ const { organization, stripeAccount } = await init();
222
+
223
+ // A legacy payment without balance items: the inline linking can't reach it, so the fee
224
+ // stays uninvoiced and the invoicer may not bill it either
225
+ const legacy = new Payment();
226
+ legacy.organizationId = membershipOrganization.id;
227
+ legacy.payingOrganizationId = organization.id;
228
+ legacy.stripeAccountId = stripeAccount.id;
229
+ legacy.method = PaymentMethod.AccountDeductions;
230
+ legacy.provider = PaymentProvider.Stripe;
231
+ legacy.status = PaymentStatus.Succeeded;
232
+ legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
233
+ legacy.price = 30_00;
234
+ legacy.paidAt = occurredAt;
235
+ await legacy.save();
236
+
237
+ const fee = await createFee(organization, stripeAccount, { amount: 30_00 });
238
+ expect(fee.balanceItemId).toBeNull();
239
+
240
+ await invoiceMonth();
241
+
242
+ expect(await getFeePayments(organization)).toHaveLength(0);
243
+ expect((await ApplicationFee.getByID(fee.id))!.balanceItemId).toBeNull();
244
+ });
245
+
246
+ test('fees of the current month are not billed yet', async () => {
247
+ const { organization, stripeAccount } = await init();
248
+ const now = new Date();
249
+ await createFee(organization, stripeAccount, { when: new Date(now.getFullYear(), now.getMonth(), 1, 12) });
250
+
251
+ await createInvoicer().generateInvoices(membershipOrganization);
252
+
253
+ const payments = await Payment.select()
254
+ .where('payingOrganizationId', organization.id)
255
+ .where('method', PaymentMethod.AccountDeductions)
256
+ .fetch();
257
+ expect(payments).toHaveLength(0);
258
+ });
259
+
260
+ test('a broken account does not block the other accounts', async () => {
261
+ const { organization, stripeAccount } = await init();
262
+ const broken = await stripeMocker.createStripeAccount(organization.id);
263
+
264
+ await createFee(organization, stripeAccount, { amount: 30_00 });
265
+ await createFee(organization, broken, { amount: 40_00 });
266
+
267
+ // A month the legacy invoicer billed may never be billed again: that group throws
268
+ const legacy = new Payment();
269
+ legacy.organizationId = membershipOrganization.id;
270
+ legacy.payingOrganizationId = organization.id;
271
+ legacy.stripeAccountId = broken.id;
272
+ legacy.method = PaymentMethod.AccountDeductions;
273
+ legacy.provider = PaymentProvider.Stripe;
274
+ legacy.status = PaymentStatus.Succeeded;
275
+ legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
276
+ legacy.price = 40_00;
277
+ legacy.paidAt = occurredAt;
278
+ await legacy.save();
279
+
280
+ await invoiceMonth();
281
+
282
+ const payments = await getFeePayments(organization);
283
+ expect(payments).toHaveLength(1);
284
+ expect(payments[0].price).toBe(30_00);
285
+ });
286
+
287
+ test('fees without the Stripe account they were deducted from are skipped, not retried forever', async () => {
288
+ const { organization, stripeAccount } = await init();
289
+ const removed = await stripeMocker.createStripeAccount(organization.id);
290
+
291
+ await createFee(organization, stripeAccount, { amount: 30_00 });
292
+ const orphaned = await createFee(organization, removed, { amount: 40_00 });
293
+
294
+ // Deleting the account row clears payingStripeAccountId: that fee can no longer be checked
295
+ // against what the legacy invoicer billed per account, so it is not billed at all
296
+ await removed.delete();
297
+
298
+ await WebmasterReport.group('Overslaan applicatiekosten', async () => {
299
+ await invoiceMonth();
300
+ });
301
+
302
+ const payments = await getFeePayments(organization);
303
+ expect(payments).toHaveLength(1);
304
+ expect(payments[0].price).toBe(30_00);
305
+ expect((await ApplicationFee.getByID(orphaned.id))!.balanceItemId).toBeNull();
306
+
307
+ // Skipping is silent here: the sync that stored the fee already reported it, and this run
308
+ // repeats every night
309
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Overslaan applicatiekosten'));
310
+ expect(emails).toHaveLength(0);
311
+ });
312
+
313
+ test('charges of a billed fee keep pointing at the deduction row', async () => {
314
+ const { organization, stripeAccount } = await init();
315
+ const fee = await createFee(organization, stripeAccount);
316
+
317
+ await invoiceMonth();
318
+
319
+ const charge = await SettlementCharge.getByID(fee.settlementChargeId!);
320
+ expect(charge).toBeDefined();
321
+ expect(charge!.amount).toBe(-30_00);
322
+ });
323
+
324
+ test('fees of a deleted organization are never billed, and do not block the other accounts', async () => {
325
+ const { organization, stripeAccount } = await init();
326
+ const { organization: other, stripeAccount: otherAccount } = await init();
327
+
328
+ const orphaned = await createFee(organization, stripeAccount, { amount: 30_00 });
329
+ await createFee(other, otherAccount, { amount: 40_00 });
330
+
331
+ // Takes the Stripe account and the deduction charge with it, but not our income
332
+ await organization.delete();
333
+
334
+ await invoiceMonth();
335
+
336
+ const payments = await getFeePayments(other);
337
+ expect(payments).toHaveLength(1);
338
+ expect(payments[0].price).toBe(40_00);
339
+
340
+ const stored = await ApplicationFee.getByID(orphaned.id);
341
+ expect(stored).toBeDefined();
342
+ expect(stored!.payingOrganizationId).toBeNull();
343
+ expect(stored!.settlementChargeId).toBeNull();
344
+ expect(stored!.balanceItemId).toBeNull();
345
+ });
346
+ });