Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions backend/billing/domain/BillingEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { StrategyRegistry } from './StrategyRegistry';
import { FlatPricingStrategy } from './strategies/FlatPricingStrategy';
import { PerSeatPricingStrategy } from './strategies/PerSeatPricingStrategy';
import { TieredPricingStrategy } from './strategies/TieredPricingStrategy';
import { UsageBasedPricingStrategy } from './strategies/UsageBasedPricingStrategy';
import type { Amount, BillingPlan, BillingSubscriber, BillingUsage } from './types';

const resolvePlanTypeCode = (plan: BillingPlan): string =>
plan.typeCode ??
(plan as BillingPlan & { planTypeCode?: string }).planTypeCode ??
(plan as BillingPlan & { type?: string }).type ??
(plan as BillingPlan & { code?: string }).code ??
'fallback';

export class BillingEngine {
private readonly registry: StrategyRegistry;

constructor(registry: StrategyRegistry = BillingEngine.defaultRegistry()) {
this.registry = registry;
}

static defaultRegistry(): StrategyRegistry {
return new StrategyRegistry([
new FlatPricingStrategy(),
new PerSeatPricingStrategy(),
new UsageBasedPricingStrategy(),
new TieredPricingStrategy(),
]);
}

calculate(usage: BillingUsage, plan: BillingPlan, subscriber: BillingSubscriber): Amount {
return this.registry.resolve(resolvePlanTypeCode(plan)).calculate(usage, plan, subscriber);
}

calculateInvoiceAmount(usage: BillingUsage, plan: BillingPlan, subscriber: BillingSubscriber): Amount {
return this.calculate(usage, plan, subscriber);
}

calculateInvoice(usage: BillingUsage, plan: BillingPlan, subscriber: BillingSubscriber): Amount {
return this.calculate(usage, plan, subscriber);
}
}
6 changes: 6 additions & 0 deletions backend/billing/domain/PricingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Amount, BillingPlan, BillingSubscriber, BillingUsage, PricingStrategyCode } from './types';

export interface PricingStrategy {
readonly code: PricingStrategyCode;
calculate(usage: BillingUsage, plan: BillingPlan, subscriber: BillingSubscriber): Amount;
}
32 changes: 32 additions & 0 deletions backend/billing/domain/StrategyRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FallbackPricingStrategy } from './strategies/FallbackPricingStrategy';
import type { PricingStrategy } from './PricingStrategy';
import type { PricingStrategyCode } from './types';

const normalizeCode = (code: PricingStrategyCode): PricingStrategyCode =>
code.trim().toLowerCase().replace(/[\s-]+/g, '_');

export class StrategyRegistry {
private readonly strategies = new Map<PricingStrategyCode, PricingStrategy>();
private readonly fallbackStrategy: PricingStrategy;

constructor(strategies: PricingStrategy[] = [], fallbackStrategy: PricingStrategy = new FallbackPricingStrategy()) {
this.fallbackStrategy = fallbackStrategy;
strategies.forEach((strategy) => this.register(strategy));
}

register(strategy: PricingStrategy): void {
this.strategies.set(normalizeCode(strategy.code), strategy);
}

resolve(code: PricingStrategyCode): PricingStrategy {
return this.strategies.get(normalizeCode(code)) ?? this.fallbackStrategy;
}

has(code: PricingStrategyCode): boolean {
return this.strategies.has(normalizeCode(code));
}

list(): PricingStrategy[] {
return [...this.strategies.values()];
}
}
21 changes: 21 additions & 0 deletions backend/billing/domain/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export { BillingEngine } from './BillingEngine';
export { StrategyRegistry } from './StrategyRegistry';
export type { PricingStrategy } from './PricingStrategy';
export {
createAmount,
getBillingQuantity,
getUsageUnits,
} from './types';
export type {
Amount,
BillingPlan,
BillingSubscriber,
BillingUsage,
PricingStrategyCode,
PricingTier,
} from './types';
export { FlatPricingStrategy } from './strategies/FlatPricingStrategy';
export { PerSeatPricingStrategy } from './strategies/PerSeatPricingStrategy';
export { UsageBasedPricingStrategy } from './strategies/UsageBasedPricingStrategy';
export { TieredPricingStrategy } from './strategies/TieredPricingStrategy';
export { FallbackPricingStrategy } from './strategies/FallbackPricingStrategy';
10 changes: 10 additions & 0 deletions backend/billing/domain/strategies/FallbackPricingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createAmount, type BillingPlan, type BillingSubscriber, type BillingUsage } from '../types';
import type { PricingStrategy } from '../PricingStrategy';

