WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 799537e

Browse files
refactor(eligibility): implement eligibility checker with validation requirements and integrate into frequency switch process
1 parent 3ecd467 commit 799537e

File tree

8 files changed

+179
-166
lines changed

8 files changed

+179
-166
lines changed
Lines changed: 6 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,6 @@
1-
import { ValidationError } from '@modules/errors';
2-
import { getIfDefined } from '@modules/nullAndUndefined';
3-
import { logger } from '@modules/routing/logger';
4-
import type { SimpleInvoiceItem } from '@modules/zuora/billingPreview';
5-
import { getNextInvoiceTotal } from '@modules/zuora/billingPreview';
6-
import type { ZuoraSubscription } from '@modules/zuora/types';
7-
import type { ZuoraCatalogHelper } from '@modules/zuora-catalog/zuoraCatalog';
8-
import type { Dayjs } from 'dayjs';
9-
import dayjs from 'dayjs';
10-
11-
export class EligibilityChecker {
12-
constructor() {}
13-
14-
assertGenerallyEligible = async (
15-
subscription: ZuoraSubscription,
16-
accountBalance: number,
17-
getNextInvoiceItems: () => Promise<SimpleInvoiceItem[]>,
18-
) => {
19-
logger.log('Checking basic eligibility for the subscription');
20-
this.assertValidState(
21-
subscription.status === 'Active',
22-
validationRequirements.isActive,
23-
subscription.status,
24-
);
25-
this.assertValidState(
26-
accountBalance === 0,
27-
validationRequirements.zeroAccountBalance,
28-
`${accountBalance}`,
29-
);
30-
31-
logger.log(
32-
'ensuring there are no refunds/discounts expected on the affected invoices',
33-
);
34-
const nextInvoiceItems = await getNextInvoiceItems();
35-
this.assertValidState(
36-
nextInvoiceItems.every((item) => item.amount >= 0),
37-
validationRequirements.noNegativePreviewItems,
38-
JSON.stringify(nextInvoiceItems),
39-
);
40-
41-
logger.log(
42-
"making sure there's a payment due - avoid zero contribution amounts",
43-
);
44-
const nextInvoiceTotal = nextInvoiceItems
45-
.map((item) => item.amount)
46-
.reduce((a, b) => a + b);
47-
this.assertValidState(
48-
nextInvoiceTotal > 0,
49-
validationRequirements.nextInvoiceGreaterThanZero,
50-
JSON.stringify(nextInvoiceItems),
51-
);
52-
53-
logger.log('Subscription is generally eligible for the discount');
54-
};
55-
56-
assertNextPaymentIsAtCatalogPrice = (
57-
catalog: ZuoraCatalogHelper,
58-
invoiceItems: SimpleInvoiceItem[],
59-
discountableProductRatePlanId: string,
60-
currency: string,
61-
) => {
62-
const catalogPrice = catalog.getCatalogPrice(
63-
discountableProductRatePlanId,
64-
currency,
65-
);
66-
67-
// Work out how much the cost of the next invoice will be
68-
const nextInvoiceTotal = getIfDefined(
69-
getNextInvoiceTotal(invoiceItems),
70-
`No next invoice found for account containing this subscription`,
71-
);
72-
73-
this.assertValidState(
74-
nextInvoiceTotal >= catalogPrice,
75-
validationRequirements.atLeastCatalogPrice + ' of ' + catalogPrice,
76-
nextInvoiceTotal + ' ' + currency,
77-
);
78-
};
79-
80-
assertEligibleForFreePeriod = (
81-
discountProductRatePlanId: string,
82-
subscription: ZuoraSubscription,
83-
now: Dayjs,
84-
) => {
85-
this.assertValidState(
86-
dayjs(subscription.contractEffectiveDate).add(2, 'months').isBefore(now),
87-
validationRequirements.twoMonthsMin,
88-
subscription.contractEffectiveDate.toDateString(),
89-
);
90-
this.assertNoRepeats(discountProductRatePlanId, subscription);
91-
};
92-
93-
assertNoRepeats = (
94-
discountProductRatePlanId: string,
95-
subscription: ZuoraSubscription,
96-
) => {
97-
this.assertValidState(
98-
subscription.ratePlans.every(
99-
(rp) => rp.productRatePlanId !== discountProductRatePlanId,
100-
),
101-
validationRequirements.notAlreadyUsed,
102-
discountProductRatePlanId,
103-
);
104-
};
105-
106-
private assertValidState(
107-
isValid: boolean,
108-
message: string,
109-
actual: string,
110-
): asserts isValid {
111-
return assertValidState(isValid, message, actual);
112-
}
113-
}
114-
115-
export function assertValidState(
116-
isValid: boolean,
117-
message: string,
118-
actual: string,
119-
): asserts isValid {
120-
logger.log(`Asserting <${message}>`);
121-
if (!isValid) {
122-
logger.log(
123-
`FAILED: subscription did not meet precondition <${message}> (was ${actual})`,
124-
);
125-
throw new ValidationError(
126-
`subscription did not meet precondition <${message}> (was ${actual})`,
127-
);
128-
}
129-
}
130-
131-
export const validationRequirements = {
132-
twoMonthsMin: 'subscription was taken out more than 2 months ago',
133-
notAlreadyUsed: 'this discount has not already been used',
134-
noNegativePreviewItems: `next invoice has no negative items`,
135-
isActive: 'subscription status is active',
136-
zeroAccountBalance: 'account balance is zero',
137-
atLeastCatalogPrice: 'next invoice must be at least the catalog price',
138-
nextInvoiceGreaterThanZero: 'next invoice total must be greater than zero',
139-
mustHaveDiscountDefined: 'subscription must have a discount defined',
140-
};
1+
// Re-exporting from shared module for backward compatibility
2+
export {
3+
EligibilityChecker,
4+
assertValidState,
5+
validationRequirements,
6+
} from '@modules/eligibility/eligibilityChecker';

