@stamhoofd/backend 2.131.0 → 2.132.1
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/endpoints/admin/organizations/ChargeOrganizationsEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +143 -0
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +11 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +12 -5
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
- 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/StripeInvoicer.ts +0 -4
- package/src/helpers/StripePayoutChecker.ts +0 -4
- package/src/seeds/1783520259-make-old-group-filters-readable.ts +454 -0
- package/src/services/InvoiceService.ts +21 -0
- package/src/services/uitpas/searchUitpasEvents.ts +3 -1
- package/tests/helpers/MollieMocker.ts +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.132.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -57,20 +57,20 @@
|
|
|
57
57
|
"@simonbackx/simple-endpoints": "1.21.1",
|
|
58
58
|
"@simonbackx/simple-errors": "1.5.0",
|
|
59
59
|
"@simonbackx/simple-logging": "1.0.1",
|
|
60
|
-
"@stamhoofd/backend-env": "2.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
60
|
+
"@stamhoofd/backend-env": "2.132.1",
|
|
61
|
+
"@stamhoofd/backend-i18n": "2.132.1",
|
|
62
|
+
"@stamhoofd/backend-middleware": "2.132.1",
|
|
63
|
+
"@stamhoofd/crons": "2.132.1",
|
|
64
|
+
"@stamhoofd/email": "2.132.1",
|
|
65
|
+
"@stamhoofd/excel-writer": "2.132.1",
|
|
66
|
+
"@stamhoofd/logging": "2.132.1",
|
|
67
|
+
"@stamhoofd/models": "2.132.1",
|
|
68
|
+
"@stamhoofd/object-differ": "2.132.1",
|
|
69
|
+
"@stamhoofd/queues": "2.132.1",
|
|
70
|
+
"@stamhoofd/sql": "2.132.1",
|
|
71
|
+
"@stamhoofd/structures": "2.132.1",
|
|
72
|
+
"@stamhoofd/types": "2.132.1",
|
|
73
|
+
"@stamhoofd/utility": "2.132.1",
|
|
74
74
|
"archiver": "7.0.1",
|
|
75
75
|
"axios": "1.16.0",
|
|
76
76
|
"base-x": "3.0.11",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"stripe": "16.12.0"
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stamhoofd/test-utils": "2.
|
|
94
|
+
"@stamhoofd/test-utils": "2.132.1",
|
|
95
95
|
"@types/cookie": "0.6.0",
|
|
96
96
|
"@types/luxon": "3.7.1",
|
|
97
97
|
"@types/mailparser": "3.4.6",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"publishConfig": {
|
|
108
108
|
"access": "public"
|
|
109
109
|
},
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "99e4195c2638521cf24f1e5ebef5ae9bf5009d1d"
|
|
111
111
|
}
|
|
@@ -58,7 +58,7 @@ export class ChargeOrganizationsEndpoint extends Endpoint<Params, Query, Body, R
|
|
|
58
58
|
filter: body.filter,
|
|
59
59
|
limit: 100,
|
|
60
60
|
}), {
|
|
61
|
-
fetch: GetOrganizationsEndpoint.buildData,
|
|
61
|
+
fetch: request => GetOrganizationsEndpoint.buildData(request),
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
for await (const data of dataGenerator) {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
3
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import type { Organization, User } from '@stamhoofd/models';
|
|
5
|
+
import { BalanceItem, BalanceItemFactory, Invoice, InvoicedBalanceItem, OrganizationFactory, Payment, Token, UserFactory } from '@stamhoofd/models';
|
|
6
|
+
import type { InvoiceStruct } from '@stamhoofd/structures';
|
|
7
|
+
import { PaymentMethod, PaymentStatus, PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
8
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
9
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
10
|
+
import { PatchInvoicesEndpoint } from './PatchInvoicesEndpoint.js';
|
|
11
|
+
|
|
12
|
+
describe('Endpoint.PatchInvoicesEndpoint', () => {
|
|
13
|
+
const endpoint = new PatchInvoicesEndpoint();
|
|
14
|
+
|
|
15
|
+
const createBalanceItem = async ({ organization, unitPrice = 10_00 }: { organization: Organization; unitPrice?: number }) => {
|
|
16
|
+
return await new BalanceItemFactory({
|
|
17
|
+
organizationId: organization.id,
|
|
18
|
+
amount: 1,
|
|
19
|
+
unitPrice,
|
|
20
|
+
}).create();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const createInvoice = async ({ organization, balanceItemIds, number = '1' }: { organization: Organization; balanceItemIds: string[]; number?: string | null }) => {
|
|
24
|
+
const invoice = new Invoice();
|
|
25
|
+
invoice.organizationId = organization.id;
|
|
26
|
+
invoice.number = number;
|
|
27
|
+
invoice.invoicedAt = number ? new Date() : null;
|
|
28
|
+
await invoice.save();
|
|
29
|
+
|
|
30
|
+
for (const balanceItemId of balanceItemIds) {
|
|
31
|
+
const item = new InvoicedBalanceItem();
|
|
32
|
+
item.organizationId = organization.id;
|
|
33
|
+
item.invoiceId = invoice.id;
|
|
34
|
+
item.balanceItemId = balanceItemId;
|
|
35
|
+
item.name = 'Test item';
|
|
36
|
+
item.unitPrice = 10_00;
|
|
37
|
+
item.balanceInvoicedAmount = 10_00;
|
|
38
|
+
await item.save();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Make sure the invoiced cache of the balance items is up to date, like it would be for a real invoice.
|
|
42
|
+
await BalanceItem.updateInvoiced(balanceItemIds);
|
|
43
|
+
|
|
44
|
+
return invoice;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const createPayment = async ({ organization, invoice }: { organization: Organization; invoice: Invoice }) => {
|
|
48
|
+
const payment = new Payment();
|
|
49
|
+
payment.organizationId = organization.id;
|
|
50
|
+
payment.method = PaymentMethod.PointOfSale;
|
|
51
|
+
payment.status = PaymentStatus.Succeeded;
|
|
52
|
+
payment.price = 10_00;
|
|
53
|
+
payment.invoiceId = invoice.id;
|
|
54
|
+
await payment.save();
|
|
55
|
+
return payment;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const patchInvoices = async ({ body, organization, user }: { body: PatchableArrayAutoEncoder<InvoiceStruct>; organization: Organization; user: User }) => {
|
|
59
|
+
const token = await Token.createToken(user);
|
|
60
|
+
const request = Request.buildJson('PATCH', '/invoices', organization.getApiHost(), body);
|
|
61
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
62
|
+
return await testServer.test<InvoiceStruct[]>(endpoint, request);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('Deleting invoices', () => {
|
|
66
|
+
test('deletes the invoice, its invoiced balance items and unlinks the payments', async () => {
|
|
67
|
+
const organization = await new OrganizationFactory({}).create();
|
|
68
|
+
const user = await new UserFactory({
|
|
69
|
+
organization,
|
|
70
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
71
|
+
}).create();
|
|
72
|
+
|
|
73
|
+
const balanceItem = await createBalanceItem({ organization });
|
|
74
|
+
const invoice = await createInvoice({ organization, balanceItemIds: [balanceItem.id] });
|
|
75
|
+
const payment = await createPayment({ organization, invoice });
|
|
76
|
+
|
|
77
|
+
// Sanity check: the balance item is marked as invoiced
|
|
78
|
+
const before = await BalanceItem.getByID(balanceItem.id);
|
|
79
|
+
expect(before!.priceInvoiced).toBe(10_00);
|
|
80
|
+
|
|
81
|
+
const body = new PatchableArray() as PatchableArrayAutoEncoder<InvoiceStruct>;
|
|
82
|
+
body.addDelete(invoice.id);
|
|
83
|
+
|
|
84
|
+
const response = await patchInvoices({ body, organization, user });
|
|
85
|
+
expect(response.status).toBe(200);
|
|
86
|
+
|
|
87
|
+
// Invoice is gone
|
|
88
|
+
expect(await Invoice.getByID(invoice.id)).toBeUndefined();
|
|
89
|
+
|
|
90
|
+
// Invoiced balance items are cascade deleted
|
|
91
|
+
const remainingItems = await InvoicedBalanceItem.select().where('invoiceId', invoice.id).fetch();
|
|
92
|
+
expect(remainingItems).toHaveLength(0);
|
|
93
|
+
|
|
94
|
+
// Payment is kept but unlinked
|
|
95
|
+
const reloadedPayment = await Payment.getByID(payment.id);
|
|
96
|
+
expect(reloadedPayment).toBeDefined();
|
|
97
|
+
expect(reloadedPayment!.invoiceId).toBeNull();
|
|
98
|
+
|
|
99
|
+
// Invoiced cache of the balance item is recalculated
|
|
100
|
+
const after = await BalanceItem.getByID(balanceItem.id);
|
|
101
|
+
expect(after!.priceInvoiced).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('user without full access cannot delete invoices', async () => {
|
|
105
|
+
const organization = await new OrganizationFactory({}).create();
|
|
106
|
+
const user = await new UserFactory({
|
|
107
|
+
organization,
|
|
108
|
+
permissions: Permissions.create({ level: PermissionLevel.Read }),
|
|
109
|
+
}).create();
|
|
110
|
+
|
|
111
|
+
const balanceItem = await createBalanceItem({ organization });
|
|
112
|
+
const invoice = await createInvoice({ organization, balanceItemIds: [balanceItem.id] });
|
|
113
|
+
|
|
114
|
+
const body = new PatchableArray() as PatchableArrayAutoEncoder<InvoiceStruct>;
|
|
115
|
+
body.addDelete(invoice.id);
|
|
116
|
+
|
|
117
|
+
await expect(patchInvoices({ body, organization, user })).rejects.toThrow(STExpect.errorWithCode('permission_denied'));
|
|
118
|
+
|
|
119
|
+
// Invoice is untouched
|
|
120
|
+
expect(await Invoice.getByID(invoice.id)).toBeDefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('cannot delete an invoice of another organization', async () => {
|
|
124
|
+
const organization = await new OrganizationFactory({}).create();
|
|
125
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
126
|
+
const user = await new UserFactory({
|
|
127
|
+
organization,
|
|
128
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
129
|
+
}).create();
|
|
130
|
+
|
|
131
|
+
const balanceItem = await createBalanceItem({ organization: otherOrganization });
|
|
132
|
+
const invoice = await createInvoice({ organization: otherOrganization, balanceItemIds: [balanceItem.id] });
|
|
133
|
+
|
|
134
|
+
const body = new PatchableArray() as PatchableArrayAutoEncoder<InvoiceStruct>;
|
|
135
|
+
body.addDelete(invoice.id);
|
|
136
|
+
|
|
137
|
+
await expect(patchInvoices({ body, organization, user })).rejects.toThrow(STExpect.errorWithCode('not_found'));
|
|
138
|
+
|
|
139
|
+
// Invoice is untouched
|
|
140
|
+
expect(await Invoice.getByID(invoice.id)).toBeDefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -6,7 +6,7 @@ import { Invoice as InvoiceStruct } from '@stamhoofd/structures';
|
|
|
6
6
|
|
|
7
7
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
8
8
|
import { Context } from '../../../../helpers/Context.js';
|
|
9
|
-
import
|
|
9
|
+
import { Invoice } from '@stamhoofd/models';
|
|
10
10
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
11
11
|
import { ViesHelper } from '../../../../helpers/ViesHelper.js';
|
|
12
12
|
import { InvoiceService } from '../../../../services/InvoiceService.js';
|
|
@@ -57,8 +57,17 @@ export class PatchInvoicesEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
57
57
|
invoices.push(model);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
for (const id of request.body.getDeletes()) {
|
|
61
|
+
const model = await Invoice.getByID(id);
|
|
62
|
+
if (!model || model.organizationId !== organization.id) {
|
|
63
|
+
throw Context.auth.notFoundOrNoAccess($t('%ZcE'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await InvoiceService.delete(model);
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
return new Response(
|
|
61
|
-
await AuthenticatedStructures.invoices(invoices),
|
|
70
|
+
await AuthenticatedStructures.invoices(invoices, true),
|
|
62
71
|
);
|
|
63
72
|
}
|
|
64
73
|
}
|
package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { CountFilteredRequest, CountResponse, PaymentCustomer, PaymentMethod, Pe
|
|
|
6
6
|
import { Context } from '../../../../helpers/Context.js';
|
|
7
7
|
import { GetReceivableBalancesEndpoint } from './GetReceivableBalancesEndpoint.js';
|
|
8
8
|
import type { BalanceItem } from '@stamhoofd/models';
|
|
9
|
-
import { Organization, User } from '@stamhoofd/models';
|
|
9
|
+
import { Organization, Platform, User } from '@stamhoofd/models';
|
|
10
10
|
import { CachedBalance } from '@stamhoofd/models';
|
|
11
11
|
import { PaymentService } from '../../../../services/PaymentService.js';
|
|
12
12
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
@@ -15,12 +15,12 @@ import { BalanceItemService } from '../../../../services/BalanceItemService.js';
|
|
|
15
15
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
16
16
|
|
|
17
17
|
type Params = Record<string, never>;
|
|
18
|
-
type Query =
|
|
19
|
-
type Body =
|
|
18
|
+
type Query = undefined;
|
|
19
|
+
type Body = CountFilteredRequest;
|
|
20
20
|
type ResponseBody = undefined;
|
|
21
21
|
|
|
22
22
|
export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
23
|
-
|
|
23
|
+
bodyDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
|
|
24
24
|
|
|
25
25
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
26
26
|
if (request.method !== 'POST') {
|
|
@@ -38,6 +38,7 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
|
|
|
38
38
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
39
39
|
const sellingOrganization = await Context.setOrganizationScope();
|
|
40
40
|
const { user } = await Context.authenticate();
|
|
41
|
+
const platform = await Platform.getShared();
|
|
41
42
|
|
|
42
43
|
if (!await Context.auth.canManageFinances(sellingOrganization.id)) {
|
|
43
44
|
throw Context.auth.error();
|
|
@@ -53,7 +54,7 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
await QueueHandler.schedule(queueId, async () => {
|
|
56
|
-
const query = await GetReceivableBalancesEndpoint.buildQuery(request.
|
|
57
|
+
const query = await GetReceivableBalancesEndpoint.buildQuery(request.body);
|
|
57
58
|
|
|
58
59
|
for await (const cachedBalance of query.all()) {
|
|
59
60
|
if (cachedBalance.organizationId !== sellingOrganization.id) {
|
|
@@ -80,6 +81,12 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
|
|
|
80
81
|
continue;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
if (total <= 4_00_00 && sellingOrganization.id === platform.membershipOrganizationId && STAMHOOFD.userMode === 'organization') {
|
|
85
|
+
// Skip too small payments for Stamhoofd
|
|
86
|
+
console.error('Skipped charging too small payment for', cachedBalance.objectId, cachedBalance.objectType);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
83
90
|
let payingOrganization: Organization | null = null;
|
|
84
91
|
let customerUser: User | null = null;
|
|
85
92
|
if (cachedBalance.objectType === ReceivableBalanceType.organization) {
|
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
|
|
|
@@ -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
|
+
});
|
|
@@ -16,7 +16,11 @@ type MollieSettlement = {
|
|
|
16
16
|
};
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Both payments (tr_...) and refunds (re_...) settled in a settlement are matched to a local
|
|
21
|
+
* payment through the mollieId of their MolliePayment link, so we only need their id here.
|
|
22
|
+
*/
|
|
23
|
+
type MollieSettlementEntryJSON = {
|
|
20
24
|
id: string;
|
|
21
25
|
};
|
|
22
26
|
|
|
@@ -146,64 +150,88 @@ export async function checkMollieSettlementsFor(token: string, checkAll = false)
|
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
async function updateSettlement(token: string, settlement: MollieSettlement
|
|
153
|
+
async function updateSettlement(token: string, settlement: MollieSettlement) {
|
|
154
|
+
// Regular payments settled in this settlement
|
|
155
|
+
await updateSettlementResource(token, settlement, 'payments');
|
|
156
|
+
|
|
157
|
+
// Refunds settled in this settlement. These are negative entries linked to a refund payment
|
|
158
|
+
// (created by the mollie-refunds cron), so we can set their settlement metadata too.
|
|
159
|
+
await updateSettlementResource(token, settlement, 'refunds');
|
|
160
|
+
|
|
161
|
+
// Chargebacks settled in this settlement. Like refunds, these are negative entries linked to a
|
|
162
|
+
// chargeback payment (created by the mollie-chargebacks cron).
|
|
163
|
+
await updateSettlementResource(token, settlement, 'chargebacks');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Loop over all entries (payments, refunds or chargebacks) that are part of a settlement and set the
|
|
168
|
+
* settlement metadata on the matching local payment. Every resource exposes its entries under
|
|
169
|
+
* `_embedded[resource]` and uses the same pagination, so they share this logic.
|
|
170
|
+
*/
|
|
171
|
+
async function updateSettlementResource(token: string, settlement: MollieSettlement, resource: 'payments' | 'refunds' | 'chargebacks', fromId?: string) {
|
|
150
172
|
const limit = 250;
|
|
151
173
|
|
|
152
|
-
|
|
153
|
-
const request = await axios.get('https://api.mollie.com/v2/settlements/' + settlement.id + '/payments?limit=' + limit + (fromPaymentId ? ('&from=' + encodeURIComponent(fromPaymentId)) : ''), {
|
|
174
|
+
const request = await axios.get('https://api.mollie.com/v2/settlements/' + settlement.id + '/' + resource + '?limit=' + limit + (fromId ? ('&from=' + encodeURIComponent(fromId)) : ''), {
|
|
154
175
|
headers: {
|
|
155
176
|
Authorization: 'Bearer ' + token,
|
|
156
177
|
},
|
|
157
178
|
});
|
|
158
179
|
|
|
159
180
|
if (request.status === 200) {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
for (const mollie of molliePayments) {
|
|
163
|
-
// Search payment
|
|
164
|
-
const mps = await MolliePayment.where({ mollieId: mollie.id });
|
|
165
|
-
if (mps.length == 1) {
|
|
166
|
-
const mp = mps[0];
|
|
167
|
-
const payment = await Payment.getByID(mp.paymentId);
|
|
168
|
-
if (payment) {
|
|
169
|
-
payment.settlement = Settlement.create({
|
|
170
|
-
id: settlement.id,
|
|
171
|
-
reference: settlement.reference,
|
|
172
|
-
settledAt: new Date(settlement.settledAt),
|
|
173
|
-
amount: Math.round(parseFloat(settlement.amount.value) * 100) * 100,
|
|
174
|
-
});
|
|
175
|
-
const saved = await payment.save();
|
|
176
|
-
|
|
177
|
-
if (saved) {
|
|
178
|
-
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
179
|
-
const order = await Order.getForPayment(null, payment.id);
|
|
180
|
-
if (order) {
|
|
181
|
-
order.updatedAt = new Date();
|
|
182
|
-
order.forceSaveProperty('updatedAt');
|
|
183
|
-
await order.save();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// TODO: Mark registrations as 'saved'
|
|
187
|
-
}
|
|
181
|
+
const entries = request.data._embedded[resource] as MollieSettlementEntryJSON[];
|
|
188
182
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
console.log(payment.settlement);
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
console.log('Missing payment ' + mp.paymentId);
|
|
195
|
-
}
|
|
196
|
-
} else {
|
|
197
|
-
// Probably a payment in a different system/platform
|
|
198
|
-
// console.log("No mollie payment found for id "+mollie.id)
|
|
199
|
-
}
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
await applySettlementToPayment(settlement, entry.id);
|
|
200
185
|
}
|
|
201
186
|
|
|
202
187
|
// Check next page
|
|
203
|
-
if (request.data._links.next) {
|
|
204
|
-
await
|
|
188
|
+
if (request.data._links.next && entries.length > 0) {
|
|
189
|
+
await updateSettlementResource(token, settlement, resource, entries[entries.length - 1].id);
|
|
205
190
|
}
|
|
206
191
|
} else {
|
|
207
192
|
console.error(request.data);
|
|
208
193
|
}
|
|
209
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Find the local payment linked to a Mollie payment or refund id and store the settlement metadata.
|
|
198
|
+
*/
|
|
199
|
+
async function applySettlementToPayment(settlement: MollieSettlement, mollieId: string) {
|
|
200
|
+
// Search payment
|
|
201
|
+
const mps = await MolliePayment.where({ mollieId });
|
|
202
|
+
if (mps.length === 1) {
|
|
203
|
+
const mp = mps[0];
|
|
204
|
+
const payment = await Payment.getByID(mp.paymentId);
|
|
205
|
+
if (payment) {
|
|
206
|
+
payment.settlement = Settlement.create({
|
|
207
|
+
id: settlement.id,
|
|
208
|
+
reference: settlement.reference,
|
|
209
|
+
settledAt: new Date(settlement.settledAt),
|
|
210
|
+
amount: Math.round(parseFloat(settlement.amount.value) * 100) * 100,
|
|
211
|
+
});
|
|
212
|
+
const saved = await payment.save();
|
|
213
|
+
|
|
214
|
+
if (saved) {
|
|
215
|
+
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
216
|
+
const order = await Order.getForPayment(null, payment.id);
|
|
217
|
+
if (order) {
|
|
218
|
+
order.updatedAt = new Date();
|
|
219
|
+
order.forceSaveProperty('updatedAt');
|
|
220
|
+
await order.save();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// TODO: Mark registrations as 'saved'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (STAMHOOFD.environment === 'development') {
|
|
227
|
+
console.log('Updated settlement of payment ' + payment.id);
|
|
228
|
+
console.log(payment.settlement);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
console.log('Missing payment ' + mp.paymentId);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
// Probably a payment in a different system/platform
|
|
235
|
+
// console.log("No mollie payment found for id "+mollieId)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -57,10 +57,6 @@ export class ApplicationFeeDetails {
|
|
|
57
57
|
this.combine(ApplicationFeeDetails.fromStripe(fee));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
remove(fee: Stripe.BalanceTransaction) {
|
|
61
|
-
this.combine(ApplicationFeeDetails.fromStripe(fee));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
60
|
combine(other: ApplicationFeeDetails) {
|
|
65
61
|
this.serviceFee += other.serviceFee;
|
|
66
62
|
this.transferFee += other.transferFee;
|
|
@@ -69,13 +69,9 @@ export class StripePayoutChecker {
|
|
|
69
69
|
payout: payout.id,
|
|
70
70
|
// Via the Application Fee object, we can get the original payment metadata
|
|
71
71
|
expand: ['data.source', 'data.source.application_fee', 'data.source.application_fee.originating_transaction'],
|
|
72
|
-
// TODO: ALSO DO CARDS! (type: 'charge')
|
|
73
|
-
// type: 'payment'
|
|
74
72
|
};
|
|
75
73
|
|
|
76
74
|
for await (const balanceItem of this.stripe.balanceTransactions.list(params)) {
|
|
77
|
-
// TODO
|
|
78
|
-
|
|
79
75
|
if (balanceItem.type === 'charge' || balanceItem.type === 'payment') {
|
|
80
76
|
await this.handleBalanceItem(payout, balanceItem);
|
|
81
77
|
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Group, Organization, RegistrationPeriod } from '@stamhoofd/models';
|
|
3
|
+
import type { RecordCategory, RegistrationPeriodBase, StamhoofdCompareValue, StamhoofdFilter, StamhoofdMagicRelationFilter } from '@stamhoofd/structures';
|
|
4
|
+
import { GroupType } from '@stamhoofd/structures';
|
|
5
|
+
import { SeedTools } from '../helpers/SeedTools.js';
|
|
6
|
+
|
|
7
|
+
export async function startMigration(dryRun = false) {
|
|
8
|
+
await SeedTools.loop({
|
|
9
|
+
batchSize: 100,
|
|
10
|
+
query: Organization.select(),
|
|
11
|
+
action: async (organization: Organization) => {
|
|
12
|
+
await fixUnreadableGroupFilters(organization, dryRun);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (dryRun) {
|
|
17
|
+
throw new Error('Migration did not finish because of dryRun');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default new Migration(async () => {
|
|
22
|
+
if (STAMHOOFD.environment === 'test') {
|
|
23
|
+
console.log('skipped in tests');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
|
|
28
|
+
console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dryRun = false;
|
|
33
|
+
await startMigration(dryRun);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function getAllRecordCategories(organization: Organization): RecordCategory[] {
|
|
37
|
+
function getAllRecordCategoiesRecursive(category: RecordCategory) {
|
|
38
|
+
const results: RecordCategory[] = [category];
|
|
39
|
+
|
|
40
|
+
for (const child of category.childCategories) {
|
|
41
|
+
results.push(...getAllRecordCategoiesRecursive(child));
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return organization.meta.recordsConfiguration.recordCategories.flatMap(c => getAllRecordCategoiesRecursive(c));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fixUnreadableGroupFilters(organization: Organization, dryRun: boolean) {
|
|
50
|
+
const filtersWithGroupFilters: StamhoofdFilter[] = getAllRecordCategories(organization).flatMap((c) => {
|
|
51
|
+
const filter = c.filter;
|
|
52
|
+
if (filter === null) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [filter.enabledWhen, filter.requiredWhen].filter((f) => {
|
|
57
|
+
return hasRegistrationsFilter(f);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (filtersWithGroupFilters.length === 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('organization:', organization.name);
|
|
66
|
+
|
|
67
|
+
for (const filter of filtersWithGroupFilters) {
|
|
68
|
+
const groupIds = getGroupIdsFromFilter(filter);
|
|
69
|
+
if (groupIds.length === 0) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const groups = await Group.getByIDs(...new Set(groupIds));
|
|
74
|
+
const groupMap = new Map(groups.map(g => [g.id, g]));
|
|
75
|
+
|
|
76
|
+
console.error('filter before: ', JSON.stringify(filter));
|
|
77
|
+
|
|
78
|
+
await makeGroupFiltersReadable(filter, groupMap);
|
|
79
|
+
|
|
80
|
+
console.error('filter after: ', JSON.stringify(filter));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!dryRun) {
|
|
84
|
+
await organization.save();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class StamhoofdFilterHelper {
|
|
89
|
+
static isRecordOrArray(value: StamhoofdFilter): value is { [key: string]: StamhoofdFilter } | StamhoofdFilter[] {
|
|
90
|
+
if (typeof value !== 'object' || value === null || value instanceof Date) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static isRecord(value: StamhoofdFilter): value is { [key: string]: StamhoofdFilter } {
|
|
98
|
+
if (!this.isRecordOrArray(value) || Array.isArray(value)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static isRecordWithSingleEntry(value: StamhoofdFilter): value is { [key: string]: StamhoofdFilter } {
|
|
106
|
+
if (!this.isRecord(value)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.entries(value).length === 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static isEmptyRecord(value: StamhoofdFilter): value is { [key: string]: never } {
|
|
114
|
+
if (!this.isRecord(value)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Object.entries(value).length === 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static hasRegistrationsFilter(filter: StamhoofdFilter): boolean {
|
|
122
|
+
if (filter === null) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof filter !== 'object') {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (filter instanceof Date) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(filter)) {
|
|
135
|
+
return filter.some(f => this.hasRegistrationsFilter(f));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (this.isRegistrationFilter(filter)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// iterate properties
|
|
143
|
+
for (const [, value] of Object.entries(filter as object) as [string, StamhoofdFilter | StamhoofdCompareValue][]) {
|
|
144
|
+
if (this.hasRegistrationsFilter(value)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
static isRegistrationFilter(filter: StamhoofdFilter): filter is { registrations: StamhoofdFilter } & StamhoofdFilter {
|
|
153
|
+
if (!StamhoofdFilterHelper.isRecord(filter)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return Object.entries(filter).some(entry => entry[0] === 'registrations');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hasRegistrationsFilter(filter: StamhoofdFilter): boolean {
|
|
162
|
+
if (filter === null) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof filter !== 'object') {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (filter instanceof Date) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (Array.isArray(filter)) {
|
|
175
|
+
return filter.some(f => hasRegistrationsFilter(f));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isRegistrationFilter(filter)) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// iterate properties
|
|
183
|
+
for (const [, value] of Object.entries(filter as object) as [string, StamhoofdFilter | StamhoofdCompareValue][]) {
|
|
184
|
+
if (hasRegistrationsFilter(value)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isRegistrationFilter(filter: StamhoofdFilter): filter is { registrations: StamhoofdFilter } & StamhoofdFilter {
|
|
193
|
+
if (!StamhoofdFilterHelper.isRecord(filter)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return Object.entries(filter).some(entry => entry[0] === 'registrations');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getGroupIdsFromRegistrationsFilter(registrationFilter: { registrations: StamhoofdFilter }): string[] {
|
|
201
|
+
if (!StamhoofdFilterHelper.isRecordWithSingleEntry(registrationFilter)) {
|
|
202
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entries = Object.entries(registrationFilter);
|
|
206
|
+
|
|
207
|
+
let currentEntry = entries[0];
|
|
208
|
+
|
|
209
|
+
while (true) {
|
|
210
|
+
const currentKey = currentEntry[0];
|
|
211
|
+
|
|
212
|
+
switch (currentKey) {
|
|
213
|
+
case 'registrations':
|
|
214
|
+
case '$elemMatch':
|
|
215
|
+
case '$or':
|
|
216
|
+
case '$and':
|
|
217
|
+
case '$not':
|
|
218
|
+
{
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default: throw new Error(`Invalid registration filter (currentKey: ${currentKey}): ` + JSON.stringify(registrationFilter));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const currentValue = currentEntry[1];
|
|
225
|
+
|
|
226
|
+
if (StamhoofdFilterHelper.isRecordWithSingleEntry(currentValue)) {
|
|
227
|
+
currentEntry = Object.entries(currentValue)[0];
|
|
228
|
+
} else if (Array.isArray(currentValue)) {
|
|
229
|
+
return currentValue.map(item => getGroupIdsFromGroupFilters(item));
|
|
230
|
+
} else {
|
|
231
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getGroupIdsFromGroupFilters(filter: StamhoofdFilter) {
|
|
237
|
+
const groupId = (filter as { group?: { id?: { $eq?: string } } })?.group?.id?.$eq;
|
|
238
|
+
if (typeof groupId !== 'string') {
|
|
239
|
+
throw new Error('Invalid group filter: ' + JSON.stringify(filter));
|
|
240
|
+
}
|
|
241
|
+
return groupId;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getGroupIdsFromFilter(filter: StamhoofdFilter): string[] {
|
|
245
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(filter)) {
|
|
246
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(filter)) {
|
|
250
|
+
return filter.flatMap(item => getGroupIdsFromFilter(item));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const all: string[] = [];
|
|
254
|
+
|
|
255
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
256
|
+
if (key === 'registrations') {
|
|
257
|
+
all.push(...getGroupIdsFromRegistrationsFilter({ [key]: value }));
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(value)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
if (value.length === 0) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const isSomeRecordOrArray = value.some(item => StamhoofdFilterHelper.isRecordOrArray(item));
|
|
270
|
+
|
|
271
|
+
if (!isSomeRecordOrArray) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const item of value) {
|
|
276
|
+
// ignore null
|
|
277
|
+
if (item === null) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const isRecordOrArray = StamhoofdFilterHelper.isRecordOrArray(item);
|
|
282
|
+
if (!isRecordOrArray) {
|
|
283
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
all.push(...getGroupIdsFromFilter(item));
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
all.push(...getGroupIdsFromFilter(value));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return all;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function makeGroupFiltersReadable(filter: StamhoofdFilter, groupMap: Map<string, Group>): Promise<void> {
|
|
298
|
+
async function getRelationName(group: Group | null | undefined) {
|
|
299
|
+
if (!group) {
|
|
300
|
+
return 'Verwijderde groep';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const groupName = group.settings.name.toString();
|
|
304
|
+
|
|
305
|
+
let period: RegistrationPeriodBase | undefined = undefined;
|
|
306
|
+
|
|
307
|
+
// fetch model if no cached period
|
|
308
|
+
const periodModel = (await RegistrationPeriod.getByID(group.periodId));
|
|
309
|
+
if (periodModel) {
|
|
310
|
+
period = periodModel.getBaseStructure();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (period) {
|
|
314
|
+
return `${groupName} (${period.nameShort})`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return groupName;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function getRelationFilter(filter: StamhoofdFilter, groupMap: Map<string, Group>): Promise<StamhoofdMagicRelationFilter> {
|
|
321
|
+
const groupId = (filter as { group?: { id?: { $eq?: string } } })?.group?.id?.$eq;
|
|
322
|
+
if (typeof groupId !== 'string') {
|
|
323
|
+
throw new Error('Invalid group filter: ' + JSON.stringify(filter));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const group = groupMap.get(groupId);
|
|
327
|
+
if (!group) {
|
|
328
|
+
// double check if group exists, if not: set custom name
|
|
329
|
+
const existingGroup = await Group.getByID(groupId);
|
|
330
|
+
if (existingGroup) {
|
|
331
|
+
// should never happen
|
|
332
|
+
throw new Error(`Group with id ${groupId} exists but is not included in the groupMap`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const relationFilter: StamhoofdMagicRelationFilter = {
|
|
337
|
+
$: '$rel',
|
|
338
|
+
value: groupId,
|
|
339
|
+
type: GroupType.Membership,
|
|
340
|
+
name: await getRelationName(group),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return relationFilter;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function makeRegistrationFilterReadable(registrationFilter: { registrations: StamhoofdFilter }): Promise<void> {
|
|
347
|
+
if (!StamhoofdFilterHelper.isRecordWithSingleEntry(registrationFilter)) {
|
|
348
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const entries = Object.entries(registrationFilter);
|
|
352
|
+
|
|
353
|
+
let parentFilter: StamhoofdFilter | null = null;
|
|
354
|
+
let currentEntry = entries[0];
|
|
355
|
+
const relationFilterArray: StamhoofdMagicRelationFilter[] = [];
|
|
356
|
+
let orParentFilter: StamhoofdFilter | null = null;
|
|
357
|
+
|
|
358
|
+
while (true) {
|
|
359
|
+
const currentKey = currentEntry[0];
|
|
360
|
+
|
|
361
|
+
switch (currentKey) {
|
|
362
|
+
case '$not':
|
|
363
|
+
case '$and': {
|
|
364
|
+
throw new Error(`${currentKey} not supported`);
|
|
365
|
+
}
|
|
366
|
+
case 'registrations':
|
|
367
|
+
case '$elemMatch':
|
|
368
|
+
{
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case '$or': {
|
|
372
|
+
if (!parentFilter) {
|
|
373
|
+
throw new Error('Not expected parentFilter to be null: ' + JSON.stringify(registrationFilter));
|
|
374
|
+
}
|
|
375
|
+
orParentFilter = parentFilter;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
default: throw new Error(`Invalid registration filter (currentKey: ${currentKey}): ` + JSON.stringify(registrationFilter));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const currentValue = currentEntry[1];
|
|
382
|
+
|
|
383
|
+
if (StamhoofdFilterHelper.isRecordWithSingleEntry(currentValue)) {
|
|
384
|
+
parentFilter = currentEntry[1];
|
|
385
|
+
currentEntry = Object.entries(currentValue)[0];
|
|
386
|
+
} else if (Array.isArray(currentValue)) {
|
|
387
|
+
if (orParentFilter === null) {
|
|
388
|
+
throw new Error('Not expected orParentFilter to be null');
|
|
389
|
+
}
|
|
390
|
+
for (const item of currentValue) {
|
|
391
|
+
const relationFilter = await getRelationFilter(item, groupMap);
|
|
392
|
+
relationFilterArray.push(relationFilter);
|
|
393
|
+
}
|
|
394
|
+
delete orParentFilter[currentKey];
|
|
395
|
+
(orParentFilter as any)['groupId'] = {
|
|
396
|
+
$in: relationFilterArray,
|
|
397
|
+
};
|
|
398
|
+
return;
|
|
399
|
+
} else {
|
|
400
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(filter)) {
|
|
406
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (Array.isArray(filter)) {
|
|
410
|
+
for (const item of filter) {
|
|
411
|
+
await makeGroupFiltersReadable(item, groupMap);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
417
|
+
if (key === 'registrations') {
|
|
418
|
+
await makeRegistrationFilterReadable({ [key]: value });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(value)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (Array.isArray(value)) {
|
|
426
|
+
if (value.length === 0) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const isSomeRecordOrArray = value.some(item => StamhoofdFilterHelper.isRecordOrArray(item));
|
|
431
|
+
|
|
432
|
+
if (!isSomeRecordOrArray) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const item of value) {
|
|
437
|
+
// ignore null
|
|
438
|
+
if (item === null) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const isRecordOrArray = StamhoofdFilterHelper.isRecordOrArray(item);
|
|
443
|
+
if (!isRecordOrArray) {
|
|
444
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await makeGroupFiltersReadable(item, groupMap);
|
|
448
|
+
}
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
await makeGroupFiltersReadable(value, groupMap);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -302,6 +302,27 @@ export class InvoiceService {
|
|
|
302
302
|
return model;
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Permanently delete an invoice.
|
|
307
|
+
*
|
|
308
|
+
* Deleting the invoice cascades on the database level:
|
|
309
|
+
* - the linked InvoicedBalanceItems are deleted (ON DELETE CASCADE)
|
|
310
|
+
* - payments linked to this invoice have their invoiceId reset (ON DELETE SET NULL), so they can be invoiced again
|
|
311
|
+
* - other invoices referencing this one via negativeInvoiceId have it reset (ON DELETE SET NULL)
|
|
312
|
+
*
|
|
313
|
+
* After deletion the invoiced cache of the affected balance items is recalculated.
|
|
314
|
+
*/
|
|
315
|
+
static async delete(invoice: Invoice) {
|
|
316
|
+
// Collect the affected balance items before deleting, because the invoiced balance items are cascade deleted.
|
|
317
|
+
const { invoicedBalanceItems } = await Invoice.loadBalanceItems([invoice]);
|
|
318
|
+
const balanceItemIds = Formatter.uniqueArray(invoicedBalanceItems.map(i => i.balanceItemId));
|
|
319
|
+
|
|
320
|
+
await invoice.delete();
|
|
321
|
+
|
|
322
|
+
// Recalculate the invoiced amount cache of the balance items that were invoiced by this invoice.
|
|
323
|
+
await BalanceItemService.updateInvoiced(balanceItemIds);
|
|
324
|
+
}
|
|
325
|
+
|
|
305
326
|
private static shouldForwardInvoice(invoice: Invoice, organization: Organization) {
|
|
306
327
|
if (invoice.didSendPeppol) {
|
|
307
328
|
return {
|
|
@@ -72,17 +72,19 @@ export async function searchUitpasEvents(clientId: string, uitpasOrganizerId: st
|
|
|
72
72
|
human: $t(`%1BZ`),
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
|
-
const baseUrl = 'https://search-test.uitdatabank.be/events';
|
|
75
|
+
const baseUrl = STAMHOOFD.UITPAS_API_URL?.includes('test') ? 'https://search-test.uitdatabank.be/events' : 'https://search.uitdatabank.be/events';
|
|
76
76
|
const params = new URLSearchParams();
|
|
77
77
|
params.append('clientId', clientId);
|
|
78
78
|
params.append('organizerId', uitpasOrganizerId);
|
|
79
79
|
params.append('embed', 'true');
|
|
80
80
|
params.append('uitpas', 'true');
|
|
81
|
+
params.append('disableDefaultFilters', 'true');
|
|
81
82
|
params.append('start', '0');
|
|
82
83
|
params.append('limit', '200');
|
|
83
84
|
if (textQuery) {
|
|
84
85
|
params.append('text', textQuery);
|
|
85
86
|
}
|
|
87
|
+
params.append('sort[availableTo]', 'desc'); // last available first
|
|
86
88
|
const url = `${baseUrl}?${params.toString()}`;
|
|
87
89
|
const myHeaders = new Headers();
|
|
88
90
|
myHeaders.append('Accept', 'application/json');
|
|
@@ -53,6 +53,22 @@ export type MollieMockRefund = {
|
|
|
53
53
|
metadata: Record<string, unknown> | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
export type MollieMockSettlement = {
|
|
57
|
+
id: string;
|
|
58
|
+
reference: string;
|
|
59
|
+
status: 'open' | 'pending' | 'paidout' | 'failed';
|
|
60
|
+
amount: { currency: string; value: string };
|
|
61
|
+
createdAt: string;
|
|
62
|
+
/** null for the still-open settlement, a date once it has been paid out */
|
|
63
|
+
settledAt: string | null;
|
|
64
|
+
/** Mollie payment ids (tr_...) settled in this settlement */
|
|
65
|
+
paymentIds: string[];
|
|
66
|
+
/** Mollie refund ids (re_...) settled in this settlement */
|
|
67
|
+
refundIds: string[];
|
|
68
|
+
/** Mollie chargeback ids (chb_...) settled in this settlement */
|
|
69
|
+
chargebackIds: string[];
|
|
70
|
+
};
|
|
71
|
+
|
|
56
72
|
const MOLLIE_CHECKOUT_URL = 'https://molliecheckout/';
|
|
57
73
|
|
|
58
74
|
/**
|
|
@@ -76,6 +92,7 @@ export class MollieMocker {
|
|
|
76
92
|
mandates: MollieMockMandate[] = [];
|
|
77
93
|
chargebacks: MollieMockChargeback[] = [];
|
|
78
94
|
refunds: MollieMockRefund[] = [];
|
|
95
|
+
settlements: MollieMockSettlement[] = [];
|
|
79
96
|
|
|
80
97
|
#forceFailure = false;
|
|
81
98
|
|
|
@@ -85,6 +102,7 @@ export class MollieMocker {
|
|
|
85
102
|
this.mandates = [];
|
|
86
103
|
this.chargebacks = [];
|
|
87
104
|
this.refunds = [];
|
|
105
|
+
this.settlements = [];
|
|
88
106
|
this.#forceFailure = false;
|
|
89
107
|
}
|
|
90
108
|
|
|
@@ -206,6 +224,26 @@ export class MollieMocker {
|
|
|
206
224
|
return this.#listResource('chargebacks', this.chargebacks.map(c => this.#chargebackResource(c)));
|
|
207
225
|
}
|
|
208
226
|
|
|
227
|
+
// settlements + nested payments/refunds (drives the settlements cron)
|
|
228
|
+
if (parts[0] === 'settlements' && method === 'GET') {
|
|
229
|
+
if (parts.length === 1) {
|
|
230
|
+
return this.#listResource('settlements', this.settlements.map(s => this.#settlementResource(s)));
|
|
231
|
+
}
|
|
232
|
+
const settlement = this.settlements.find(s => s.id === parts[1]);
|
|
233
|
+
if (settlement && parts.length === 3 && parts[2] === 'payments') {
|
|
234
|
+
const items = this.payments.filter(p => settlement.paymentIds.includes(p.id));
|
|
235
|
+
return this.#listResource('payments', items.map(p => this.#paymentResource(p)));
|
|
236
|
+
}
|
|
237
|
+
if (settlement && parts.length === 3 && parts[2] === 'refunds') {
|
|
238
|
+
const items = this.refunds.filter(r => settlement.refundIds.includes(r.id));
|
|
239
|
+
return this.#listResource('refunds', items.map(r => this.#refundResource(r)));
|
|
240
|
+
}
|
|
241
|
+
if (settlement && parts.length === 3 && parts[2] === 'chargebacks') {
|
|
242
|
+
const items = this.chargebacks.filter(c => settlement.chargebackIds.includes(c.id));
|
|
243
|
+
return this.#listResource('chargebacks', items.map(c => this.#chargebackResource(c)));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
209
247
|
console.error('MollieMocker: unhandled request', method, uri);
|
|
210
248
|
return [500, { detail: 'MollieMocker: unhandled request ' + method + ' ' + uri }];
|
|
211
249
|
}
|
|
@@ -512,6 +550,41 @@ export class MollieMocker {
|
|
|
512
550
|
};
|
|
513
551
|
}
|
|
514
552
|
|
|
553
|
+
// ---- Settlements ------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Register a settled (paid out) settlement that groups the given payments and refunds.
|
|
557
|
+
* Used to drive the settlements cron (CheckSettlements).
|
|
558
|
+
*/
|
|
559
|
+
createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date } = {}): MollieMockSettlement {
|
|
560
|
+
const settlement: MollieMockSettlement = {
|
|
561
|
+
id: this.createId('stl'),
|
|
562
|
+
reference: '1234567.' + (this.settlements.length + 1).toString().padStart(4, '0') + '.01',
|
|
563
|
+
status: 'paidout',
|
|
564
|
+
amount: { currency: 'EUR', value: options.value ?? '0.00' },
|
|
565
|
+
createdAt: new Date().toISOString(),
|
|
566
|
+
settledAt: (options.settledAt ?? new Date()).toISOString(),
|
|
567
|
+
paymentIds: (options.payments ?? []).map(p => p.id),
|
|
568
|
+
refundIds: (options.refunds ?? []).map(r => r.id),
|
|
569
|
+
chargebackIds: (options.chargebacks ?? []).map(c => c.id),
|
|
570
|
+
};
|
|
571
|
+
this.settlements.push(settlement);
|
|
572
|
+
return settlement;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#settlementResource(settlement: MollieMockSettlement) {
|
|
576
|
+
return {
|
|
577
|
+
resource: 'settlement',
|
|
578
|
+
id: settlement.id,
|
|
579
|
+
reference: settlement.reference,
|
|
580
|
+
status: settlement.status,
|
|
581
|
+
amount: settlement.amount,
|
|
582
|
+
createdAt: settlement.createdAt,
|
|
583
|
+
settledAt: settlement.settledAt ?? null,
|
|
584
|
+
_links: { self: { href: 'https://api.mollie.com/v2/settlements/' + settlement.id, type: 'application/hal+json' } },
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
515
588
|
// ---- List helper ------------------------------------------------------
|
|
516
589
|
|
|
517
590
|
#listResource(binderName: string, items: unknown[]): [number, unknown] {
|