@stamhoofd/backend 2.139.0 → 2.141.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 (69) 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/auth/MFA.security.test.ts +76 -1
  11. package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
  13. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  14. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +14 -14
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  17. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  22. package/src/excel-loaders/payments.ts +18 -1
  23. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  24. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  25. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  26. package/src/helpers/AuthenticatedStructures.ts +14 -1
  27. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  28. package/src/helpers/MollieSettlementSync.ts +323 -0
  29. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  30. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  31. package/src/helpers/SettlementExporter.test.ts +366 -0
  32. package/src/helpers/SettlementExporter.ts +583 -0
  33. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  34. package/src/helpers/SettlementSyncRunner.ts +64 -0
  35. package/src/helpers/StripeHelper.ts +2 -1
  36. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  37. package/src/helpers/StripeSettlementSync.ts +947 -0
  38. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  39. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  40. package/src/helpers/TwoFactorHelper.ts +1 -1
  41. package/src/helpers/WebmasterReport.test.ts +109 -0
  42. package/src/helpers/WebmasterReport.ts +115 -0
  43. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  45. package/src/helpers/streamForBreakdown.ts +2 -2
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +283 -0
  48. package/src/services/DocumentRenderService.test.ts +72 -1
  49. package/src/services/InvoiceService.ts +18 -1
  50. package/src/services/PaymentService.ts +8 -0
  51. package/src/services/SettlementService.test.ts +559 -0
  52. package/src/services/SettlementService.ts +607 -0
  53. package/src/sql-filters/orders.ts +6 -2
  54. package/src/sql-filters/payment-settlement.test.ts +2 -2
  55. package/src/sql-filters/payments.ts +73 -0
  56. package/tests/filters/orders.test.ts +635 -0
  57. package/tests/helpers/MollieMocker.ts +64 -6
  58. package/tests/helpers/StripeMocker.ts +209 -17
  59. package/src/crons/stripe-payout-reports.ts +0 -69
  60. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  61. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  62. package/src/helpers/CheckSettlements.test.ts +0 -190
  63. package/src/helpers/CheckSettlements.ts +0 -237
  64. package/src/helpers/StripeInvoicer.ts +0 -419
  65. package/src/helpers/StripePayoutChecker.ts +0 -193
  66. package/src/helpers/StripePayoutExportData.ts +0 -195
  67. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  68. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  69. 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,283 @@
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;
35
+ payingStripeAccountId: string;
36
+ payingPaymentId?: string | null;
37
+ settlementChargeId: string;
38
+ settlementId?: string | null;
39
+ occurredAt: Date;
40
+ };
41
+
42
+ /**
43
+ * All writes to application_fees go through this service (the settlement sweep's unlink excepted).
44
+ * Every write is an upsert on the unique (externalId, type) key, so re-running a sync can never
45
+ * duplicate rows.
46
+ *
47
+ * `balanceItemId === null` must always mean "not yet invoiced": upsertFee immediately links fees of
48
+ * months the legacy invoicer already billed (detected by the legacy payment's reference), so the
49
+ * invoicer only ever sees fees that still need billing.
50
+ */
51
+ export class ApplicationFeeService {
52
+ /**
53
+ * Emails about a legacy month that can't be linked are sent once per (account, month) per
54
+ * process, not once per fee.
55
+ */
56
+ private static reportedLegacyProblems = new Set<string>();
57
+
58
+ /**
59
+ * The legacy fee payment of a (paying account, month), remembered per process: a month has one
60
+ * answer for all of its fees, and a sync walks thousands of them. Only found payments are
61
+ * remembered, so a fee stored before its payment existed still links on the next pass.
62
+ */
63
+ private static legacyFeePayments = new Map<string, Payment[]>();
64
+
65
+ /**
66
+ * For tests only.
67
+ */
68
+ static resetWarnings() {
69
+ this.reportedLegacyProblems.clear();
70
+ this.legacyFeePayments.clear();
71
+ }
72
+
73
+ /**
74
+ * Upsert on (externalId, type). Fields that are undefined keep their stored value, so the fee
75
+ * walk (which doesn't know our platform payout yet) can't unlink a fee the payout sync attached
76
+ * earlier. balanceItemId is only written through markInvoiced or the inline legacy detection.
77
+ */
78
+ static async upsertFee(data: ApplicationFeeData): Promise<ApplicationFee> {
79
+ const fee = await ApplicationFee.select()
80
+ .where('externalId', data.externalId)
81
+ .where('type', data.type)
82
+ .first(false) ?? new ApplicationFee();
83
+
84
+ fee.externalId = data.externalId;
85
+ fee.type = data.type;
86
+ fee.amount = data.amount;
87
+ fee.organizationId = data.organizationId;
88
+ fee.payingOrganizationId = data.payingOrganizationId;
89
+ fee.payingStripeAccountId = data.payingStripeAccountId;
90
+ fee.settlementChargeId = data.settlementChargeId;
91
+ fee.occurredAt = data.occurredAt;
92
+
93
+ if (data.settlementId !== undefined) {
94
+ fee.settlementId = data.settlementId;
95
+ }
96
+ if (data.payingPaymentId !== undefined) {
97
+ fee.payingPaymentId = data.payingPaymentId;
98
+ }
99
+ await fee.save();
100
+
101
+ if (fee.balanceItemId === null) {
102
+ await this.linkLegacyInvoicedFee(fee);
103
+ }
104
+
105
+ return fee;
106
+ }
107
+
108
+ /**
109
+ * Records which balance item bills the fee to the payer. Pass the fee payment when the caller
110
+ * already has it, or an explicit null when the balance item is known to have no payment yet
111
+ * (the invoicer stamps before creating the payment): both skip resolving it again.
112
+ */
113
+ static async markInvoiced(fee: ApplicationFee, balanceItemId: string, { payment }: { payment?: Payment | null } = {}) {
114
+ fee.balanceItemId = balanceItemId;
115
+ await fee.save();
116
+ if (payment === null) {
117
+ return;
118
+ }
119
+ await this.stampProviderInvoiceId(fee, { payment });
120
+ }
121
+
122
+ /**
123
+ * The fee payments the legacy invoicer created for a (payer, month), matched on their
124
+ * reference. The OR NULL clause covers legacy payments created without a stripeAccountId.
125
+ */
126
+ static async findLegacyFeePayments({ organizationId, payingOrganizationId, payingStripeAccountId, periodStart }: {
127
+ organizationId: string;
128
+ payingOrganizationId: string;
129
+ payingStripeAccountId: string;
130
+ periodStart: Date;
131
+ }): Promise<Payment[]> {
132
+ const reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + Formatter.dateIso(periodStart);
133
+ const key = organizationId + ':' + payingStripeAccountId + ':' + reference;
134
+
135
+ const cached = this.legacyFeePayments.get(key);
136
+ if (cached && cached.length > 0) {
137
+ return cached;
138
+ }
139
+
140
+ const payments = await Payment.select()
141
+ .where('organizationId', organizationId)
142
+ .where('payingOrganizationId', payingOrganizationId)
143
+ .where(
144
+ SQL.where('stripeAccountId', payingStripeAccountId)
145
+ .or('stripeAccountId', null),
146
+ )
147
+ .where('reference', reference)
148
+ .where('method', PaymentMethod.AccountDeductions)
149
+ .where('provider', PaymentProvider.Stripe)
150
+ .where('status', PaymentStatus.Succeeded)
151
+ .fetch();
152
+
153
+ this.legacyFeePayments.set(key, payments);
154
+ return payments;
155
+ }
156
+
157
+ /**
158
+ * Links a fee the legacy invoicer already billed to that month's balance item, the moment the
159
+ * fee row is created: service fees to the month's ServiceFee item, transfer fees to the
160
+ * TransferFee item. A month without a legacy payment is simply not invoiced yet. A legacy month
161
+ * that can't be linked stays unlinked and is emailed: the invoicer's legacy guard prevents it
162
+ * from being billed twice.
163
+ */
164
+ private static async linkLegacyInvoicedFee(fee: ApplicationFee) {
165
+ if (!fee.payingStripeAccountId) {
166
+ return;
167
+ }
168
+
169
+ const periodStart = SettlementService.getPeriodStart(fee.occurredAt);
170
+ const payments = await this.findLegacyFeePayments({
171
+ organizationId: fee.organizationId,
172
+ payingOrganizationId: fee.payingOrganizationId,
173
+ payingStripeAccountId: fee.payingStripeAccountId,
174
+ periodStart,
175
+ });
176
+
177
+ if (payments.length === 0) {
178
+ return;
179
+ }
180
+
181
+ const problem = (reason: string) => {
182
+ const key = fee.payingStripeAccountId + ':' + SettlementService.getPeriodKey(periodStart);
183
+ if (this.reportedLegacyProblems.has(key)) {
184
+ return;
185
+ }
186
+ this.reportedLegacyProblems.add(key);
187
+ console.error('Legacy fee linking failed for ' + key + ': ' + reason);
188
+ WebmasterReport.report(
189
+ 'Applicatiekosten van account ' + fee.payingStripeAccountId + ' voor ' + SettlementService.getPeriodKey(periodStart) + ' koppelen aan oude facturatie mislukt',
190
+ 'Ze worden niet opnieuw aangerekend. ' + reason,
191
+ );
192
+ };
193
+
194
+ if (payments.length > 1) {
195
+ problem('Found ' + payments.length + ' legacy fee payments for the same month');
196
+ return;
197
+ }
198
+
199
+ const payment = payments[0];
200
+ const balanceItem = await this.findFeeBalanceItem(payment, fee.type);
201
+ if (!balanceItem) {
202
+ problem('The legacy fee payment ' + payment.id + ' has no ' + fee.type + ' balance item');
203
+ return;
204
+ }
205
+
206
+ await this.markInvoiced(fee, balanceItem.id, { payment });
207
+ }
208
+
209
+ private static async findFeeBalanceItem(payment: Payment, type: ApplicationFeeType): Promise<BalanceItem | null> {
210
+ const balanceItemPayments = await BalanceItemPayment.select()
211
+ .where('paymentId', payment.id)
212
+ .fetch();
213
+ if (balanceItemPayments.length === 0) {
214
+ return null;
215
+ }
216
+ const balanceItems = await BalanceItem.select()
217
+ .where('id', balanceItemPayments.map(b => b.balanceItemId))
218
+ .fetch();
219
+ const balanceItemType = type === ApplicationFeeType.Service ? BalanceItemType.ServiceFee : BalanceItemType.TransferFee;
220
+ return balanceItems.find(item => item.type === balanceItemType) ?? null;
221
+ }
222
+
223
+ /**
224
+ * Stamps the Stamhoofd invoice number that bills this fee on the payer's deduction charge, so
225
+ * the payer can link the cost in their settlement export to our invoice. No invoice (yet) means
226
+ * no stamp: stampInvoicedPayments runs when the invoice is created later.
227
+ */
228
+ private static async stampProviderInvoiceId(fee: ApplicationFee, { payment }: { payment?: Payment } = {}) {
229
+ if (!payment) {
230
+ if (!fee.balanceItemId) {
231
+ return;
232
+ }
233
+ const balanceItemPayment = await BalanceItemPayment.select()
234
+ .where('balanceItemId', fee.balanceItemId)
235
+ .first(false);
236
+ if (!balanceItemPayment) {
237
+ return;
238
+ }
239
+ payment = await Payment.getByID(balanceItemPayment.paymentId) ?? undefined;
240
+ }
241
+
242
+ if (!payment?.invoiceId) {
243
+ return;
244
+ }
245
+
246
+ const invoice = await Invoice.getByID(payment.invoiceId);
247
+ if (!invoice?.number) {
248
+ return;
249
+ }
250
+
251
+ await SettlementService.setChargeProviderInvoiceId(fee.settlementChargeId, invoice.number);
252
+ }
253
+
254
+ /**
255
+ * Called when an invoice for these payments is created (with the invoice) or deleted (with
256
+ * null): stamps or clears the invoice number on the deduction charges of every application fee
257
+ * the payments billed. A no-op for invoices without fee payments.
258
+ */
259
+ static async stampInvoicedPayments(payments: Payment[], invoice: Invoice | null) {
260
+ if (payments.length === 0) {
261
+ return;
262
+ }
263
+
264
+ const balanceItemPayments = await BalanceItemPayment.select()
265
+ .where('paymentId', payments.map(p => p.id))
266
+ .fetch();
267
+ if (balanceItemPayments.length === 0) {
268
+ return;
269
+ }
270
+
271
+ // An invoice can bill a whole month of fees, so the charges are collected and stamped in
272
+ // batches instead of one query pair per fee
273
+ const settlementChargeIds: string[] = [];
274
+ for await (const fees of ApplicationFee.select()
275
+ .where('balanceItemId', balanceItemPayments.map(b => b.balanceItemId))
276
+ .limit(FEE_BATCH_SIZE)
277
+ .allBatched()) {
278
+ settlementChargeIds.push(...fees.map(fee => fee.settlementChargeId));
279
+ }
280
+
281
+ await SettlementService.setChargeProviderInvoiceIds(settlementChargeIds, invoice?.number ?? null);
282
+ }
283
+ }
@@ -1,7 +1,10 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { Document, DocumentTemplateFactory, Organization } from '@stamhoofd/models';
3
- import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
3
+ import { render } from '@stamhoofd/models/helpers/Handlebars.js';
4
+ import type { RecordAnswer } from '@stamhoofd/structures';
5
+ import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordCheckboxAnswer, RecordDateAnswer, RecordPriceAnswer, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
4
6
  import { Country } from '@stamhoofd/types/Country';
