@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.
Files changed (65) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  12. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  13. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  14. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  15. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  16. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  17. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  18. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  19. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  22. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  23. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  24. package/src/excel-loaders/payments.ts +18 -1
  25. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  26. package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
  27. package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
  28. package/src/helpers/AuthenticatedStructures.ts +14 -1
  29. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  30. package/src/helpers/MollieSettlementSync.ts +323 -0
  31. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  32. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  33. package/src/helpers/SettlementExporter.test.ts +388 -0
  34. package/src/helpers/SettlementExporter.ts +593 -0
  35. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  36. package/src/helpers/SettlementSyncRunner.ts +64 -0
  37. package/src/helpers/StripeHelper.ts +2 -1
  38. package/src/helpers/StripeSettlementSync.test.ts +997 -0
  39. package/src/helpers/StripeSettlementSync.ts +1053 -0
  40. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  41. package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
  42. package/src/helpers/WebmasterReport.test.ts +109 -0
  43. package/src/helpers/WebmasterReport.ts +115 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  45. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +301 -0
  48. package/src/services/InvoiceService.ts +18 -1
  49. package/src/services/SettlementService.test.ts +632 -0
  50. package/src/services/SettlementService.ts +650 -0
  51. package/src/sql-filters/payment-settlement.test.ts +2 -2
  52. package/src/sql-filters/payments.ts +73 -0
  53. package/tests/helpers/MollieMocker.ts +64 -6
  54. package/tests/helpers/StripeMocker.ts +209 -17
  55. package/src/crons/stripe-payout-reports.ts +0 -69
  56. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  57. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  58. package/src/helpers/CheckSettlements.test.ts +0 -190
  59. package/src/helpers/CheckSettlements.ts +0 -237
  60. package/src/helpers/StripeInvoicer.ts +0 -419
  61. package/src/helpers/StripePayoutChecker.ts +0 -193
  62. package/src/helpers/StripePayoutExportData.ts +0 -195
  63. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  64. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  65. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,424 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { BalanceItem, BalanceItemPayment, Organization, Payment, StripeAccount, User } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { QueueHandler } from '@stamhoofd/queues';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPaymentProviderName, PaymentCustomer, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, TranslatedString } from '@stamhoofd/structures';
