@stamhoofd/backend 2.140.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 (61) 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/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  11. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  12. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  15. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  16. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  17. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  20. package/src/excel-loaders/payments.ts +18 -1
  21. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  22. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  23. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  24. package/src/helpers/AuthenticatedStructures.ts +14 -1
  25. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  26. package/src/helpers/MollieSettlementSync.ts +323 -0
  27. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  28. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  29. package/src/helpers/SettlementExporter.test.ts +366 -0
  30. package/src/helpers/SettlementExporter.ts +583 -0
  31. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  32. package/src/helpers/SettlementSyncRunner.ts +64 -0
  33. package/src/helpers/StripeHelper.ts +2 -1
  34. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  35. package/src/helpers/StripeSettlementSync.ts +947 -0
  36. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  37. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  38. package/src/helpers/WebmasterReport.test.ts +109 -0
  39. package/src/helpers/WebmasterReport.ts +115 -0
  40. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  41. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  42. package/src/services/ApplicationFeeService.test.ts +256 -0
  43. package/src/services/ApplicationFeeService.ts +283 -0
  44. package/src/services/InvoiceService.ts +18 -1
  45. package/src/services/SettlementService.test.ts +559 -0
  46. package/src/services/SettlementService.ts +607 -0
  47. package/src/sql-filters/payment-settlement.test.ts +2 -2
  48. package/src/sql-filters/payments.ts +73 -0
  49. package/tests/helpers/MollieMocker.ts +64 -6
  50. package/tests/helpers/StripeMocker.ts +209 -17
  51. package/src/crons/stripe-payout-reports.ts +0 -69
  52. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  53. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  54. package/src/helpers/CheckSettlements.test.ts +0 -190
  55. package/src/helpers/CheckSettlements.ts +0 -237
  56. package/src/helpers/StripeInvoicer.ts +0 -419
  57. package/src/helpers/StripePayoutChecker.ts +0 -193
  58. package/src/helpers/StripePayoutExportData.ts +0 -195
  59. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  60. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  61. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,285 @@