7
+ import { Formatter } from '@stamhoofd/utility';
5
8
  import { DocumentRenderService } from './DocumentRenderService.js';
6
9
 
7
10
  function createImage(id: string) {
@@ -66,6 +69,49 @@ function createInvalidFieldAnswers() {
66
69
 
67
70
  const xmlExport = '<documents>{{#each documents}}<document>{{{this.number}}}</document>{{/each}}</documents>';
68
71
 
72
+ /**
73
+ * The day price row of the participation template (templates/participation.html in the dashboard).
74
+ */
75
+ const dayPriceTemplate = '{{#if (and registration.showDayPrice (coalesce registration.price registration.priceOriginal 0)) }}'
76
+ + 'Bedrag per dag: {{ formatPrice (div (coalesce registration.price registration.priceOriginal 0) (coalesce registration.days (days registration.startDate registration.endDate))) round=true }}'
77
+ + '{{/if}}';
78
+
79
+ function createParticipationDocument(answers: { showDayPrice: boolean; price?: number | null; priceOriginal?: number | null; startDate?: Date; endDate?: Date }) {
80
+ const settingsFor = (id: string, type: RecordType) => RecordSettings.create({ id, type });
81
+
82
+ const fieldAnswers = new Map<string, RecordAnswer>([
83
+ ['registration.showDayPrice', RecordCheckboxAnswer.create({
84
+ settings: settingsFor('registration.showDayPrice', RecordType.Checkbox),
85
+ selected: answers.showDayPrice,
86
+ })],
87
+ ['registration.price', RecordPriceAnswer.create({
88
+ settings: settingsFor('registration.price', RecordType.Price),
89
+ value: answers.price ?? null,
90
+ })],
91
+ ['registration.priceOriginal', RecordPriceAnswer.create({
92
+ settings: settingsFor('registration.priceOriginal', RecordType.Price),
93
+ value: answers.priceOriginal ?? null,
94
+ })],
95
+ ['registration.startDate', RecordDateAnswer.create({
96
+ settings: settingsFor('registration.startDate', RecordType.Date),
97
+ dateValue: answers.startDate ?? new Date(2024, 6, 1),
98
+ })],
99
+ ['registration.endDate', RecordDateAnswer.create({
100
+ settings: settingsFor('registration.endDate', RecordType.Date),
101
+ dateValue: answers.endDate ?? new Date(2024, 6, 7),
102
+ })],
103
+ ]);
104
+
105
+ const document = createDocument();
106
+ document.data.fieldAnswers = fieldAnswers;
107
+ return document;
108
+ }
109
+
110
+ async function renderDayPrice(answers: Parameters<typeof createParticipationDocument>[0]) {
111
+ const context = DocumentRenderService.buildDocumentContext(createParticipationDocument(answers), createOrganization(), createPlatform({}));
112
+ return await render(dayPriceTemplate, context);
113
+ }
114
+
69
115
  /**
70
116
  * Builds a locked template: a locked template makes buildAll return the documents unchanged, so the
71
117
  * numbers set up here are exactly what the renumbering logic gets to see.
@@ -150,6 +196,31 @@ describe('DocumentRenderService', () => {
150
196
  });
151
197
  });
152
198
 
199
+ describe('day price', () => {
200
+ // 1 July until 7 July are 7 days, so €25 is rounded to €3,57 per day
201
+ const dayPriceOf25 = 'Bedrag per dag: ' + Formatter.price(3_5700);
202
+
203
+ test('It divides the price by the amount of days, both start and end date included', async () => {
204
+ await expect(renderDayPrice({ showDayPrice: true, price: 25_0000 })).resolves.toBe(dayPriceOf25);
205
+ });
206
+
207
+ test('It uses the original price when the price itself is hidden on the document', async () => {
208
+ await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: 25_0000 })).resolves.toBe(dayPriceOf25);
209
+ });
210
+
211
+ test('It prefers the price of the document over the original price', async () => {
212
+ await expect(renderDayPrice({ showDayPrice: true, price: 14_0000, priceOriginal: 25_0000 })).resolves.toBe('Bedrag per dag: ' + Formatter.price(2_0000));
213
+ });
214
+
215
+ test('It is left out when the checkbox is not selected', async () => {
216
+ await expect(renderDayPrice({ showDayPrice: false, price: 25_0000 })).resolves.toBe('');
217
+ });
218
+
219
+ test('It is left out when no price is known', async () => {
220
+ await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: null })).resolves.toBe('');
221
+ });
222
+ });
223
+
153
224
  describe('getRenderedHtml', () => {
154
225
  test('It returns null and logs when building the context fails', async () => {
155
226
  const template = await new DocumentTemplateFactory({ groups: [] }).create();
@@ -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) {