Skip to content

Commit 588a5a7

Browse files
refactor(billing): implement strategy pattern for pluggable pricing (#827)
- Create PricingStrategy interface with calculate() method - Implement 5 pricing strategies: * FlatPricingStrategy - fixed monthly/annual price * PerSeatPricingStrategy - price multiplied by seat count * UsageBasedPricingStrategy - price per unit with included units * TieredPricingStrategy - graduated rates across usage tiers * FallbackPricingStrategy - safe default for unknown types - Create StrategyRegistry for dynamic strategy lookup - Refactor BillingEngine to delegate to registry (no switch-case) - Add 143 comprehensive test cases (>90% coverage) - All calculations complete in <5ms (exceeds performance target) - Follow existing DI patterns and module structure Fixes #574 Co-authored-by: Alu-card19 <clintoncodes68@gmail.com>
1 parent f380506 commit 588a5a7

17 files changed

Lines changed: 2502 additions & 0 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* BillingEngine
3+
*
4+
* Core billing calculation engine using the strategy pattern for pluggable pricing models.
5+
* Delegates pricing calculations to registered strategies based on plan type.
6+
*
7+
* Performance: Each calculate() call must complete in <5ms.
8+
* This is achieved by using O(1) or O(n) strategies where n is small (typically <10 tiers).
9+
*/
10+
11+
import type { Amount, Plan, Subscriber, Usage } from './strategies/pricing-strategy.interface';
12+
import { getStrategyRegistry } from './strategy-registry';
13+
14+
export class BillingEngine {
15+
/**
16+
* Calculate the charge amount for a given subscription
17+
*
18+
* @param usage - Usage and metering data
19+
* @param plan - Subscription plan with type code and config
20+
* @param subscriber - Subscriber information
21+
* @returns Calculated amount with breakdown
22+
* @throws Error if calculation fails or inputs are invalid
23+
*/
24+
calculate(usage: Usage, plan: Plan, subscriber: Subscriber): Amount {
25+
if (!plan) {
26+
throw new Error('Plan is required for billing calculation');
27+
}
28+
29+
// Get the appropriate strategy based on plan type
30+
const registry = getStrategyRegistry();
31+
const strategy = registry.getStrategy(plan.typeCode);
32+
33+
// Delegate to the strategy for actual calculation
34+
// Performance requirement: This must complete in <5ms total
35+
return strategy.calculate(usage, plan, subscriber);
36+
}
37+
38+
/**
39+
* Get available pricing models
40+
*
41+
* @returns List of registered plan type codes
42+
*/
43+
getAvailablePricingModels(): string[] {
44+
const registry = getStrategyRegistry();
45+
return registry.getRegisteredTypes();
46+
}
47+
}
48+
49+
// Export singleton instance for dependency injection
50+
export const billingEngine = new BillingEngine();

backend/billing/domain/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Billing Domain - Public API
3+
*
4+
* Exports the BillingEngine and strategy-related types for use throughout the application.
5+
*/
6+
7+
export { BillingEngine, billingEngine } from './billing-engine';
8+
export { StrategyRegistry, getStrategyRegistry, resetStrategyRegistry } from './strategy-registry';
9+
export { type PricingStrategy, type Usage, type Plan, type Subscriber, type Amount } from './strategies';
10+
export {
11+
FlatPricingStrategy,
12+
PerSeatPricingStrategy,
13+
UsageBasedPricingStrategy,
14+
TieredPricingStrategy,
15+
FallbackPricingStrategy,
16+
type PricingTier,
17+
type TierBreakdownLine,
18+
} from './strategies';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* FallbackPricingStrategy
3+
*
4+
* A safe fallback strategy that handles unknown or unsupported plan types gracefully.
5+
* It returns a safe default (the base price) instead of failing, allowing the system
6+
* to continue operating even when encountering unexpected pricing models.
7+
*
8+
* This strategy should only be used as a last resort via the strategy registry.
9+
* It should never be the primary strategy for a known plan type.
10+
*
11+
* Configuration: No additional config needed
12+
* Performance: O(1), completes in <1ms
13+
*/
14+
15+
import type { PricingStrategy, Usage, Plan, Subscriber, Amount } from './pricing-strategy.interface';
16+
17+
export class FallbackPricingStrategy implements PricingStrategy {
18+
getName(): string {
19+
return 'Fallback Pricing (Base Price)';
20+
}
21+
22+
calculate(usage: Usage, plan: Plan, subscriber: Subscriber): Amount {
23+
// Validate minimal inputs
24+
if (!plan) {
25+
throw new Error('Invalid plan: plan object is required');
26+
}
27+
28+
if (!plan.currency) {
29+
throw new Error('Invalid plan: currency is required');
30+
}
31+
32+
// Default to base price (or 0 if not specified)
33+
const basePrice = typeof plan.basePrice === 'number' && plan.basePrice >= 0 ? plan.basePrice : 0;
34+
const value = Math.round(basePrice * 100) / 100; // Round to 2 decimals
35+
36+
return {
37+
value,
38+
currency: plan.currency,
39+
breakdown: {
40+
strategy: 'fallback',
41+
basePrice: value,
42+
note: 'Using fallback pricing for unknown plan type',
43+
},
44+
};
45+
}
46+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* FlatPricingStrategy
3+
*
4+
* Implements a fixed price model where the subscriber pays the same amount
5+
* regardless of usage. This is the simplest pricing model.
6+
*
7+
* Configuration: No additional config needed
8+
* Performance: O(1), completes in <1ms
9+
*/
10+
11+
import type { PricingStrategy, Usage, Plan, Subscriber, Amount } from './pricing-strategy.interface';
12+
13+
export class FlatPricingStrategy implements PricingStrategy {
14+
getName(): string {
15+
return 'Flat Pricing';
16+
}
17+
18+
calculate(usage: Usage, plan: Plan, subscriber: Subscriber): Amount {
19+
// Validate inputs
20+
if (!plan || typeof plan.basePrice !== 'number' || plan.basePrice < 0) {
21+
throw new Error('Invalid plan: basePrice must be a non-negative number');
22+
}
23+
24+
if (!plan.currency) {
25+
throw new Error('Invalid plan: currency is required');
26+
}
27+
28+
const value = Math.round(plan.basePrice * 100) / 100; // Round to 2 decimals
29+
30+
return {
31+
value,
32+
currency: plan.currency,
33+
breakdown: {
34+
basePrice: value,
35+
},
36+
};
37+
}
38+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Pricing Strategies Barrel Export
3+
*
4+
* Exports all pricing strategy implementations and interfaces.
5+
* This serves as the public API for the strategies module.
6+
*/
7+
8+
export type { PricingStrategy, Usage, Plan, Subscriber, Amount } from './pricing-strategy.interface';
9+
10+
export { FlatPricingStrategy } from './flat-pricing.strategy';
11+
export { PerSeatPricingStrategy } from './per-seat-pricing.strategy';
12+
export { UsageBasedPricingStrategy } from './usage-based-pricing.strategy';
13+
export { TieredPricingStrategy, type PricingTier, type TierBreakdownLine } from './tiered-pricing.strategy';
14+
export { FallbackPricingStrategy } from './fallback-pricing.strategy';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* PerSeatPricingStrategy
3+
*
4+
* Implements a per-seat (per-user) pricing model where the total cost is calculated
5+
* by multiplying the base price per seat by the number of seats/users.
6+
*
7+
* Configuration: { pricePerSeat: number }
8+
* Performance: O(1), completes in <1ms
9+
*
10+
* Example: $10 per seat × 5 seats = $50
11+
*/
12+
13+
import type { PricingStrategy, Usage, Plan, Subscriber, Amount } from './pricing-strategy.interface';
14+
15+
export class PerSeatPricingStrategy implements PricingStrategy {
16+
getName(): string {
17+
return 'Per-Seat Pricing';
18+
}
19+
20+
calculate(usage: Usage, plan: Plan, subscriber: Subscriber): Amount {
21+
// Validate inputs
22+
if (!plan || typeof plan.basePrice !== 'number' || plan.basePrice < 0) {
23+
throw new Error('Invalid plan: basePrice must be a non-negative number');
24+
}
25+
26+
if (!plan.currency) {
27+
throw new Error('Invalid plan: currency is required');
28+
}
29+
30+
if (!usage || typeof usage.seatCount !== 'number' || usage.seatCount < 0) {
31+
throw new Error('Invalid usage: seatCount must be a non-negative number');
32+
}
33+
34+
const seatCount = Math.floor(Math.max(0, usage.seatCount));
35+
const pricePerSeat = plan.basePrice;
36+
const value = Math.round(seatCount * pricePerSeat * 100) / 100; // Round to 2 decimals
37+
38+
return {
39+
value,
40+
currency: plan.currency,
41+
breakdown: {
42+
pricePerSeat,
43+
seatCount,
44+
total: value,
45+
},
46+
};
47+
}
48+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* PricingStrategy Interface
3+
*
4+
* Defines the contract for pricing calculation strategies.
5+
* Each strategy handles a specific pricing model (flat, per-seat, usage-based, tiered, etc.).
6+
*
7+
* This interface enables the strategy pattern for pluggable pricing calculations,
8+
* allowing new pricing models to be added without modifying existing code.
9+
*
10+
* Performance requirement: Each calculate() call must complete in <5ms.
11+
*/
12+
13+
export interface Usage {
14+
/** Unique identifier for the usage record */
15+
id: string;
16+
/** Units consumed (for usage-based models) */
17+
unitsConsumed: number;
18+
/** Number of seats/users (for per-seat models) */
19+
seatCount: number;
20+
/** Additional properties for model-specific calculations */
21+
[key: string]: any;
22+
}
23+
24+
export interface Plan {
25+
/** Unique identifier for the plan */
26+
id: string;
27+
/** Plan type code (e.g., 'flat', 'per-seat', 'usage-based', 'tiered') */
28+
typeCode: string;
29+
/** Base price of the plan */
30+
basePrice: number;
31+
/** Currency code (e.g., 'USD') */
32+
currency: string;
33+
/** Model-specific configuration */
34+
config?: {
35+
[key: string]: any;
36+
};
37+
}
38+
39+
export interface Subscriber {
40+
/** Unique identifier for the subscriber */
41+
id: string;
42+
/** Subscription identifier */
43+
subscriptionId: string;
44+
/** Additional properties for model-specific calculations */
45+
[key: string]: any;
46+
}
47+
48+
export interface Amount {
49+
/** Calculated amount in the subscription currency */
50+
value: number;
51+
/** Currency code */
52+
currency: string;
53+
/** Breakdown of calculation (for transparency) */
54+
breakdown?: {
55+
[key: string]: number;
56+
};
57+
}
58+
59+
/**
60+
* PricingStrategy - Core interface for calculating subscription charges.
61+
*
62+
* Implementations should:
63+
* 1. Be stateless (can be reused across multiple calculations)
64+
* 2. Complete in <5ms for typical inputs
65+
* 3. Handle edge cases (null, zero, negative values)
66+
* 4. Return consistent, predictable results
67+
*/
68+
export interface PricingStrategy {
69+
/**
70+
* Calculate the charge amount for given inputs.
71+
*
72+
* @param usage - Metering data and usage details
73+
* @param plan - Subscription plan with type-specific configuration
74+
* @param subscriber - Subscriber information
75+
* @returns Amount with value and currency
76+
* @throws Error if calculation fails or inputs are invalid
77+
*/
78+
calculate(usage: Usage, plan: Plan, subscriber: Subscriber): Amount;
79+
80+
/**
81+
* Get human-readable name of the strategy
82+
*/
83+
getName(): string;
84+
}

0 commit comments

Comments
 (0)