@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,140 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { AutoEncoder, DateDecoder, EnumDecoder, field } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Email } from '@stamhoofd/email';
|
|
7
|
+
import { Organization, Platform } from '@stamhoofd/models';
|
|
8
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
9
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
10
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
11
|
+
|
|
12
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
13
|
+
import { SettlementExporter } from '../../../../helpers/SettlementExporter.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* payment_settlements grows like payments, so an export can't span an unbounded range.
|
|
17
|
+
*/
|
|
18
|
+
const MAXIMUM_RANGE_MS = 1100 * 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
type Params = Record<string, never>;
|
|
21
|
+
class Body extends AutoEncoder {
|
|
22
|
+
@field({ decoder: DateDecoder })
|
|
23
|
+
start: Date;
|
|
24
|
+
|
|
25
|
+
@field({ decoder: DateDecoder })
|
|
26
|
+
end: Date;
|
|
27
|
+
|
|
28
|
+
@field({ decoder: new EnumDecoder(PaymentProvider), nullable: true, optional: true })
|
|
29
|
+
provider: PaymentProvider | null = null;
|
|
30
|
+
}
|
|
31
|
+
type Query = undefined;
|
|
32
|
+
type ResponseBody = undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the settlements reconciliation report (from the stored tables only, no provider API calls)
|
|
36
|
+
* and email it to the requesting admin.
|
|
37
|
+
*
|
|
38
|
+
* An export always covers exactly the payouts of the organization in context, for any finance
|
|
39
|
+
* admin of that organization. The platform's own payouts belong to the membership organization, so
|
|
40
|
+
* platform admins export those by scoping to the membership organization like any other.
|
|
41
|
+
*/
|
|
42
|
+
export class SettlementsExportEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
43
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
44
|
+
|
|
45
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
46
|
+
if (request.method !== 'POST') {
|
|
47
|
+
return [false];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const params = Endpoint.parseParameters(request.url, '/settlements/export', {});
|
|
51
|
+
|
|
52
|
+
if (params) {
|
|
53
|
+
return [true, params as Params];
|
|
54
|
+
}
|
|
55
|
+
return [false];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static async authenticate() {
|
|
59
|
+
const organization = await Context.setOrganizationScope();
|
|
60
|
+
const { user } = await Context.authenticate();
|
|
61
|
+
|
|
62
|
+
if (!await Context.auth.canManageFinances(organization.id)) {
|
|
63
|
+
throw Context.auth.error();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const platform = await Platform.getSharedStruct();
|
|
67
|
+
const enabled = platform.config.featureFlags.includes('settlements')
|
|
68
|
+
|| organization.privateMeta.featureFlags.includes('settlements');
|
|
69
|
+
|
|
70
|
+
if (!enabled) {
|
|
71
|
+
throw new SimpleError({
|
|
72
|
+
code: 'not_available',
|
|
73
|
+
message: 'Settlements are not enabled for this organization',
|
|
74
|
+
statusCode: 400,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { organization, user, platform };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
82
|
+
const { organization, user, platform } = await SettlementsExportEndpoint.authenticate();
|
|
83
|
+
|
|
84
|
+
const { start, end, provider } = request.body;
|
|
85
|
+
|
|
86
|
+
if (end.getTime() < start.getTime()) {
|
|
87
|
+
throw new SimpleError({
|
|
88
|
+
code: 'invalid_range',
|
|
89
|
+
message: 'The export ends before it starts',
|
|
90
|
+
statusCode: 400,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (end.getTime() - start.getTime() > MAXIMUM_RANGE_MS) {
|
|
95
|
+
throw new SimpleError({
|
|
96
|
+
code: 'range_too_large',
|
|
97
|
+
message: 'The requested export range is too large',
|
|
98
|
+
statusCode: 400,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The membership organization sells the platform's fee invoices
|
|
103
|
+
const membershipOrganization = platform.membershipOrganizationId ? await Organization.getByID(platform.membershipOrganizationId) : null;
|
|
104
|
+
if (!membershipOrganization) {
|
|
105
|
+
throw new SimpleError({
|
|
106
|
+
code: 'missing_membership_organization',
|
|
107
|
+
message: 'Platform has no membership organization configured',
|
|
108
|
+
statusCode: 400,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Serialized so concurrent exports can't compete for memory; the result arrives by email
|
|
113
|
+
QueueHandler.schedule('settlements-export', async () => {
|
|
114
|
+
const exporter = new SettlementExporter({
|
|
115
|
+
start,
|
|
116
|
+
end,
|
|
117
|
+
provider,
|
|
118
|
+
organization,
|
|
119
|
+
sellingOrganization: membershipOrganization,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await exporter.sendEmail({
|
|
123
|
+
to: [{ email: user.email, name: user.name }],
|
|
124
|
+
});
|
|
125
|
+
}).catch((e) => {
|
|
126
|
+
// The request already returned: without this the export just never arrives
|
|
127
|
+
console.error('Settlements export failed', e);
|
|
128
|
+
|
|
129
|
+
Email.send({
|
|
130
|
+
from: Email.getWebmasterFromEmail(),
|
|
131
|
+
to: [{ email: user.email, name: user.name }],
|
|
132
|
+
subject: 'Uitbetalingen export mislukt',
|
|
133
|
+
text: 'Het maken van de export van ' + Formatter.dateTime(start) + ' tot ' + Formatter.dateTime(end) + ' is mislukt. Probeer een kortere periode of neem contact op.\n',
|
|
134
|
+
type: 'transactional',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return new Response(undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
3
|
+
import { OrganizationFactory, Payment, Token as TokenModel, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
5
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
7
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
8
|
+
|
|
9
|
+
import { StripeMocker } from '../../../../../tests/helpers/StripeMocker.js';
|
|
10
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
11
|
+
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
12
|
+
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
13
|
+
import { GetSettlementsSyncStatusEndpoint } from './GetSettlementsSyncStatusEndpoint.js';
|
|
14
|
+
import { SettlementsSyncEndpoint } from './SettlementsSyncEndpoint.js';
|
|
15
|
+
|
|
16
|
+
describe('Endpoint.SettlementsSync', () => {
|
|
17
|
+
const endpoint = new SettlementsSyncEndpoint();
|
|
18
|
+
const statusEndpoint = new GetSettlementsSyncStatusEndpoint();
|
|
19
|
+
const stripeMocker = new StripeMocker();
|
|
20
|
+
|
|
21
|
+
let membershipOrganization: Organization;
|
|
22
|
+
let admin: User;
|
|
23
|
+
let adminToken: Token;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
membershipOrganization = await initMembershipOrganization();
|
|
27
|
+
({ admin, adminToken } = await initPlatformAdmin());
|
|
28
|
+
stripeMocker.start();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
stripeMocker.stop();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
stripeMocker.clear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const post = async (organization: Organization, token: Token, body: Record<string, unknown> = {}) => {
|
|
40
|
+
const request = Request.buildJson('POST', '/settlements/sync', organization.getApiHost(), {
|
|
41
|
+
start: new Date(2026, 0, 1).getTime(),
|
|
42
|
+
end: new Date(2026, 0, 31).getTime(),
|
|
43
|
+
providers: [PaymentProvider.Stripe],
|
|
44
|
+
...body,
|
|
45
|
+
});
|
|
46
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
47
|
+
return await testServer.test(endpoint, request);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const getStatus = async (organization: Organization, token: Token) => {
|
|
51
|
+
const request = Request.buildJson('GET', '/settlements/sync/status', organization.getApiHost());
|
|
52
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
53
|
+
return await testServer.test(statusEndpoint, request);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test('A platform admin can run the sync, which stores the payouts', async () => {
|
|
57
|
+
// A payment on our own platform account: a connected organization's payment only passes
|
|
58
|
+
// through our balance and is settled by its own payout
|
|
59
|
+
const payment = new Payment();
|
|
60
|
+
payment.organizationId = membershipOrganization.id;
|
|
61
|
+
payment.method = PaymentMethod.Bancontact;
|
|
62
|
+
payment.provider = PaymentProvider.Stripe;
|
|
63
|
+
payment.status = PaymentStatus.Succeeded;
|
|
64
|
+
payment.price = 100_00_00;
|
|
65
|
+
payment.paidAt = new Date(2026, 0, 15);
|
|
66
|
+
await payment.save();
|
|
67
|
+
|
|
68
|
+
const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
|
|
69
|
+
stripeMocker.createBalanceTransaction({
|
|
70
|
+
type: 'charge',
|
|
71
|
+
amount: 10000,
|
|
72
|
+
created: new Date(2026, 0, 15),
|
|
73
|
+
payout: payout.id,
|
|
74
|
+
source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const response = await post(membershipOrganization, adminToken);
|
|
78
|
+
expect(response.status).toBe(200);
|
|
79
|
+
|
|
80
|
+
await QueueHandler.awaitAll();
|
|
81
|
+
|
|
82
|
+
const settlement = await Settlement.select().where('externalId', payout.id).first(true);
|
|
83
|
+
expect(settlement.syncedAt).not.toBeNull();
|
|
84
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
85
|
+
|
|
86
|
+
// The queue is empty again
|
|
87
|
+
const statusResponse = await getStatus(membershipOrganization, adminToken);
|
|
88
|
+
expect(statusResponse.body).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('A user without platform full access cannot run the sync', async () => {
|
|
92
|
+
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
93
|
+
const token = await TokenModel.createToken(user);
|
|
94
|
+
|
|
95
|
+
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
96
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
97
|
+
);
|
|
98
|
+
await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
|
|
99
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('The sync is not available for other organizations', async () => {
|
|
104
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
105
|
+
|
|
106
|
+
await expect(post(otherOrganization, adminToken)).rejects.toThrow(
|
|
107
|
+
STExpect.simpleError({ code: 'not_available' }),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { ArrayDecoder, AutoEncoder, BooleanDecoder, DateDecoder, EnumDecoder, field } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Platform } from '@stamhoofd/models';
|
|
7
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
8
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
9
|
+
import { SettlementsSyncStatus } from '@stamhoofd/structures/settlements/SettlementsSyncStatus.js';
|
|
10
|
+
|
|
11
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
12
|
+
import { SettlementSyncRunner } from '../../../../helpers/SettlementSyncRunner.js';
|
|
13
|
+
|
|
14
|
+
type Params = Record<string, never>;
|
|
15
|
+
class Body extends AutoEncoder {
|
|
16
|
+
@field({ decoder: DateDecoder, optional: true })
|
|
17
|
+
start: Date = new Date(2025, 0, 1);
|
|
18
|
+
|
|
19
|
+
@field({ decoder: DateDecoder, nullable: true, optional: true })
|
|
20
|
+
end: Date | null = null;
|
|
21
|
+
|
|
22
|
+
@field({ decoder: new ArrayDecoder(new EnumDecoder(PaymentProvider)), nullable: true, optional: true })
|
|
23
|
+
providers: PaymentProvider[] | null = null;
|
|
24
|
+
|
|
25
|
+
@field({ decoder: BooleanDecoder, optional: true })
|
|
26
|
+
force = false;
|
|
27
|
+
}
|
|
28
|
+
type Query = undefined;
|
|
29
|
+
type ResponseBody = undefined;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Manually run the settlement sync (backfill) over a period. Everything is an upsert and
|
|
33
|
+
* already-synced settlements are skipped unless force, so re-running is cheap.
|
|
34
|
+
*/
|
|
35
|
+
export class SettlementsSyncEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
36
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
37
|
+
|
|
38
|
+
static queue: SettlementsSyncStatus[] = [];
|
|
39
|
+
|
|
40
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
41
|
+
if (request.method !== 'POST') {
|
|
42
|
+
return [false];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const params = Endpoint.parseParameters(request.url, '/settlements/sync', {});
|
|
46
|
+
|
|
47
|
+
if (params) {
|
|
48
|
+
return [true, params as Params];
|
|
49
|
+
}
|
|
50
|
+
return [false];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A sync covers the provider accounts of the whole platform: only platform admins, and only
|
|
55
|
+
* scoped to the platform membership organization (the owner of the platform's own payouts).
|
|
56
|
+
*/
|
|
57
|
+
static async authenticate() {
|
|
58
|
+
const organization = await Context.setOrganizationScope();
|
|
59
|
+
const { user } = await Context.authenticate();
|
|
60
|
+
|
|
61
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
62
|
+
throw Context.auth.error();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const platform = await Platform.getShared();
|
|
66
|
+
if (!platform.membershipOrganizationId || platform.membershipOrganizationId !== organization.id) {
|
|
67
|
+
throw new SimpleError({
|
|
68
|
+
code: 'not_available',
|
|
69
|
+
message: 'Settlement syncs are only available for the platform membership organization',
|
|
70
|
+
statusCode: 400,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { organization, user };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
78
|
+
await SettlementsSyncEndpoint.authenticate();
|
|
79
|
+
|
|
80
|
+
const { start, end, providers, force } = request.body;
|
|
81
|
+
|
|
82
|
+
const item = SettlementsSyncStatus.create({
|
|
83
|
+
start,
|
|
84
|
+
end,
|
|
85
|
+
force,
|
|
86
|
+
});
|
|
87
|
+
SettlementsSyncEndpoint.queue.push(item);
|
|
88
|
+
|
|
89
|
+
QueueHandler.schedule('settlement-sync', async () => {
|
|
90
|
+
try {
|
|
91
|
+
const runner = new SettlementSyncRunner();
|
|
92
|
+
runner.callback = (summary) => {
|
|
93
|
+
item.count = summary.synced + summary.skipped + summary.failed;
|
|
94
|
+
item.failed = summary.failed + summary.failedFeeMonths;
|
|
95
|
+
};
|
|
96
|
+
await runner.run({ start, end, providers, stripe: { force } });
|
|
97
|
+
} finally {
|
|
98
|
+
SettlementsSyncEndpoint.queue.splice(SettlementsSyncEndpoint.queue.indexOf(item), 1);
|
|
99
|
+
}
|
|
100
|
+
}).catch(console.error);
|
|
101
|
+
|
|
102
|
+
return new Response(undefined);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stam
|
|
|
3
3
|
import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
|
|
4
4
|
import { Order, StripeAccount } from '@stamhoofd/models';
|
|
5
5
|
import type { OrderData } from '@stamhoofd/structures';
|
|
6
|
-
import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
|
|
6
|
+
import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
|
|
7
7
|
import { Formatter } from '@stamhoofd/utility';
|
|
8
8
|
import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
|
|
9
9
|
import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
|
|
@@ -594,6 +594,23 @@ function getSettlementColumns(): XlsxTransformerColumn<PaymentGeneral>[] {
|
|
|
594
594
|
};
|
|
595
595
|
},
|
|
596
596
|
},
|
|
597
|
+
{
|
|
598
|
+
id: 'settlement.check',
|
|
599
|
+
name: $t('%ZkA'),
|
|
600
|
+
width: 24,
|
|
601
|
+
getValue: (object: PaymentGeneralWithStripeAccount) => {
|
|
602
|
+
// Only meaningful for fee payments: their payout lines are derived from the
|
|
603
|
+
// application fees they bill, so a matching sum means completely paid out. Other
|
|
604
|
+
// methods either don't pay out or always match
|
|
605
|
+
if (object.method !== PaymentMethod.AccountDeductions) {
|
|
606
|
+
return { value: '' };
|
|
607
|
+
}
|
|
608
|
+
const settled = object.settlements.reduce((total, line) => total + line.amount, 0);
|
|
609
|
+
return {
|
|
610
|
+
value: settled === object.price ? '✓' : $t('%Zk0'),
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
},
|
|
597
614
|
];
|
|
598
615
|
}
|
|
599
616
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type Stripe from 'stripe';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The service/transfer split of one or more application fees. `fromStripe` is the single source of
|
|
5
|
+
* the split rule: the serviceFee metadata on the originating charge (in cents) is the service part,
|
|
6
|
+
* the remainder of the fee is the transfer part.
|
|
7
|
+
*/
|
|
8
|
+
export class ApplicationFeeDetails {
|
|
9
|
+
transferFee = 0;
|
|
10
|
+
serviceFee = 0;
|
|
11
|
+
count = 0;
|
|
12
|
+
minimumDate: Date | null = null;
|
|
13
|
+
maximumDate: Date | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(details: { count?: number; transferFee: number; serviceFee: number; minimumDate: Date | null; maximumDate: Date | null }) {
|
|
16
|
+
this.count = details.count ?? 0;
|
|
17
|
+
this.transferFee = details.transferFee;
|
|
18
|
+
this.serviceFee = details.serviceFee;
|
|
19
|
+
this.minimumDate = details.minimumDate;
|
|
20
|
+
this.maximumDate = details.maximumDate;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get amount() {
|
|
24
|
+
return this.transferFee + this.serviceFee;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static fromStripe(transaction: Pick<Stripe.BalanceTransaction, 'source' | 'amount' | 'created'>) {
|
|
28
|
+
const source = transaction.source as Stripe.ApplicationFee;
|
|
29
|
+
|
|
30
|
+
// Only a destination charge has an originating transaction on our own account. A direct
|
|
31
|
+
// charge (Standard accounts) keeps it on the connected account, where this walk can't read
|
|
32
|
+
// the metadata that splits the fee
|
|
33
|
+
const originatingTransaction = source.originating_transaction;
|
|
34
|
+
if (!originatingTransaction || typeof originatingTransaction === 'string') {
|
|
35
|
+
throw new Error('Application fee ' + source.id + ' has no expanded originating transaction, which is not supported for direct charges');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const metadata = (originatingTransaction as Stripe.Charge).metadata;
|
|
39
|
+
|
|
40
|
+
const serviceFeeStr = metadata.serviceFee as unknown;
|
|
41
|
+
if (serviceFeeStr === undefined || typeof serviceFeeStr !== 'string') {
|
|
42
|
+
throw new Error('Missing serviceFee metadata');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsed = parseInt(serviceFeeStr);
|
|
46
|
+
if (isNaN(parsed) || !isFinite(parsed)) {
|
|
47
|
+
throw new Error('Invalid serviceFee metadata');
|
|
48
|
+
}
|
|
49
|
+
const serviceFee = parsed * 100; // in cents
|
|
50
|
+
const transferFee = transaction.amount * 100 - serviceFee;
|
|
51
|
+
|
|
52
|
+
// Both parts are billed as their own balance item, so a negative one would invent a credit
|
|
53
|
+
// out of wrong metadata instead of failing
|
|
54
|
+
if (serviceFee < 0 || transferFee < 0) {
|
|
55
|
+
throw new Error('Application fee of ' + transaction.amount * 100 + ' does not cover its serviceFee of ' + serviceFee);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new ApplicationFeeDetails({
|
|
59
|
+
count: 1,
|
|
60
|
+
serviceFee,
|
|
61
|
+
transferFee,
|
|
62
|
+
minimumDate: new Date(transaction.created * 1000),
|
|
63
|
+
maximumDate: new Date(transaction.created * 1000),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|