export class FallbackPricingStrategy implements PricingStrategy {
readonly code = 'fallback';

calculate(_usage: BillingUsage, plan: BillingPlan, _subscriber: BillingSubscriber) {
return createAmount(plan.price, plan.currency);
}
}
10 changes: 10 additions & 0 deletions backend/billing/domain/strategies/FlatPricingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createAmount, type BillingPlan, type BillingSubscriber, type BillingUsage } from '../types';
import type { PricingStrategy } from '../PricingStrategy';

export class FlatPricingStrategy implements PricingStrategy {
readonly code = 'flat';

calculate(_usage: BillingUsage, plan: BillingPlan, _subscriber: BillingSubscriber) {
return createAmount(plan.price, plan.currency);
}
}
11 changes: 11 additions & 0 deletions backend/billing/domain/strategies/PerSeatPricingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createAmount, getBillingQuantity, type BillingPlan, type BillingSubscriber, type BillingUsage } from '../types';
import type { PricingStrategy } from '../PricingStrategy';

export class PerSeatPricingStrategy implements PricingStrategy {
readonly code = 'per_seat';

calculate(usage: BillingUsage, plan: BillingPlan, subscriber: BillingSubscriber) {
const seats = getBillingQuantity(usage, plan, subscriber);
return createAmount(plan.price * seats, plan.currency);
}
}
46 changes: 46 additions & 0 deletions backend/billing/domain/strategies/TieredPricingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createAmount, getUsageUnits, type BillingPlan, type BillingSubscriber, type BillingUsage, type PricingTier } from '../types';
import type { PricingStrategy } from '../PricingStrategy';

const normalizeTiers = (tiers: PricingTier[]): PricingTier[] =>
[...tiers].sort((left, right) => {
const leftLimit = left.upTo ?? Number.POSITIVE_INFINITY;
const rightLimit = right.upTo ?? Number.POSITIVE_INFINITY;
return leftLimit - rightLimit;
});

export class TieredPricingStrategy implements PricingStrategy {
readonly code = 'tiered';

calculate(usage: BillingUsage, plan: BillingPlan, _subscriber: BillingSubscriber) {
const units = getUsageUnits(usage);
const tiers = plan.tiers ?? [];

if (tiers.length === 0) {
return createAmount(plan.price * units, plan.currency);
}

const orderedTiers = normalizeTiers(tiers);
let remaining = units;
let lowerBound = 0;
let total = 0;

for (const tier of orderedTiers) {
if (remaining <= 0) break;

const tierLimit = tier.upTo ?? Number.POSITIVE_INFINITY;
const tierWidth = tierLimit === Number.POSITIVE_INFINITY ? remaining : Math.max(tierLimit - lowerBound, 0);
const quantity = Math.min(remaining, tierWidth);

total += quantity * tier.unitPrice;
remaining -= quantity;
lowerBound = tierLimit === Number.POSITIVE_INFINITY ? lowerBound + quantity : tierLimit;
}

if (remaining > 0) {
const lastTier = orderedTiers[orderedTiers.length - 1];
total += remaining * lastTier.unitPrice;
}

return createAmount(total, plan.currency);
}
}
12 changes: 12 additions & 0 deletions backend/billing/domain/strategies/UsageBasedPricingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createAmount, getUsageUnits, type BillingPlan, type BillingSubscriber, type BillingUsage } from '../types';
import type { PricingStrategy } from '../PricingStrategy';

export class UsageBasedPricingStrategy implements PricingStrategy {
readonly code = 'usage_based';

calculate(usage: BillingUsage, plan: BillingPlan, _subscriber: BillingSubscriber) {
const rate = plan.usageUnitPrice ?? plan.price;
const units = getUsageUnits(usage);
return createAmount(rate * units, plan.currency);
}
}
5 changes: 5 additions & 0 deletions backend/billing/domain/strategies/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { FlatPricingStrategy } from './FlatPricingStrategy';
export { PerSeatPricingStrategy } from './PerSeatPricingStrategy';
export { UsageBasedPricingStrategy } from './UsageBasedPricingStrategy';
export { TieredPricingStrategy } from './TieredPricingStrategy';
export { FallbackPricingStrategy } from './FallbackPricingStrategy';
53 changes: 53 additions & 0 deletions backend/billing/domain/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export interface Amount {
value: number;
currency: string;
}

export interface BillingUsage {
units?: number;
seats?: number;
metadata?: Record<string, unknown>;
}

export interface BillingPlan {
typeCode: string;
price: number;
currency: string;
usageUnitPrice?: number;
seatCount?: number;
tiers?: PricingTier[];
metadata?: Record<string, unknown>;
}

export interface BillingSubscriber {
id: string;
seatCount?: number;
seats?: number;
metadata?: Record<string, unknown>;
}

export interface PricingTier {
upTo: number | null;
unitPrice: number;
}

export type PricingStrategyCode = string;

export const createAmount = (value: number, currency: string): Amount => ({
value: Number.isFinite(value) ? Number(value.toFixed(2)) : 0,
currency,
});

