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
62 changes: 62 additions & 0 deletions backend/services/billing/__tests__/trialService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { TrialManagementService, WebhookPayload } from '../trialService';

describe('TrialManagementService', () => {
let service: TrialManagementService;

beforeEach(() => {
service = new TrialManagementService('http://mock-webhook.local/events');
});

describe('checkTrialsEndingSoon', () => {
it('should generate alerts for trials ending within the threshold', () => {
const now = Date.now();
const oneDayInMs = 24 * 60 * 60 * 1000;

const subscriptions = [
{ id: 1, status: 'Trialing', nextChargeAt: (now + oneDayInMs) / 1000 }, // ending in 1 day
{ id: 2, status: 'Trialing', nextChargeAt: (now + 5 * oneDayInMs) / 1000 }, // ending in 5 days (too far)
{ id: 3, status: 'Active', nextChargeAt: (now + oneDayInMs) / 1000 }, // not a trial
];

const alerts = service.checkTrialsEndingSoon(subscriptions, 3);

expect(alerts).toHaveLength(1);
expect(alerts[0].subscriptionId).toBe(1);
expect(alerts[0].event).toBe('trial_ending_soon');
expect(alerts[0].data.timeRemainingDays).toBe(2); // Math.ceil(timeRemaining / oneDayInMs)
});
});

describe('processExpiredTrials', () => {
it('should return IDs of expired trials', () => {
const now = Date.now();
const oneDayInMs = 24 * 60 * 60 * 1000;

const subscriptions = [
{ id: 1, status: 'Trialing', nextChargeAt: (now - oneDayInMs) / 1000 }, // expired 1 day ago
{ id: 2, status: 'Trialing', nextChargeAt: (now + oneDayInMs) / 1000 }, // still active
];

const expiredIds = service.processExpiredTrials(subscriptions);

expect(expiredIds).toHaveLength(1);
expect(expiredIds[0]).toBe(1);
});
});

describe('getTrialAnalytics', () => {
it('should correctly calculate conversion rate', () => {
const analytics = service.getTrialAnalytics(100, 25);

expect(analytics.totalTrials).toBe(100);
expect(analytics.convertedTrials).toBe(25);
expect(analytics.conversionRate).toBe(0.25);
});

it('should handle zero historical trials', () => {
const analytics = service.getTrialAnalytics(0, 0);

expect(analytics.conversionRate).toBe(0);
});
});
});
154 changes: 154 additions & 0 deletions backend/services/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,157 @@ export { PricingStrategyFactory, PlanType } from './strategyFactory';
export { BillingEngine, BillingEngineConfig } from './billingEngine';
export { PricingAnalyticsService, RevenueMetrics } from './billingAnalytics';

