@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.
Files changed (42) hide show
  1. package/package.json +17 -17
  2. package/src/crons/index.ts +1 -0
  3. package/src/crons/stripe-payout-reports.ts +69 -0
  4. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  6. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  12. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +1 -1
  14. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  16. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  17. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  18. package/src/helpers/AuthenticatedStructures.ts +2 -2
  19. package/src/helpers/CheckSettlements.test.ts +190 -0
  20. package/src/helpers/CheckSettlements.ts +73 -45
  21. package/src/helpers/MembershipCharger.ts +26 -1
  22. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  23. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  24. package/src/helpers/StripePayoutExportData.ts +195 -0
  25. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  26. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  27. package/src/helpers/StripePayoutReporter.ts +585 -0
  28. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  29. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  30. package/src/services/uitpas/UitpasService.ts +1 -1
  31. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  32. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  33. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  34. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  35. package/src/services/uitpas/registerTicketSales.ts +1 -1
  36. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  37. package/tests/e2e/documents.test.ts +3 -1
  38. package/tests/helpers/MollieMocker.ts +73 -0
  39. package/tests/vitest.global.setup.ts +1 -1
  40. package/tests/vitest.setup.ts +4 -2
  41. package/vitest.config.js +10 -0
  42. /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
- async function createBalanceItem({ registration, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
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
  }
@@ -58,7 +58,7 @@ export class PatchInvoicesEndpoint extends Endpoint<Params, Query, Body, Respons
58
58
  }
59
59
 
60
60
  return new Response(
61
- await AuthenticatedStructures.invoices(invoices),
61
+ await AuthenticatedStructures.invoices(invoices, true),
62
62
  );
63
63
  }
64
64
  }
@@ -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, false);
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
+ });