@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,256 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { BalanceItem, BalanceItemPayment, Invoice, OrganizationFactory, Payment, StripeAccount } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
5
+ import { BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
6
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
7
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
11
+ import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from './ApplicationFeeService.js';
12
+ import { SettlementService } from './SettlementService.js';
13
+
14
+ describe('ApplicationFeeService', () => {
15
+ let membershipOrganization: Organization;
16
+
17
+ // A month the legacy invoicer would have billed
18
+ const occurredAt = new Date(2025, 2, 14);
19
+ const legacyReference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2025-03-01';
20
+
21
+ beforeAll(async () => {
22
+ membershipOrganization = await initMembershipOrganization();
23
+ });
24
+
25
+ beforeEach(() => {
26
+ ApplicationFeeService.resetWarnings();
27
+ });
28
+
29
+ const init = async () => {
30
+ const organization = await new OrganizationFactory({}).create();
31
+ const stripeAccount = new StripeAccount();
32
+ stripeAccount.organizationId = organization.id;
33
+ stripeAccount.accountId = 'acct_' + uuidv4();
34
+ await stripeAccount.save();
35
+ return { organization, stripeAccount };
36
+ };
37
+
38
+ /**
39
+ * The fee payment and balance items the legacy invoicer created for a month.
40
+ */
41
+ const createLegacyFeePayment = async (organization: Organization, stripeAccount: StripeAccount | null, { types = [BalanceItemType.ServiceFee, BalanceItemType.TransferFee], price = 2_50_00 } = {}) => {
42
+ const payment = new Payment();
43
+ payment.organizationId = membershipOrganization.id;
44
+ payment.payingOrganizationId = organization.id;
45
+ payment.stripeAccountId = stripeAccount?.id ?? null;
46
+ payment.method = PaymentMethod.AccountDeductions;
47
+ payment.provider = PaymentProvider.Stripe;
48
+ payment.status = PaymentStatus.Succeeded;
49
+ payment.reference = legacyReference;
50
+ payment.price = price;
51
+ payment.paidAt = occurredAt;
52
+ await payment.save();
53
+
54
+ const balanceItems: BalanceItem[] = [];
55
+ for (const type of types) {
56
+ const item = new BalanceItem();
57
+ item.type = type;
58
+ item.organizationId = membershipOrganization.id;
59
+ item.payingOrganizationId = organization.id;
60
+ item.unitPrice = price;
61
+ item.quantity = 1;
62
+ item.status = BalanceItemStatus.Hidden;
63
+ await item.save();
64
+ balanceItems.push(item);
65
+
66
+ const balanceItemPayment = new BalanceItemPayment();
67
+ balanceItemPayment.balanceItemId = item.id;
68
+ balanceItemPayment.paymentId = payment.id;
69
+ balanceItemPayment.organizationId = membershipOrganization.id;
70
+ balanceItemPayment.price = price;
71
+ await balanceItemPayment.save();
72
+ }
73
+
74
+ return { payment, balanceItems };
75
+ };
76
+
77
+ const upsertFee = async (organization: Organization, stripeAccount: StripeAccount, { type = ApplicationFeeType.Service, amount = 30_00 } = {}) => {
78
+ const externalId = 'fee_' + uuidv4();
79
+ const charge = await SettlementService.upsertCharge({
80
+ type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
81
+ externalId: externalId + ':' + type,
82
+ amount: -amount,
83
+ applicationFeeId: externalId,
84
+ organizationId: organization.id,
85
+ stripeAccountId: stripeAccount.id,
86
+ occurredAt,
87
+ });
88
+
89
+ const fee = await ApplicationFeeService.upsertFee({
90
+ externalId,
91
+ type,
92
+ amount,
93
+ organizationId: membershipOrganization.id,
94
+ payingOrganizationId: organization.id,
95
+ payingStripeAccountId: stripeAccount.id,
96
+ settlementChargeId: charge.id,
97
+ occurredAt,
98
+ });
99
+
100
+ return { fee, charge };
101
+ };
102
+
103
+ describe('legacy linking', () => {
104
+ test('a fee of a month the legacy invoicer billed is linked right away', async () => {
105
+ const { organization, stripeAccount } = await init();
106
+ const { balanceItems } = await createLegacyFeePayment(organization, stripeAccount);
107
+
108
+ const { fee } = await upsertFee(organization, stripeAccount, { type: ApplicationFeeType.Service });
109
+ const { fee: transferFee } = await upsertFee(organization, stripeAccount, { type: ApplicationFeeType.Transfer });
110
+
111
+ expect(fee.balanceItemId).toBe(balanceItems.find(i => i.type === BalanceItemType.ServiceFee)!.id);
112
+ expect(transferFee.balanceItemId).toBe(balanceItems.find(i => i.type === BalanceItemType.TransferFee)!.id);
113
+ });
114
+
115
+ test('legacy payments without a Stripe account are found too', async () => {
116
+ const { organization, stripeAccount } = await init();
117
+ const { balanceItems } = await createLegacyFeePayment(organization, null);
118
+
119
+ const { fee } = await upsertFee(organization, stripeAccount);
120
+
121
+ expect(fee.balanceItemId).toBe(balanceItems.find(i => i.type === BalanceItemType.ServiceFee)!.id);
122
+ });
123
+
124
+ test('a month that was never billed stays uninvoiced', async () => {
125
+ const { organization, stripeAccount } = await init();
126
+
127
+ const { fee } = await upsertFee(organization, stripeAccount);
128
+
129
+ expect(fee.balanceItemId).toBeNull();
130
+ });
131
+
132
+ test('a legacy payment without the matching balance item leaves the fee uninvoiced', async () => {
133
+ const { organization, stripeAccount } = await init();
134
+ await createLegacyFeePayment(organization, stripeAccount, { types: [BalanceItemType.ServiceFee] });
135
+
136
+ const { fee } = await upsertFee(organization, stripeAccount, { type: ApplicationFeeType.Transfer });
137
+
138
+ expect(fee.balanceItemId).toBeNull();
139
+ });
140
+
141
+ test('re-storing the same fee links it as soon as the legacy payment exists', async () => {
142
+ const { organization, stripeAccount } = await init();
143
+ const externalId = 'fee_' + uuidv4();
144
+ const charge = await SettlementService.upsertCharge({
145
+ type: SettlementChargeType.ApplicationFeeService,
146
+ externalId: externalId + ':' + ApplicationFeeType.Service,
147
+ amount: -30_00,
148
+ applicationFeeId: externalId,
149
+ organizationId: organization.id,
150
+ occurredAt,
151
+ });
152
+
153
+ const data = {
154
+ externalId,
155
+ type: ApplicationFeeType.Service,
156
+ amount: 30_00,
157
+ organizationId: membershipOrganization.id,
158
+ payingOrganizationId: organization.id,
159
+ payingStripeAccountId: stripeAccount.id,
160
+ settlementChargeId: charge.id,
161
+ occurredAt,
162
+ };
163
+
164
+ const unlinked = await ApplicationFeeService.upsertFee(data);
165
+ expect(unlinked.balanceItemId).toBeNull();
166
+
167
+ const { balanceItems } = await createLegacyFeePayment(organization, stripeAccount);
168
+ const linked = await ApplicationFeeService.upsertFee(data);
169
+
170
+ expect(linked.id).toBe(unlinked.id);
171
+ expect(linked.balanceItemId).toBe(balanceItems.find(i => i.type === BalanceItemType.ServiceFee)!.id);
172
+ });
173
+ });
174
+
175
+ describe('providerInvoiceId', () => {
176
+ const createInvoice = async (payment: Payment, number: string | null) => {
177
+ const invoice = new Invoice();
178
+ invoice.organizationId = membershipOrganization.id;
179
+ invoice.payingOrganizationId = payment.payingOrganizationId;
180
+ invoice.number = number;
181
+ invoice.totalWithVAT = payment.price;
182
+ await invoice.save();
183
+
184
+ payment.invoiceId = invoice.id;
185
+ await payment.save();
186
+ return invoice;
187
+ };
188
+
189
+ test('the invoice number is stamped on the deduction charge when the fee is linked', async () => {
190
+ const { organization, stripeAccount } = await init();
191
+ const { payment } = await createLegacyFeePayment(organization, stripeAccount);
192
+ await createInvoice(payment, '2025001');
193
+
194
+ const { charge } = await upsertFee(organization, stripeAccount);
195
+
196
+ expect((await SettlementCharge.getByID(charge.id))!.providerInvoiceId).toBe('2025001');
197
+ });
198
+
199
+ test('a draft invoice has no number to stamp yet', async () => {
200
+ const { organization, stripeAccount } = await init();
201
+ const { payment } = await createLegacyFeePayment(organization, stripeAccount);
202
+ await createInvoice(payment, null);
203
+
204
+ const { charge } = await upsertFee(organization, stripeAccount);
205
+
206
+ expect((await SettlementCharge.getByID(charge.id))!.providerInvoiceId).toBeNull();
207
+ });
208
+
209
+ test('stampInvoicedPayments stamps and clears the charges of an invoice', async () => {
210
+ const { organization, stripeAccount } = await init();
211
+ const { payment } = await createLegacyFeePayment(organization, stripeAccount);
212
+ const { charge, fee } = await upsertFee(organization, stripeAccount);
213
+ expect(fee.balanceItemId).not.toBeNull();
214
+
215
+ const invoice = await createInvoice(payment, '2025042');
216
+ await ApplicationFeeService.stampInvoicedPayments([payment], invoice);
217
+ expect((await SettlementCharge.getByID(charge.id))!.providerInvoiceId).toBe('2025042');
218
+
219
+ await ApplicationFeeService.stampInvoicedPayments([payment], null);
220
+ expect((await SettlementCharge.getByID(charge.id))!.providerInvoiceId).toBeNull();
221
+ });
222
+
223
+ test('an invoice without application fees is a no-op', async () => {
224
+ const { organization } = await init();
225
+ const payment = new Payment();
226
+ payment.organizationId = membershipOrganization.id;
227
+ payment.payingOrganizationId = organization.id;
228
+ payment.method = PaymentMethod.Transfer;
229
+ payment.status = PaymentStatus.Succeeded;
230
+ payment.price = 10_00;
231
+ await payment.save();
232
+
233
+ await expect(ApplicationFeeService.stampInvoicedPayments([payment], null)).resolves.toBeUndefined();
234
+ });
235
+ });
236
+
237
+ test('upserting the same fee twice keeps one row', async () => {
238
+ const { organization, stripeAccount } = await init();
239
+ const { fee } = await upsertFee(organization, stripeAccount);
240
+
241
+ const again = await ApplicationFeeService.upsertFee({
242
+ externalId: fee.externalId,
243
+ type: fee.type,
244
+ amount: 35_00,
245
+ organizationId: membershipOrganization.id,
246
+ payingOrganizationId: organization.id,
247
+ payingStripeAccountId: stripeAccount.id,
248
+ settlementChargeId: fee.settlementChargeId,
249
+ occurredAt,
250
+ });
251
+
252
+ expect(again.id).toBe(fee.id);
253
+ expect(again.amount).toBe(35_00);
254
+ expect(await ApplicationFee.select().where('externalId', fee.externalId).count()).toBe(1);
255
+ });
256
+ });
@@ -0,0 +1,301 @@
1
+ import { BalanceItem, BalanceItemPayment, Invoice, Payment } from '@stamhoofd/models';
2
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
3
+ import { SQL } from '@stamhoofd/sql';
4
+ import { BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
5
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
6
+ import { Formatter } from '@stamhoofd/utility';
7
+
8
+ import { WebmasterReport } from '../helpers/WebmasterReport.js';
9
+ import { SettlementService } from './SettlementService.js';
10
+
11
+ /**
12
+ * Reference prefix of the fee payments the legacy invoicer created, one per (account, month).
13
+ * Only used to detect them: new fee payments use FEE_PAYMENT_REFERENCE_PREFIX.
14
+ */
15
+ export const LEGACY_FEE_PAYMENT_REFERENCE_PREFIX = 'stripe-fees-';
16
+
17
+ export const FEE_PAYMENT_REFERENCE_PREFIX = 'application-fees-';
18
+
19
+ /**
20
+ * Fees read per batch when a whole invoice is stamped at once.
21
+ */
22
+ const FEE_BATCH_SIZE = 500;
23
+
24
+ export type ApplicationFeeData = {
25
+ externalId: string;
26
+ type: ApplicationFeeType;
27
+ amount: number;
28
+
29
+ /**
30
+ * The organization that charged (and receives) the fee.
31
+ */
32
+ organizationId: string;
33
+
34
+ payingOrganizationId?: string | null;
35
+ payingStripeAccountId?: string | null;
36
+ payingPaymentId?: string | null;
37
+
38
+ settlementChargeId?: string | null;
39
+ settlementId?: string | null;
40
+ occurredAt: Date;
41
+ };
42
+
43
+ /**
44
+ * All writes to application_fees go through this service (the settlement sweep's unlink excepted).
45
+ * Every write is an upsert on the unique (externalId, type) key, so re-running a sync can never
46
+ * duplicate rows.
47
+ *
48
+ * `balanceItemId === null` must always mean "not yet invoiced": upsertFee immediately links fees of
49
+ * months the legacy invoicer already billed (detected by the legacy payment's reference), so the
50
+ * invoicer only ever sees fees that still need billing.
51
+ */
52
+ export class ApplicationFeeService {
53
+ /**
54
+ * Emails about a legacy month that can't be linked are sent once per (account, month) per
55
+ * process, not once per fee.
56
+ */
57
+ private static reportedLegacyProblems = new Set<string>();
58
+
59
+ /**
60
+ * The legacy fee payment of a (paying account, month), remembered per process: a month has one
61
+ * answer for all of its fees, and a sync walks thousands of them. Only found payments are
62
+ * remembered, so a fee stored before its payment existed still links on the next pass.
63
+ */
64
+ private static legacyFeePayments = new Map<string, Payment[]>();
65
+
66
+ /**
67
+ * For tests only.
68
+ */
69
+ static resetWarnings() {
70
+ this.reportedLegacyProblems.clear();
71
+ this.legacyFeePayments.clear();
72
+ }
73
+
74
+ /**
75
+ * Upsert on (externalId, type). Fields that are undefined keep their stored value, so the fee
76
+ * walk (which doesn't know our platform payout yet) can't unlink a fee the payout sync attached
77
+ * earlier. balanceItemId is only written through markInvoiced or the inline legacy detection.
78
+ */
79
+ static async upsertFee(data: ApplicationFeeData): Promise<ApplicationFee> {
80
+ const fee = await ApplicationFee.select()
81
+ .where('externalId', data.externalId)
82
+ .where('type', data.type)
83
+ .first(false) ?? new ApplicationFee();
84
+
85
+ fee.externalId = data.externalId;
86
+ fee.type = data.type;
87
+ fee.amount = data.amount;
88
+ fee.organizationId = data.organizationId;
89
+ fee.occurredAt = data.occurredAt;
90
+
91
+ if (data.settlementId !== undefined) {
92
+ fee.settlementId = data.settlementId;
93
+ }
94
+
95
+ if (data.payingPaymentId !== undefined) {
96
+ fee.payingPaymentId = data.payingPaymentId;
97
+ }
98
+
99
+ if (data.settlementChargeId !== undefined) {
100
+ fee.settlementChargeId = data.settlementChargeId;
101
+ }
102
+
103
+ if (data.payingOrganizationId !== undefined) {
104
+ fee.payingOrganizationId = data.payingOrganizationId;
105
+ }
106
+
107
+ if (data.payingStripeAccountId !== undefined) {
108
+ fee.payingStripeAccountId = data.payingStripeAccountId;
109
+ }
110
+
111
+ await fee.save();
112
+
113
+ if (fee.balanceItemId === null) {
114
+ await this.linkLegacyInvoicedFee(fee);
115
+ }
116
+
117
+ return fee;
118
+ }
119
+
120
+ /**
121
+ * Records which balance item bills the fee to the payer. Pass the fee payment when the caller
122
+ * already has it, or an explicit null when the balance item is known to have no payment yet
123
+ * (the invoicer stamps before creating the payment): both skip resolving it again.
124
+ */
125
+ static async markInvoiced(fee: ApplicationFee, balanceItemId: string, { payment }: { payment?: Payment | null } = {}) {
126
+ fee.balanceItemId = balanceItemId;
127
+ await fee.save();
128
+ if (payment === null) {
129
+ return;
130
+ }
131
+ await this.stampProviderInvoiceId(fee, { payment });
132
+ }
133
+
134
+ /**
135
+ * The fee payments the legacy invoicer created for a (payer, month), matched on their
136
+ * reference. The OR NULL clause covers legacy payments created without a stripeAccountId.
137
+ */
138
+ static async findLegacyFeePayments({ organizationId, payingOrganizationId, payingStripeAccountId, periodStart }: {
139
+ organizationId: string;
140
+ payingOrganizationId: string;
141
+ payingStripeAccountId: string;
142
+ periodStart: Date;
143
+ }): Promise<Payment[]> {
144
+ const reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + Formatter.dateIso(periodStart);
145
+ const key = organizationId + ':' + payingStripeAccountId + ':' + reference;
146
+
147
+ const cached = this.legacyFeePayments.get(key);
148
+ if (cached && cached.length > 0) {
149
+ return cached;
150
+ }
151
+
152
+ const payments = await Payment.select()
153
+ .where('organizationId', organizationId)
154
+ .where('payingOrganizationId', payingOrganizationId)
155
+ .where(
156
+ SQL.where('stripeAccountId', payingStripeAccountId)
157
+ .or('stripeAccountId', null),
158
+ )
159
+ .where('reference', reference)
160
+ .where('method', PaymentMethod.AccountDeductions)
161
+ .where('provider', PaymentProvider.Stripe)
162
+ .where('status', PaymentStatus.Succeeded)
163
+ .fetch();
164
+
165
+ this.legacyFeePayments.set(key, payments);
166
+ return payments;
167
+ }
168
+
169
+ /**
170
+ * Links a fee the legacy invoicer already billed to that month's balance item, the moment the
171
+ * fee row is created: service fees to the month's ServiceFee item, transfer fees to the
172
+ * TransferFee item. A month without a legacy payment is simply not invoiced yet. A legacy month
173
+ * that can't be linked stays unlinked and is emailed: the invoicer's legacy guard prevents it
174
+ * from being billed twice.
175
+ */
176
+ private static async linkLegacyInvoicedFee(fee: ApplicationFee) {
177
+ if (!fee.payingStripeAccountId || !fee.payingOrganizationId) {
178
+ return;
179
+ }
180
+
181
+ const periodStart = SettlementService.getPeriodStart(fee.occurredAt);
182
+ const payments = await this.findLegacyFeePayments({
183
+ organizationId: fee.organizationId,
184
+ payingOrganizationId: fee.payingOrganizationId,
185
+ payingStripeAccountId: fee.payingStripeAccountId,
186
+ periodStart,
187
+ });
188
+
189
+ if (payments.length === 0) {
190
+ return;
191
+ }
192
+
193
+ const problem = (reason: string) => {
194
+ const key = fee.payingStripeAccountId + ':' + SettlementService.getPeriodKey(periodStart);
195
+ if (this.reportedLegacyProblems.has(key)) {
196
+ return;
197
+ }
198
+ this.reportedLegacyProblems.add(key);
199
+ console.error('Legacy fee linking failed for ' + key + ': ' + reason);
200
+ WebmasterReport.report(
201
+ 'Applicatiekosten van account ' + fee.payingStripeAccountId + ' voor ' + SettlementService.getPeriodKey(periodStart) + ' koppelen aan oude facturatie mislukt',
202
+ 'Ze worden niet opnieuw aangerekend. ' + reason,
203
+ );
204
+ };
205
+
206
+ if (payments.length > 1) {
207
+ problem('Found ' + payments.length + ' legacy fee payments for the same month');
208
+ return;
209
+ }
210
+
211
+ const payment = payments[0];
212
+ const balanceItem = await this.findFeeBalanceItem(payment, fee.type);
213
+ if (!balanceItem) {
214
+ problem('The legacy fee payment ' + payment.id + ' has no ' + fee.type + ' balance item');
215
+ return;
216
+ }
217
+
218
+ await this.markInvoiced(fee, balanceItem.id, { payment });
219
+ }
220
+
221
+ private static async findFeeBalanceItem(payment: Payment, type: ApplicationFeeType): Promise<BalanceItem | null> {
222
+ const balanceItemPayments = await BalanceItemPayment.select()
223
+ .where('paymentId', payment.id)
224
+ .fetch();
225
+ if (balanceItemPayments.length === 0) {
226
+ return null;
227
+ }
228
+ const balanceItems = await BalanceItem.select()
229
+ .where('id', balanceItemPayments.map(b => b.balanceItemId))
230
+ .fetch();
231
+ const balanceItemType = type === ApplicationFeeType.Service ? BalanceItemType.ServiceFee : BalanceItemType.TransferFee;
232
+
233
+ // In legacy migrations, the type has been set to 'Other' - fallback to that (contains both service fees and transfer fees in one balance item)
234
+ return balanceItems.find(item => item.type === balanceItemType) ?? (balanceItems.length === 1 ? balanceItems.find(item => item.type === BalanceItemType.Other) : null) ?? null;
235
+ }
236
+
237
+ /**
238
+ * Stamps the Stamhoofd invoice number that bills this fee on the payer's deduction charge, so
239
+ * the payer can link the cost in their settlement export to our invoice. No invoice (yet) means
240
+ * no stamp: stampInvoicedPayments runs when the invoice is created later.
241
+ */
242
+ private static async stampProviderInvoiceId(fee: ApplicationFee, { payment }: { payment?: Payment } = {}) {
243
+ if (!fee.settlementChargeId) {
244
+ return;
245
+ }
246
+
247
+ if (!payment) {
248
+ if (!fee.balanceItemId) {
249
+ return;
250
+ }
251
+ const balanceItemPayment = await BalanceItemPayment.select()
252
+ .where('balanceItemId', fee.balanceItemId)
253
+ .first(false);
254
+ if (!balanceItemPayment) {
255
+ return;
256
+ }
257
+ payment = await Payment.getByID(balanceItemPayment.paymentId) ?? undefined;
258
+ }
259
+
260
+ if (!payment?.invoiceId) {
261
+ return;
262
+ }
263
+
264
+ const invoice = await Invoice.getByID(payment.invoiceId);
265
+ if (!invoice?.number) {
266
+ return;
267
+ }
268
+
269
+ await SettlementService.setChargeProviderInvoiceId(fee.settlementChargeId, invoice.number);
270
+ }
271
+
272
+ /**
273
+ * Called when an invoice for these payments is created (with the invoice) or deleted (with
274
+ * null): stamps or clears the invoice number on the deduction charges of every application fee
275
+ * the payments billed. A no-op for invoices without fee payments.
276
+ */
277
+ static async stampInvoicedPayments(payments: Payment[], invoice: Invoice | null) {
278
+ if (payments.length === 0) {
279
+ return;
280
+ }
281
+
282
+ const balanceItemPayments = await BalanceItemPayment.select()
283
+ .where('paymentId', payments.map(p => p.id))
284
+ .fetch();
285
+ if (balanceItemPayments.length === 0) {
286
+ return;
287
+ }
288
+
289
+ // An invoice can bill a whole month of fees, so the charges are collected and stamped in
290
+ // batches instead of one query pair per fee
291
+ const settlementChargeIds: string[] = [];
292
+ for await (const fees of ApplicationFee.select()
293
+ .where('balanceItemId', balanceItemPayments.map(b => b.balanceItemId))
294
+ .limit(FEE_BATCH_SIZE)
295
+ .allBatched()) {
296
+ settlementChargeIds.push(...fees.map(fee => fee.settlementChargeId).filter((id): id is string => id !== null));
297
+ }
298
+
299
+ await SettlementService.setChargeProviderInvoiceIds(settlementChargeIds, invoice?.number ?? null);
300
+ }
301
+ }
@@ -7,6 +7,8 @@ import type { Invoice as InvoiceStruct } from '@stamhoofd/structures';
7
7
  import { EmailTemplateType, PaymentStatus, Recipient, Replacement } from '@stamhoofd/structures';
8
8
  import { Formatter } from '@stamhoofd/utility';
9
9
  import { ViesHelper } from '../helpers/ViesHelper.js';
10
+ import { WebmasterReport } from '../helpers/WebmasterReport.js';
11
+ import { ApplicationFeeService } from './ApplicationFeeService.js';
10
12
  import { BalanceItemService } from './BalanceItemService.js';
11
13
  import { InvoicePdfService } from './InvoicePdfService.js';
12
14
  import { InvoiceXMlService } from './InvoiceXMLService.js';
@@ -301,6 +303,16 @@ export class InvoiceService {
301
303
  throw e;
302
304
  }
303
305
 
306
+ try {
307
+ // The deduction charges of application fees billed by these payments now link to a
308
+ // numbered invoice. Only after the try/catch: a stamping error must never delete an
309
+ // invoice that was already sent
310
+ await ApplicationFeeService.stampInvoicedPayments(payments, model);
311
+ } catch (e) {
312
+ console.error('Failed to stamp application fee charges for invoice ' + model.id, e);
313
+ WebmasterReport.report('Factuurnummer op applicatiekosten zetten mislukt voor factuur ' + (model.number ?? model.id) + ' (de factuur zelf is wel verstuurd)', e);
314
+ }
315
+
304
316
  return model;
305
317
  }
306
318
 
@@ -337,14 +349,19 @@ export class InvoiceService {
337
349
  * After deletion the invoiced cache of the affected balance items is recalculated.
338
350
  */
339
351
  static async delete(invoice: Invoice) {
340
- // Collect the affected balance items before deleting, because the invoiced balance items are cascade deleted.
352
+ // Collect the affected balance items and payments before deleting, because the invoiced
353
+ // balance items are cascade deleted and the payments' invoiceId is reset
341
354
  const { invoicedBalanceItems } = await Invoice.loadBalanceItems([invoice]);
342
355
  const balanceItemIds = Formatter.uniqueArray(invoicedBalanceItems.map(i => i.balanceItemId));
356
+ const payments = await Payment.select().where('invoiceId', invoice.id).fetch();
343
357
 
344
358
  await invoice.delete();
345
359
 
346
360
  // Recalculate the invoiced amount cache of the balance items that were invoiced by this invoice.
347
361
  await BalanceItemService.updateInvoiced(balanceItemIds);
362
+
363
+ // The invoice number stamped on application fee deduction charges no longer exists
364
+ await ApplicationFeeService.stampInvoicedPayments(payments, null);
348
365
  }
349
366
 
350
367
  private static shouldForwardInvoice(invoice: Invoice, organization: Organization) {