6
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
7
+ import { Formatter } from '@stamhoofd/utility';
8
+
9
+ import { ApplicationFeeService, FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
10
+ import { PaymentService } from '../services/PaymentService.js';
11
+ import { SettlementService } from '../services/SettlementService.js';
12
+ import { VATService } from '../services/VATService.js';
13
+ import { StripeSettlementSync } from './StripeSettlementSync.js';
14
+ import { WebmasterReport } from './WebmasterReport.js';
15
+
16
+ /**
17
+ * A month can hold hundreds of thousands of fees, so they are never all loaded at once: every pass
18
+ * iterates them in batches of this size.
19
+ */
20
+ const FEE_BATCH_SIZE = 500;
21
+
22
+ /**
23
+ * How far back the invoicer bills automatically. Older fees need someone to look at why they were
24
+ * never billed, instead of every run walking further and further back.
25
+ */
26
+ const MAXIMUM_INVOICE_MONTHS = 12;
27
+
28
+ /**
29
+ * Stored timestamps have no milliseconds, so a boundary compared against them may not either.
30
+ */
31
+ function truncateToSecond(date: Date): Date {
32
+ const truncated = new Date(date);
33
+ truncated.setMilliseconds(0);
34
+ return truncated;
35
+ }
36
+
37
+ /**
38
+ * What one paying Stripe account owes for one month.
39
+ */
40
+ type AccountTotals = {
41
+ payingOrganizationId: string;
42
+ amountPerType: Map<ApplicationFeeType, number>;
43
+ };
44
+
45
+ /**
46
+ * Bills the stored application fees to the paying organizations: per (account, month) with
47
+ * uninvoiced fees, one ServiceFee/TransferFee balance item pair paid by one AccountDeductions
48
+ * payment (the fees were already deducted from the account's payouts).
49
+ *
50
+ * Idempotency is per fee: a fee with a balanceItemId is billed, everything else still needs
51
+ * billing. A fee that arrives after its month was billed simply lands in an extra payment on the
52
+ * next run. The payment reference is informational only.
53
+ */
54
+ export class ApplicationFeeInvoicer {
55
+ readonly #secretKey: string;
56
+
57
+ constructor({ secretKey }: { secretKey: string }) {
58
+ this.#secretKey = secretKey;
59
+ }
60
+
61
+ static reference(periodStart: Date): string {
62
+ return FEE_PAYMENT_REFERENCE_PREFIX + Formatter.dateIso(periodStart);
63
+ }
64
+
65
+ /**
66
+ * Bills every uninvoiced fee of the months before the current one.
67
+ */
68
+ async generateInvoices(sellingOrganization: Organization): Promise<void> {
69
+ if (!sellingOrganization.meta.companies[0]) {
70
+ return;
71
+ }
72
+
73
+ // A month that can't be billed usually can't be billed for any of its accounts either:
74
+ // report all of them in one email instead of one per month per account
75
+ await WebmasterReport.group('Aanrekenen applicatiekosten', async () => {
76
+ await this.#billUninvoicedMonths(sellingOrganization);
77
+ });
78
+ }
79
+
80
+ async #billUninvoicedMonths(sellingOrganization: Organization): Promise<void> {
81
+ // Fees stored while this run is walking are excluded from every pass, so the totals a
82
+ // balance item is created for and the fees stamped with it can't drift apart. Stored
83
+ // createdAt has no milliseconds, so neither may this boundary
84
+ const snapshot = truncateToSecond(new Date());
85
+
86
+ const currentPeriodStart = SettlementService.getPeriodStart(new Date());
87
+ const oldest = await this.#selectBillableFees(sellingOrganization)
88
+ .where('occurredAt', '<', currentPeriodStart)
89
+ .where('createdAt', '<', snapshot)
90
+ .orderBy('occurredAt', 'ASC')
91
+ .first(false);
92
+
93
+ if (!oldest) {
94
+ return;
95
+ }
96
+
97
+ // A month that can't be billed (its data needs repair first) stays uninvoiced, so without a
98
+ // window every following run would walk it again, forever
99
+ const windowStart = new Date(currentPeriodStart.getFullYear(), currentPeriodStart.getMonth() - MAXIMUM_INVOICE_MONTHS, 1);
100
+ const oldestMonth = SettlementService.getPeriodStart(oldest.occurredAt);
101
+
102
+ if (oldestMonth < windowStart) {
103
+ WebmasterReport.report(
104
+ 'Applicatiekosten van voor ' + Formatter.dateIso(windowStart) + ' worden niet meer aangerekend',
105
+ 'Er staan nog niet-aangerekende applicatiekosten van ' + Formatter.dateIso(oldestMonth) + '. Die maand valt buiten het venster van ' + MAXIMUM_INVOICE_MONTHS + ' maanden en wordt niet meer automatisch aangerekend.',
106
+ );
107
+ }
108
+
109
+ const platformSync = new StripeSettlementSync({ secretKey: this.#secretKey });
110
+ let month = oldestMonth < windowStart ? windowStart : oldestMonth;
111
+
112
+ while (month < currentPeriodStart) {
113
+ const nextMonth = new Date(month.getFullYear(), month.getMonth() + 1, 1);
114
+
115
+ // Walking a month at Stripe is expensive: only months that still owe us something
116
+ if (!await this.#hasUninvoicedFees(sellingOrganization, month, nextMonth, snapshot)) {
117
+ month = nextMonth;
118
+ continue;
119
+ }
120
+
121
+ // Only a complete, error-free fee walk may invoice the month: a missing fee means the
122
+ // month waits (and someone gets an email), never a short invoice
123
+ const { start, end } = SettlementService.getMonthUnixStartEnd(month);
124
+ try {
125
+ await platformSync.syncFees({ start: new Date(start * 1000), end: new Date(end * 1000) });
126
+ await this.generateInvoicesForMonth(sellingOrganization, month, snapshot);
127
+ } catch (e) {
128
+ console.error('Invoicing application fees failed for month ' + Formatter.dateIso(month), e);
129
+ WebmasterReport.report('Aanmaken kosten-facturatie voor ' + Formatter.dateIso(month) + ' overgeslagen', e);
130
+ }
131
+
132
+ month = new Date(month.getFullYear(), month.getMonth() + 1, 1);
133
+ }
134
+ }
135
+
136
+ async #hasUninvoicedFees(sellingOrganization: Organization, periodStart: Date, nextPeriodStart: Date, snapshot: Date): Promise<boolean> {
137
+ return !!await this.#selectUninvoicedFees(sellingOrganization, periodStart, nextPeriodStart, snapshot).first(false);
138
+ }
139
+
140
+ /**
141
+ * Bills the uninvoiced fees of one month, one payment per paying account. An error for one
142
+ * account never affects the other accounts.
143
+ */
144
+ async generateInvoicesForMonth(sellingOrganization: Organization, month: Date, snapshot: Date = truncateToSecond(new Date())): Promise<void> {
145
+ const periodStart = SettlementService.getPeriodStart(month);
146
+ const nextPeriodStart = new Date(periodStart.getFullYear(), periodStart.getMonth() + 1, 1);
147
+ const reference = ApplicationFeeInvoicer.reference(periodStart);
148
+
149
+ await QueueHandler.schedule(reference, async () => {
150
+ const totalsPerAccount = new Map<string, AccountTotals>();
151
+
152
+ // Both ids are non-null: #selectUninvoicedFees only returns fees that can be billed
153
+ for await (const fee of this.#selectUninvoicedFees(sellingOrganization, periodStart, nextPeriodStart, snapshot).all()) {
154
+ const totals = totalsPerAccount.get(fee.payingStripeAccountId!) ?? {
155
+ payingOrganizationId: fee.payingOrganizationId!,
156
+ amountPerType: new Map<ApplicationFeeType, number>(),
157
+ };
158
+ totals.amountPerType.set(fee.type, (totals.amountPerType.get(fee.type) ?? 0) + fee.amount);
159
+ totalsPerAccount.set(fee.payingStripeAccountId!, totals);
160
+ }
161
+
162
+ for (const [payingStripeAccountId, totals] of totalsPerAccount) {
163
+ try {
164
+ await this.#invoiceGroup({ sellingOrganization, payingStripeAccountId, totals, periodStart, nextPeriodStart, snapshot });
165
+ } catch (e) {
166
+ console.error('Invoicing application fees failed for account ' + payingStripeAccountId + ' - ' + reference, e);
167
+ WebmasterReport.report('Aanrekenen applicatiekosten voor ' + payingStripeAccountId + ' - ' + reference + ' mislukt', e);
168
+ }
169
+ }
170
+ });
171
+ }
172
+
173
+ /**
174
+ * A fee this invoicer cannot bill is left out everywhere, or every run would walk its month at
175
+ * Stripe and report it again: without a paying organization there is nobody left to bill, and
176
+ * without its Stripe account a month cannot be checked against what the legacy invoicer billed
177
+ * per account — billing it anyway risks charging it twice. Both are reported by the sync that
178
+ * stored them (StripeSettlementSync.reportUnattributedFee).
179
+ */
180
+ #selectUninvoicedFees(sellingOrganization: Organization, periodStart: Date, nextPeriodStart: Date, snapshot: Date) {
181
+ return this.#selectBillableFees(sellingOrganization)
182
+ .where('occurredAt', '>=', periodStart)
183
+ .where('occurredAt', '<', nextPeriodStart)
184
+ .where('createdAt', '<', snapshot)
185
+ .limit(FEE_BATCH_SIZE);
186
+ }
187
+
188
+ #selectBillableFees(sellingOrganization: Organization) {
189
+ return ApplicationFee.select()
190
+ .where('organizationId', sellingOrganization.id)
191
+ .where('balanceItemId', null)
192
+ .where('payingOrganizationId', '!=', null)
193
+ .where('payingStripeAccountId', '!=', null);
194
+ }
195
+
196
+ async #invoiceGroup({ sellingOrganization, payingStripeAccountId, totals, periodStart, nextPeriodStart, snapshot }: {
197
+ sellingOrganization: Organization;
198
+ payingStripeAccountId: string;
199
+ totals: AccountTotals;
200
+ periodStart: Date;
201
+ nextPeriodStart: Date;
202
+ snapshot: Date;
203
+ }): Promise<void> {
204
+ const seller = sellingOrganization.meta.companies[0];
205
+ if (!seller) {
206
+ return;
207
+ }
208
+
209
+ const stripeAccount = await StripeAccount.getByID(payingStripeAccountId);
210
+ if (!stripeAccount) {
211
+ throw new SimpleError({
212
+ code: 'stripe_account_not_found',
213
+ message: 'Stripe account ' + payingStripeAccountId + ' of uninvoiced application fees does not exist',
214
+ });
215
+ }
216
+
217
+ const organization = await Organization.getByID(totals.payingOrganizationId);
218
+ if (!organization) {
219
+ throw new SimpleError({
220
+ code: 'organization_not_found',
221
+ message: 'No organization found for Stripe account ' + stripeAccount.accountId,
222
+ });
223
+ }
224
+
225
+ // Uninvoiced fees in a month the legacy invoicer billed mean the inline legacy linking
226
+ // failed (and already emailed): billing them again would charge the account twice
227
+ const legacyPayments = await ApplicationFeeService.findLegacyFeePayments({
228
+ organizationId: sellingOrganization.id,
229
+ payingOrganizationId: organization.id,
230
+ payingStripeAccountId: stripeAccount.id,
231
+ periodStart,
232
+ });
233
+ if (legacyPayments.length > 0) {
234
+ throw new SimpleError({
235
+ code: 'legacy_month_not_linked',
236
+ message: 'Month ' + Formatter.dateIso(periodStart) + ' was billed by the legacy invoicer but has uninvoiced fees: linking failed, not billing them again',
237
+ });
238
+ }
239
+
240
+ await this.#assertNoOrphanedBalanceItems(sellingOrganization, stripeAccount.id, periodStart, nextPeriodStart);
241
+
242
+ const customer = PaymentCustomer.create({
243
+ company: organization.defaultCompanies[0],
244
+ });
245
+
246
+ if (customer.company!.isSameEntity(seller)) {
247
+ throw new SimpleError({
248
+ code: 'same_customer',
249
+ message: 'Cannot invoice self',
250
+ });
251
+ }
252
+
253
+ const totalAmount = [...totals.amountPerType.values()].reduce((total, amount) => total + amount, 0);
254
+ if (totalAmount === 0) {
255
+ return;
256
+ }
257
+
258
+ const monthEnd = new Date(nextPeriodStart.getTime() - 1000);
259
+ const itemPerType = new Map<ApplicationFeeType, BalanceItem>();
260
+
261
+ for (const type of [ApplicationFeeType.Service, ApplicationFeeType.Transfer]) {
262
+ const amount = totals.amountPerType.get(type) ?? 0;
263
+ if (amount === 0) {
264
+ continue;
265
+ }
266
+ itemPerType.set(type, await this.#createBalanceItem({ sellingOrganization, organization, type, amount, periodStart, monthEnd }));
267
+ }
268
+
269
+ const balanceItems = [...itemPerType.values()];
270
+ let total = 0;
271
+ for (const balanceItem of balanceItems) {
272
+ total += balanceItem.priceWithVAT;
273
+ }
274
+ if (total !== totalAmount) {
275
+ throw new SimpleError({
276
+ code: 'price_mismatched',
277
+ message: 'The charged amount does not match the total application fee for the payment',
278
+ });
279
+ }
280
+
281
+ // Stamp before creating the payment: a crash in between leaves detectable unpaid items
282
+ // (see #assertNoOrphanedBalanceItems), never fees that get billed twice
283
+ let stampedAmount = 0;
284
+ for await (const fees of this.#selectUninvoicedFees(sellingOrganization, periodStart, nextPeriodStart, snapshot)
285
+ .where('payingStripeAccountId', stripeAccount.id)
286
+ .allBatched()) {
287
+ for (const fee of fees) {
288
+ const item = itemPerType.get(fee.type);
289
+ if (!item) {
290
+ throw new SimpleError({
291
+ code: 'missing_balance_item',
292
+ message: 'No ' + fee.type + ' balance item was created for fee ' + fee.externalId,
293
+ });
294
+ }
295
+ await ApplicationFeeService.markInvoiced(fee, item.id, { payment: null });
296
+ stampedAmount += fee.amount;
297
+ }
298
+ }
299
+
300
+ if (stampedAmount !== totalAmount) {
301
+ throw new SimpleError({
302
+ code: 'price_mismatched',
303
+ message: 'Stamped ' + stampedAmount + ' of application fees but billed ' + totalAmount,
304
+ });
305
+ }
306
+
307
+ const systemUser = await User.getSystem();
308
+
309
+ const payment = new Payment();
310
+ payment.adminUserId = systemUser.id;
311
+
312
+ // The receiver of the fees
313
+ payment.organizationId = sellingOrganization.id;
314
+
315
+ // The payer
316
+ payment.payingOrganizationId = organization.id;
317
+ payment.customer = customer;
318
+
319
+ payment.status = PaymentStatus.Pending;
320
+ payment.price = totalAmount;
321
+ payment.roundingAmount = 0;
322
+ payment.method = PaymentMethod.AccountDeductions;
323
+ payment.type = PaymentType.Payment;
324
+ payment.createMandate = null;
325
+ payment.reference = ApplicationFeeInvoicer.reference(periodStart);
326
+
327
+ payment.provider = PaymentProvider.Stripe;
328
+ payment.stripeAccountId = stripeAccount.id;
329
+ await payment.save();
330
+
331
+ for (const balanceItem of balanceItems) {
332
+ const balanceItemPayment = new BalanceItemPayment();
333
+ balanceItemPayment.balanceItemId = balanceItem.id;
334
+ balanceItemPayment.paymentId = payment.id;
335
+ balanceItemPayment.organizationId = payment.organizationId;
336
+ balanceItemPayment.price = balanceItem.priceWithVAT;
337
+ await balanceItemPayment.save();
338
+ }
339
+
340
+ // Paid now, not in the billed month: the invoices cron only picks up recent payments, so a
341
+ // month that is billed late would otherwise never end up on an invoice
342
+ await PaymentService.handlePaymentStatusUpdate(payment, sellingOrganization, PaymentStatus.Succeeded, new Date());
343
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
344
+ }
345
+
346
+ /**
347
+ * A crash between stamping the fees and creating the payment leaves balance items without a
348
+ * payment. Never bill on top of that: the totals of a new payment would no longer match the
349
+ * items, someone has to repair first.
350
+ */
351
+ async #assertNoOrphanedBalanceItems(sellingOrganization: Organization, payingStripeAccountId: string, periodStart: Date, nextPeriodStart: Date): Promise<void> {
352
+ const checked = new Set<string>();
353
+
354
+ for await (const fees of ApplicationFee.select()
355
+ .where('organizationId', sellingOrganization.id)
356
+ .where('payingStripeAccountId', payingStripeAccountId)
357
+ .where('balanceItemId', '!=', null)
358
+ .where('occurredAt', '>=', periodStart)
359
+ .where('occurredAt', '<', nextPeriodStart)
360
+ .limit(FEE_BATCH_SIZE)
361
+ .allBatched()) {
362
+ const balanceItemIds = Formatter.uniqueArray(fees.map(fee => fee.balanceItemId!)).filter(id => !checked.has(id));
363
+ if (balanceItemIds.length === 0) {
364
+ continue;
365
+ }
366
+ balanceItemIds.forEach(id => checked.add(id));
367
+
368
+ const balanceItemPayments = await BalanceItemPayment.select()
369
+ .where('balanceItemId', balanceItemIds)
370
+ .fetch();
371
+ const paidItemIds = new Set(balanceItemPayments.map(b => b.balanceItemId));
372
+
373
+ const orphaned = balanceItemIds.filter(id => !paidItemIds.has(id));
374
+ if (orphaned.length > 0) {
375
+ throw new SimpleError({
376
+ code: 'orphaned_balance_items',
377
+ message: 'Balance items ' + orphaned.join(', ') + ' bill application fees but have no payment: repair before invoicing more fees of this month',
378
+ });
379
+ }
380
+ }
381
+ }
382
+
383
+ async #createBalanceItem({ sellingOrganization, organization, type, amount, periodStart, monthEnd }: {
384
+ sellingOrganization: Organization;
385
+ organization: Organization;
386
+ type: ApplicationFeeType;
387
+ amount: number;
388
+ periodStart: Date;
389
+ monthEnd: Date;
390
+ }): Promise<BalanceItem> {
391
+ const item = new BalanceItem();
392
+ item.type = type === ApplicationFeeType.Service ? BalanceItemType.ServiceFee : BalanceItemType.TransferFee;
393
+ item.description = type === ApplicationFeeType.Service
394
+ ? $t('%1Wd', {
395
+ startDate: Formatter.startDate(periodStart, false, true),
396
+ endDate: Formatter.endDate(monthEnd, false, true),
397
+ })
398
+ : $t('%1Xx', {
399
+ startDate: Formatter.startDate(periodStart, false, true),
400
+ endDate: Formatter.endDate(monthEnd, false, true),
401
+ });
402
+ item.relations.set(BalanceItemRelationType.PaymentProvider, BalanceItemRelation.create({
403
+ id: PaymentProvider.Stripe,
404
+ name: TranslatedString.create(getPaymentProviderName(PaymentProvider.Stripe)),
405
+ }));
406
+ item.payingOrganizationId = organization.id;
407
+ item.organizationId = sellingOrganization.id;
408
+ item.VATPercentage = 21;
409
+ item.VATExcempt = VATService.getVATExcempt({
410
+ company: organization.defaultCompanies[0] ?? null,
411
+ sellingOrganization,
412
+ type: 'services',
413
+ });
414
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
415
+ item.quantity = 1;
416
+ item.unitPrice = amount;
417
+ item.createdAt = new Date();
418
+ item.status = BalanceItemStatus.Hidden;
419
+ item.startDate = periodStart;
420
+ item.endDate = monthEnd;
421
+ await item.save();
422
+ return item;
423
+ }
424
+ }
@@ -43,15 +43,28 @@ export class AuthenticatedStructures {
43
43
  }