1
+ import type { StripeAccount } from '@stamhoofd/models';
2
+ import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
5
+ import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
6
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
7
+ import { Address, BalanceItemType, Company, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
8
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
9
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
10
+ import { Country } from '@stamhoofd/types/Country';
11
+ import { v4 as uuidv4 } from 'uuid';
12
+
13
+ import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
14
+ import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
15
+ import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
16
+ import { SettlementService } from '../services/SettlementService.js';
17
+ import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
18
+
19
+ describe('ApplicationFeeInvoicer', () => {
20
+ const stripeMocker = new StripeMocker();
21
+ let membershipOrganization: Organization;
22
+
23
+ // A month in the past that no other test writes fee rows in
24
+ const month = new Date(2024, 6, 1);
25
+ const occurredAt = new Date(2024, 6, 10);
26
+ const reference = 'application-fees-2024-07-01';
27
+
28
+ const belgianAddress = () => Address.create({
29
+ street: 'Teststraat',
30
+ number: '1',
31
+ postalCode: '9000',
32
+ city: 'Gent',
33
+ country: Country.Belgium,
34
+ });
35
+
36
+ beforeAll(async () => {
37
+ stripeMocker.start();
38
+
39
+ membershipOrganization = await initMembershipOrganization();
40
+ membershipOrganization.meta.companies = [
41
+ Company.create({
42
+ name: 'Platform BV',
43
+ companyNumber: '0700000000',
44
+ VATNumber: 'BE0700000000',
45
+ address: belgianAddress(),
46
+ }),
47
+ ];
48
+ await membershipOrganization.save();
49
+ });
50
+
51
+ afterAll(() => {
52
+ stripeMocker.stop();
53
+ });
54
+
55
+ beforeEach(async () => {
56
+ stripeMocker.clear();
57
+ ApplicationFeeService.resetWarnings();
58
+
59
+ // The test database persists across runs: leftover fees of this month would be billed again
60
+ await ApplicationFee.delete()
61
+ .where('occurredAt', '>=', month)
62
+ .where('occurredAt', '<', new Date(2024, 7, 1));
63
+ });
64
+
65
+ const init = async () => {
66
+ const organization = await new OrganizationFactory({}).create();
67
+ organization.meta.companies = [
68
+ Company.create({
69
+ name: 'Testvereniging VZW ' + organization.id,
70
+ companyNumber: '0500000000',
71
+ VATNumber: 'BE0500000000',
72
+ address: belgianAddress(),
73
+ }),
74
+ ];
75
+ await organization.save();
76
+
77
+ const stripeAccount = await stripeMocker.createStripeAccount(organization.id);
78
+ return { organization, stripeAccount };
79
+ };
80
+
81
+ const createFee = async (organization: Organization, stripeAccount: StripeAccount, { type = ApplicationFeeType.Service, amount = 30_00, settlement = null as Settlement | null, when = occurredAt } = {}) => {
82
+ const externalId = 'fee_' + uuidv4();
83
+ const charge = await SettlementService.upsertCharge({
84
+ type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
85
+ externalId: externalId + ':' + type,
86
+ amount: -amount,
87
+ applicationFeeId: externalId,
88
+ organizationId: organization.id,
89
+ stripeAccountId: stripeAccount.id,
90
+ occurredAt: when,
91
+ });
92
+
93
+ return await ApplicationFeeService.upsertFee({
94
+ externalId,
95
+ type,
96
+ amount,
97
+ organizationId: membershipOrganization.id,
98
+ payingOrganizationId: organization.id,
99
+ payingStripeAccountId: stripeAccount.id,
100
+ settlementChargeId: charge.id,
101
+ settlementId: settlement?.id ?? null,
102
+ occurredAt: when,
103
+ });
104
+ };
105
+
106
+ const getFeePayments = async (organization: Organization) => {
107
+ return await Payment.select()
108
+ .where('payingOrganizationId', organization.id)
109
+ .where('reference', reference)
110
+ .where('method', PaymentMethod.AccountDeductions)
111
+ .fetch();
112
+ };
113
+
114
+ const createInvoicer = () => new ApplicationFeeInvoicer({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
115
+
116
+ /**
117
+ * Bills the month as if the run started a second from now: fees stored in the current second
118
+ * are deliberately left for the next run, and a test stores them right before billing.
119
+ */
120
+ const invoiceMonth = async () => {
121
+ await createInvoicer().generateInvoicesForMonth(membershipOrganization, month, new Date(Date.now() + 1000));
122
+ };
123
+
124
+ test('a month is billed per paying account, and every fee carries its balance item', async () => {
125
+ const { organization, stripeAccount } = await init();
126
+ const serviceFee = await createFee(organization, stripeAccount, { type: ApplicationFeeType.Service, amount: 30_00 });
127
+ const transferFee = await createFee(organization, stripeAccount, { type: ApplicationFeeType.Transfer, amount: 2_20_00 });
128
+
129
+ await invoiceMonth();
130
+
131
+ const payments = await getFeePayments(organization);
132
+ expect(payments).toHaveLength(1);
133
+ expect(payments[0]).toMatchObject({
134
+ price: 2_50_00,
135
+ status: PaymentStatus.Succeeded,
136
+ provider: PaymentProvider.Stripe,
137
+ organizationId: membershipOrganization.id,
138
+ stripeAccountId: stripeAccount.id,
139
+ });
140
+
141
+ const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payments[0].id).fetch();
142
+ const balanceItems = await BalanceItem.select().where('id', balanceItemPayments.map(b => b.balanceItemId)).fetch();
143
+ const serviceItem = balanceItems.find(i => i.type === BalanceItemType.ServiceFee)!;
144
+ const transferItem = balanceItems.find(i => i.type === BalanceItemType.TransferFee)!;
145
+
146
+ expect(serviceItem.unitPrice).toBe(30_00);
147
+ expect(transferItem.unitPrice).toBe(2_20_00);
148
+ expect(serviceItem.organizationId).toBe(membershipOrganization.id);
149
+ expect(serviceItem.payingOrganizationId).toBe(organization.id);
150
+
151
+ expect((await ApplicationFee.getByID(serviceFee.id))!.balanceItemId).toBe(serviceItem.id);
152
+ expect((await ApplicationFee.getByID(transferFee.id))!.balanceItemId).toBe(transferItem.id);
153
+ });
154
+
155
+ test('fees of different accounts are billed separately', async () => {
156
+ const { organization, stripeAccount } = await init();
157
+ const second = await stripeMocker.createStripeAccount(organization.id);
158
+
159
+ await createFee(organization, stripeAccount, { amount: 30_00 });
160
+ await createFee(organization, second, { amount: 40_00 });
161
+
162
+ await invoiceMonth();
163
+
164
+ const payments = await getFeePayments(organization);
165
+ expect(payments).toHaveLength(2);
166
+ expect(payments.map(p => p.price).sort((a, b) => a - b)).toEqual([30_00, 40_00]);
167
+ expect(payments.map(p => p.stripeAccountId).sort()).toEqual([stripeAccount.id, second.id].sort());
168
+ });
169
+
170
+ test('re-running bills nothing more', async () => {
171
+ const { organization, stripeAccount } = await init();
172
+ await createFee(organization, stripeAccount);
173
+
174
+ await invoiceMonth();
175
+ await invoiceMonth();
176
+
177
+ expect(await getFeePayments(organization)).toHaveLength(1);
178
+ });
179
+
180
+ test('a fee that arrives after its month was billed lands in an extra payment', async () => {
181
+ const { organization, stripeAccount } = await init();
182
+ await createFee(organization, stripeAccount, { amount: 30_00 });
183
+
184
+ await invoiceMonth();
185
+
186
+ const late = await createFee(organization, stripeAccount, { amount: 5_00 });
187
+ await invoiceMonth();
188
+
189
+ const payments = await getFeePayments(organization);
190
+ expect(payments).toHaveLength(2);
191
+ expect(payments.map(p => p.price).sort((a, b) => a - b)).toEqual([5_00, 30_00]);
192
+ expect((await ApplicationFee.getByID(late.id))!.balanceItemId).not.toBeNull();
193
+ });
194
+
195
+ test('the fee payment is settled by the payouts that contained its fees', async () => {
196
+ const { organization, stripeAccount } = await init();
197
+ const payout = await SettlementService.upsertSettlement({
198
+ provider: PaymentProvider.Stripe,
199
+ externalId: 'po_' + uuidv4(),
200
+ stripeAccountId: null,
201
+ organizationId: membershipOrganization.id,
202
+ amount: 30_00,
203
+ settledAt: new Date(2024, 6, 20),
204
+ });
205
+ await createFee(organization, stripeAccount, { amount: 30_00, settlement: payout });
206
+
207
+ await invoiceMonth();
208
+
209
+ const payment = (await getFeePayments(organization))[0];
210
+ const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
211
+ expect(lines).toHaveLength(1);
212
+ expect(lines[0]).toMatchObject({ settlementId: payout.id, amount: 30_00 });
213
+
214
+ // Completely paid out: the lines add up to the payment
215
+ expect(lines.reduce((total, line) => total + line.amount, 0)).toBe(payment.price);
216
+ });
217
+
218
+ test('a month the legacy invoicer billed is never billed again', async () => {
219
+ const { organization, stripeAccount } = await init();
220
+
221
+ // A legacy payment without balance items: the inline linking can't reach it, so the fee
222
+ // stays uninvoiced and the invoicer may not bill it either
223
+ const legacy = new Payment();
224
+ legacy.organizationId = membershipOrganization.id;
225
+ legacy.payingOrganizationId = organization.id;
226
+ legacy.stripeAccountId = stripeAccount.id;
227
+ legacy.method = PaymentMethod.AccountDeductions;
228
+ legacy.provider = PaymentProvider.Stripe;
229
+ legacy.status = PaymentStatus.Succeeded;
230
+ legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
231
+ legacy.price = 30_00;
232
+ legacy.paidAt = occurredAt;
233
+ await legacy.save();
234
+
235
+ const fee = await createFee(organization, stripeAccount, { amount: 30_00 });
236
+ expect(fee.balanceItemId).toBeNull();
237
+
238
+ await invoiceMonth();
239
+
240
+ expect(await getFeePayments(organization)).toHaveLength(0);
241
+ expect((await ApplicationFee.getByID(fee.id))!.balanceItemId).toBeNull();
242
+ });
243
+
244
+ test('fees of the current month are not billed yet', async () => {
245
+ const { organization, stripeAccount } = await init();
246
+ const now = new Date();
247
+ await createFee(organization, stripeAccount, { when: new Date(now.getFullYear(), now.getMonth(), 1, 12) });
248
+
249
+ await createInvoicer().generateInvoices(membershipOrganization);
250
+
251
+ const payments = await Payment.select()
252
+ .where('payingOrganizationId', organization.id)
253
+ .where('method', PaymentMethod.AccountDeductions)
254
+ .fetch();
255
+ expect(payments).toHaveLength(0);
256
+ });
257
+
258
+ test('a broken account does not block the other accounts', async () => {
259
+ const { organization, stripeAccount } = await init();
260
+ const broken = await stripeMocker.createStripeAccount(organization.id);
261
+
262
+ await createFee(organization, stripeAccount, { amount: 30_00 });
263
+ await createFee(organization, broken, { amount: 40_00 });
264
+
265
+ // Deleting the account clears payingStripeAccountId: that group can't be billed anymore
266
+ await broken.delete();
267
+
268
+ await invoiceMonth();
269
+
270
+ const payments = await getFeePayments(organization);
271
+ expect(payments).toHaveLength(1);
272
+ expect(payments[0].price).toBe(30_00);
273
+ });
274
+
275
+ test('charges of a billed fee keep pointing at the deduction row', async () => {
276
+ const { organization, stripeAccount } = await init();
277
+ const fee = await createFee(organization, stripeAccount);
278
+
279
+ await invoiceMonth();
280
+
281
+ const charge = await SettlementCharge.getByID(fee.settlementChargeId);
282
+ expect(charge).toBeDefined();
283
+ expect(charge!.amount).toBe(-30_00);
284
+ });
285
+ });