@stamhoofd/backend 2.139.0 → 2.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
- 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 +14 -14
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +366 -0
- package/src/helpers/SettlementExporter.ts +583 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +828 -0
- package/src/helpers/StripeSettlementSync.ts +947 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
- package/src/helpers/TwoFactorHelper.ts +1 -1
- 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/helpers/streamForBreakdown.ts +2 -2
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/PaymentService.ts +8 -0
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/filters/orders.test.ts +635 -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,104 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { ArrayDecoder, AutoEncoder, BooleanDecoder, DateDecoder, EnumDecoder, field } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Platform } from '@stamhoofd/models';
|
|
7
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
8
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
9
|
+
import { SettlementsSyncStatus } from '@stamhoofd/structures/settlements/SettlementsSyncStatus.js';
|
|
10
|
+
|
|
11
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
12
|
+
import { SettlementSyncRunner } from '../../../../helpers/SettlementSyncRunner.js';
|
|
13
|
+
|
|
14
|
+
type Params = Record<string, never>;
|
|
15
|
+
class Body extends AutoEncoder {
|
|
16
|
+
@field({ decoder: DateDecoder, optional: true })
|
|
17
|
+
start: Date = new Date(2025, 0, 1);
|
|
18
|
+
|
|
19
|
+
@field({ decoder: DateDecoder, nullable: true, optional: true })
|
|
20
|
+
end: Date | null = null;
|
|
21
|
+
|
|
22
|
+
@field({ decoder: new ArrayDecoder(new EnumDecoder(PaymentProvider)), nullable: true, optional: true })
|
|
23
|
+
providers: PaymentProvider[] | null = null;
|
|
24
|
+
|
|
25
|
+
@field({ decoder: BooleanDecoder, optional: true })
|
|
26
|
+
force = false;
|
|
27
|
+
}
|
|
28
|
+
type Query = undefined;
|
|
29
|
+
type ResponseBody = undefined;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Manually run the settlement sync (backfill) over a period. Everything is an upsert and
|
|
33
|
+
* already-synced settlements are skipped unless force, so re-running is cheap.
|
|
34
|
+
*/
|
|
35
|
+
export class SettlementsSyncEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
36
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
37
|
+
|
|
38
|
+
static queue: SettlementsSyncStatus[] = [];
|
|
39
|
+
|
|
40
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
41
|
+
if (request.method !== 'POST') {
|
|
42
|
+
return [false];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const params = Endpoint.parseParameters(request.url, '/settlements/sync', {});
|
|
46
|
+
|
|
47
|
+
if (params) {
|
|
48
|
+
return [true, params as Params];
|
|
49
|
+
}
|
|
50
|
+
return [false];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A sync covers the provider accounts of the whole platform: only platform admins, and only
|
|
55
|
+
* scoped to the platform membership organization (the owner of the platform's own payouts).
|
|
56
|
+
*/
|
|
57
|
+
static async authenticate() {
|
|
58
|
+
const organization = await Context.setOrganizationScope();
|
|
59
|
+
const { user } = await Context.authenticate();
|
|
60
|
+
|
|
61
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
62
|
+
throw Context.auth.error();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const platform = await Platform.getShared();
|
|
66
|
+
if (!platform.membershipOrganizationId || platform.membershipOrganizationId !== organization.id) {
|
|
67
|
+
throw new SimpleError({
|
|
68
|
+
code: 'not_available',
|
|
69
|
+
message: 'Settlement syncs are only available for the platform membership organization',
|
|
70
|
+
statusCode: 400,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { organization, user };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
78
|
+
await SettlementsSyncEndpoint.authenticate();
|
|
79
|
+
|
|
80
|
+
const { start, end, providers, force } = request.body;
|
|
81
|
+
|
|
82
|
+
const item = SettlementsSyncStatus.create({
|
|
83
|
+
start,
|
|
84
|
+
end,
|
|
85
|
+
force,
|
|
86
|
+
});
|
|
87
|
+
SettlementsSyncEndpoint.queue.push(item);
|
|
88
|
+
|
|
89
|
+
QueueHandler.schedule('settlement-sync', async () => {
|
|
90
|
+
try {
|
|
91
|
+
const runner = new SettlementSyncRunner();
|
|
92
|
+
runner.callback = (summary) => {
|
|
93
|
+
item.count = summary.synced + summary.skipped + summary.failed;
|
|
94
|
+
item.failed = summary.failed + summary.failedFeeMonths;
|
|
95
|
+
};
|
|
96
|
+
await runner.run({ start, end, providers, stripe: { force } });
|
|
97
|
+
} finally {
|
|
98
|
+
SettlementsSyncEndpoint.queue.splice(SettlementsSyncEndpoint.queue.indexOf(item), 1);
|
|
99
|
+
}
|
|
100
|
+
}).catch(console.error);
|
|
101
|
+
|
|
102
|
+
return new Response(undefined);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stam
|
|
|
3
3
|
import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
|
|
4
4
|
import { Order, StripeAccount } from '@stamhoofd/models';
|
|
5
5
|
import type { OrderData } from '@stamhoofd/structures';
|
|
6
|
-
import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
|
|
6
|
+
import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
|
|
7
7
|
import { Formatter } from '@stamhoofd/utility';
|
|
8
8
|
import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
|
|
9
9
|
import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
|
|
@@ -594,6 +594,23 @@ function getSettlementColumns(): XlsxTransformerColumn<PaymentGeneral>[] {
|
|
|
594
594
|
};
|
|
595
595
|
},
|
|
596
596
|
},
|
|
597
|
+
{
|
|
598
|
+
id: 'settlement.check',
|
|
599
|
+
name: $t('%ZkA'),
|
|
600
|
+
width: 24,
|
|
601
|
+
getValue: (object: PaymentGeneralWithStripeAccount) => {
|
|
602
|
+
// Only meaningful for fee payments: their payout lines are derived from the
|
|
603
|
+
// application fees they bill, so a matching sum means completely paid out. Other
|
|
604
|
+
// methods either don't pay out or always match
|
|
605
|
+
if (object.method !== PaymentMethod.AccountDeductions) {
|
|
606
|
+
return { value: '' };
|
|
607
|
+
}
|
|
608
|
+
const settled = object.settlements.reduce((total, line) => total + line.amount, 0);
|
|
609
|
+
return {
|
|
610
|
+
value: settled === object.price ? '✓' : $t('%Zk0'),
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
},
|
|
597
614
|
];
|
|
598
615
|
}
|
|
599
616
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type Stripe from 'stripe';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The service/transfer split of one or more application fees. `fromStripe` is the single source of
|
|
5
|
+
* the split rule: the serviceFee metadata on the originating charge (in cents) is the service part,
|
|
6
|
+
* the remainder of the fee is the transfer part.
|
|
7
|
+
*/
|
|
8
|
+
export class ApplicationFeeDetails {
|
|
9
|
+
transferFee = 0;
|
|
10
|
+
serviceFee = 0;
|
|
11
|
+
count = 0;
|
|
12
|
+
minimumDate: Date | null = null;
|
|
13
|
+
maximumDate: Date | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(details: { count?: number; transferFee: number; serviceFee: number; minimumDate: Date | null; maximumDate: Date | null }) {
|
|
16
|
+
this.count = details.count ?? 0;
|
|
17
|
+
this.transferFee = details.transferFee;
|
|
18
|
+
this.serviceFee = details.serviceFee;
|
|
19
|
+
this.minimumDate = details.minimumDate;
|
|
20
|
+
this.maximumDate = details.maximumDate;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get amount() {
|
|
24
|
+
return this.transferFee + this.serviceFee;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static fromStripe(transaction: Pick<Stripe.BalanceTransaction, 'source' | 'amount' | 'created'>) {
|
|
28
|
+
const source = transaction.source as Stripe.ApplicationFee;
|
|
29
|
+
|
|
30
|
+
// Only a destination charge has an originating transaction on our own account. A direct
|
|
31
|
+
// charge (Standard accounts) keeps it on the connected account, where this walk can't read
|
|
32
|
+
// the metadata that splits the fee
|
|
33
|
+
const originatingTransaction = source.originating_transaction;
|
|
34
|
+
if (!originatingTransaction || typeof originatingTransaction === 'string') {
|
|
35
|
+
throw new Error('Application fee ' + source.id + ' has no expanded originating transaction, which is not supported for direct charges');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const metadata = (originatingTransaction as Stripe.Charge).metadata;
|
|
39
|
+
|
|
40
|
+
const serviceFeeStr = metadata.serviceFee as unknown;
|
|
41
|
+
if (serviceFeeStr === undefined || typeof serviceFeeStr !== 'string') {
|
|
42
|
+
throw new Error('Missing serviceFee metadata');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsed = parseInt(serviceFeeStr);
|
|
46
|
+
if (isNaN(parsed) || !isFinite(parsed)) {
|
|
47
|
+
throw new Error('Invalid serviceFee metadata');
|
|
48
|
+
}
|
|
49
|
+
const serviceFee = parsed * 100; // in cents
|
|
50
|
+
const transferFee = transaction.amount * 100 - serviceFee;
|
|
51
|
+
|
|
52
|
+
// Both parts are billed as their own balance item, so a negative one would invent a credit
|
|
53
|
+
// out of wrong metadata instead of failing
|
|
54
|
+
if (serviceFee < 0 || transferFee < 0) {
|
|
55
|
+
throw new Error('Application fee of ' + transaction.amount * 100 + ' does not cover its serviceFee of ' + serviceFee);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new ApplicationFeeDetails({
|
|
59
|
+
count: 1,
|
|
60
|
+
serviceFee,
|
|
61
|
+
transferFee,
|
|
62
|
+
minimumDate: new Date(transaction.created * 1000),
|
|
63
|
+
maximumDate: new Date(transaction.created * 1000),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { StripeAccount } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
4
|
+
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
5
|
+
import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
7
|
+
import { Address, BalanceItemType, Company, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
8
|
+
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
9
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
10
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
11
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
12
|
+
|
|
13
|
+
import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
|
|
14
|
+
import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
|
|
15
|
+
import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
|
|
16
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
17
|
+
import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
|
|
18
|
+
|
|
19
|
+
describe('ApplicationFeeInvoicer', () => {
|
|
20
|
+
const stripeMocker = new StripeMocker();
|
|
21
|
+
let membershipOrganization: Organization;
|
|
22
|
+
|
|
23
|
+
// A month in the past that no other test writes fee rows in
|
|
24
|
+
const month = new Date(2024, 6, 1);
|
|
25
|
+
const occurredAt = new Date(2024, 6, 10);
|
|
26
|
+
const reference = 'application-fees-2024-07-01';
|
|
27
|
+
|
|
28
|
+
const belgianAddress = () => Address.create({
|
|
29
|
+
street: 'Teststraat',
|
|
30
|
+
number: '1',
|
|
31
|
+
postalCode: '9000',
|
|
32
|
+
city: 'Gent',
|
|
33
|
+
country: Country.Belgium,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
stripeMocker.start();
|
|
38
|
+
|
|
39
|
+
membershipOrganization = await initMembershipOrganization();
|
|
40
|
+
membershipOrganization.meta.companies = [
|
|
41
|
+
Company.create({
|
|
42
|
+
name: 'Platform BV',
|
|
43
|
+
companyNumber: '0700000000',
|
|
44
|
+
VATNumber: 'BE0700000000',
|
|
45
|
+
address: belgianAddress(),
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
48
|
+
await membershipOrganization.save();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
stripeMocker.stop();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
stripeMocker.clear();
|
|
57
|
+
ApplicationFeeService.resetWarnings();
|
|
58
|
+
|
|
59
|
+
// The test database persists across runs: leftover fees of this month would be billed again
|
|
60
|
+
await ApplicationFee.delete()
|
|
61
|
+
.where('occurredAt', '>=', month)
|
|
62
|
+
.where('occurredAt', '<', new Date(2024, 7, 1));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const init = async () => {
|
|
66
|
+
const organization = await new OrganizationFactory({}).create();
|
|
67
|
+
organization.meta.companies = [
|
|
68
|
+
Company.create({
|
|
69
|
+
name: 'Testvereniging VZW ' + organization.id,
|
|
70
|
+
companyNumber: '0500000000',
|
|
71
|
+
VATNumber: 'BE0500000000',
|
|
72
|
+
address: belgianAddress(),
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
await organization.save();
|
|
76
|
+
|
|
77
|
+
const stripeAccount = await stripeMocker.createStripeAccount(organization.id);
|
|
78
|
+
return { organization, stripeAccount };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const createFee = async (organization: Organization, stripeAccount: StripeAccount, { type = ApplicationFeeType.Service, amount = 30_00, settlement = null as Settlement | null, when = occurredAt } = {}) => {
|
|
82
|
+
const externalId = 'fee_' + uuidv4();
|
|
83
|
+
const charge = await SettlementService.upsertCharge({
|
|
84
|
+
type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
|
|
85
|
+
externalId: externalId + ':' + type,
|
|
86
|
+
amount: -amount,
|
|
87
|
+
applicationFeeId: externalId,
|
|
88
|
+
organizationId: organization.id,
|
|
89
|
+
stripeAccountId: stripeAccount.id,
|
|
90
|
+
occurredAt: when,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return await ApplicationFeeService.upsertFee({
|
|
94
|
+
externalId,
|
|
95
|
+
type,
|
|
96
|
+
amount,
|
|
97
|
+
organizationId: membershipOrganization.id,
|
|
98
|
+
payingOrganizationId: organization.id,
|
|
99
|
+
payingStripeAccountId: stripeAccount.id,
|
|
100
|
+
settlementChargeId: charge.id,
|
|
101
|
+
settlementId: settlement?.id ?? null,
|
|
102
|
+
occurredAt: when,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getFeePayments = async (organization: Organization) => {
|
|
107
|
+
return await Payment.select()
|
|
108
|
+
.where('payingOrganizationId', organization.id)
|
|
109
|
+
.where('reference', reference)
|
|
110
|
+
.where('method', PaymentMethod.AccountDeductions)
|
|
111
|
+
.fetch();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const createInvoicer = () => new ApplicationFeeInvoicer({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bills the month as if the run started a second from now: fees stored in the current second
|
|
118
|
+
* are deliberately left for the next run, and a test stores them right before billing.
|
|
119
|
+
*/
|
|
120
|
+
const invoiceMonth = async () => {
|
|
121
|
+
await createInvoicer().generateInvoicesForMonth(membershipOrganization, month, new Date(Date.now() + 1000));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
test('a month is billed per paying account, and every fee carries its balance item', async () => {
|
|
125
|
+
const { organization, stripeAccount } = await init();
|
|
126
|
+
const serviceFee = await createFee(organization, stripeAccount, { type: ApplicationFeeType.Service, amount: 30_00 });
|
|
127
|
+
const transferFee = await createFee(organization, stripeAccount, { type: ApplicationFeeType.Transfer, amount: 2_20_00 });
|
|
128
|
+
|
|
129
|
+
await invoiceMonth();
|
|
130
|
+
|
|
131
|
+
const payments = await getFeePayments(organization);
|
|
132
|
+
expect(payments).toHaveLength(1);
|
|
133
|
+
expect(payments[0]).toMatchObject({
|
|
134
|
+
price: 2_50_00,
|
|
135
|
+
status: PaymentStatus.Succeeded,
|
|
136
|
+
provider: PaymentProvider.Stripe,
|
|
137
|
+
organizationId: membershipOrganization.id,
|
|
138
|
+
stripeAccountId: stripeAccount.id,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payments[0].id).fetch();
|
|
142
|
+
const balanceItems = await BalanceItem.select().where('id', balanceItemPayments.map(b => b.balanceItemId)).fetch();
|
|
143
|
+
const serviceItem = balanceItems.find(i => i.type === BalanceItemType.ServiceFee)!;
|
|
144
|
+
const transferItem = balanceItems.find(i => i.type === BalanceItemType.TransferFee)!;
|
|
145
|
+
|
|
146
|
+
expect(serviceItem.unitPrice).toBe(30_00);
|
|
147
|
+
expect(transferItem.unitPrice).toBe(2_20_00);
|
|
148
|
+
expect(serviceItem.organizationId).toBe(membershipOrganization.id);
|
|
149
|
+
expect(serviceItem.payingOrganizationId).toBe(organization.id);
|
|
150
|
+
|
|
151
|
+
expect((await ApplicationFee.getByID(serviceFee.id))!.balanceItemId).toBe(serviceItem.id);
|
|
152
|
+
expect((await ApplicationFee.getByID(transferFee.id))!.balanceItemId).toBe(transferItem.id);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('fees of different accounts are billed separately', async () => {
|
|
156
|
+
const { organization, stripeAccount } = await init();
|
|
157
|
+
const second = await stripeMocker.createStripeAccount(organization.id);
|
|
158
|
+
|
|
159
|
+
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
160
|
+
await createFee(organization, second, { amount: 40_00 });
|
|
161
|
+
|
|
162
|
+
await invoiceMonth();
|
|
163
|
+
|
|
164
|
+
const payments = await getFeePayments(organization);
|
|
165
|
+
expect(payments).toHaveLength(2);
|
|
166
|
+
expect(payments.map(p => p.price).sort((a, b) => a - b)).toEqual([30_00, 40_00]);
|
|
167
|
+
expect(payments.map(p => p.stripeAccountId).sort()).toEqual([stripeAccount.id, second.id].sort());
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('re-running bills nothing more', async () => {
|
|
171
|
+
const { organization, stripeAccount } = await init();
|
|
172
|
+
await createFee(organization, stripeAccount);
|
|
173
|
+
|
|
174
|
+
await invoiceMonth();
|
|
175
|
+
await invoiceMonth();
|
|
176
|
+
|
|
177
|
+
expect(await getFeePayments(organization)).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a fee that arrives after its month was billed lands in an extra payment', async () => {
|
|
181
|
+
const { organization, stripeAccount } = await init();
|
|
182
|
+
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
183
|
+
|
|
184
|
+
await invoiceMonth();
|
|
185
|
+
|
|
186
|
+
const late = await createFee(organization, stripeAccount, { amount: 5_00 });
|
|
187
|
+
await invoiceMonth();
|
|
188
|
+
|
|
189
|
+
const payments = await getFeePayments(organization);
|
|
190
|
+
expect(payments).toHaveLength(2);
|
|
191
|
+
expect(payments.map(p => p.price).sort((a, b) => a - b)).toEqual([5_00, 30_00]);
|
|
192
|
+
expect((await ApplicationFee.getByID(late.id))!.balanceItemId).not.toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the fee payment is settled by the payouts that contained its fees', async () => {
|
|
196
|
+
const { organization, stripeAccount } = await init();
|
|
197
|
+
const payout = await SettlementService.upsertSettlement({
|
|
198
|
+
provider: PaymentProvider.Stripe,
|
|
199
|
+
externalId: 'po_' + uuidv4(),
|
|
200
|
+
stripeAccountId: null,
|
|
201
|
+
organizationId: membershipOrganization.id,
|
|
202
|
+
amount: 30_00,
|
|
203
|
+
settledAt: new Date(2024, 6, 20),
|
|
204
|
+
});
|
|
205
|
+
await createFee(organization, stripeAccount, { amount: 30_00, settlement: payout });
|
|
206
|
+
|
|
207
|
+
await invoiceMonth();
|
|
208
|
+
|
|
209
|
+
const payment = (await getFeePayments(organization))[0];
|
|
210
|
+
const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
|
|
211
|
+
expect(lines).toHaveLength(1);
|
|
212
|
+
expect(lines[0]).toMatchObject({ settlementId: payout.id, amount: 30_00 });
|
|
213
|
+
|
|
214
|
+
// Completely paid out: the lines add up to the payment
|
|
215
|
+
expect(lines.reduce((total, line) => total + line.amount, 0)).toBe(payment.price);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a month the legacy invoicer billed is never billed again', async () => {
|
|
219
|
+
const { organization, stripeAccount } = await init();
|
|
220
|
+
|
|
221
|
+
// A legacy payment without balance items: the inline linking can't reach it, so the fee
|
|
222
|
+
// stays uninvoiced and the invoicer may not bill it either
|
|
223
|
+
const legacy = new Payment();
|
|
224
|
+
legacy.organizationId = membershipOrganization.id;
|
|
225
|
+
legacy.payingOrganizationId = organization.id;
|
|
226
|
+
legacy.stripeAccountId = stripeAccount.id;
|
|
227
|
+
legacy.method = PaymentMethod.AccountDeductions;
|
|
228
|
+
legacy.provider = PaymentProvider.Stripe;
|
|
229
|
+
legacy.status = PaymentStatus.Succeeded;
|
|
230
|
+
legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
|
|
231
|
+
legacy.price = 30_00;
|
|
232
|
+
legacy.paidAt = occurredAt;
|
|
233
|
+
await legacy.save();
|
|
234
|
+
|
|
235
|
+
const fee = await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
236
|
+
expect(fee.balanceItemId).toBeNull();
|
|
237
|
+
|
|
238
|
+
await invoiceMonth();
|
|
239
|
+
|
|
240
|
+
expect(await getFeePayments(organization)).toHaveLength(0);
|
|
241
|
+
expect((await ApplicationFee.getByID(fee.id))!.balanceItemId).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('fees of the current month are not billed yet', async () => {
|
|
245
|
+
const { organization, stripeAccount } = await init();
|
|
246
|
+
const now = new Date();
|
|
247
|
+
await createFee(organization, stripeAccount, { when: new Date(now.getFullYear(), now.getMonth(), 1, 12) });
|
|
248
|
+
|
|
249
|
+
await createInvoicer().generateInvoices(membershipOrganization);
|
|
250
|
+
|
|
251
|
+
const payments = await Payment.select()
|
|
252
|
+
.where('payingOrganizationId', organization.id)
|
|
253
|
+
.where('method', PaymentMethod.AccountDeductions)
|
|
254
|
+
.fetch();
|
|
255
|
+
expect(payments).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a broken account does not block the other accounts', async () => {
|
|
259
|
+
const { organization, stripeAccount } = await init();
|
|
260
|
+
const broken = await stripeMocker.createStripeAccount(organization.id);
|
|
261
|
+
|
|
262
|
+
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
263
|
+
await createFee(organization, broken, { amount: 40_00 });
|
|
264
|
+
|
|
265
|
+
// Deleting the account clears payingStripeAccountId: that group can't be billed anymore
|
|
266
|
+
await broken.delete();
|
|
267
|
+
|
|
268
|
+
await invoiceMonth();
|
|
269
|
+
|
|
270
|
+
const payments = await getFeePayments(organization);
|
|
271
|
+
expect(payments).toHaveLength(1);
|
|
272
|
+
expect(payments[0].price).toBe(30_00);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('charges of a billed fee keep pointing at the deduction row', async () => {
|
|
276
|
+
const { organization, stripeAccount } = await init();
|
|
277
|
+
const fee = await createFee(organization, stripeAccount);
|
|
278
|
+
|
|
279
|
+
await invoiceMonth();
|
|
280
|
+
|
|
281
|
+
const charge = await SettlementCharge.getByID(fee.settlementChargeId);
|
|
282
|
+
expect(charge).toBeDefined();
|
|
283
|
+
expect(charge!.amount).toBe(-30_00);
|
|
284
|
+
});
|
|
285
|
+
});
|