44
44
  }
45
45
 
46
- const includeSettlements = checkPermissions && !!Context.user && !!Context.user.permissions && payments.every(p => !!Context.optionalAuth?.checkScope(p.organizationId));
46
+ // A payout tells an organization's whole story: its total, the bank reference and our sync
47
+ // diagnostics. Being able to read a payment (e.g. as the manager of the member who made it)
48
+ // is not enough — only someone who may manage the organization's payments sees them
49
+ const includeSettlements = checkPermissions
50
+ && !!Context.user
51
+ && !!Context.user.permissions
52
+ && (await Promise.all(
53
+ Formatter.uniqueArray(payments.map(p => p.organizationId)).map(async organizationId =>
54
+ !!Context.optionalAuth?.checkScope(organizationId) && !!(await Context.optionalAuth?.canManagePayments(organizationId)),
55
+ ),
56
+ )).every(allowed => allowed);
47
57
 
48
58
  const { payingOrganizations } = await Payment.loadPayingOrganizations(payments);
59
+ const { paymentSettlements, settlements } = includeSettlements ? await Payment.loadSettlements(payments) : { paymentSettlements: [], settlements: [] };
49
60
 
50
61
  return Payment.getGeneralStructureFromRelations({
51
62
  payments,
52
63
  balanceItemPayments,
53
64
  balanceItems,
54
65
  payingOrganizations,
66
+ paymentSettlements,
67
+ settlements,
55
68
  }, includeSettlements);
56
69
  }
57
70