@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,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
|
+
}
|
|
@@ -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
|
|
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) {
|