// Plan Templates and Dynamic Pricing Tiers
export {
export { MeteringService, meteringService } from './meteringService';
export type { UsageMetric, UsageIngestResult, UsageIngestStatus } from './meteringService';
export { TieredPricingCalculator, buildSimpleTiers } from './tieredPricingCalculator';
export { handleUsageIngestion } from './usageIngestionApi';
export type { UsageEventPayload, UsageIngestResponse } from './usageIngestionApi';
export { UsageBillingCloseCron, usageBillingCloseCron } from './usageBillingCloseCron';
export type { UsageBillingCloseReport, UsageBillingCloseEntry, MeterAccount } from './usageBillingCloseCron';
export { AlignmentService, alignmentService } from './alignmentService';
export type { AlignmentConfirmation } from './alignmentService';
export { ConsolidationEngine, consolidationEngine } from './consolidationEngine';
export { PricingService } from './pricingService';
export type { PriceRecommendation, ABTestScenario, PricingContext } from './pricingService';
export { TaxService } from './taxService';
export type {
TaxType,
TaxJurisdiction,
TaxRateEntry,
TaxRateChangeEvent,
CustomerTaxStatus,
TaxRemittanceLineItem,
TaxRemittanceReport,
TaxCalculationResult,
TaxInvoiceContext,
NexusReport,
MidCycleTaxChange,
DigitalGoodsClass,
DigitalGoodsTaxRule,
TaxRemittanceReportRequest,
} from './taxTypes';
export { DunningService, dunningService } from './dunningService';
export type {
BackoffPolicy,
FailureType,
RetryScheduleConfig,
RetryAnalytics,
} from './dunningService';

// Metered pricing and tiered overage rating (issue #935). Mirrors the
// `subtrackr-metering` Soroban contract; see metering.ts for why.
export {
MeteringPricingError,
buildOverageLadder,
marginalUnitPrice,
quoteMeter,
rateMeter,
rateUsage,
toContractTiers,
validateMeterPricingPlan,
validateOverageTiers,
} from './metering';
export type {
MeteredPricingModel,
MeterPricingPlan,
OverageTier,
RateUsageInput,
RatedMeterLine,
RatedTierLine,
RatedUsageBill,
} from './metering';

// Per-tenant invoice branding and rendering (issue #937).
export {
FALLBACK_BRANDING,
InvoiceCustomizationService,
escapeHtml,
normalizeBranding,
} from './invoiceCustomizationService';
export type { DeliveryResult, RenderedInvoice } from './invoiceCustomizationService';
export { ProrationService, prorationService } from './proration';
export type {
ProrationConfiguration,
ProrationAnalytics,
ProrationDispute,
MidCycleChangeRequest,
} from './proration';
export { streamExport, reconcile } from './accountingExportService';
export type {
AccountingFormat,
TransactionType,
TransactionRecord,
ExportFilter,
StreamExportOptions,
ReconciliationResult,
} from './accountingExportService';
export {
BackendPartnerService,
} from './partnerService';
export type { SplitConfiguration, PartnerPayoutSchedule } from '../../../src/types/partner';

// Credit system — see creditService.ts for architectural notes.
export { CreditService, creditService, creditReportToCsv } from './creditService';
export type {
AccountCreditSummary,
ApplyCreditInput,
ApplyCreditResult,
CreditAccount,
CreditAuditPage,
CreditAuditQuery,
CreditBucketBreakdown,
CreditEntry,
CreditEntryKind,
CreditExpiryForecast,
CreditLot,
CreditReport,
CreditUsageTrendPoint,
ExpirationPolicy,
IssueCreditInput,
PrepaymentTransaction,
PrepaymentWallet,
TopAccount,
TransferCreditInput,
} from './creditTypes';
export type {
IMeteringService,
IPricingService,
ITaxService,
IDunningService,
IAccountingExportService,
IPartnerService,
ICreditService,
} from './interfaces';
export { BillingError, BillingErrorCode } from './errors';

// Strategy Pattern Pricing exports (Issue #741)
export { PricingStrategy, PricingContext as PricingStrategyContext, PricingResult, PricingAnalytics } from './pricingStrategy';
export { FlatRateStrategy } from './flatRateStrategy';
export { UsageBasedStrategy } from './usageBasedStrategy';
export { TieredPricingStrategy } from './tieredStrategy';
export { DynamicPricingStrategy } from './dynamicStrategy';
export { PricingStrategyFactory, PlanType } from './strategyFactory';
export { BillingEngine, BillingEngineConfig } from './billingEngine';
export { PricingAnalyticsService, RevenueMetrics } from './billingAnalytics';

// Plan Templates and Dynamic Pricing Tiers
export {
PlanTemplateService,
validateTiers,
validateTemplateDraft,
quoteTemplate,
resolvePlan
} from './planTemplateService';
export type {
PricingTier,
PlanTemplate,
TemplateOverrides,
ResolvedPlan,
TemplateAnalytics
} from './planTemplateService';

// Trial Management
export { TrialManagementService } from './trialService';
export type { TrialAnalytics, WebhookPayload } from './trialService';
96 changes: 96 additions & 0 deletions backend/services/billing/trialService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { BillingEngine } from './billingEngine';

export interface TrialAnalytics {
totalTrials: number;
convertedTrials: number;
conversionRate: number;
}

export interface WebhookPayload {
event: string;
subscriptionId: number;
data: any;
}

export class TrialManagementService {
constructor(private webhookUrl?: string) {}

/**
* Checks subscriptions nearing their trial end date and triggers a webhook event.
* This handles the conversion optimization logic (e.g. reminders/discounts).
*/
public checkTrialsEndingSoon(
subscriptions: any[],
warningThresholdDays: number = 3
): WebhookPayload[] {
const alerts: WebhookPayload[] = [];
const now = Date.now();
const thresholdMs = warningThresholdDays * 24 * 60 * 60 * 1000;

for (const sub of subscriptions) {
if (sub.status === 'Trialing' || sub.status === 4 /* Trialing Enum */) {
const timeRemaining = (sub.nextChargeAt || sub.next_charge_at) * 1000 - now;

if (timeRemaining > 0 && timeRemaining <= thresholdMs) {
const payload: WebhookPayload = {
event: 'trial_ending_soon',
subscriptionId: sub.id,
data: {
timeRemainingDays: Math.ceil(timeRemaining / (24 * 60 * 60 * 1000)),
},
};
alerts.push(payload);
this.triggerWebhook(payload);
}
}
}

return alerts;
}

/**
* Evaluates expired trials and triggers conversion logic.
*/
public processExpiredTrials(subscriptions: any[]): number[] {
const convertedIds: number[] = [];
const now = Date.now();

for (const sub of subscriptions) {
if (sub.status === 'Trialing' || sub.status === 4 /* Trialing Enum */) {
const endTimeMs = (sub.nextChargeAt || sub.next_charge_at) * 1000;

if (now >= endTimeMs) {
// In a real system, we would trigger a charge here via BillingEngine.
// For now, we return the IDs to be converted.
convertedIds.push(sub.id);
}
}
}

return convertedIds;
}

/**
* Calculates trial conversion metrics.
*/
public getTrialAnalytics(
totalHistoricalTrials: number,
totalConverted: number
): TrialAnalytics {
return {
totalTrials: totalHistoricalTrials,
convertedTrials: totalConverted,
conversionRate:
totalHistoricalTrials > 0
? totalConverted / totalHistoricalTrials
: 0,
};
}

private triggerWebhook(payload: WebhookPayload) {
if (this.webhookUrl) {
// Mock webhook dispatch
console.log(`[Webhook Dispatch] ${this.webhookUrl} ->`, payload);
}
}
}
18 changes: 9 additions & 9 deletions contracts/subscription/src/gas_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::gas_profiler::GasProfile;
#[derive(Clone)]
pub enum GasStorageKey {
/// Function gas profile: StorageKey::GasProfile(function_name)
GasProfile(SorobanString),
GasProfile(String),
/// Daily gas usage: StorageKey::DailyGasUsage(timestamp)
DailyGasUsage(u64),
/// Weekly gas usage: StorageKey::WeeklyGasUsage(timestamp)
Expand All @@ -18,9 +18,9 @@ pub enum GasStorageKey {
/// Total number of contract calls
TotalCallCount,
/// Gas alert count by type
AlertCount(SorobanString),
AlertCount(String),
/// Last recorded gas usage for a function
LastGasUsage(SorobanString),
LastGasUsage(String),
}

/// Gas metrics storage handler
Expand All @@ -38,7 +38,7 @@ impl GasMetricsStorage {
pub fn get_profile(
env: &Env,
storage: &Address,
function_name: &SorobanString,
function_name: &String,
) -> Option<GasProfile> {
// Retrieve and deserialize profile
None
Expand Down Expand Up @@ -111,26 +111,26 @@ impl GasMetricsStorage {

/// Record gas alert
pub fn record_alert(env: &Env, storage: &Address, alert_type: &str) {
let alert_key = SorobanString::from_str(env, alert_type);
let alert_key = String::from_str(env, alert_type);
// Increment alert count
}

/// Get gas alert count by type
pub fn get_alert_count(env: &Env, storage: &Address, alert_type: &str) -> u64 {
let alert_key = SorobanString::from_str(env, alert_type);
let alert_key = String::from_str(env, alert_type);
// Retrieve alert count
0
}

/// Update last recorded gas usage for a function
pub fn update_last_usage(env: &Env, storage: &Address, function_name: &str, gas_used: u64) {
let fname = SorobanString::from_str(env, function_name);
let fname = String::from_str(env, function_name);
// Update last usage
}

/// Get last recorded gas usage
pub fn get_last_usage(env: &Env, storage: &Address, function_name: &str) -> Option<u64> {
let fname = SorobanString::from_str(env, function_name);
let fname = String::from_str(env, function_name);
// Retrieve last usage
None
}
Expand All @@ -151,7 +151,7 @@ impl GasMetricsStorage {
}

/// Helper function to format gas profile storage key
fn format_gas_profile_key(env: &Env, function_name: &SorobanString) -> SorobanString {
fn format_gas_profile_key(env: &Env, function_name: &String) -> String {
// Format: "gas_profile_{function_name}"
function_name.clone()
}
Loading