@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.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +366 -0
- package/src/helpers/SettlementExporter.ts +583 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +828 -0
- package/src/helpers/StripeSettlementSync.ts +947 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
- package/src/helpers/WebmasterReport.test.ts +109 -0
- package/src/helpers/WebmasterReport.ts +115 -0
- package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
- package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/helpers/MollieMocker.ts +64 -6
- package/tests/helpers/StripeMocker.ts +209 -17
- package/src/crons/stripe-payout-reports.ts +0 -69
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
- package/src/helpers/CheckSettlements.test.ts +0 -190
- package/src/helpers/CheckSettlements.ts +0 -237
- package/src/helpers/StripeInvoicer.ts +0 -419
- package/src/helpers/StripePayoutChecker.ts +0 -193
- package/src/helpers/StripePayoutExportData.ts +0 -195
- package/src/helpers/StripePayoutExportExcel.ts +0 -280
- package/src/helpers/StripePayoutReporter.test.ts +0 -419
- 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
|
+
});
|