@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.
- 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/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- 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 +346 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +424 -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 +388 -0
- package/src/helpers/SettlementExporter.ts +593 -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 +997 -0
- package/src/helpers/StripeSettlementSync.ts +1053 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +160 -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 +301 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +632 -0
- package/src/services/SettlementService.ts +650 -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,632 @@
|
|
|
1
|
+
import type { Organization } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItem, BalanceItemPayment, OrganizationFactory, Payment, StripeAccount } 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 as SettlementModel } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
7
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
8
|
+
import { BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus, SettlementReference } from '@stamhoofd/structures';
|
|
9
|
+
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
10
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
11
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
12
|
+
import { ReportedRows, SettlementService } from './SettlementService.js';
|
|
13
|
+
|
|
14
|
+
describe('SettlementService', () => {
|
|
15
|
+
let organization: Organization;
|
|
16
|
+
let stripeAccount: StripeAccount;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
organization = await new OrganizationFactory({}).create();
|
|
20
|
+
stripeAccount = await createStripeAccount();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
async function createPayment(price = 50_00_00, method = PaymentMethod.Bancontact, organizationId = organization.id) {
|
|
24
|
+
const payment = new Payment();
|
|
25
|
+
payment.organizationId = organizationId;
|
|
26
|
+
payment.method = method;
|
|
27
|
+
payment.provider = PaymentProvider.Stripe;
|
|
28
|
+
payment.status = PaymentStatus.Succeeded;
|
|
29
|
+
payment.price = price;
|
|
30
|
+
payment.paidAt = new Date();
|
|
31
|
+
await payment.save();
|
|
32
|
+
return payment;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function createStripeAccount(organizationId = organization.id) {
|
|
36
|
+
const account = new StripeAccount();
|
|
37
|
+
account.organizationId = organizationId;
|
|
38
|
+
account.accountId = 'acct_' + uuidv4();
|
|
39
|
+
await account.save();
|
|
40
|
+
return account;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A fee with its deduction charge, as the sync stores them.
|
|
45
|
+
*/
|
|
46
|
+
async function createApplicationFee({ amount = 1_00_00, type = ApplicationFeeType.Service, settlement = null as SettlementModel | null, balanceItemId = null as string | null, occurredAt = new Date(2026, 0, 14), payingOrganizationId = organization.id, payingStripeAccountId = null as string | null } = {}) {
|
|
47
|
+
const externalId = 'fee_' + uuidv4();
|
|
48
|
+
const charge = await SettlementService.upsertCharge({
|
|
49
|
+
type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
|
|
50
|
+
externalId: externalId + ':' + type,
|
|
51
|
+
amount: -amount,
|
|
52
|
+
applicationFeeId: externalId,
|
|
53
|
+
organizationId: payingOrganizationId,
|
|
54
|
+
occurredAt,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const fee = new ApplicationFee();
|
|
58
|
+
fee.externalId = externalId;
|
|
59
|
+
fee.type = type;
|
|
60
|
+
fee.amount = amount;
|
|
61
|
+
fee.organizationId = organization.id;
|
|
62
|
+
fee.payingOrganizationId = payingOrganizationId;
|
|
63
|
+
fee.payingStripeAccountId = payingStripeAccountId ?? stripeAccount.id;
|
|
64
|
+
fee.settlementChargeId = charge.id;
|
|
65
|
+
fee.settlementId = settlement?.id ?? null;
|
|
66
|
+
fee.balanceItemId = balanceItemId;
|
|
67
|
+
fee.occurredAt = occurredAt;
|
|
68
|
+
await fee.save();
|
|
69
|
+
return { fee, charge };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A fee the invoicer will never bill: its payer is gone, so there is no payout of theirs to
|
|
74
|
+
* deduct it from either.
|
|
75
|
+
*/
|
|
76
|
+
async function createUncollectibleApplicationFee({ amount = 1_00_00, settlement = null as SettlementModel | null, occurredAt = new Date(2026, 0, 14) } = {}) {
|
|
77
|
+
const fee = new ApplicationFee();
|
|
78
|
+
fee.externalId = 'fee_' + uuidv4();
|
|
79
|
+
fee.type = ApplicationFeeType.Service;
|
|
80
|
+
fee.amount = amount;
|
|
81
|
+
fee.organizationId = organization.id;
|
|
82
|
+
fee.settlementId = settlement?.id ?? null;
|
|
83
|
+
fee.occurredAt = occurredAt;
|
|
84
|
+
await fee.save();
|
|
85
|
+
return fee;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A fee payment with one balance item, like the invoicer creates.
|
|
90
|
+
*/
|
|
91
|
+
async function createFeePayment(price = 2_00_00) {
|
|
92
|
+
const payment = await createPayment(price, PaymentMethod.AccountDeductions);
|
|
93
|
+
|
|
94
|
+
const balanceItem = new BalanceItem();
|
|
95
|
+
balanceItem.type = BalanceItemType.ServiceFee;
|
|
96
|
+
balanceItem.organizationId = organization.id;
|
|
97
|
+
balanceItem.payingOrganizationId = organization.id;
|
|
98
|
+
balanceItem.unitPrice = price;
|
|
99
|
+
balanceItem.quantity = 1;
|
|
100
|
+
balanceItem.status = BalanceItemStatus.Hidden;
|
|
101
|
+
await balanceItem.save();
|
|
102
|
+
|
|
103
|
+
const balanceItemPayment = new BalanceItemPayment();
|
|
104
|
+
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
105
|
+
balanceItemPayment.paymentId = payment.id;
|
|
106
|
+
balanceItemPayment.organizationId = organization.id;
|
|
107
|
+
balanceItemPayment.price = price;
|
|
108
|
+
await balanceItemPayment.save();
|
|
109
|
+
|
|
110
|
+
return { payment, balanceItem };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function settlementData(overrides: Partial<Parameters<typeof SettlementService.upsertSettlement>[0]> = {}) {
|
|
114
|
+
return {
|
|
115
|
+
provider: PaymentProvider.Stripe,
|
|
116
|
+
externalId: 'po_' + uuidv4(),
|
|
117
|
+
organizationId: organization.id,
|
|
118
|
+
amount: 370_00_00,
|
|
119
|
+
reference: 'STRIPE PAYOUT',
|
|
120
|
+
settledAt: new Date(2026, 0, 15),
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('upsertSettlement', () => {
|
|
126
|
+
test('re-running updates the same row instead of duplicating', async () => {
|
|
127
|
+
const data = settlementData();
|
|
128
|
+
const created = await SettlementService.upsertSettlement(data);
|
|
129
|
+
|
|
130
|
+
const updated = await SettlementService.upsertSettlement({ ...data, amount: 400_00_00 });
|
|
131
|
+
|
|
132
|
+
expect(updated.id).toBe(created.id);
|
|
133
|
+
expect(updated.amount).toBe(400_00_00);
|
|
134
|
+
expect(await Settlement.select().where('externalId', data.externalId).count()).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('the same externalId of a different provider is a different settlement', async () => {
|
|
138
|
+
const externalId = 'shared_' + uuidv4();
|
|
139
|
+
const stripe = await SettlementService.upsertSettlement(settlementData({ externalId }));
|
|
140
|
+
const mollie = await SettlementService.upsertSettlement(settlementData({ externalId, provider: PaymentProvider.Mollie }));
|
|
141
|
+
|
|
142
|
+
expect(mollie.id).not.toBe(stripe.id);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('undefined optional fields keep their stored value', async () => {
|
|
146
|
+
const data = settlementData({ stripeAccountId: null });
|
|
147
|
+
const created = await SettlementService.upsertSettlement(data);
|
|
148
|
+
expect(created.organizationId).toBe(organization.id);
|
|
149
|
+
|
|
150
|
+
const updated = await SettlementService.upsertSettlement({
|
|
151
|
+
provider: data.provider,
|
|
152
|
+
externalId: data.externalId,
|
|
153
|
+
organizationId: organization.id,
|
|
154
|
+
amount: data.amount,
|
|
155
|
+
settledAt: data.settledAt,
|
|
156
|
+
});
|
|
157
|
+
expect(updated.stripeAccountId).toBe(null);
|
|
158
|
+
expect(updated.reference).toBe('STRIPE PAYOUT');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('upsertPaymentLine', () => {
|
|
163
|
+
test('idempotent per (settlement, externalId)', async () => {
|
|
164
|
+
const payment = await createPayment();
|
|
165
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
166
|
+
|
|
167
|
+
const line = await SettlementService.upsertPaymentLine(settlement, {
|
|
168
|
+
paymentId: payment.id,
|
|
169
|
+
amount: 50_00_00,
|
|
170
|
+
externalId: 'txn_1',
|
|
171
|
+
occurredAt: new Date(2026, 0, 14),
|
|
172
|
+
});
|
|
173
|
+
const again = await SettlementService.upsertPaymentLine(settlement, {
|
|
174
|
+
paymentId: payment.id,
|
|
175
|
+
amount: 51_00_00,
|
|
176
|
+
externalId: 'txn_1',
|
|
177
|
+
occurredAt: new Date(2026, 0, 14),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(again.id).toBe(line.id);
|
|
181
|
+
expect(again.amount).toBe(51_00_00);
|
|
182
|
+
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a transaction that moved to another payout leaves nothing behind', async () => {
|
|
186
|
+
const payment = await createPayment();
|
|
187
|
+
const from = await SettlementService.upsertSettlement(settlementData({ amount: 50_00_00 }));
|
|
188
|
+
const to = await SettlementService.upsertSettlement(settlementData({ amount: 50_00_00 }));
|
|
189
|
+
|
|
190
|
+
await SettlementService.upsertPaymentLine(from, {
|
|
191
|
+
paymentId: payment.id,
|
|
192
|
+
amount: 50_00_00,
|
|
193
|
+
externalId: 'txn_moved',
|
|
194
|
+
occurredAt: new Date(2026, 0, 14),
|
|
195
|
+
});
|
|
196
|
+
await SettlementService.finishSync(from, { transactionCount: 1 });
|
|
197
|
+
expect(from.unexplainedAmount).toBe(0);
|
|
198
|
+
|
|
199
|
+
// Stripe reports the same transaction in a later payout: counting it in both would
|
|
200
|
+
// settle the payment twice
|
|
201
|
+
await SettlementService.upsertPaymentLine(to, {
|
|
202
|
+
paymentId: payment.id,
|
|
203
|
+
amount: 50_00_00,
|
|
204
|
+
externalId: 'txn_moved',
|
|
205
|
+
occurredAt: new Date(2026, 0, 20),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
|
|
209
|
+
expect(lines).toHaveLength(1);
|
|
210
|
+
expect(lines[0].settlementId).toBe(to.id);
|
|
211
|
+
|
|
212
|
+
// The payout it left is recomputed right away, instead of waiting for its next walk
|
|
213
|
+
expect((await Settlement.getByID(from.id))!.unexplainedAmount).toBe(50_00_00);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('the line is stored on the organization of the payout', async () => {
|
|
217
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
218
|
+
const payment = await createPayment(50_00_00, PaymentMethod.Bancontact, otherOrganization.id);
|
|
219
|
+
const settlement = await SettlementService.upsertSettlement(settlementData({ organizationId: otherOrganization.id }));
|
|
220
|
+
|
|
221
|
+
const line = await SettlementService.upsertPaymentLine(settlement, {
|
|
222
|
+
paymentId: payment.id,
|
|
223
|
+
amount: 50_00_00,
|
|
224
|
+
externalId: 'txn_organization',
|
|
225
|
+
occurredAt: new Date(2026, 0, 14),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(line.organizationId).toBe(otherOrganization.id);
|
|
229
|
+
expect((await PaymentSettlement.getByID(line.id))!.organizationId).toBe(otherOrganization.id);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('upsertCharge', () => {
|
|
234
|
+
test('idempotent by externalId, and undefined fields keep their stored value', async () => {
|
|
235
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
236
|
+
const externalId = 'fee_' + uuidv4() + ':ApplicationFeeService';
|
|
237
|
+
|
|
238
|
+
const created = await SettlementService.upsertCharge({
|
|
239
|
+
type: SettlementChargeType.ApplicationFeeService,
|
|
240
|
+
externalId,
|
|
241
|
+
amount: -1_00_00,
|
|
242
|
+
settlementId: settlement.id,
|
|
243
|
+
applicationFeeId: 'fee_123',
|
|
244
|
+
organizationId: organization.id,
|
|
245
|
+
occurredAt: new Date(2026, 0, 14),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// The fee walk doesn't know the settlement: it may not unlink the row
|
|
249
|
+
const updated = await SettlementService.upsertCharge({
|
|
250
|
+
type: SettlementChargeType.ApplicationFeeService,
|
|
251
|
+
externalId,
|
|
252
|
+
amount: -1_10_00,
|
|
253
|
+
organizationId: organization.id,
|
|
254
|
+
occurredAt: new Date(2026, 0, 14),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(updated.id).toBe(created.id);
|
|
258
|
+
expect(updated.amount).toBe(-1_10_00);
|
|
259
|
+
expect(updated.settlementId).toBe(settlement.id);
|
|
260
|
+
expect(updated.applicationFeeId).toBe('fee_123');
|
|
261
|
+
|
|
262
|
+
// An explicit null does clear the link
|
|
263
|
+
const cleared = await SettlementService.upsertCharge({
|
|
264
|
+
type: SettlementChargeType.ApplicationFeeService,
|
|
265
|
+
externalId,
|
|
266
|
+
amount: -1_10_00,
|
|
267
|
+
settlementId: null,
|
|
268
|
+
organizationId: organization.id,
|
|
269
|
+
occurredAt: new Date(2026, 0, 14),
|
|
270
|
+
});
|
|
271
|
+
expect(cleared.settlementId).toBe(null);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('sweepSettlement', () => {
|
|
276
|
+
test('deletes payout-only rows, but unlinks what outlives the payout', async () => {
|
|
277
|
+
const payment = await createPayment();
|
|
278
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
279
|
+
|
|
280
|
+
const keptLine = await SettlementService.upsertPaymentLine(settlement, {
|
|
281
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_kept', occurredAt: new Date(2026, 0, 14),
|
|
282
|
+
});
|
|
283
|
+
const movedLine = await SettlementService.upsertPaymentLine(settlement, {
|
|
284
|
+
paymentId: payment.id, amount: 10_00_00, externalId: 'txn_moved', occurredAt: new Date(2026, 0, 14),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// A derived fee line is never reported by a walk: it belongs to the fee payment
|
|
288
|
+
const derivedLine = await SettlementService.upsertPaymentLine(settlement, {
|
|
289
|
+
paymentId: payment.id, amount: 2_00_00, externalId: null, occurredAt: new Date(2026, 0, 14),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const { fee, charge: referencedCharge } = await createApplicationFee({ settlement });
|
|
293
|
+
const providerFee = await SettlementService.upsertCharge({
|
|
294
|
+
type: SettlementChargeType.ProviderTransactionFee,
|
|
295
|
+
externalId: 'txn_moved:fee:0',
|
|
296
|
+
amount: -30_00,
|
|
297
|
+
settlementId: settlement.id,
|
|
298
|
+
organizationId: organization.id,
|
|
299
|
+
occurredAt: new Date(2026, 0, 14),
|
|
300
|
+
});
|
|
301
|
+
const keptFee = await SettlementService.upsertCharge({
|
|
302
|
+
type: SettlementChargeType.ProviderTransactionFee,
|
|
303
|
+
externalId: 'txn_kept:fee:0',
|
|
304
|
+
amount: -25_00,
|
|
305
|
+
settlementId: settlement.id,
|
|
306
|
+
organizationId: organization.id,
|
|
307
|
+
occurredAt: new Date(2026, 0, 14),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// The deduction charge is linked to this settlement too, but no longer reported
|
|
311
|
+
referencedCharge.settlementId = settlement.id;
|
|
312
|
+
await referencedCharge.save();
|
|
313
|
+
|
|
314
|
+
const reported = new ReportedRows();
|
|
315
|
+
reported.paymentLine(keptLine);
|
|
316
|
+
reported.charge(keptFee);
|
|
317
|
+
const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
|
|
318
|
+
|
|
319
|
+
expect(await PaymentSettlement.getByID(keptLine.id)).toBeDefined();
|
|
320
|
+
expect(await PaymentSettlement.getByID(movedLine.id)).toBeUndefined();
|
|
321
|
+
expect(await PaymentSettlement.getByID(derivedLine.id)).toBeDefined();
|
|
322
|
+
expect(await SettlementCharge.getByID(providerFee.id)).toBeUndefined();
|
|
323
|
+
expect((await SettlementCharge.getByID(keptFee.id))?.settlementId).toBe(settlement.id);
|
|
324
|
+
|
|
325
|
+
// A charge an application fee points at may never be deleted (foreign key), only unlinked
|
|
326
|
+
const unlinkedCharge = await SettlementCharge.getByID(referencedCharge.id);
|
|
327
|
+
expect(unlinkedCharge).toBeDefined();
|
|
328
|
+
expect(unlinkedCharge!.settlementId).toBe(null);
|
|
329
|
+
|
|
330
|
+
expect(unlinkedFees.map(f => f.id)).toEqual([fee.id]);
|
|
331
|
+
expect((await ApplicationFee.getByID(fee.id))!.settlementId).toBe(null);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('keeps the application fees the walk reported', async () => {
|
|
335
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
336
|
+
const { fee } = await createApplicationFee({ settlement });
|
|
337
|
+
|
|
338
|
+
const reported = new ReportedRows();
|
|
339
|
+
reported.applicationFee(fee);
|
|
340
|
+
const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
|
|
341
|
+
|
|
342
|
+
expect(unlinkedFees).toHaveLength(0);
|
|
343
|
+
expect((await ApplicationFee.getByID(fee.id))!.settlementId).toBe(settlement.id);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('updatePaymentSettlementsForAccountDeductionPayment', () => {
|
|
348
|
+
test('one line per payout, summing the fees it contained', async () => {
|
|
349
|
+
const { payment, balanceItem } = await createFeePayment(3_25_00);
|
|
350
|
+
const first = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 10) }));
|
|
351
|
+
const second = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 20) }));
|
|
352
|
+
|
|
353
|
+
await createApplicationFee({ settlement: first, balanceItemId: balanceItem.id, amount: 1_00_00, occurredAt: new Date(2026, 0, 5) });
|
|
354
|
+
await createApplicationFee({ settlement: first, balanceItemId: balanceItem.id, amount: 50_00, occurredAt: new Date(2026, 0, 8) });
|
|
355
|
+
await createApplicationFee({ settlement: second, balanceItemId: balanceItem.id, amount: 1_50_00, occurredAt: new Date(2026, 0, 15) });
|
|
356
|
+
|
|
357
|
+
// A fee that isn't paid out yet doesn't produce a line
|
|
358
|
+
const { fee: pending } = await createApplicationFee({ balanceItemId: balanceItem.id, amount: 25_00 });
|
|
359
|
+
|
|
360
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
361
|
+
|
|
362
|
+
const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
|
|
363
|
+
expect(lines).toHaveLength(2);
|
|
364
|
+
expect(lines.every(line => line.externalId === null)).toBe(true);
|
|
365
|
+
|
|
366
|
+
const bySettlement = new Map(lines.map(line => [line.settlementId, line]));
|
|
367
|
+
expect(bySettlement.get(first.id)!.amount).toBe(1_50_00);
|
|
368
|
+
expect(bySettlement.get(first.id)!.occurredAt).toEqual(new Date(2026, 0, 8));
|
|
369
|
+
expect(bySettlement.get(second.id)!.amount).toBe(1_50_00);
|
|
370
|
+
|
|
371
|
+
// Not settled completely yet: the lines don't add up to the payment price
|
|
372
|
+
expect(lines.reduce((total, line) => total + line.amount, 0)).not.toBe(payment.price);
|
|
373
|
+
|
|
374
|
+
// Paying out the last fee completes the payment
|
|
375
|
+
pending.settlementId = second.id;
|
|
376
|
+
await pending.save();
|
|
377
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
378
|
+
|
|
379
|
+
const completed = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
|
|
380
|
+
expect(completed.reduce((total, line) => total + line.amount, 0)).toBe(payment.price);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('every fee payment keeps its own line in the same payout', async () => {
|
|
384
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
385
|
+
const first = await createFeePayment(1_00_00);
|
|
386
|
+
const second = await createFeePayment(2_00_00);
|
|
387
|
+
await createApplicationFee({ settlement, balanceItemId: first.balanceItem.id, amount: 1_00_00 });
|
|
388
|
+
await createApplicationFee({ settlement, balanceItemId: second.balanceItem.id, amount: 2_00_00 });
|
|
389
|
+
|
|
390
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(first.payment);
|
|
391
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(second.payment);
|
|
392
|
+
// Re-running may not duplicate: the lines have no externalId to upsert on
|
|
393
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(first.payment);
|
|
394
|
+
|
|
395
|
+
const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
|
|
396
|
+
expect(lines).toHaveLength(2);
|
|
397
|
+
expect(lines.find(line => line.paymentId === first.payment.id)!.amount).toBe(1_00_00);
|
|
398
|
+
expect(lines.find(line => line.paymentId === second.payment.id)!.amount).toBe(2_00_00);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('a line disappears again when its fees are unlinked from the payout', async () => {
|
|
402
|
+
const { payment, balanceItem } = await createFeePayment(1_00_00);
|
|
403
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
404
|
+
const { fee } = await createApplicationFee({ settlement, balanceItemId: balanceItem.id, amount: 1_00_00 });
|
|
405
|
+
|
|
406
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
407
|
+
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(1);
|
|
408
|
+
|
|
409
|
+
fee.settlementId = null;
|
|
410
|
+
await fee.save();
|
|
411
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
412
|
+
|
|
413
|
+
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('other payment methods are left alone', async () => {
|
|
417
|
+
const payment = await createPayment();
|
|
418
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
419
|
+
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(0);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('finishSync and markSyncFailed', () => {
|
|
424
|
+
test('finishSync caches the reconciliation delta and resets the failure count', async () => {
|
|
425
|
+
const payment = await createPayment();
|
|
426
|
+
const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 48_70_00 }));
|
|
427
|
+
await SettlementService.markSyncFailed(settlement);
|
|
428
|
+
expect(settlement.syncFailureCount).toBe(1);
|
|
429
|
+
expect(settlement.syncedAt).toBe(null);
|
|
430
|
+
|
|
431
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
432
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_1', occurredAt: new Date(2026, 0, 14),
|
|
433
|
+
});
|
|
434
|
+
await SettlementService.upsertCharge({
|
|
435
|
+
type: SettlementChargeType.ApplicationFeeService,
|
|
436
|
+
externalId: 'fee_' + uuidv4() + ':ApplicationFeeService',
|
|
437
|
+
amount: -1_00_00,
|
|
438
|
+
settlementId: settlement.id,
|
|
439
|
+
organizationId: organization.id,
|
|
440
|
+
occurredAt: new Date(2026, 0, 14),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await SettlementService.finishSync(settlement, { transactionCount: 2 });
|
|
444
|
+
|
|
445
|
+
// 48.70 - (50.00 - 1.00) = -0.30 unexplained
|
|
446
|
+
expect(settlement.unexplainedAmount).toBe(-30_00);
|
|
447
|
+
expect(settlement.pendingFees).toBe(0);
|
|
448
|
+
expect(settlement.transactionCount).toBe(2);
|
|
449
|
+
expect(settlement.syncedAt).not.toBe(null);
|
|
450
|
+
expect(settlement.syncFailureCount).toBe(0);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('fees waiting to be invoiced explain the payout until their payment does', async () => {
|
|
454
|
+
const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 1_00_00 }));
|
|
455
|
+
const { fee } = await createApplicationFee({ settlement, amount: 1_00_00 });
|
|
456
|
+
|
|
457
|
+
await SettlementService.finishSync(settlement, { transactionCount: 1 });
|
|
458
|
+
expect(settlement.pendingFees).toBe(1_00_00);
|
|
459
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
460
|
+
|
|
461
|
+
// Once invoiced, the fee payment's derived line explains it instead: counting both
|
|
462
|
+
// would explain the same money twice. Nothing re-syncs a healthy payout, so the cached
|
|
463
|
+
// columns have to follow the line right away
|
|
464
|
+
const { payment, balanceItem } = await createFeePayment(1_00_00);
|
|
465
|
+
fee.balanceItemId = balanceItem.id;
|
|
466
|
+
await fee.save();
|
|
467
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
468
|
+
|
|
469
|
+
const invoiced = await Settlement.getByID(settlement.id);
|
|
470
|
+
expect(invoiced!.pendingFees).toBe(0);
|
|
471
|
+
expect(invoiced!.unexplainedAmount).toBe(0);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('a payout that loses its derived line has the fee pending again', async () => {
|
|
475
|
+
const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 1_00_00 }));
|
|
476
|
+
const { payment, balanceItem } = await createFeePayment(1_00_00);
|
|
477
|
+
const { fee } = await createApplicationFee({ settlement, balanceItemId: balanceItem.id, amount: 1_00_00 });
|
|
478
|
+
|
|
479
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
480
|
+
await SettlementService.finishSync(settlement, { transactionCount: 1 });
|
|
481
|
+
expect(settlement.pendingFees).toBe(0);
|
|
482
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
483
|
+
|
|
484
|
+
// The payout no longer contains the fee: its line goes, and so does what it explained
|
|
485
|
+
fee.settlementId = null;
|
|
486
|
+
await fee.save();
|
|
487
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
|
|
488
|
+
|
|
489
|
+
const unlinked = await Settlement.getByID(settlement.id);
|
|
490
|
+
expect(unlinked!.pendingFees).toBe(0);
|
|
491
|
+
expect(unlinked!.unexplainedAmount).toBe(1_00_00);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('fees the invoicer will never bill explain the payout without ever being invoiced', async () => {
|
|
495
|
+
const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 3_00_00 }));
|
|
496
|
+
await createApplicationFee({ settlement, amount: 1_00_00 });
|
|
497
|
+
await createUncollectibleApplicationFee({ settlement, amount: 1_00_00 });
|
|
498
|
+
|
|
499
|
+
// A payer we know, but not the account the fee was deducted from: the invoicer skips it
|
|
500
|
+
const accountless = await createApplicationFee({ settlement, amount: 1_00_00 });
|
|
501
|
+
accountless.fee.payingStripeAccountId = null;
|
|
502
|
+
await accountless.fee.save();
|
|
503
|
+
|
|
504
|
+
await SettlementService.finishSync(settlement, { transactionCount: 3 });
|
|
505
|
+
|
|
506
|
+
// Only the fee that can still be billed is worth waiting for
|
|
507
|
+
expect(settlement.pendingFees).toBe(1_00_00);
|
|
508
|
+
expect(settlement.uncollectibleFees).toBe(2_00_00);
|
|
509
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('getApplicationFeeSettlementIdsForPayingOrganization', () => {
|
|
514
|
+
test('deleting the paying organization keeps its fees as uncollectible income', async () => {
|
|
515
|
+
const payer = await new OrganizationFactory({}).create();
|
|
516
|
+
const payerAccount = await createStripeAccount(payer.id);
|
|
517
|
+
const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 1_00_00 }));
|
|
518
|
+
const { fee, charge } = await createApplicationFee({ settlement, amount: 1_00_00, payingOrganizationId: payer.id, payingStripeAccountId: payerAccount.id });
|
|
519
|
+
|
|
520
|
+
// An organization that took Stripe payments is the only kind that owes fees: its
|
|
521
|
+
// payments reference the account the delete has to cascade through
|
|
522
|
+
const payerPayment = await createPayment(10_00_00, PaymentMethod.Bancontact, payer.id);
|
|
523
|
+
payerPayment.stripeAccountId = payerAccount.id;
|
|
524
|
+
await payerPayment.save();
|
|
525
|
+
|
|
526
|
+
await SettlementService.finishSync(settlement, { transactionCount: 1 });
|
|
527
|
+
expect(settlement.pendingFees).toBe(1_00_00);
|
|
528
|
+
|
|
529
|
+
const settlementIds = await SettlementService.getApplicationFeeSettlementIdsForPayingOrganization(payer.id);
|
|
530
|
+
expect(settlementIds).toEqual([settlement.id]);
|
|
531
|
+
|
|
532
|
+
await payer.delete();
|
|
533
|
+
await SettlementService.refreshTotalsForIds(settlementIds);
|
|
534
|
+
|
|
535
|
+
// The deduction charge went with the organization; the fee itself is our income and stays
|
|
536
|
+
expect(await SettlementCharge.getByID(charge.id)).toBeUndefined();
|
|
537
|
+
const stored = await ApplicationFee.getByID(fee.id);
|
|
538
|
+
expect(stored).toBeDefined();
|
|
539
|
+
expect(stored!.payingOrganizationId).toBeNull();
|
|
540
|
+
expect(stored!.settlementChargeId).toBeNull();
|
|
541
|
+
|
|
542
|
+
const after = await Settlement.getByID(settlement.id);
|
|
543
|
+
expect(after!.pendingFees).toBe(0);
|
|
544
|
+
expect(after!.uncollectibleFees).toBe(1_00_00);
|
|
545
|
+
expect(after!.unexplainedAmount).toBe(0);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe('updateLegacySettlementReference', () => {
|
|
550
|
+
test('the largest line wins and re-syncs never flip-flop the column', async () => {
|
|
551
|
+
const payment = await createPayment();
|
|
552
|
+
const original = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 15) }));
|
|
553
|
+
const refundPayout = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 22) }));
|
|
554
|
+
|
|
555
|
+
await SettlementService.upsertPaymentLine(original, {
|
|
556
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_pay', occurredAt: new Date(2026, 0, 14),
|
|
557
|
+
});
|
|
558
|
+
await SettlementService.upsertPaymentLine(refundPayout, {
|
|
559
|
+
paymentId: payment.id, amount: -20_00_00, externalId: 'txn_refund', occurredAt: new Date(2026, 0, 21),
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
await SettlementService.updateLegacySettlementReference(payment);
|
|
563
|
+
expect(payment.settlement?.id).toBe(original.externalId);
|
|
564
|
+
expect(payment.settlement?.amount).toBe(original.amount);
|
|
565
|
+
|
|
566
|
+
await SettlementService.updateLegacySettlementReference(payment);
|
|
567
|
+
expect(payment.settlement?.id).toBe(original.externalId);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('keeps the deprecated fee field when the primary settlement is unchanged', async () => {
|
|
571
|
+
const payment = await createPayment();
|
|
572
|
+
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
573
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
574
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_pay', occurredAt: new Date(2026, 0, 14),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// A historically stored blob that still carries a fee
|
|
578
|
+
payment.settlement = SettlementReference.create({
|
|
579
|
+
id: settlement.externalId,
|
|
580
|
+
reference: 'OLD',
|
|
581
|
+
settledAt: new Date(2026, 0, 15),
|
|
582
|
+
amount: 370_00_00,
|
|
583
|
+
fee: 1_23_00,
|
|
584
|
+
});
|
|
585
|
+
await payment.save();
|
|
586
|
+
|
|
587
|
+
await SettlementService.updateLegacySettlementReference(payment);
|
|
588
|
+
expect(payment.settlement?.fee).toBe(1_23_00);
|
|
589
|
+
|
|
590
|
+
// A different primary settlement can't invent a fee
|
|
591
|
+
const bigger = await SettlementService.upsertSettlement(settlementData());
|
|
592
|
+
await SettlementService.upsertPaymentLine(bigger, {
|
|
593
|
+
paymentId: payment.id, amount: 60_00_00, externalId: 'txn_bigger', occurredAt: new Date(2026, 0, 20),
|
|
594
|
+
});
|
|
595
|
+
await SettlementService.updateLegacySettlementReference(payment);
|
|
596
|
+
expect(payment.settlement?.id).toBe(bigger.externalId);
|
|
597
|
+
expect(payment.settlement?.fee).toBe(0);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test('a payout of another organization is never the payment\'s payout', async () => {
|
|
601
|
+
const stripeAccount = await createStripeAccount();
|
|
602
|
+
const payment = await createPayment();
|
|
603
|
+
payment.stripeAccountId = stripeAccount.id;
|
|
604
|
+
await payment.save();
|
|
605
|
+
|
|
606
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
607
|
+
const foreignPayout = await SettlementService.upsertSettlement(settlementData({ organizationId: otherOrganization.id, settledAt: new Date(2026, 0, 10) }));
|
|
608
|
+
const organizationPayout = await SettlementService.upsertSettlement(settlementData({ stripeAccountId: stripeAccount.id, settledAt: new Date(2026, 0, 15) }));
|
|
609
|
+
|
|
610
|
+
await SettlementService.upsertPaymentLine(foreignPayout, {
|
|
611
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_foreign', occurredAt: new Date(2026, 0, 9),
|
|
612
|
+
});
|
|
613
|
+
await SettlementService.upsertPaymentLine(organizationPayout, {
|
|
614
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_organization', occurredAt: new Date(2026, 0, 9),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await SettlementService.updateLegacySettlementReference(payment);
|
|
618
|
+
expect(payment.settlement?.id).toBe(organizationPayout.externalId);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('a payment without settlement rows is left untouched', async () => {
|
|
622
|
+
const payment = await createPayment();
|
|
623
|
+
payment.settlement = SettlementReference.create({
|
|
624
|
+
id: 'legacy', reference: 'LEGACY', settledAt: new Date(2026, 0, 15), amount: 370_00_00,
|
|
625
|
+
});
|
|
626
|
+
await payment.save();
|
|
627
|
+
|
|
628
|
+
await SettlementService.updateLegacySettlementReference(payment);
|
|
629
|
+
expect(payment.settlement?.id).toBe('legacy');
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
});
|