handlers/product-switch-api/src/frequencySwitchEndpoint.ts

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { assertValidState } from '@modules/eligibility/eligibilityChecker';
2+
import { validationRequirements as sharedValidationRequirements } from '@modules/eligibility/eligibilityChecker';
13
import { ValidationError } from '@modules/errors';
24
import type { IsoCurrency } from '@modules/internationalisation/currency';
35
import { getIfDefined } from '@modules/nullAndUndefined';
@@ -40,11 +42,12 @@ import {
4042

4143
/**
4244
* Validation requirements for frequency switch eligibility.
43-
* Each requirement includes a description of what must pass and details about why.
45+
* Extends the shared validation requirements with frequency-specific checks.
4446
*/
4547
export const frequencySwitchValidationRequirements = {
46-
subscriptionActive: 'subscription status is active',
47-
zeroAccountBalance: 'account balance is zero',
48+
// Reuse shared validation requirements for consistency
49+
...sharedValidationRequirements,
50+
// Frequency-specific validation requirements
4851
hasEligibleCharges:
4952
'subscription has at least one active recurring charge eligible for frequency switch',
5053
singleEligibleCharge:
@@ -54,28 +57,6 @@ export const frequencySwitchValidationRequirements = {
5457
'contribution amount is zero (non-zero contributions cannot be preserved during frequency switch)',
5558
};
5659

57-
/**
58-
* Assert that a condition is valid for frequency switch eligibility.
59-
* Throws ValidationError if condition fails, capturing the requirement and actual value.
60-
*
61-
* @param isValid Whether the validation passed
62-
* @param requirement Description of the requirement from frequencySwitchValidationRequirements
63-
* @param actual The actual value that failed validation
64-
* @throws ValidationError with formatted message including requirement and actual value
65-
*/
66-
function assertValidState(
67-
isValid: boolean,
68-
requirement: string,
69-
actual: string,
70-
): asserts isValid {
71-
logger.log(`Asserting <${requirement}>`);
72-
if (!isValid) {
73-
const message = `subscription did not meet precondition <${requirement}> (was ${actual})`;
74-
logger.log(`FAILED: ${message}`);
75-
throw new ValidationError(message);
76-
}
77-
}
78-
7960
/**
8061
* Select the SupporterPlus Monthly rate plan and its subscription charge eligible for a frequency switch.
8162
* Uses the product catalog to find the exact rate plan to switch to, validating both the rate plan
@@ -102,7 +83,7 @@ export function selectCandidateSubscriptionCharge(
10283
): { ratePlan: RatePlan; charge: RatePlanCharge } {
10384
assertValidState(
10485
subscription.status === 'Active',
105-
frequencySwitchValidationRequirements.subscriptionActive,
86+
frequencySwitchValidationRequirements.isActive,
10687
subscription.status,
10788
);
10889

modules/eligibility/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "eligibility",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"build": "tsc --noEmit",
6+
"test": "jest --group=-integration",
7+
"lint": "eslint **/*.ts"
8+
},
9+
"dependencies": {
10+
"dayjs": "catalog:"
11+
}
12+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { ValidationError } from '@modules/errors';
2+
import { getIfDefined } from '@modules/nullAndUndefined';
3+
import { logger } from '@modules/routing/logger';
4+
import type { SimpleInvoiceItem } from '@modules/zuora/billingPreview';
5+
import { getNextInvoiceTotal } from '@modules/zuora/billingPreview';
6+
import type { ZuoraSubscription } from '@modules/zuora/types';
7+
import type { ZuoraCatalogHelper } from '@modules/zuora-catalog/zuoraCatalog';
8+
import type { Dayjs } from 'dayjs';
9+
import dayjs from 'dayjs';
10+
11+
export class EligibilityChecker {
12+
constructor() {}
13+
14+
assertGenerallyEligible = async (
15+
subscription: ZuoraSubscription,
16+
accountBalance: number,
17+
getNextInvoiceItems: () => Promise<SimpleInvoiceItem[]>,
18+
) => {
19+
logger.log('Checking basic eligibility for the subscription');
20+
this.assertValidState(
21+
subscription.status === 'Active',
22+
validationRequirements.isActive,
23+
subscription.status,
24+
);
25+
this.assertValidState(
26+
accountBalance === 0,
27+
validationRequirements.zeroAccountBalance,
28+
`${accountBalance}`,
29+
);
30+
31+
logger.log(
32+
'ensuring there are no refunds/discounts expected on the affected invoices',
33+
);
34+
const nextInvoiceItems = await getNextInvoiceItems();
35+
this.assertValidState(
36+
nextInvoiceItems.every((item) => item.amount >= 0),
37+
validationRequirements.noNegativePreviewItems,
38+
JSON.stringify(nextInvoiceItems),
39+
);
40+
41+
logger.log(
42+
"making sure there's a payment due - avoid zero contribution amounts",
43+
);
44+
const nextInvoiceTotal = nextInvoiceItems
45+
.map((item) => item.amount)
46+
.reduce((a, b) => a + b);
47+
this.assertValidState(
48+
nextInvoiceTotal > 0,
49+
validationRequirements.nextInvoiceGreaterThanZero,
50+
JSON.stringify(nextInvoiceItems),
51+
);
52+
53+
logger.log('Subscription is generally eligible for the discount');
54+
};
55+
56+
assertNextPaymentIsAtCatalogPrice = (
57+
catalog: ZuoraCatalogHelper,
58+
invoiceItems: SimpleInvoiceItem[],
59+
discountableProductRatePlanId: string,
60+
currency: string,
61+
) => {
62+
const catalogPrice = catalog.getCatalogPrice(
63+
discountableProductRatePlanId,
64+
currency,
65+
);
66+
67+
// Work out how much the cost of the next invoice will be
68+
const nextInvoiceTotal = getIfDefined(
69+
getNextInvoiceTotal(invoiceItems),
70+
`No next invoice found for account containing this subscription`,
71+
);
72+
73+
this.assertValidState(
74+
nextInvoiceTotal >= catalogPrice,
75+
validationRequirements.atLeastCatalogPrice + ' of ' + catalogPrice,
76+
nextInvoiceTotal + ' ' + currency,
77+
);
78+
};
79+
80+
assertEligibleForFreePeriod = (
81+
discountProductRatePlanId: string,
82+
subscription: ZuoraSubscription,
83+
now: Dayjs,
84+
) => {
85+
this.assertValidState(
86+
dayjs(subscription.contractEffectiveDate).add(2, 'months').isBefore(now),
87+
validationRequirements.twoMonthsMin,
88+
subscription.contractEffectiveDate.toDateString(),
89+
);
90+
this.assertNoRepeats(discountProductRatePlanId, subscription);
91+
};
92+
93+
assertNoRepeats = (
94+
discountProductRatePlanId: string,
95+
subscription: ZuoraSubscription,
96+
) => {
97+
this.assertValidState(
98+
subscription.ratePlans.every(
99+
(rp) => rp.productRatePlanId !== discountProductRatePlanId,
100+
),
101+
validationRequirements.notAlreadyUsed,
102+
discountProductRatePlanId,
103+
);
104+
};
105+
106+
private assertValidState(
107+
isValid: boolean,
108+
message: string,
109+
actual: string,
110+
): asserts isValid {
111+
return assertValidState(isValid, message, actual);
112+
}
113+
}
114+
115+
export function assertValidState(
116+
isValid: boolean,
117+
message: string,
118+
actual: string,
119+
): asserts isValid {
120+
logger.log(`Asserting <${message}>`);
121+
if (!isValid) {
122+
logger.log(
123+
`FAILED: subscription did not meet precondition <${message}> (was ${actual})`,
124+
);
125+
throw new ValidationError(
126+
`subscription did not meet precondition <${message}> (was ${actual})`,
127+
);
128+
}
129+
}
130+
131+
export const validationRequirements = {
132+
twoMonthsMin: 'subscription was taken out more than 2 months ago',
133+
notAlreadyUsed: 'this discount has not already been used',
134+
noNegativePreviewItems: `next invoice has no negative items`,
135+
isActive: 'subscription status is active',
136+
zeroAccountBalance: 'account balance is zero',
137+
atLeastCatalogPrice: 'next invoice must be at least the catalog price',
138+
nextInvoiceGreaterThanZero: 'next invoice total must be greater than zero',
139+
mustHaveDiscountDefined: 'subscription must have a discount defined',
140+
};

modules/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"build": "tsc --noEmit",
88
"check-formatting": "prettier --check **.ts",
99
"fix-formatting": "prettier --write **.ts"
10+
},
11+
"dependencies": {
12+
"dayjs": "catalog:"
1013
}
1114
}

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)