@stamhoofd/backend 2.139.0 → 2.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
- 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 +14 -14
- 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/TwoFactorHelper.ts +1 -1
- 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/helpers/streamForBreakdown.ts +2 -2
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/PaymentService.ts +8 -0
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/filters/orders.test.ts +635 -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
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
-
import { Email } from '@stamhoofd/email';
|
|
3
|
-
import { BalanceItem, BalanceItemPayment, Organization, Payment, StripeAccount, User } from '@stamhoofd/models';
|
|
4
|
-
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPaymentProviderName, PaymentCustomer, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, TranslatedString } from '@stamhoofd/structures';
|
|
6
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
7
|
-
import Stripe from 'stripe';
|
|
8
|
-
import { PaymentService } from '../services/PaymentService.js';
|
|
9
|
-
import { VATService } from '../services/VATService.js';
|
|
10
|
-
import { SQL } from '@stamhoofd/sql';
|
|
11
|
-
|
|
12
|
-
export class ApplicationFeeDetails {
|
|
13
|
-
transferFee = 0;
|
|
14
|
-
serviceFee = 0;
|
|
15
|
-
count = 0;
|
|
16
|
-
minimumDate: Date | null = null;
|
|
17
|
-
maximumDate: Date | null = null;
|
|
18
|
-
|
|
19
|
-
constructor(details: { count?: number; transferFee: number; serviceFee: number; minimumDate: Date | null; maximumDate: Date | null }) {
|
|
20
|
-
this.count = details.count ?? 0;
|
|
21
|
-
this.transferFee = details.transferFee;
|
|
22
|
-
this.serviceFee = details.serviceFee;
|
|
23
|
-
this.minimumDate = details.minimumDate;
|
|
24
|
-
this.maximumDate = details.maximumDate;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
get amount() {
|
|
28
|
-
return this.transferFee + this.serviceFee;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
static fromStripe(transaction: Stripe.BalanceTransaction) {
|
|
32
|
-
const source = transaction.source as Stripe.ApplicationFee;
|
|
33
|
-
const metadata = (source.originating_transaction as Stripe.Charge).metadata;
|
|
34
|
-
|
|
35
|
-
const serviceFeeStr = metadata.serviceFee as unknown;
|
|
36
|
-
if (serviceFeeStr === undefined || typeof serviceFeeStr !== 'string') {
|
|
37
|
-
throw new Error('Missing serviceFee metadata');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const parsed = parseInt(serviceFeeStr);
|
|
41
|
-
if (isNaN(parsed) || !isFinite(parsed)) {
|
|
42
|
-
throw new Error('Invalid serviceFee metadata');
|
|
43
|
-
}
|
|
44
|
-
const serviceFee = parsed * 100; // in cents
|
|
45
|
-
const transferFee = transaction.amount * 100 - serviceFee;
|
|
46
|
-
|
|
47
|
-
return new ApplicationFeeDetails({
|
|
48
|
-
count: 1,
|
|
49
|
-
serviceFee,
|
|
50
|
-
transferFee,
|
|
51
|
-
minimumDate: new Date(transaction.created * 1000),
|
|
52
|
-
maximumDate: new Date(transaction.created * 1000),
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
add(fee: Stripe.BalanceTransaction) {
|
|
57
|
-
this.combine(ApplicationFeeDetails.fromStripe(fee));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
combine(other: ApplicationFeeDetails) {
|
|
61
|
-
this.serviceFee += other.serviceFee;
|
|
62
|
-
this.transferFee += other.transferFee;
|
|
63
|
-
this.count += other.count;
|
|
64
|
-
if (other.minimumDate && (this.minimumDate === null || other.minimumDate < this.minimumDate)) {
|
|
65
|
-
this.minimumDate = other.minimumDate;
|
|
66
|
-
}
|
|
67
|
-
if (other.maximumDate && (this.maximumDate === null || other.maximumDate > this.maximumDate)) {
|
|
68
|
-
this.maximumDate = other.maximumDate;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
class StripeReport {
|
|
74
|
-
readonly applicationFeesPerAccount = new Map<string, ApplicationFeeDetails>();
|
|
75
|
-
|
|
76
|
-
add(balanceTransaction: Stripe.BalanceTransaction) {
|
|
77
|
-
if (balanceTransaction.type === 'application_fee' || balanceTransaction.type === 'application_fee_refund') {
|
|
78
|
-
const source = balanceTransaction.source as Stripe.ApplicationFee;
|
|
79
|
-
const account = typeof source.account === 'string' ? source.account : source.account.id;
|
|
80
|
-
|
|
81
|
-
if (account) {
|
|
82
|
-
// We don't included refunded fees because these are test payments from test account
|
|
83
|
-
const currentAmount = this.applicationFeesPerAccount.get(account);
|
|
84
|
-
if (currentAmount === undefined) {
|
|
85
|
-
this.applicationFeesPerAccount.set(account, ApplicationFeeDetails.fromStripe(balanceTransaction));
|
|
86
|
-
} else {
|
|
87
|
-
currentAmount.add(balanceTransaction);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
combine(report: StripeReport) {
|
|
94
|
-
for (const [account, amount] of report.applicationFeesPerAccount.entries()) {
|
|
95
|
-
const currentAmount = this.applicationFeesPerAccount.get(account);
|
|
96
|
-
if (currentAmount === undefined) {
|
|
97
|
-
this.applicationFeesPerAccount.set(account, amount);
|
|
98
|
-
} else {
|
|
99
|
-
currentAmount.combine(amount);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return this;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export class StripeReportInvoicer {
|
|
107
|
-
private readonly report: StripeReport;
|
|
108
|
-
start: Date;
|
|
109
|
-
end: Date;
|
|
110
|
-
|
|
111
|
-
constructor(report: StripeReport, start: Date, end: Date) {
|
|
112
|
-
this.report = report;
|
|
113
|
-
this.start = start;
|
|
114
|
-
this.end = end;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async generateInvoices(sellingOrganization: Organization, reference: string) {
|
|
118
|
-
const payments: Payment[] = [];
|
|
119
|
-
for (const [account, details] of this.report.applicationFeesPerAccount) {
|
|
120
|
-
try {
|
|
121
|
-
const payment = await this.generateInvoice(sellingOrganization, reference, account, details);
|
|
122
|
-
if (payment) {
|
|
123
|
-
payments.push(payment);
|
|
124
|
-
}
|
|
125
|
-
} catch (e) {
|
|
126
|
-
console.error(e);
|
|
127
|
-
// Send email notification of this error
|
|
128
|
-
Email.sendWebmaster({
|
|
129
|
-
subject: 'Aanmaken Stripe facturen voor ' + account + ' - ' + reference + ' mislukt',
|
|
130
|
-
html: 'Aanmaken Stripe facturen voor ' + account + ' - ' + reference + ' mislukt. <br><br> ' + e.toString(),
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return payments;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
static async hasPayment(sellingOrganization: Organization, reference: string) {
|
|
138
|
-
return !!await Payment.select()
|
|
139
|
-
.where('organizationId', sellingOrganization.id)
|
|
140
|
-
.where('reference', reference)
|
|
141
|
-
.where('status', PaymentStatus.Succeeded)
|
|
142
|
-
.where('method', PaymentMethod.AccountDeductions)
|
|
143
|
-
.where('provider', PaymentProvider.Stripe)
|
|
144
|
-
.first(false);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async generateInvoice(sellingOrganization: Organization, reference: string, accountId: string, applicationFee: ApplicationFeeDetails) {
|
|
148
|
-
if (applicationFee.amount === 0) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const seller = sellingOrganization.meta.companies[0];
|
|
153
|
-
if (!seller) {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Search for the organization in Stamhoofd
|
|
158
|
-
const stripeAccount = await StripeAccount.select().where('accountId', accountId).first(false);
|
|
159
|
-
if (!stripeAccount) {
|
|
160
|
-
if (STAMHOOFD.environment === 'test') {
|
|
161
|
-
console.error('No organization found for Stripe account ' + accountId);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
throw new SimpleError({
|
|
165
|
-
code: 'stripe_account_not_found',
|
|
166
|
-
message: 'No organization found for Stripe account ' + accountId,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
const organization = await Organization.getByID(stripeAccount.organizationId);
|
|
170
|
-
|
|
171
|
-
if (!organization) {
|
|
172
|
-
throw new SimpleError({
|
|
173
|
-
code: 'organization_not_found',
|
|
174
|
-
message: 'No organization found for Stripe account ' + accountId,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const existingPayments = await Payment.select()
|
|
179
|
-
.where('organizationId', sellingOrganization.id)
|
|
180
|
-
.where('payingOrganizationId', organization.id)
|
|
181
|
-
.where(
|
|
182
|
-
// required because the same organization can have multiple stripe accounts. Null = legacy
|
|
183
|
-
SQL.where('stripeAccountId', stripeAccount.id)
|
|
184
|
-
.or('stripeAccountId', null),
|
|
185
|
-
)
|
|
186
|
-
.where('reference', reference)
|
|
187
|
-
.where('method', PaymentMethod.AccountDeductions)
|
|
188
|
-
.where('provider', PaymentProvider.Stripe)
|
|
189
|
-
.where('status', PaymentStatus.Succeeded)
|
|
190
|
-
.fetch();
|
|
191
|
-
|
|
192
|
-
for (const i of existingPayments) {
|
|
193
|
-
if (i.price !== applicationFee.amount) {
|
|
194
|
-
throw new SimpleError({
|
|
195
|
-
code: 'payment_already_exists_with_different_amount',
|
|
196
|
-
message: 'Payment already exists with different amount ' + organization.id + ' expected ' + applicationFee.amount + ' got ' + i.price + ' in payment ' + i.id,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (existingPayments.length) {
|
|
202
|
-
console.warn('Tried to create an already created stripe invoice payment ' + existingPayments.map(p => p.id).join(', '));
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const customer = PaymentCustomer.create({
|
|
207
|
-
company: organization.defaultCompanies[0],
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
if (customer.company!.isSameEntity(seller)) {
|
|
211
|
-
throw new SimpleError({
|
|
212
|
-
code: 'same_customer',
|
|
213
|
-
message: 'Cannot invoice self',
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const balanceItems: BalanceItem[] = [];
|
|
218
|
-
|
|
219
|
-
if (applicationFee.serviceFee !== 0) {
|
|
220
|
-
const item = new BalanceItem();
|
|
221
|
-
item.type = BalanceItemType.ServiceFee;
|
|
222
|
-
item.description = $t('%1Wd', {
|
|
223
|
-
startDate: Formatter.startDate(this.start, false, true),
|
|
224
|
-
endDate: Formatter.endDate(this.end, false, true),
|
|
225
|
-
});
|
|
226
|
-
item.relations.set(BalanceItemRelationType.PaymentProvider, BalanceItemRelation.create({
|
|
227
|
-
id: PaymentProvider.Stripe,
|
|
228
|
-
name: TranslatedString.create(getPaymentProviderName(PaymentProvider.Stripe)),
|
|
229
|
-
}));
|
|
230
|
-
item.payingOrganizationId = organization.id;
|
|
231
|
-
item.organizationId = sellingOrganization.id;
|
|
232
|
-
item.VATPercentage = 21;
|
|
233
|
-
item.VATExcempt = VATService.getVATExcempt({
|
|
234
|
-
company: organization.defaultCompanies[0] ?? null,
|
|
235
|
-
sellingOrganization,
|
|
236
|
-
type: 'services',
|
|
237
|
-
});
|
|
238
|
-
item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
|
|
239
|
-
item.quantity = 1;
|
|
240
|
-
item.unitPrice = applicationFee.serviceFee;
|
|
241
|
-
item.createdAt = new Date();
|
|
242
|
-
item.status = BalanceItemStatus.Hidden;
|
|
243
|
-
item.startDate = this.start;
|
|
244
|
-
item.endDate = this.end;
|
|
245
|
-
await item.save();
|
|
246
|
-
balanceItems.push(item);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (applicationFee.transferFee !== 0) {
|
|
250
|
-
const item = new BalanceItem();
|
|
251
|
-
item.type = BalanceItemType.TransferFee;
|
|
252
|
-
item.description = $t('%1Xx', {
|
|
253
|
-
startDate: Formatter.startDate(this.start, false, true),
|
|
254
|
-
endDate: Formatter.endDate(this.end, false, true),
|
|
255
|
-
});
|
|
256
|
-
item.relations.set(BalanceItemRelationType.PaymentProvider, BalanceItemRelation.create({
|
|
257
|
-
id: PaymentProvider.Stripe,
|
|
258
|
-
name: TranslatedString.create(getPaymentProviderName(PaymentProvider.Stripe)),
|
|
259
|
-
}));
|
|
260
|
-
item.payingOrganizationId = organization.id;
|
|
261
|
-
item.organizationId = sellingOrganization.id;
|
|
262
|
-
item.VATPercentage = 21;
|
|
263
|
-
item.VATExcempt = VATService.getVATExcempt({
|
|
264
|
-
company: organization.defaultCompanies[0] ?? null,
|
|
265
|
-
sellingOrganization,
|
|
266
|
-
type: 'services',
|
|
267
|
-
});
|
|
268
|
-
item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
|
|
269
|
-
item.quantity = 1;
|
|
270
|
-
item.unitPrice = applicationFee.transferFee;
|
|
271
|
-
item.createdAt = new Date();
|
|
272
|
-
item.status = BalanceItemStatus.Hidden;
|
|
273
|
-
item.startDate = this.start;
|
|
274
|
-
item.endDate = this.end;
|
|
275
|
-
await item.save();
|
|
276
|
-
balanceItems.push(item);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
let total = 0;
|
|
280
|
-
for (const balanceItem of balanceItems) {
|
|
281
|
-
total += balanceItem.priceWithVAT;
|
|
282
|
-
}
|
|
283
|
-
if (total !== applicationFee.amount) {
|
|
284
|
-
throw new SimpleError({
|
|
285
|
-
code: 'price_mismatched',
|
|
286
|
-
message: 'The charged amount does not match the total application fee for the payment',
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const systemUser = await User.getSystem();
|
|
291
|
-
|
|
292
|
-
// Done validation
|
|
293
|
-
const payment = new Payment();
|
|
294
|
-
payment.adminUserId = systemUser.id;
|
|
295
|
-
|
|
296
|
-
// Who will receive this money?
|
|
297
|
-
payment.organizationId = sellingOrganization.id;
|
|
298
|
-
|
|
299
|
-
// Who paid
|
|
300
|
-
payment.payingOrganizationId = organization.id;
|
|
301
|
-
payment.customer = customer;
|
|
302
|
-
|
|
303
|
-
payment.status = PaymentStatus.Pending;
|
|
304
|
-
payment.price = applicationFee.amount;
|
|
305
|
-
payment.roundingAmount = 0;
|
|
306
|
-
payment.method = PaymentMethod.AccountDeductions;
|
|
307
|
-
payment.type = PaymentType.Payment;
|
|
308
|
-
payment.createMandate = null;
|
|
309
|
-
payment.reference = reference;
|
|
310
|
-
|
|
311
|
-
payment.provider = PaymentProvider.Stripe;
|
|
312
|
-
payment.stripeAccountId = stripeAccount.id;
|
|
313
|
-
await payment.save();
|
|
314
|
-
|
|
315
|
-
for (const balanceItem of balanceItems) {
|
|
316
|
-
// Create one balance item payment to pay it in one payment
|
|
317
|
-
const balanceItemPayment = new BalanceItemPayment();
|
|
318
|
-
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
319
|
-
balanceItemPayment.paymentId = payment.id;
|
|
320
|
-
balanceItemPayment.organizationId = payment.organizationId;
|
|
321
|
-
balanceItemPayment.price = balanceItem.priceWithVAT;
|
|
322
|
-
await balanceItemPayment.save();
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
await PaymentService.handlePaymentStatusUpdate(payment, sellingOrganization, PaymentStatus.Succeeded, this.start);
|
|
326
|
-
return payment;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export class StripeInvoicer {
|
|
331
|
-
private stripe: Stripe;
|
|
332
|
-
|
|
333
|
-
constructor({ secretKey }: { secretKey: string }) {
|
|
334
|
-
this.stripe = new Stripe(secretKey, { apiVersion: '2024-06-20', typescript: true, maxNetworkRetries: 1, timeout: 10000 });
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
static getMonthUnixStartEnd(date: Date) {
|
|
338
|
-
const start = Math.floor((new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0).getTime()) / 1000);
|
|
339
|
-
const end = Math.ceil((new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime() - 1000) / 1000);
|
|
340
|
-
return { start, end };
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Loops all months until all invoices are generated
|
|
344
|
-
async generateAllInvoices(sellingOrganization: Organization, options?: { force?: boolean; start?: Date; forceLast?: boolean }) {
|
|
345
|
-
const startMonth = options?.start ?? new Date(2026, 0, 1);
|
|
346
|
-
const stopAt = new Date(Date.now() + (STAMHOOFD.environment === 'production' ? (-60 * 60 * 24 * 1000) : (60 * 60 * 24 * 1000 * 31))); // One day margin before creating invoices
|
|
347
|
-
let currentMonth = new Date(startMonth);
|
|
348
|
-
|
|
349
|
-
while (true) {
|
|
350
|
-
const { start, end } = StripeInvoicer.getMonthUnixStartEnd(currentMonth);
|
|
351
|
-
|
|
352
|
-
if (end >= stopAt.getTime() / 1000) {
|
|
353
|
-
// Stop
|
|
354
|
-
break;
|
|
355
|
-
}
|
|
356
|
-
const nextMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
|
357
|
-
const { end: endNext } = StripeInvoicer.getMonthUnixStartEnd(nextMonth);
|
|
358
|
-
const isLastMonth = endNext >= stopAt.getTime() / 1000;
|
|
359
|
-
await this.generateInvoices(sellingOrganization, currentMonth, { ...options, force: (isLastMonth && !!options?.forceLast) || !!options?.force });
|
|
360
|
-
currentMonth = nextMonth;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async generateInvoices(sellingOrganization: Organization, month: Date, options?: { force?: boolean }) {
|
|
365
|
-
const { start, end } = StripeInvoicer.getMonthUnixStartEnd(month);
|
|
366
|
-
const reference = 'stripe-fees-' + Formatter.dateIso(new Date(start * 1000));
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
await QueueHandler.schedule(reference, async () => {
|
|
370
|
-
if (!options?.force && await StripeReportInvoicer.hasPayment(sellingOrganization, reference)) {
|
|
371
|
-
console.log('Already invoiced month', reference);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
console.log('Generating invoices for ', reference, start, end);
|
|
376
|
-
const reportFees = await this.fetchBalanceItems({
|
|
377
|
-
created: {
|
|
378
|
-
gte: start,
|
|
379
|
-
lte: end,
|
|
380
|
-
},
|
|
381
|
-
type: 'application_fee',
|
|
382
|
-
expand: ['data.source', 'data.source.originating_transaction'],
|
|
383
|
-
});
|
|
384
|
-
const reportRefund = await this.fetchBalanceItems({
|
|
385
|
-
created: {
|
|
386
|
-
gte: start,
|
|
387
|
-
lte: end,
|
|
388
|
-
},
|
|
389
|
-
type: 'application_fee_refund',
|
|
390
|
-
expand: ['data.source', 'data.source.originating_transaction'],
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
const report = reportFees.combine(reportRefund);
|
|
394
|
-
const invoicer = new StripeReportInvoicer(report, new Date(start * 1000), new Date(end * 1000));
|
|
395
|
-
await invoicer.generateInvoices(sellingOrganization, reference);
|
|
396
|
-
});
|
|
397
|
-
} catch (e) {
|
|
398
|
-
console.error(e);
|
|
399
|
-
|
|
400
|
-
// Send email notification of this error
|
|
401
|
-
Email.sendWebmaster({
|
|
402
|
-
subject: 'Aanmaken Stripe facturen voor ' + Formatter.dateNumber(month) + ' mislukt',
|
|
403
|
-
html: 'Aanmaken Stripe facturen voor ' + Formatter.dateNumber(month) + ' mislukt. <br><br> ' + e.toString(),
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
private async fetchBalanceItems(options: Stripe.BalanceTransactionListParams) {
|
|
409
|
-
// For the given payout, fetch all balance items
|
|
410
|
-
const params = { ...options };
|
|
411
|
-
const report = new StripeReport();
|
|
412
|
-
|
|
413
|
-
for await (const balanceItem of this.stripe.balanceTransactions.list(params)) {
|
|
414
|
-
report.add(balanceItem);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
return report;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { Order, Payment, StripeCheckoutSession, StripePaymentIntent } from '@stamhoofd/models';
|
|
2
|
-
import { Settlement } from '@stamhoofd/structures';
|
|
3
|
-
import Stripe from 'stripe';
|
|
4
|
-
import { passthroughFetch } from './passthroughFetch.js';
|
|
5
|
-
|
|
6
|
-
export class StripePayoutChecker {
|
|
7
|
-
private stripe: Stripe;
|
|
8
|
-
private stripePlatform: Stripe;
|
|
9
|
-
|
|
10
|
-
constructor({ secretKey, stripeAccount }: { secretKey: string; stripeAccount?: string }) {
|
|
11
|
-
this.stripe = new Stripe(
|
|
12
|
-
secretKey, {
|
|
13
|
-
apiVersion: '2024-06-20',
|
|
14
|
-
typescript: true,
|
|
15
|
-
maxNetworkRetries: 1,
|
|
16
|
-
timeout: 10000,
|
|
17
|
-
stripeAccount,
|
|
18
|
-
httpClient: STAMHOOFD.environment === 'test'
|
|
19
|
-
? Stripe.createFetchHttpClient(passthroughFetch)
|
|
20
|
-
: undefined,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
this.stripePlatform = new Stripe(
|
|
24
|
-
secretKey, {
|
|
25
|
-
apiVersion: '2024-06-20',
|
|
26
|
-
typescript: true,
|
|
27
|
-
maxNetworkRetries: 1,
|
|
28
|
-
timeout: 10000,
|
|
29
|
-
httpClient: STAMHOOFD.environment === 'test'
|
|
30
|
-
? Stripe.createFetchHttpClient(passthroughFetch)
|
|
31
|
-
: undefined,
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async checkSettlements(checkAll = false) {
|
|
36
|
-
// Check last 2 weeks + 3 day margin, unless we check them all
|
|
37
|
-
const d = new Date();
|
|
38
|
-
d.setDate(d.getDate() - 20);
|
|
39
|
-
|
|
40
|
-
if (checkAll) {
|
|
41
|
-
d.setFullYear(2022, 11, 1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Loop all payouts
|
|
45
|
-
try {
|
|
46
|
-
// Fetch all payouts that are paid out
|
|
47
|
-
for await (const payout of this.stripe.payouts.list({
|
|
48
|
-
status: 'paid',
|
|
49
|
-
arrival_date: {
|
|
50
|
-
gte: Math.floor(d.getTime() / 1000),
|
|
51
|
-
},
|
|
52
|
-
})) {
|
|
53
|
-
// Get all payments for this payout
|
|
54
|
-
try {
|
|
55
|
-
await this.fetchBalanceItems(payout);
|
|
56
|
-
} catch (e) {
|
|
57
|
-
console.error('Error for payout ' + payout.id);
|
|
58
|
-
console.error(e);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
} catch (e) {
|
|
62
|
-
console.error(e);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
private async fetchBalanceItems(payout: Stripe.Payout) {
|
|
67
|
-
// For the given payout, fetch all balance items
|
|
68
|
-
const params = {
|
|
69
|
-
payout: payout.id,
|
|
70
|
-
// Via the Application Fee object, we can get the original payment metadata
|
|
71
|
-
expand: ['data.source', 'data.source.application_fee', 'data.source.application_fee.originating_transaction'],
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
for await (const balanceItem of this.stripe.balanceTransactions.list(params)) {
|
|
75
|
-
if (balanceItem.type === 'charge' || balanceItem.type === 'payment') {
|
|
76
|
-
await this.handleBalanceItem(payout, balanceItem);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private async handleBalanceItem(payout: Stripe.Payout, balanceItem: Stripe.BalanceTransaction) {
|
|
84
|
-
if (!balanceItem.source || typeof balanceItem.source === 'string') {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (balanceItem.source.object !== 'charge') {
|
|
88
|
-
console.log('No payment id set for charge ' + balanceItem.source.id);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
let paymentId = balanceItem.source.metadata.payment;
|
|
93
|
-
|
|
94
|
-
if (!paymentId) {
|
|
95
|
-
// Search in the metadata of the application fee, originating transaction
|
|
96
|
-
if (typeof balanceItem.source.application_fee !== 'string' && balanceItem.source.application_fee) {
|
|
97
|
-
const applicationFee = balanceItem.source.application_fee;
|
|
98
|
-
|
|
99
|
-
if (applicationFee.originating_transaction !== 'string' && applicationFee.originating_transaction) {
|
|
100
|
-
const originatingTransaction = applicationFee.originating_transaction as Stripe.Charge;
|
|
101
|
-
paymentId = originatingTransaction.metadata.payment;
|
|
102
|
-
|
|
103
|
-
if (!paymentId) {
|
|
104
|
-
// Historical bug where we didn't save payment in metadata
|
|
105
|
-
// Try to look it up by payment intent id
|
|
106
|
-
|
|
107
|
-
if (originatingTransaction.payment_intent) {
|
|
108
|
-
const paymentIntentId = typeof originatingTransaction.payment_intent === 'string' ? originatingTransaction.payment_intent : originatingTransaction.payment_intent.id;
|
|
109
|
-
const stripePayments = await StripePaymentIntent.where({
|
|
110
|
-
stripeIntentId: paymentIntentId,
|
|
111
|
-
}, { limit: 1 });
|
|
112
|
-
|
|
113
|
-
if (stripePayments.length === 1) {
|
|
114
|
-
paymentId = stripePayments[0].paymentId;
|
|
115
|
-
console.log('Found missing payment metadata for payment intent ', originatingTransaction.payment_intent, paymentId);
|
|
116
|
-
} else {
|
|
117
|
-
// Probably a card payment
|
|
118
|
-
// Search for the checkout session
|
|
119
|
-
const checkoutSession = await this.stripePlatform.checkout.sessions.list({
|
|
120
|
-
payment_intent: paymentIntentId,
|
|
121
|
-
});
|
|
122
|
-
if (checkoutSession.data.length === 1) {
|
|
123
|
-
const session = checkoutSession.data[0];
|
|
124
|
-
console.log('Found checkout session for payment intent ', paymentIntentId, session);
|
|
125
|
-
|
|
126
|
-
// Search
|
|
127
|
-
const stripeCheckoutSessions = await StripeCheckoutSession.where({
|
|
128
|
-
stripeSessionId: session.id,
|
|
129
|
-
}, { limit: 1 });
|
|
130
|
-
|
|
131
|
-
if (stripeCheckoutSessions.length === 1) {
|
|
132
|
-
paymentId = stripeCheckoutSessions[0].paymentId;
|
|
133
|
-
console.log('Found missing payment metadata for payment intent ', originatingTransaction.payment_intent, paymentId);
|
|
134
|
-
} else {
|
|
135
|
-
console.log('No payment found for checkout session ' + session.id);
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
console.log('No Stripe Checkout Sessions found for payment intent ' + paymentIntentId);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (!paymentId) {
|
|
148
|
-
console.log(balanceItem);
|
|
149
|
-
console.log('No payment id set for charge ' + balanceItem.source.id);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const applicationFee = balanceItem.source.application_fee_amount;
|
|
154
|
-
const otherFees = balanceItem.fee;
|
|
155
|
-
const totalFees = Math.max(otherFees, (applicationFee ?? 0));
|
|
156
|
-
|
|
157
|
-
// Cool, we can store this in the database now.
|
|
158
|
-
|
|
159
|
-
const payment = await Payment.getByID(paymentId);
|
|
160
|
-
if (!payment) {
|
|
161
|
-
console.log('Invalid payment id set for charge ' + balanceItem.source.id + ': ' + paymentId);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (payment.price !== balanceItem.amount * 100) {
|
|
166
|
-
console.log('Amount mismatch for payment ' + payment.id + ': ' + payment.price + ' !== ' + (balanceItem.amount * 100));
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const settlement = Settlement.create({
|
|
171
|
-
id: payout.id,
|
|
172
|
-
reference: payout.statement_descriptor ?? '',
|
|
173
|
-
settledAt: new Date(payout.arrival_date * 1000),
|
|
174
|
-
amount: payout.amount * 100,
|
|
175
|
-
// Set only if application fee is witheld
|
|
176
|
-
fee: totalFees * 100,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
payment.settlement = settlement;
|
|
180
|
-
payment.transferFee = totalFees * 100 - payment.serviceFeePayout;
|
|
181
|
-
|
|
182
|
-
// Force an updatedAt timestamp of the related order
|
|
183
|
-
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
184
|
-
const order = await Order.getForPayment(null, payment.id);
|
|
185
|
-
if (order) {
|
|
186
|
-
order.updatedAt = new Date();
|
|
187
|
-
order.forceSaveProperty('updatedAt');
|
|
188
|
-
await order.save();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await payment.save();
|
|
192
|
-
}
|
|
193
|
-
}
|