export const getBillingQuantity = (
usage: BillingUsage,
plan: BillingPlan,
subscriber: BillingSubscriber
): number => {
const quantity = usage.seats ?? subscriber.seats ?? subscriber.seatCount ?? plan.seatCount ?? usage.units ?? 1;
return Number.isFinite(quantity) && quantity > 0 ? quantity : 1;
};

export const getUsageUnits = (usage: BillingUsage): number => {
const units = usage.units ?? 0;
return Number.isFinite(units) && units > 0 ? units : 0;
};
1 change: 1 addition & 0 deletions backend/billing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './domain';
89 changes: 89 additions & 0 deletions backend/billing/tests/billingEngine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { performance } from 'perf_hooks';
import { BillingEngine } from '../domain/BillingEngine';
import { StrategyRegistry } from '../domain/StrategyRegistry';
import { FlatPricingStrategy } from '../domain/strategies/FlatPricingStrategy';
import { PerSeatPricingStrategy } from '../domain/strategies/PerSeatPricingStrategy';
import { TieredPricingStrategy } from '../domain/strategies/TieredPricingStrategy';
import { UsageBasedPricingStrategy } from '../domain/strategies/UsageBasedPricingStrategy';
import type { BillingPlan } from '../domain/types';

describe('BillingEngine', () => {
it('delegates to the registered strategy for each plan type', () => {
const engine = new BillingEngine(
new StrategyRegistry([
new FlatPricingStrategy(),
new PerSeatPricingStrategy(),
new UsageBasedPricingStrategy(),
new TieredPricingStrategy(),
])
);

expect(
engine.calculate({ units: 2 }, { typeCode: 'flat', price: 5, currency: 'USD' }, { id: 'sub' })
).toEqual({ value: 5, currency: 'USD' });
expect(
engine.calculate(
{ seats: 3 },
{ typeCode: 'per_seat', price: 5, currency: 'USD' },
{ id: 'sub', seatCount: 1 }
)
).toEqual({ value: 15, currency: 'USD' });
expect(
engine.calculate(
{ units: 10 },
{ typeCode: 'usage_based', price: 2, currency: 'USD' },
{ id: 'sub' }
)
).toEqual({ value: 20, currency: 'USD' });
expect(
engine.calculate(
{ units: 5 },
{ typeCode: 'tiered', price: 0, currency: 'USD', tiers: [{ upTo: null, unitPrice: 3 }] },
{ id: 'sub' }
)
).toEqual({ value: 15, currency: 'USD' });
});

it('uses the fallback strategy for unsupported plan types', () => {
const engine = new BillingEngine();

expect(
engine.calculate({ units: 12 }, { typeCode: 'custom', price: 9.5, currency: 'USD' }, { id: 'sub' })
).toEqual({ value: 9.5, currency: 'USD' });
});

it('accepts common plan type aliases when resolving a strategy', () => {
const engine = new BillingEngine();
const aliasPlan = { planTypeCode: 'usage-based', price: 4, currency: 'USD' } as BillingPlan;

expect(engine.calculate({ units: 2 }, aliasPlan, { id: 'sub' })).toEqual({ value: 8, currency: 'USD' });
});

it('aliases calculateInvoice and calculateInvoiceAmount', () => {
const engine = new BillingEngine();
const usage = { units: 4 };
const plan = { typeCode: 'flat', price: 2, currency: 'USD' };
const subscriber = { id: 'sub' };

expect(engine.calculateInvoice(usage, plan, subscriber)).toEqual({ value: 2, currency: 'USD' });
expect(engine.calculateInvoiceAmount(usage, plan, subscriber)).toEqual({ value: 2, currency: 'USD' });
});

it('calculates invoices quickly enough for repeated lookups', () => {
const engine = new BillingEngine();
const plan = { typeCode: 'tiered', price: 0, currency: 'USD', tiers: [{ upTo: null, unitPrice: 1 }] };
const subscriber = { id: 'sub' };

const start = performance.now();
let total = 0;

for (let i = 0; i < 10000; i += 1) {
total += engine.calculate({ units: i % 10 }, plan, subscriber).value;
}

const elapsed = performance.now() - start;

expect(total).toBeGreaterThan(0);
expect(elapsed / 10000).toBeLessThan(5);
});
});
15 changes: 15 additions & 0 deletions backend/billing/tests/flatPricingStrategy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FlatPricingStrategy } from '../domain/strategies/FlatPricingStrategy';

describe('FlatPricingStrategy', () => {
it('returns the plan price as-is', () => {
const strategy = new FlatPricingStrategy();

const amount = strategy.calculate(
{},
{ typeCode: 'flat', price: 19.99, currency: 'USD' },
{ id: 'sub-1' }
);

expect(amount).toEqual({ value: 19.99, currency: 'USD' });
});
});
Loading