@stamhoofd/backend 2.140.0 → 2.142.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +388 -0
- package/src/helpers/SettlementExporter.ts +593 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +997 -0
- package/src/helpers/StripeSettlementSync.ts +1053 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
- package/src/helpers/WebmasterReport.test.ts +109 -0
- package/src/helpers/WebmasterReport.ts +115 -0
- package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
- package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +301 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +632 -0
- package/src/services/SettlementService.ts +650 -0
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/helpers/MollieMocker.ts +64 -6
- package/tests/helpers/StripeMocker.ts +209 -17
- package/src/crons/stripe-payout-reports.ts +0 -69
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
- package/src/helpers/CheckSettlements.test.ts +0 -190
- package/src/helpers/CheckSettlements.ts +0 -237
- package/src/helpers/StripeInvoicer.ts +0 -419
- package/src/helpers/StripePayoutChecker.ts +0 -193
- package/src/helpers/StripePayoutExportData.ts +0 -195
- package/src/helpers/StripePayoutExportExcel.ts +0 -280
- package/src/helpers/StripePayoutReporter.test.ts +0 -419
- package/src/helpers/StripePayoutReporter.ts +0 -585
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Nightly settlement sync for all providers. Everything is an upsert and synced payouts are
|
|
2
|
+
// skipped, so the windowed re-walk is cheap.
|
|
3
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
4
|
+
import { Email } from '@stamhoofd/email';
|
|
5
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
7
|
+
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
8
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
9
|
+
|
|
10
|
+
import { SettlementSyncRunner } from '../helpers/SettlementSyncRunner.js';
|
|
11
|
+
import { isApplicationFeeInvoicingEnabled } from './stripe-invoices.js';
|
|
12
|
+
|
|
13
|
+
registerCron('settlement-sync', syncSettlements);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many days of settlements the nightly run re-walks.
|
|
17
|
+
*/
|
|
18
|
+
const SYNC_WINDOW_DAYS = 30;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fees received in a platform payout are billed by the monthly invoicer, so pendingFees taking a
|
|
22
|
+
* while to reach 0 is normal. Older than this it means the invoicer failed or skipped something.
|
|
23
|
+
*/
|
|
24
|
+
const MAXIMUM_PENDING_FEES_AGE_MS = 31 * 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
let lastSettlementSync: Date | null = null;
|
|
27
|
+
|
|
28
|
+
async function syncSettlements() {
|
|
29
|
+
if (STAMHOOFD.environment !== 'production') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Between 5 and 6 AM Brussels time.
|
|
34
|
+
if (Formatter.luxon().hour !== 5) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wait for the next day before doing a new sync
|
|
39
|
+
const today = new Date();
|
|
40
|
+
if (lastSettlementSync && Formatter.dateIso(lastSettlementSync) === Formatter.dateIso(today)) {
|
|
41
|
+
console.log('Settlement sync done for this day');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('Syncing settlements...');
|
|
46
|
+
|
|
47
|
+
const start = new Date(today.getTime() - SYNC_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
48
|
+
|
|
49
|
+
const runner = new SettlementSyncRunner();
|
|
50
|
+
await runner.run({
|
|
51
|
+
start,
|
|
52
|
+
providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
|
|
53
|
+
stripe: { retryUnsynced: true },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await reportProblemSettlements();
|
|
57
|
+
|
|
58
|
+
lastSettlementSync = new Date();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every unsynced settlement or non-zero delta is a real question to answer, not noise.
|
|
63
|
+
*
|
|
64
|
+
* Exported for tests only.
|
|
65
|
+
*/
|
|
66
|
+
export async function reportProblemSettlements() {
|
|
67
|
+
// Only payouts that actually arrived: money that failed or was canceled holds no transactions
|
|
68
|
+
// to reconcile
|
|
69
|
+
const unexplained = await Settlement.select()
|
|
70
|
+
.where('status', SettlementStatus.Paid)
|
|
71
|
+
.where('unexplainedAmount', '!=', 0)
|
|
72
|
+
.limit(50)
|
|
73
|
+
.fetch();
|
|
74
|
+
|
|
75
|
+
const unsynced = await Settlement.select()
|
|
76
|
+
.where('status', SettlementStatus.Paid)
|
|
77
|
+
.where('syncedAt', null)
|
|
78
|
+
.limit(50)
|
|
79
|
+
.fetch();
|
|
80
|
+
|
|
81
|
+
// Fees only stop being pending once the invoicer bills them: while it is off, every platform
|
|
82
|
+
// payout would be reported forever
|
|
83
|
+
const stalePendingFees = isApplicationFeeInvoicingEnabled()
|
|
84
|
+
? await Settlement.select()
|
|
85
|
+
.where('status', SettlementStatus.Paid)
|
|
86
|
+
.where('pendingFees', '!=', 0)
|
|
87
|
+
.where('settledAt', '<', new Date(Date.now() - MAXIMUM_PENDING_FEES_AGE_MS))
|
|
88
|
+
.limit(50)
|
|
89
|
+
.fetch()
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
if (unexplained.length === 0 && unsynced.length === 0 && stalePendingFees.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
|
|
97
|
+
|
|
98
|
+
const describe = (settlement: Settlement) => {
|
|
99
|
+
return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + Formatter.price(settlement.uncollectibleFees) + ' niet-aanrekenbare kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
Email.sendWebmaster({
|
|
103
|
+
subject: 'Uitbetalingen met problemen: ' + total,
|
|
104
|
+
html: 'Deze uitbetalingen kloppen niet of konden niet gesynchroniseerd worden: <br><br>'
|
|
105
|
+
+ 'Onverklaard verschil:<br>' + (unexplained.map(describe).join('<br>') || 'geen') + '<br><br>'
|
|
106
|
+
+ 'Niet gesynchroniseerd:<br>' + (unsynced.map(describe).join('<br>') || 'geen') + '<br><br>'
|
|
107
|
+
+ 'Kosten te lang niet gefactureerd:<br>' + (stalePendingFees.map(describe).join('<br>') || 'geen'),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
3
|
-
import { StripeInvoicer } from '../helpers/StripeInvoicer.js';
|
|
4
2
|
import { Organization, Platform } from '@stamhoofd/models';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
|
|
5
|
+
import { ApplicationFeeInvoicer } from '../helpers/ApplicationFeeInvoicer.js';
|
|
5
6
|
|
|
6
7
|
registerCron('stripe-invoices', createStripeInvoices);
|
|
7
8
|
|
|
8
9
|
let lastStripeInvoice: Date | null = null;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether application fees are billed automatically. The sync stores what we received either way,
|
|
13
|
+
* so anything that reports on uninvoiced fees has to ask this first: while it is off, fees staying
|
|
14
|
+
* uninvoiced is the expected state, not a problem.
|
|
15
|
+
*
|
|
16
|
+
* The settlements feature is not released yet: allow production at go-live.
|
|
17
|
+
*/
|
|
18
|
+
export function isApplicationFeeInvoicingEnabled(): boolean {
|
|
19
|
+
return (STAMHOOFD.environment as string) === 'development'
|
|
20
|
+
&& STAMHOOFD.userMode !== 'platform'
|
|
21
|
+
&& STAMHOOFD.STRIPE_CONNECT_METHOD !== 'standard';
|
|
22
|
+
}
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
async function createStripeInvoices() {
|
|
25
|
+
if (!isApplicationFeeInvoicingEnabled()) {
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -41,9 +47,9 @@ async function createStripeInvoices() {
|
|
|
41
47
|
|
|
42
48
|
const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
|
|
43
49
|
|
|
44
|
-
const invoicer = new
|
|
50
|
+
const invoicer = new ApplicationFeeInvoicer({
|
|
45
51
|
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
46
52
|
});
|
|
47
|
-
await invoicer.
|
|
53
|
+
await invoicer.generateInvoices(membershipOrganization);
|
|
48
54
|
lastStripeInvoice = new Date();
|
|
49
55
|
}
|
package/src/crons.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { registerCron } from '@stamhoofd/crons';
|
|
|
3
3
|
import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
|
|
4
4
|
import { SQL } from '@stamhoofd/sql';
|
|
5
5
|
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
6
|
-
import { checkSettlements } from './helpers/CheckSettlements.js';
|
|
7
6
|
import { OrganizationDNSService } from './services/OrganizationDNSService.js';
|
|
8
7
|
import { PaymentService } from './services/PaymentService.js';
|
|
9
8
|
import { RegistrationService } from './services/RegistrationService.js';
|
|
@@ -193,7 +192,6 @@ async function checkOldPayments() {
|
|
|
193
192
|
// 5 days - 10 days
|
|
194
193
|
async function checkOldDirectDebitPayments() {
|
|
195
194
|
let timeout = 60 * 1000 * 60 * 24 * 5;
|
|
196
|
-
const timeout2 = 60 * 1000 * 60 * 24 * 10;
|
|
197
195
|
|
|
198
196
|
if (STAMHOOFD.environment === 'development') {
|
|
199
197
|
// For Mollie, webhooks won't work, so we poll in the backend
|
|
@@ -207,8 +205,7 @@ async function checkOldDirectDebitPayments() {
|
|
|
207
205
|
PaymentMethod.DirectDebit,
|
|
208
206
|
])
|
|
209
207
|
.and('status', [PaymentStatus.Created, PaymentStatus.Pending])
|
|
210
|
-
.and('createdAt', '<', new Date(new Date().getTime() - timeout))
|
|
211
|
-
.and('createdAt', '>', new Date(new Date().getTime() - timeout2)),
|
|
208
|
+
.and('createdAt', '<', new Date(new Date().getTime() - timeout)),
|
|
212
209
|
)
|
|
213
210
|
.orderBy('createdAt', 'ASC')
|
|
214
211
|
.limit(500)
|
|
@@ -357,7 +354,6 @@ async function checkReservedUntil() {
|
|
|
357
354
|
}
|
|
358
355
|
}
|
|
359
356
|
|
|
360
|
-
registerCron('checkSettlements', checkSettlements);
|
|
361
357
|
registerCron('checkExpirationEmails', checkExpirationEmails);
|
|
362
358
|
registerCron('checkReservedUntil', checkReservedUntil);
|
|
363
359
|
registerCron('checkDNS', checkDNS);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { BalanceItemFactory, Invoice, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
4
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
5
|
+
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
6
|
+
import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
7
|
+
import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
8
|
+
import { PaymentMethod, PaymentStatus } from '@stamhoofd/structures';
|
|
9
|
+
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
10
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
11
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
12
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
13
|
+
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
14
|
+
import { initPlatformAdmin } from '../../../../tests/init/index.js';
|
|
15
|
+
import { initMembershipOrganization } from '../../../../tests/init/initMembershipOrganization.js';
|
|
16
|
+
import { PatchOrganizationsEndpoint } from './PatchOrganizationsEndpoint.js';
|
|
17
|
+
|
|
18
|
+
const baseUrl = `/admin/organizations`;
|
|
19
|
+
const endpoint = new PatchOrganizationsEndpoint();
|
|
20
|
+
|
|
21
|
+
describe('Endpoint.PatchOrganizationsEndpoint', () => {
|
|
22
|
+
let membershipOrganization: Organization;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
membershipOrganization = await initMembershipOrganization();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const deleteOrganization = async (organization: Organization) => {
|
|
33
|
+
const { adminToken } = await initPlatformAdmin();
|
|
34
|
+
|
|
35
|
+
const patch: PatchableArrayAutoEncoder<OrganizationStruct> = new PatchableArray();
|
|
36
|
+
patch.addDelete(organization.id);
|
|
37
|
+
|
|
38
|
+
// Platform admins are not scoped to an organization, so no host is required
|
|
39
|
+
const request = Request.patch({
|
|
40
|
+
path: baseUrl,
|
|
41
|
+
host: '',
|
|
42
|
+
body: patch,
|
|
43
|
+
headers: {
|
|
44
|
+
authorization: 'Bearer ' + adminToken.accessToken,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return await testServer.test(endpoint, request);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const createPayment = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
|
|
52
|
+
const payment = new Payment();
|
|
53
|
+
payment.organizationId = membershipOrganization.id;
|
|
54
|
+
payment.payingOrganizationId = payingOrganizationId;
|
|
55
|
+
payment.method = PaymentMethod.Transfer;
|
|
56
|
+
payment.status = PaymentStatus.Succeeded;
|
|
57
|
+
payment.price = 10_00;
|
|
58
|
+
await payment.save();
|
|
59
|
+
return payment;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const createApplicationFee = async ({ payingOrganizationId }: { payingOrganizationId: string }) => {
|
|
63
|
+
const charge = new SettlementCharge();
|
|
64
|
+
charge.type = SettlementChargeType.ApplicationFeeService;
|
|
65
|
+
charge.externalId = uuidv4();
|
|
66
|
+
charge.amount = -30_00;
|
|
67
|
+
charge.organizationId = payingOrganizationId;
|
|
68
|
+
charge.occurredAt = new Date();
|
|
69
|
+
await charge.save();
|
|
70
|
+
|
|
71
|
+
const fee = new ApplicationFee();
|
|
72
|
+
fee.externalId = uuidv4();
|
|
73
|
+
fee.type = ApplicationFeeType.Service;
|
|
74
|
+
fee.amount = 30_00;
|
|
75
|
+
fee.organizationId = membershipOrganization.id;
|
|
76
|
+
fee.payingOrganizationId = payingOrganizationId;
|
|
77
|
+
fee.settlementChargeId = charge.id;
|
|
78
|
+
fee.occurredAt = new Date();
|
|
79
|
+
await fee.save();
|
|
80
|
+
return fee;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createInvoice = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
|
|
84
|
+
const invoice = new Invoice();
|
|
85
|
+
invoice.organizationId = membershipOrganization.id;
|
|
86
|
+
invoice.payingOrganizationId = payingOrganizationId;
|
|
87
|
+
await invoice.save();
|
|
88
|
+
return invoice;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test('An organization without financial records can be deleted', async () => {
|
|
92
|
+
const organization = await new OrganizationFactory({}).create();
|
|
93
|
+
|
|
94
|
+
const response = await deleteOrganization(organization);
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(200);
|
|
97
|
+
expect(await Organization.getByID(organization.id)).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const payerRecords = [
|
|
101
|
+
{
|
|
102
|
+
name: 'a balance item it owes',
|
|
103
|
+
create: async (organization: Organization) => {
|
|
104
|
+
await new BalanceItemFactory({
|
|
105
|
+
organizationId: membershipOrganization.id,
|
|
106
|
+
payingOrganizationId: organization.id,
|
|
107
|
+
amount: 1,
|
|
108
|
+
unitPrice: 10_00,
|
|
109
|
+
}).create();
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'a payment it made',
|
|
114
|
+
create: async (organization: Organization) => {
|
|
115
|
+
await createPayment({ payingOrganizationId: organization.id });
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'an application fee it was charged',
|
|
120
|
+
create: async (organization: Organization) => {
|
|
121
|
+
await createApplicationFee({ payingOrganizationId: organization.id });
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'an invoice it has to pay',
|
|
126
|
+
create: async (organization: Organization) => {
|
|
127
|
+
await createInvoice({ payingOrganizationId: organization.id });
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
test.each(payerRecords)('An organization with $name cannot be deleted', async ({ create }) => {
|
|
133
|
+
const organization = await new OrganizationFactory({}).create();
|
|
134
|
+
await create(organization);
|
|
135
|
+
|
|
136
|
+
await expect(deleteOrganization(organization)).rejects.toThrow(STExpect.errorWithCode('organization_has_financial_records'));
|
|
137
|
+
expect(await Organization.getByID(organization.id)).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('Financial records the organization received do not block the delete', async () => {
|
|
141
|
+
const organization = await new OrganizationFactory({}).create();
|
|
142
|
+
|
|
143
|
+
await new BalanceItemFactory({
|
|
144
|
+
organizationId: organization.id,
|
|
145
|
+
amount: 1,
|
|
146
|
+
unitPrice: 10_00,
|
|
147
|
+
}).create();
|
|
148
|
+
|
|
149
|
+
const payment = new Payment();
|
|
150
|
+
payment.organizationId = organization.id;
|
|
151
|
+
payment.method = PaymentMethod.Transfer;
|
|
152
|
+
payment.status = PaymentStatus.Succeeded;
|
|
153
|
+
payment.price = 10_00;
|
|
154
|
+
await payment.save();
|
|
155
|
+
|
|
156
|
+
const invoice = new Invoice();
|
|
157
|
+
invoice.organizationId = organization.id;
|
|
158
|
+
await invoice.save();
|
|
159
|
+
|
|
160
|
+
const response = await deleteOrganization(organization);
|
|
161
|
+
|
|
162
|
+
expect(response.status).toBe(200);
|
|
163
|
+
expect(await Organization.getByID(organization.id)).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -3,7 +3,8 @@ import { PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encodin
|
|
|
3
3
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
-
import { Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
6
|
+
import { BalanceItem, Invoice, Organization, OrganizationRegistrationPeriod, Payment, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
7
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
7
8
|
import { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
8
9
|
|
|
9
10
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -61,6 +62,22 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// Financial records may not disappear together with the organization that had to pay them
|
|
66
|
+
const payerRecordCounts = await Promise.all([
|
|
67
|
+
BalanceItem.select().where('payingOrganizationId', id).count(),
|
|
68
|
+
Payment.select().where('payingOrganizationId', id).count(),
|
|
69
|
+
ApplicationFee.select().where('payingOrganizationId', id).count(),
|
|
70
|
+
Invoice.select().where('payingOrganizationId', id).count(),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
if (payerRecordCounts.some(count => count > 0)) {
|
|
74
|
+
throw new SimpleError({
|
|
75
|
+
code: 'organization_has_financial_records',
|
|
76
|
+
message: 'Organization is still referenced as paying organization',
|
|
77
|
+
human: $t('Er zijn nog openstaande bedragen, betalingen, transactiekosten of facturen waarvoor {organization} moet betalen of betaald heeft. Die gegevens mogen niet verloren gaan.', { organization: organization.name }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
await organization.delete();
|
|
65
82
|
}
|
|
66
83
|
|
package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts
CHANGED
|
@@ -2,17 +2,18 @@ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
|
2
2
|
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
3
3
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import type { Organization, User } from '@stamhoofd/models';
|
|
5
|
-
import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitation, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
6
6
|
import type { RegistrationInvitation as RegistrationInvitationStruct } from '@stamhoofd/structures';
|
|
7
|
-
import { PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
|
|
7
|
+
import { GroupType, PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
|
|
8
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
8
9
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
9
10
|
import { PatchRegistrationInvitationsEndpoint } from './PatchRegistrationInvitationsEndpoint.js';
|
|
10
11
|
|
|
11
12
|
describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
|
|
12
13
|
const endpoint = new PatchRegistrationInvitationsEndpoint();
|
|
13
14
|
|
|
14
|
-
const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization; user: User | null }) => {
|
|
15
|
-
const request = Request.buildJson('PATCH', '/registration-invitations', organization
|
|
15
|
+
const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization | null; user: User | null }) => {
|
|
16
|
+
const request = Request.buildJson('PATCH', '/registration-invitations', organization?.getApiHost(), patch);
|
|
16
17
|
if (user) {
|
|
17
18
|
const token = await Token.createToken(user);
|
|
18
19
|
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
@@ -401,4 +402,153 @@ describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
|
|
|
401
402
|
})).rejects.toThrow('Je hebt geen toegangsrechten om deze uitnodiging te verwijderen.');
|
|
402
403
|
});
|
|
403
404
|
});
|
|
405
|
+
|
|
406
|
+
describe('Without organization scope (platform admins)', () => {
|
|
407
|
+
beforeEach(() => {
|
|
408
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('A platform admin can invite for an event group', async () => {
|
|
412
|
+
const organization = await new OrganizationFactory({}).create();
|
|
413
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
414
|
+
|
|
415
|
+
const admin = await new UserFactory({
|
|
416
|
+
globalPermissions: Permissions.create({
|
|
417
|
+
level: PermissionLevel.Full,
|
|
418
|
+
}),
|
|
419
|
+
}).create();
|
|
420
|
+
|
|
421
|
+
const member = await new MemberFactory({
|
|
422
|
+
organization,
|
|
423
|
+
firstName: 'John',
|
|
424
|
+
lastName: 'Doe',
|
|
425
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
426
|
+
}).create();
|
|
427
|
+
|
|
428
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
429
|
+
|
|
430
|
+
patch.addPut(RegistrationInvitationRequest.create({
|
|
431
|
+
groupId: group.id,
|
|
432
|
+
memberId: member.id,
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
const response = await patchInvitations({
|
|
436
|
+
patch,
|
|
437
|
+
organization: null,
|
|
438
|
+
user: admin,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(response.body).toHaveLength(1);
|
|
442
|
+
expect(response.body[0].group.id).toBe(group.id);
|
|
443
|
+
expect(response.body[0].member.id).toBe(member.id);
|
|
444
|
+
|
|
445
|
+
// The invitation belongs to the organization of the group
|
|
446
|
+
const invitation = await RegistrationInvitation.getByID(response.body[0].id);
|
|
447
|
+
expect(invitation?.organizationId).toBe(organization.id);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('Should fail for a user without platform access', async () => {
|
|
451
|
+
const organization = await new OrganizationFactory({}).create();
|
|
452
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
453
|
+
|
|
454
|
+
const user = await new UserFactory({
|
|
455
|
+
organization,
|
|
456
|
+
permissions: Permissions.create({
|
|
457
|
+
level: PermissionLevel.Full,
|
|
458
|
+
}),
|
|
459
|
+
}).create();
|
|
460
|
+
|
|
461
|
+
const member = await new MemberFactory({
|
|
462
|
+
organization,
|
|
463
|
+
firstName: 'John',
|
|
464
|
+
lastName: 'Doe',
|
|
465
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
466
|
+
}).create();
|
|
467
|
+
|
|
468
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
469
|
+
|
|
470
|
+
patch.addPut(RegistrationInvitationRequest.create({
|
|
471
|
+
groupId: group.id,
|
|
472
|
+
memberId: member.id,
|
|
473
|
+
}));
|
|
474
|
+
|
|
475
|
+
await expect(patchInvitations({
|
|
476
|
+
patch,
|
|
477
|
+
organization: null,
|
|
478
|
+
user,
|
|
479
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('Should fail for a platform admin without access to the group', async () => {
|
|
483
|
+
const organization = await new OrganizationFactory({}).create();
|
|
484
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
485
|
+
|
|
486
|
+
// Some platform access (passes the fast throw), but no rights on this group or its organization
|
|
487
|
+
const admin = await new UserFactory({
|
|
488
|
+
globalPermissions: Permissions.create({
|
|
489
|
+
level: PermissionLevel.None,
|
|
490
|
+
resources: new Map([
|
|
491
|
+
[PermissionsResourceType.OrganizationTags, new Map([
|
|
492
|
+
['unrelated-tag-id', ResourcePermissions.create({
|
|
493
|
+
level: PermissionLevel.Read,
|
|
494
|
+
})],
|
|
495
|
+
])],
|
|
496
|
+
]),
|
|
497
|
+
}),
|
|
498
|
+
}).create();
|
|
499
|
+
|
|
500
|
+
const member = await new MemberFactory({
|
|
501
|
+
organization,
|
|
502
|
+
firstName: 'John',
|
|
503
|
+
lastName: 'Doe',
|
|
504
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
505
|
+
}).create();
|
|
506
|
+
|
|
507
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
508
|
+
|
|
509
|
+
patch.addPut(RegistrationInvitationRequest.create({
|
|
510
|
+
groupId: group.id,
|
|
511
|
+
memberId: member.id,
|
|
512
|
+
}));
|
|
513
|
+
|
|
514
|
+
await expect(patchInvitations({
|
|
515
|
+
patch,
|
|
516
|
+
organization: null,
|
|
517
|
+
user: admin,
|
|
518
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('A platform admin can delete an invitation', async () => {
|
|
522
|
+
const organization = await new OrganizationFactory({}).create();
|
|
523
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
524
|
+
|
|
525
|
+
const admin = await new UserFactory({
|
|
526
|
+
globalPermissions: Permissions.create({
|
|
527
|
+
level: PermissionLevel.Full,
|
|
528
|
+
}),
|
|
529
|
+
}).create();
|
|
530
|
+
|
|
531
|
+
const member = await new MemberFactory({
|
|
532
|
+
organization,
|
|
533
|
+
firstName: 'John',
|
|
534
|
+
lastName: 'Doe',
|
|
535
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
536
|
+
}).create();
|
|
537
|
+
|
|
538
|
+
const invitation = await new RegistrationInvitationFactory({ member, group, organization }).create();
|
|
539
|
+
|
|
540
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
541
|
+
|
|
542
|
+
patch.addDelete(invitation.id);
|
|
543
|
+
|
|
544
|
+
const response = await patchInvitations({
|
|
545
|
+
patch,
|
|
546
|
+
organization: null,
|
|
547
|
+
user: admin,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(response.body).toHaveLength(0);
|
|
551
|
+
expect(await RegistrationInvitation.getByID(invitation.id)).toBeUndefined();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
404
554
|
});
|
package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts
CHANGED
|
@@ -35,11 +35,15 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
38
|
-
const organization = await Context.
|
|
38
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
39
39
|
await Context.authenticate();
|
|
40
40
|
|
|
41
41
|
// Fast throw first (more in depth checking for patches later)
|
|
42
|
-
if (
|
|
42
|
+
if (organization) {
|
|
43
|
+
if (!await Context.auth.hasSomeAccess(organization.id)) {
|
|
44
|
+
throw Context.auth.error();
|
|
45
|
+
}
|
|
46
|
+
} else if (!Context.auth.hasSomePlatformAccess()) {
|
|
43
47
|
throw Context.auth.error();
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -48,11 +52,11 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
48
52
|
|
|
49
53
|
const puts = request.body.getPuts();
|
|
50
54
|
for (const { put } of puts) {
|
|
51
|
-
await this.checkCanCreateRegistrationInvitation(put, organization
|
|
55
|
+
const group = await this.checkCanCreateRegistrationInvitation(put, organization?.id ?? null);
|
|
52
56
|
|
|
53
57
|
const invitation = new RegistrationInvitation();
|
|
54
58
|
invitation.id = put.id;
|
|
55
|
-
invitation.organizationId =
|
|
59
|
+
invitation.organizationId = group.organizationId;
|
|
56
60
|
invitation.groupId = put.groupId;
|
|
57
61
|
invitation.memberId = put.memberId;
|
|
58
62
|
|
|
@@ -125,12 +129,13 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
125
129
|
/**
|
|
126
130
|
* Will throw if not allowed to invite.
|
|
127
131
|
* @param invitation
|
|
128
|
-
* @param organizationId
|
|
132
|
+
* @param organizationId organization scope of the request; when set, the group must belong to it (platform admins can invite without a scope)
|
|
133
|
+
* @returns the group to invite for
|
|
129
134
|
*/
|
|
130
|
-
private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string) {
|
|
135
|
+
private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string | null): Promise<Group> {
|
|
131
136
|
const group = await Group.getByID(invitation.groupId);
|
|
132
137
|
|
|
133
|
-
if (!group || group.organizationId !== organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
|
|
138
|
+
if (!group || (organizationId !== null && group.organizationId !== organizationId) || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
|
|
134
139
|
throw Context.auth.error($t(`%1ST`));
|
|
135
140
|
}
|
|
136
141
|
|
|
@@ -147,7 +152,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
147
152
|
|
|
148
153
|
if (!member
|
|
149
154
|
// in userMode 'organization' we can only invite members from the same organization
|
|
150
|
-
|| (STAMHOOFD.userMode === 'organization' && member.organizationId !== organizationId)
|
|
155
|
+
|| (STAMHOOFD.userMode === 'organization' && member.organizationId !== group.organizationId)
|
|
151
156
|
// read access is suficient
|
|
152
157
|
|| !await Context.auth.canAccessMember(member, PermissionLevel.Read)
|
|
153
158
|
) {
|
|
@@ -163,5 +168,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
163
168
|
human: $t('%1S2'),
|
|
164
169
|
});
|
|
165
170
|
}
|
|
171
|
+
|
|
172
|
+
return group;
|
|
166
173
|
}
|
|
167
174
|
}
|
package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Request } from '@simonbackx/simple-endpoints';
|
|
|
2
2
|
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
3
|
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice,
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, TranslatedString } from '@stamhoofd/structures';
|
|
6
6
|
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
7
|
import type { BalanceItemBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
8
|
import { BreakdownAmountType, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
@@ -254,13 +254,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
describe('Grouping by payout', () => {
|
|
257
|
-
const march =
|
|
258
|
-
const april =
|
|
257
|
+
const march = SettlementReference.create({ id: 'stl_march', reference: '1234567.0312.01', settledAt: new Date(2026, 2, 12), amount: 0 });
|
|
258
|
+
const april = SettlementReference.create({ id: 'stl_april', reference: '1234567.0409.01', settledAt: new Date(2026, 3, 9), amount: 0 });
|
|
259
259
|
|
|
260
260
|
/**
|
|
261
261
|
* Pays a part of a balance item, the way a payment provider settles it afterwards.
|
|
262
262
|
*/
|
|
263
|
-
const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?:
|
|
263
|
+
const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?: SettlementReference; status?: PaymentStatus; method?: PaymentMethod }) => {
|
|
264
264
|
const payment = new Payment();
|
|
265
265
|
payment.organizationId = organization.id;
|
|
266
266
|
payment.method = options.method ?? PaymentMethod.Bancontact;
|