@stamhoofd/backend 2.130.0 → 2.132.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/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
- package/src/helpers/AuthenticatedStructures.ts +2 -2
- package/src/helpers/CheckSettlements.test.ts +190 -0
- package/src/helpers/CheckSettlements.ts +73 -45
- package/src/helpers/MembershipCharger.ts +26 -1
- package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
- package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
- package/src/helpers/StripePayoutExportData.ts +195 -0
- package/src/helpers/StripePayoutExportExcel.ts +280 -0
- package/src/helpers/StripePayoutReporter.test.ts +419 -0
- package/src/helpers/StripePayoutReporter.ts +585 -0
- package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
- package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
- package/src/services/uitpas/UitpasService.ts +1 -1
- package/src/services/uitpas/cancelTicketSales.ts +1 -1
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
- package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
- package/src/services/uitpas/registerTicketSales.ts +1 -1
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/tests/e2e/documents.test.ts +3 -1
- package/tests/helpers/MollieMocker.ts +73 -0
- package/tests/vitest.global.setup.ts +1 -1
- package/tests/vitest.setup.ts +4 -2
- package/vitest.config.js +10 -0
- /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
|
@@ -5,7 +5,7 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
6
|
import { Email } from '@stamhoofd/email';
|
|
7
7
|
import type { MemberWithUsersRegistrationsAndGroups, Organization, Payment } from '@stamhoofd/models';
|
|
8
|
-
import { BalanceItem, CachedBalance, Group, Member, Platform, RateLimiter, Registration } from '@stamhoofd/models';
|
|
8
|
+
import { BalanceItem, CachedBalance, Group, Member, Platform, RateLimiter, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
9
9
|
import type { BalanceItem as BalanceItemStruct, PlatformMember, RegisterItem } from '@stamhoofd/structures';
|
|
10
10
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, IDRegisterCheckout, Payment as PaymentStruct, PermissionLevel, PlatformFamily, ReceivableBalanceType, RegisterResponse, TranslatedString } from '@stamhoofd/structures';
|
|
11
11
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -516,7 +516,26 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
516
516
|
deactivatedRegistrationGroupIds.push(existingRegistration.groupId);
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
-
|
|
519
|
+
// Cache the registration period relation per periodId, so we only load each period once.
|
|
520
|
+
const periodRelationCache = new Map<string, BalanceItemRelation | null>();
|
|
521
|
+
async function getPeriodRelation(periodId: string): Promise<BalanceItemRelation | null> {
|
|
522
|
+
if (periodRelationCache.has(periodId)) {
|
|
523
|
+
return periodRelationCache.get(periodId)!;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const period = await RegistrationPeriod.getByID(periodId);
|
|
527
|
+
const relation = period
|
|
528
|
+
? BalanceItemRelation.create({
|
|
529
|
+
id: period.id,
|
|
530
|
+
name: new TranslatedString(period.getBaseStructure().name),
|
|
531
|
+
})
|
|
532
|
+
: null;
|
|
533
|
+
|
|
534
|
+
periodRelationCache.set(periodId, relation);
|
|
535
|
+
return relation;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function createBalanceItem({ registration, periodId, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; periodId: string; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
|
|
520
539
|
// NOTE: We also need to save zero-price balance items because for online payments, we need to know which registrations to activate after payment
|
|
521
540
|
if (skipZero === true) {
|
|
522
541
|
if (unitPrice === 0 || amount === 0) {
|
|
@@ -524,6 +543,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
524
543
|
}
|
|
525
544
|
}
|
|
526
545
|
|
|
546
|
+
// Add the registration period (working year) relation so it is visible in the item description.
|
|
547
|
+
const periodRelation = await getPeriodRelation(periodId);
|
|
548
|
+
if (periodRelation) {
|
|
549
|
+
relations.set(BalanceItemRelationType.RegistrationPeriod, periodRelation);
|
|
550
|
+
}
|
|
551
|
+
|
|
527
552
|
// Create balance item
|
|
528
553
|
const balanceItem = new BalanceItem();
|
|
529
554
|
balanceItem.registrationId = registration.id;
|
|
@@ -608,6 +633,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
608
633
|
// Base price
|
|
609
634
|
await createBalanceItem({
|
|
610
635
|
registration,
|
|
636
|
+
periodId: item.group.periodId,
|
|
611
637
|
unitPrice: item.groupPrice.price.forMember(item.member),
|
|
612
638
|
type: BalanceItemType.Registration,
|
|
613
639
|
skipZero: false, // Always create at least one balance item for each registration - even when the price is zero
|
|
@@ -621,6 +647,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
621
647
|
for (const option of item.options) {
|
|
622
648
|
await createBalanceItem({
|
|
623
649
|
registration,
|
|
650
|
+
periodId: item.group.periodId,
|
|
624
651
|
amount: option.amount,
|
|
625
652
|
unitPrice: option.option.price.forMember(item.member),
|
|
626
653
|
skipZero: true, // Do not create for zero option prices
|
|
@@ -654,6 +681,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
654
681
|
// Base price
|
|
655
682
|
await createBalanceItem({
|
|
656
683
|
registration,
|
|
684
|
+
periodId: item.group.periodId,
|
|
657
685
|
unitPrice: -discountValue,
|
|
658
686
|
type: BalanceItemType.RegistrationBundleDiscount,
|
|
659
687
|
description: discount.name,
|
|
@@ -722,6 +750,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
722
750
|
|
|
723
751
|
await createBalanceItem({
|
|
724
752
|
registration: registration.registration,
|
|
753
|
+
periodId: registration.group.periodId,
|
|
725
754
|
unitPrice: -difference,
|
|
726
755
|
type: BalanceItemType.RegistrationBundleDiscount,
|
|
727
756
|
description: discount.name,
|
|
@@ -198,7 +198,7 @@ export class GetInvoicesEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
return new PaginatedResponse<InvoiceStruct[], LimitedFilteredRequest>({
|
|
201
|
-
results: await AuthenticatedStructures.invoices(invoices),
|
|
201
|
+
results: await AuthenticatedStructures.invoices(invoices, true),
|
|
202
202
|
next,
|
|
203
203
|
});
|
|
204
204
|
}
|
package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts
CHANGED
|
@@ -83,7 +83,7 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
|
|
|
83
83
|
const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type);
|
|
84
84
|
const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels);
|
|
85
85
|
const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false);
|
|
86
|
-
const invoices = await AuthenticatedStructures.invoices(invoiceModels);
|
|
86
|
+
const invoices = await AuthenticatedStructures.invoices(invoiceModels, true);
|
|
87
87
|
|
|
88
88
|
const balances = await CachedBalance.getForObjects([request.params.id], organization.id, request.params.type);
|
|
89
89
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
|
|
4
|
+
import { PayoutExportStatus, StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
|
|
5
|
+
|
|
6
|
+
type Params = Record<string, never>;
|
|
7
|
+
type Body = undefined;
|
|
8
|
+
type Query = undefined;
|
|
9
|
+
type ResponseBody = PayoutExportStatus[];
|
|
10
|
+
|
|
11
|
+
export class GetStripePayoutsExportStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
12
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
13
|
+
if (request.method !== 'GET') {
|
|
14
|
+
return [false];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const params = Endpoint.parseParameters(request.url, '/stripe/payouts/status', {});
|
|
18
|
+
|
|
19
|
+
if (params) {
|
|
20
|
+
return [true, params as Params];
|
|
21
|
+
}
|
|
22
|
+
return [false];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
26
|
+
await StripePayoutsExportEndpoint.authenticate();
|
|
27
|
+
|
|
28
|
+
return new Response(StripePayoutsExportEndpoint.queue.map((item) => {
|
|
29
|
+
return PayoutExportStatus.create(item);
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
3
|
+
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
4
|
+
import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { Token as TokenModel } from '@stamhoofd/models';
|
|
6
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
7
|
+
import { Company } from '@stamhoofd/structures';
|
|
8
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
9
|
+
import nock from 'nock';
|
|
10
|
+
|
|
11
|
+
import { resetNock } from '../../../../../tests/helpers/resetNock.js';
|
|
12
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
13
|
+
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
14
|
+
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
15
|
+
import { GetStripePayoutsExportStatusEndpoint } from './GetStripePayoutsExportStatusEndpoint.js';
|
|
16
|
+
import { StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
|
|
17
|
+
|
|
18
|
+
describe('Endpoint.StripePayoutsExport', () => {
|
|
19
|
+
const endpoint = new StripePayoutsExportEndpoint();
|
|
20
|
+
const statusEndpoint = new GetStripePayoutsExportStatusEndpoint();
|
|
21
|
+
|
|
22
|
+
let membershipOrganization: Organization;
|
|
23
|
+
let admin: User;
|
|
24
|
+
let adminToken: Token;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
membershipOrganization = await initMembershipOrganization();
|
|
28
|
+
membershipOrganization.meta.companies = [
|
|
29
|
+
Company.create({
|
|
30
|
+
name: 'Platform BV',
|
|
31
|
+
companyNumber: '0700000000',
|
|
32
|
+
VATNumber: 'BE0700000000',
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
await membershipOrganization.save();
|
|
36
|
+
|
|
37
|
+
({ admin, adminToken } = await initPlatformAdmin());
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
resetNock();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const post = async (organization: Organization, token: Token) => {
|
|
45
|
+
const request = Request.buildJson('POST', '/stripe/payouts', organization.getApiHost(), {
|
|
46
|
+
start: new Date(2026, 2, 1).getTime(),
|
|
47
|
+
end: new Date(2026, 3, 1).getTime() - 1000,
|
|
48
|
+
});
|
|
49
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
50
|
+
return await testServer.test(endpoint, request);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getStatus = async (organization: Organization, token: Token) => {
|
|
54
|
+
const request = Request.buildJson('GET', '/stripe/payouts/status', organization.getApiHost());
|
|
55
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
56
|
+
return await testServer.test(statusEndpoint, request);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('A platform admin can export payout reports for the membership organization', async () => {
|
|
60
|
+
nock('https://api.stripe.com')
|
|
61
|
+
.get('/v1/payouts')
|
|
62
|
+
.query(true)
|
|
63
|
+
.reply(200, { object: 'list', data: [], has_more: false, url: '/v1/payouts' });
|
|
64
|
+
|
|
65
|
+
const response = await post(membershipOrganization, adminToken);
|
|
66
|
+
expect(response.status).toBe(200);
|
|
67
|
+
|
|
68
|
+
// Wait for the scheduled report to complete
|
|
69
|
+
await QueueHandler.awaitAll();
|
|
70
|
+
|
|
71
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
72
|
+
const reportEmail = emails.find(e => e.subject.startsWith('Stripe Uitbetalingen'));
|
|
73
|
+
expect(reportEmail).toBeDefined();
|
|
74
|
+
expect(reportEmail!.attachments).toHaveLength(1);
|
|
75
|
+
|
|
76
|
+
// The report is sent to the user that requested it
|
|
77
|
+
expect(reportEmail!.to).toContain(admin.email);
|
|
78
|
+
|
|
79
|
+
// The queue is empty again
|
|
80
|
+
const statusResponse = await getStatus(membershipOrganization, adminToken);
|
|
81
|
+
expect(statusResponse.body).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('A user without platform full access cannot export payout reports', async () => {
|
|
85
|
+
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
86
|
+
const token = await TokenModel.createToken(user);
|
|
87
|
+
|
|
88
|
+
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
89
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
90
|
+
);
|
|
91
|
+
await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
|
|
92
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('Payout reports are not available for other organizations', async () => {
|
|
97
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
98
|
+
|
|
99
|
+
await expect(post(otherOrganization, adminToken)).rejects.toThrow(
|
|
100
|
+
STExpect.simpleError({ code: 'not_available' }),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { AutoEncoder, DateDecoder, field, IntegerDecoder } 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
|
+
|
|
9
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
10
|
+
import { StripePayoutReporter } from '../../../../helpers/StripePayoutReporter.js';
|
|
11
|
+
|
|
12
|
+
export class PayoutExportStatus extends AutoEncoder {
|
|
13
|
+
@field({ decoder: DateDecoder })
|
|
14
|
+
start: Date;
|
|
15
|
+
|
|
16
|
+
@field({ decoder: DateDecoder })
|
|
17
|
+
end: Date;
|
|
18
|
+
|
|
19
|
+
@field({ decoder: IntegerDecoder })
|
|
20
|
+
count = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Params = Record<string, never>;
|
|
24
|
+
class Body extends AutoEncoder {
|
|
25
|
+
@field({ decoder: DateDecoder })
|
|
26
|
+
start: Date;
|
|
27
|
+
|
|
28
|
+
@field({ decoder: DateDecoder })
|
|
29
|
+
end: Date;
|
|
30
|
+
}
|
|
31
|
+
type Query = undefined;
|
|
32
|
+
type ResponseBody = undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Manually build a Stripe payout report for a given period and email it (to check whether
|
|
36
|
+
* everything we charged via Stripe application fees was invoiced correctly).
|
|
37
|
+
*/
|
|
38
|
+
export class StripePayoutsExportEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
39
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
40
|
+
|
|
41
|
+
static queue: PayoutExportStatus[] = [];
|
|
42
|
+
|
|
43
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
44
|
+
if (request.method !== 'POST') {
|
|
45
|
+
return [false];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const params = Endpoint.parseParameters(request.url, '/stripe/payouts', {});
|
|
49
|
+
|
|
50
|
+
if (params) {
|
|
51
|
+
return [true, params as Params];
|
|
52
|
+
}
|
|
53
|
+
return [false];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async authenticate() {
|
|
57
|
+
const organization = await Context.setOrganizationScope();
|
|
58
|
+
const { user } = await Context.authenticate();
|
|
59
|
+
|
|
60
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
61
|
+
throw Context.auth.error();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const platform = await Platform.getShared();
|
|
65
|
+
if (!platform.membershipOrganizationId || platform.membershipOrganizationId !== organization.id) {
|
|
66
|
+
throw new SimpleError({
|
|
67
|
+
code: 'not_available',
|
|
68
|
+
message: 'Stripe payout exports are only available for the platform membership organization',
|
|
69
|
+
statusCode: 400,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { organization, user };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
77
|
+
const { organization, user } = await StripePayoutsExportEndpoint.authenticate();
|
|
78
|
+
|
|
79
|
+
if (!STAMHOOFD.STRIPE_SECRET_KEY) {
|
|
80
|
+
throw new SimpleError({
|
|
81
|
+
code: 'not_configured',
|
|
82
|
+
message: 'Stripe is not configured',
|
|
83
|
+
statusCode: 400,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const secretKey = STAMHOOFD.STRIPE_SECRET_KEY;
|
|
87
|
+
|
|
88
|
+
const start = request.body.start;
|
|
89
|
+
const end = request.body.end;
|
|
90
|
+
|
|
91
|
+
const item = PayoutExportStatus.create({
|
|
92
|
+
start,
|
|
93
|
+
end,
|
|
94
|
+
count: 0,
|
|
95
|
+
});
|
|
96
|
+
StripePayoutsExportEndpoint.queue.push(item);
|
|
97
|
+
|
|
98
|
+
// Schedule
|
|
99
|
+
QueueHandler.schedule('stripe-payout-export', async () => {
|
|
100
|
+
try {
|
|
101
|
+
const reporter = new StripePayoutReporter({
|
|
102
|
+
secretKey,
|
|
103
|
+
sellingOrganization: organization,
|
|
104
|
+
});
|
|
105
|
+
let count = 0;
|
|
106
|
+
reporter.callback = () => {
|
|
107
|
+
count++;
|
|
108
|
+
item.count = count;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await reporter.build(start, end);
|
|
112
|
+
|
|
113
|
+
// Send the report to the user that requested it
|
|
114
|
+
await reporter.sendEmail({
|
|
115
|
+
to: [{ email: user.email, name: user.name }],
|
|
116
|
+
});
|
|
117
|
+
} finally {
|
|
118
|
+
// Remove from queue
|
|
119
|
+
StripePayoutsExportEndpoint.queue.splice(StripePayoutsExportEndpoint.queue.indexOf(item), 1);
|
|
120
|
+
}
|
|
121
|
+
}).catch(console.error);
|
|
122
|
+
|
|
123
|
+
return new Response(undefined);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -70,7 +70,7 @@ export class AuthenticatedStructures {
|
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
static async invoices(invoices: Invoice[]): Promise<InvoiceStruct[]> {
|
|
73
|
+
static async invoices(invoices: Invoice[], incldueSettlements = false): Promise<InvoiceStruct[]> {
|
|
74
74
|
if (invoices.length === 0) {
|
|
75
75
|
return [];
|
|
76
76
|
}
|
|
@@ -78,7 +78,7 @@ export class AuthenticatedStructures {
|
|
|
78
78
|
const { invoicedBalanceItems } = await Invoice.loadBalanceItems(invoices);
|
|
79
79
|
|
|
80
80
|
const paymentModels = await Payment.select().where('invoiceId', invoices.map(i => i.id)).fetch();
|
|
81
|
-
const payments = await this.paymentsGeneral(paymentModels,
|
|
81
|
+
const payments = await this.paymentsGeneral(paymentModels, incldueSettlements);
|
|
82
82
|
|
|
83
83
|
return invoices.map((invoice) => {
|
|
84
84
|
const items = invoicedBalanceItems.filter(i => i.invoiceId === invoice.id);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
2
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
|
|
3
|
+
import type { MollieMockPayment, MollieMockRefund } from '../../tests/helpers/MollieMocker.js';
|
|
4
|
+
import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
|
|
5
|
+
import { checkMollieSettlementsFor } from './CheckSettlements.js';
|
|
6
|
+
|
|
7
|
+
describe('Helper.CheckSettlements', () => {
|
|
8
|
+
let mollieMocker: MollieMocker;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
mollieMocker = new MollieMocker();
|
|
12
|
+
mollieMocker.start();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
mollieMocker.stop();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mollieMocker.reset();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create an organization with a Mollie token, a succeeded Mollie payment and a Mollie refund
|
|
25
|
+
* payment reversing it. Both are linked to their Mollie ids (tr_... / re_...) like the real crons do.
|
|
26
|
+
*/
|
|
27
|
+
const init = async () => {
|
|
28
|
+
const organization = await new OrganizationFactory({}).create();
|
|
29
|
+
const token = await mollieMocker.setupToken(organization);
|
|
30
|
+
|
|
31
|
+
// Source payment
|
|
32
|
+
const payment = new Payment();
|
|
33
|
+
payment.organizationId = organization.id;
|
|
34
|
+
payment.method = PaymentMethod.Bancontact;
|
|
35
|
+
payment.provider = PaymentProvider.Mollie;
|
|
36
|
+
payment.status = PaymentStatus.Succeeded;
|
|
37
|
+
payment.type = PaymentType.Payment;
|
|
38
|
+
payment.price = 50_0000;
|
|
39
|
+
payment.paidAt = new Date();
|
|
40
|
+
await payment.save();
|
|
41
|
+
|
|
42
|
+
const mockPayment: MollieMockPayment = {
|
|
43
|
+
id: mollieMocker.createId('tr'),
|
|
44
|
+
status: 'paid',
|
|
45
|
+
amount: { currency: 'EUR', value: '50.00' },
|
|
46
|
+
internalPaymentId: payment.id,
|
|
47
|
+
redirectUrl: null,
|
|
48
|
+
sequenceType: 'oneoff',
|
|
49
|
+
customerId: null,
|
|
50
|
+
mandateId: null,
|
|
51
|
+
isCancelable: false,
|
|
52
|
+
details: null,
|
|
53
|
+
};
|
|
54
|
+
mollieMocker.payments.push(mockPayment);
|
|
55
|
+
|
|
56
|
+
const paymentLink = new MolliePayment();
|
|
57
|
+
paymentLink.paymentId = payment.id;
|
|
58
|
+
paymentLink.mollieId = mockPayment.id;
|
|
59
|
+
await paymentLink.save();
|
|
60
|
+
|
|
61
|
+
// Refund payment reversing the source payment
|
|
62
|
+
const refundPayment = new Payment();
|
|
63
|
+
refundPayment.organizationId = organization.id;
|
|
64
|
+
refundPayment.method = PaymentMethod.Bancontact;
|
|
65
|
+
refundPayment.provider = PaymentProvider.Mollie;
|
|
66
|
+
refundPayment.status = PaymentStatus.Succeeded;
|
|
67
|
+
refundPayment.type = PaymentType.Refund;
|
|
68
|
+
refundPayment.price = -20_0000;
|
|
69
|
+
refundPayment.reversingPaymentId = payment.id;
|
|
70
|
+
refundPayment.paidAt = new Date();
|
|
71
|
+
await refundPayment.save();
|
|
72
|
+
|
|
73
|
+
const mockRefund = mollieMocker.createRefund(mockPayment, { value: '20.00', status: 'refunded' });
|
|
74
|
+
|
|
75
|
+
const refundLink = new MolliePayment();
|
|
76
|
+
refundLink.paymentId = refundPayment.id;
|
|
77
|
+
refundLink.mollieId = mockRefund.id;
|
|
78
|
+
await refundLink.save();
|
|
79
|
+
|
|
80
|
+
return { organization, token, payment, refundPayment, mockPayment, mockRefund };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const runCron = async (token: { accessToken: string }) => {
|
|
84
|
+
await checkMollieSettlementsFor(token.accessToken, true);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Add a Mollie chargeback payment reversing the given source payment, linked to its Mollie
|
|
89
|
+
* chargeback id (chb_...) like the mollie-chargebacks cron does.
|
|
90
|
+
*/
|
|
91
|
+
const addChargeback = async (organizationId: string, sourcePayment: Payment, mockPayment: MollieMockPayment) => {
|
|
92
|
+
const chargebackPayment = new Payment();
|
|
93
|
+
chargebackPayment.organizationId = organizationId;
|
|
94
|
+
chargebackPayment.method = PaymentMethod.Bancontact;
|
|
95
|
+
chargebackPayment.provider = PaymentProvider.Mollie;
|
|
96
|
+
chargebackPayment.status = PaymentStatus.Succeeded;
|
|
97
|
+
chargebackPayment.type = PaymentType.Chargeback;
|
|
98
|
+
chargebackPayment.price = -sourcePayment.price;
|
|
99
|
+
chargebackPayment.reversingPaymentId = sourcePayment.id;
|
|
100
|
+
chargebackPayment.paidAt = new Date();
|
|
101
|
+
await chargebackPayment.save();
|
|
102
|
+
|
|
103
|
+
const mockChargeback = mollieMocker.createChargeback(mockPayment);
|
|
104
|
+
|
|
105
|
+
const chargebackLink = new MolliePayment();
|
|
106
|
+
chargebackLink.paymentId = chargebackPayment.id;
|
|
107
|
+
chargebackLink.mollieId = mockChargeback.id;
|
|
108
|
+
await chargebackLink.save();
|
|
109
|
+
|
|
110
|
+
return { chargebackPayment, mockChargeback };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
test('The settlement of a refund settled at Mollie is stored on the refund payment', async () => {
|
|
114
|
+
const { token, payment, refundPayment, mockPayment, mockRefund } = await init();
|
|
115
|
+
|
|
116
|
+
const settlement = mollieMocker.createSettlement({
|
|
117
|
+
payments: [mockPayment],
|
|
118
|
+
refunds: [mockRefund],
|
|
119
|
+
value: '100.00',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await runCron(token);
|
|
123
|
+
|
|
124
|
+
// The source payment gets the settlement metadata (existing behaviour)
|
|
125
|
+
const updatedPayment = await Payment.getByID(payment.id);
|
|
126
|
+
expect(updatedPayment!.settlement).toMatchObject({
|
|
127
|
+
id: settlement.id,
|
|
128
|
+
reference: settlement.reference,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The refund payment gets the same settlement metadata (new behaviour)
|
|
132
|
+
const updatedRefund = await Payment.getByID(refundPayment.id);
|
|
133
|
+
expect(updatedRefund!.settlement).toMatchObject({
|
|
134
|
+
id: settlement.id,
|
|
135
|
+
reference: settlement.reference,
|
|
136
|
+
amount: 100_0000,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('A refund that is not part of any settlement keeps no settlement', async () => {
|
|
141
|
+
const { token, refundPayment, mockPayment } = await init();
|
|
142
|
+
|
|
143
|
+
// A settlement that only contains the source payment, not the refund
|
|
144
|
+
mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00' });
|
|
145
|
+
|
|
146
|
+
await runCron(token);
|
|
147
|
+
|
|
148
|
+
const updatedRefund = await Payment.getByID(refundPayment.id);
|
|
149
|
+
expect(updatedRefund!.settlement).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('The settlement of a chargeback settled at Mollie is stored on the chargeback payment', async () => {
|
|
153
|
+
const { organization, token, payment, mockPayment } = await init();
|
|
154
|
+
const { chargebackPayment, mockChargeback } = await addChargeback(organization.id, payment, mockPayment);
|
|
155
|
+
|
|
156
|
+
const settlement = mollieMocker.createSettlement({
|
|
157
|
+
payments: [mockPayment],
|
|
158
|
+
chargebacks: [mockChargeback],
|
|
159
|
+
value: '100.00',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await runCron(token);
|
|
163
|
+
|
|
164
|
+
const updatedChargeback = await Payment.getByID(chargebackPayment.id);
|
|
165
|
+
expect(updatedChargeback!.settlement).toMatchObject({
|
|
166
|
+
id: settlement.id,
|
|
167
|
+
reference: settlement.reference,
|
|
168
|
+
amount: 100_0000,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('An unlinked refund entry in a settlement is skipped without affecting the known refund', async () => {
|
|
173
|
+
const { token, refundPayment, mockPayment, mockRefund } = await init();
|
|
174
|
+
|
|
175
|
+
// A refund that belongs to a different system: it exists at Mollie but has no MolliePayment link
|
|
176
|
+
const unlinkedRefund: MollieMockRefund = mollieMocker.createRefund(mockPayment, { value: '5.00', status: 'refunded' });
|
|
177
|
+
|
|
178
|
+
const settlement = mollieMocker.createSettlement({
|
|
179
|
+
payments: [mockPayment],
|
|
180
|
+
refunds: [unlinkedRefund, mockRefund],
|
|
181
|
+
value: '100.00',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await runCron(token);
|
|
185
|
+
|
|
186
|
+
// The known refund still gets its settlement, the unlinked one is silently ignored
|
|
187
|
+
const updatedRefund = await Payment.getByID(refundPayment.id);
|
|
188
|
+
expect(updatedRefund!.settlement).toMatchObject({ id: settlement.id });
|
|
189
|
+
});
|
|
190
|
+
});
|