Skip to content

Commit feb0c0f

Browse files
authored
Merge pull request #1075 from Tekgodfrey/feature/issue-929-trial-management
Feature/issue 929 trial management
2 parents b0a4c94 + ef9bea2 commit feb0c0f

10 files changed

Lines changed: 562 additions & 13 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { TrialManagementService, WebhookPayload } from '../trialService';
2+
3+
describe('TrialManagementService', () => {
4+
let service: TrialManagementService;
5+
6+
beforeEach(() => {
7+
service = new TrialManagementService('http://mock-webhook.local/events');
8+
});
9+
10+
describe('checkTrialsEndingSoon', () => {
11+
it('should generate alerts for trials ending within the threshold', () => {
12+
const now = Date.now();
13+
const oneDayInMs = 24 * 60 * 60 * 1000;
14+
15+
const subscriptions = [
16+
{ id: 1, status: 'Trialing', nextChargeAt: (now + oneDayInMs) / 1000 }, // ending in 1 day
17+
{ id: 2, status: 'Trialing', nextChargeAt: (now + 5 * oneDayInMs) / 1000 }, // ending in 5 days (too far)
18+
{ id: 3, status: 'Active', nextChargeAt: (now + oneDayInMs) / 1000 }, // not a trial
19+
];
20+
21+
const alerts = service.checkTrialsEndingSoon(subscriptions, 3);
22+
23+
expect(alerts).toHaveLength(1);
24+
expect(alerts[0].subscriptionId).toBe(1);
25+
expect(alerts[0].event).toBe('trial_ending_soon');
26+
expect(alerts[0].data.timeRemainingDays).toBe(2); // Math.ceil(timeRemaining / oneDayInMs)
27+
});
28+
});
29+
30+
describe('processExpiredTrials', () => {
31+
it('should return IDs of expired trials', () => {
32+
const now = Date.now();
33+
const oneDayInMs = 24 * 60 * 60 * 1000;
34+
35+
const subscriptions = [
36+
{ id: 1, status: 'Trialing', nextChargeAt: (now - oneDayInMs) / 1000 }, // expired 1 day ago
37+
{ id: 2, status: 'Trialing', nextChargeAt: (now + oneDayInMs) / 1000 }, // still active
38+
];
39+
40+
const expiredIds = service.processExpiredTrials(subscriptions);
41+
42+
expect(expiredIds).toHaveLength(1);
43+
expect(expiredIds[0]).toBe(1);
44+
});
45+
});
46+
47+
describe('getTrialAnalytics', () => {
48+
it('should correctly calculate conversion rate', () => {
49+
const analytics = service.getTrialAnalytics(100, 25);
50+
51+
expect(analytics.totalTrials).toBe(100);
52+
expect(analytics.convertedTrials).toBe(25);
53+
expect(analytics.conversionRate).toBe(0.25);
54+
});
55+
56+
it('should handle zero historical trials', () => {
57+
const analytics = service.getTrialAnalytics(0, 0);
58+
59+
expect(analytics.conversionRate).toBe(0);
60+
});
61+
});
62+
});

backend/services/billing/index.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,157 @@ export { PricingStrategyFactory, PlanType } from './strategyFactory';
131131
export { BillingEngine, BillingEngineConfig } from './billingEngine';
132132
export { PricingAnalyticsService, RevenueMetrics } from './billingAnalytics';
133133

134+
// Plan Templates and Dynamic Pricing Tiers
135+
export {
136+
export { MeteringService, meteringService } from './meteringService';
137+
export type { UsageMetric, UsageIngestResult, UsageIngestStatus } from './meteringService';
138+
export { TieredPricingCalculator, buildSimpleTiers } from './tieredPricingCalculator';
139+
export { handleUsageIngestion } from './usageIngestionApi';
140+
export type { UsageEventPayload, UsageIngestResponse } from './usageIngestionApi';
141+
export { UsageBillingCloseCron, usageBillingCloseCron } from './usageBillingCloseCron';
142+
export type { UsageBillingCloseReport, UsageBillingCloseEntry, MeterAccount } from './usageBillingCloseCron';
143+
export { AlignmentService, alignmentService } from './alignmentService';
144+
export type { AlignmentConfirmation } from './alignmentService';
145+
export { ConsolidationEngine, consolidationEngine } from './consolidationEngine';
146+
export { PricingService } from './pricingService';
147+
export type { PriceRecommendation, ABTestScenario, PricingContext } from './pricingService';
148+
export { TaxService } from './taxService';
149+
export type {
150+
TaxType,
151+
TaxJurisdiction,
152+
TaxRateEntry,
153+
TaxRateChangeEvent,
154+
CustomerTaxStatus,
155+
TaxRemittanceLineItem,
156+
TaxRemittanceReport,
157+
TaxCalculationResult,
158+
TaxInvoiceContext,
159+
NexusReport,
160+
MidCycleTaxChange,
161+
DigitalGoodsClass,
162+
DigitalGoodsTaxRule,
163+
TaxRemittanceReportRequest,
164+
} from './taxTypes';
165+
export { DunningService, dunningService } from './dunningService';
166+
export type {
167+
BackoffPolicy,
168+
FailureType,
169+
RetryScheduleConfig,
170+
RetryAnalytics,
171+
} from './dunningService';
172+
173+
// Metered pricing and tiered overage rating (issue #935). Mirrors the
174+
// `subtrackr-metering` Soroban contract; see metering.ts for why.
175+
export {
176+
MeteringPricingError,
177+
buildOverageLadder,
178+
marginalUnitPrice,
179+
quoteMeter,
180+
rateMeter,
181+
rateUsage,
182+
toContractTiers,
183+
validateMeterPricingPlan,
184+
validateOverageTiers,
185+
} from './metering';
186+
export type {
187+
MeteredPricingModel,
188+
MeterPricingPlan,
189+
OverageTier,
190+
RateUsageInput,
191+
RatedMeterLine,
192+
RatedTierLine,
193+
RatedUsageBill,
194+
} from './metering';
195+
196+
// Per-tenant invoice branding and rendering (issue #937).
197+
export {
198+
FALLBACK_BRANDING,
199+
InvoiceCustomizationService,
200+
escapeHtml,
201+
normalizeBranding,
202+
} from './invoiceCustomizationService';
203+
export type { DeliveryResult, RenderedInvoice } from './invoiceCustomizationService';
204+
export { ProrationService, prorationService } from './proration';
205+
export type {
206+
ProrationConfiguration,
207+
ProrationAnalytics,
208+
ProrationDispute,
209+
MidCycleChangeRequest,
210+
} from './proration';
211+
export { streamExport, reconcile } from './accountingExportService';
212+
export type {
213+
AccountingFormat,
214+
TransactionType,
215+
TransactionRecord,
216+
ExportFilter,
217+
StreamExportOptions,
218+
ReconciliationResult,
219+
} from './accountingExportService';
220+
export {
221+
BackendPartnerService,
222+
} from './partnerService';
223+
export type { SplitConfiguration, PartnerPayoutSchedule } from '../../../src/types/partner';
224+
225+
// Credit system — see creditService.ts for architectural notes.
226+
export { CreditService, creditService, creditReportToCsv } from './creditService';
227+
export type {
228+
AccountCreditSummary,
229+
ApplyCreditInput,
230+
ApplyCreditResult,
231+
CreditAccount,
232+
CreditAuditPage,
233+
CreditAuditQuery,
234+
CreditBucketBreakdown,
235+
CreditEntry,
236+
CreditEntryKind,
237+
CreditExpiryForecast,
238+
CreditLot,
239+
CreditReport,
240+
CreditUsageTrendPoint,
241+
ExpirationPolicy,
242+
IssueCreditInput,
243+
PrepaymentTransaction,
244+
PrepaymentWallet,
245+
TopAccount,
246+
TransferCreditInput,
247+
} from './creditTypes';
248+
export type {
249+
IMeteringService,
250+
IPricingService,
251+
ITaxService,
252+
IDunningService,
253+
IAccountingExportService,
254+
IPartnerService,
255+
ICreditService,
256+
} from './interfaces';
257+
export { BillingError, BillingErrorCode } from './errors';
258+
259+
// Strategy Pattern Pricing exports (Issue #741)
260+
export { PricingStrategy, PricingContext as PricingStrategyContext, PricingResult, PricingAnalytics } from './pricingStrategy';
261+
export { FlatRateStrategy } from './flatRateStrategy';
262+
export { UsageBasedStrategy } from './usageBasedStrategy';
263+
export { TieredPricingStrategy } from './tieredStrategy';
264+
export { DynamicPricingStrategy } from './dynamicStrategy';
265+
export { PricingStrategyFactory, PlanType } from './strategyFactory';
266+
export { BillingEngine, BillingEngineConfig } from './billingEngine';
267+
export { PricingAnalyticsService, RevenueMetrics } from './billingAnalytics';
268+
269+
// Plan Templates and Dynamic Pricing Tiers
270+
export {
271+
PlanTemplateService,
272+
validateTiers,
273+
validateTemplateDraft,
274+
quoteTemplate,
275+
resolvePlan
276+
} from './planTemplateService';
277+
export type {
278+
PricingTier,
279+
PlanTemplate,
280+
TemplateOverrides,
281+
ResolvedPlan,
282+
TemplateAnalytics
283+
} from './planTemplateService';
284+
285+
// Trial Management
286+
export { TrialManagementService } from './trialService';
287+
export type { TrialAnalytics, WebhookPayload } from './trialService';
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { BillingEngine } from './billingEngine';
2+
3+
export interface TrialAnalytics {
4+
totalTrials: number;
5+
convertedTrials: number;
6+
conversionRate: number;
7+
}
8+
9+
export interface WebhookPayload {
10+
event: string;
11+
subscriptionId: number;
12+
data: any;
13+
}
14+
15+
export class TrialManagementService {
16+
constructor(private webhookUrl?: string) {}
17+
18+
/**
19+
* Checks subscriptions nearing their trial end date and triggers a webhook event.
20+
* This handles the conversion optimization logic (e.g. reminders/discounts).
21+
*/
22+
public checkTrialsEndingSoon(
23+
subscriptions: any[],
24+
warningThresholdDays: number = 3
25+
): WebhookPayload[] {
26+
const alerts: WebhookPayload[] = [];
27+
const now = Date.now();
28+
const thresholdMs = warningThresholdDays * 24 * 60 * 60 * 1000;
29+
30+
for (const sub of subscriptions) {
31+
if (sub.status === 'Trialing' || sub.status === 4 /* Trialing Enum */) {
32+
const timeRemaining = (sub.nextChargeAt || sub.next_charge_at) * 1000 - now;
33+
34+
if (timeRemaining > 0 && timeRemaining <= thresholdMs) {
35+
const payload: WebhookPayload = {
36+
event: 'trial_ending_soon',
37+
subscriptionId: sub.id,
38+
data: {
39+
timeRemainingDays: Math.ceil(timeRemaining / (24 * 60 * 60 * 1000)),
40+
},
41+
};
42+
alerts.push(payload);
43+
this.triggerWebhook(payload);
44+
}
45+
}
46+
}
47+
48+
return alerts;
49+
}
50+
51+
/**
52+
* Evaluates expired trials and triggers conversion logic.
53+
*/
54+
public processExpiredTrials(subscriptions: any[]): number[] {
55+
const convertedIds: number[] = [];
56+
const now = Date.now();
57+
58+
for (const sub of subscriptions) {
59+
if (sub.status === 'Trialing' || sub.status === 4 /* Trialing Enum */) {
60+
const endTimeMs = (sub.nextChargeAt || sub.next_charge_at) * 1000;
61+
62+
if (now >= endTimeMs) {
63+
// In a real system, we would trigger a charge here via BillingEngine.
64+
// For now, we return the IDs to be converted.
65+
convertedIds.push(sub.id);
66+
}
67+
}
68+
}
69+
70+
return convertedIds;
71+
}
72+
73+
/**
74+
* Calculates trial conversion metrics.
75+
*/
76+
public getTrialAnalytics(
77+
totalHistoricalTrials: number,
78+
totalConverted: number
79+
): TrialAnalytics {
80+
return {
81+
totalTrials: totalHistoricalTrials,
82+
convertedTrials: totalConverted,
83+
conversionRate:
84+
totalHistoricalTrials > 0
85+
? totalConverted / totalHistoricalTrials
86+
: 0,
87+
};
88+
}
89+
90+
private triggerWebhook(payload: WebhookPayload) {
91+
if (this.webhookUrl) {
92+
// Mock webhook dispatch
93+
console.log(`[Webhook Dispatch] ${this.webhookUrl} ->`, payload);
94+
}
95+
}
96+
}

contracts/subscription/src/gas_storage.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::gas_profiler::GasProfile;
66
#[derive(Clone)]
77
pub enum GasStorageKey {
88
/// Function gas profile: StorageKey::GasProfile(function_name)
9-
GasProfile(SorobanString),
9+
GasProfile(String),
1010
/// Daily gas usage: StorageKey::DailyGasUsage(timestamp)
1111
DailyGasUsage(u64),
1212
/// Weekly gas usage: StorageKey::WeeklyGasUsage(timestamp)
@@ -18,9 +18,9 @@ pub enum GasStorageKey {
1818
/// Total number of contract calls
1919
TotalCallCount,
2020
/// Gas alert count by type
21-
AlertCount(SorobanString),
21+
AlertCount(String),
2222
/// Last recorded gas usage for a function
23-
LastGasUsage(SorobanString),
23+
LastGasUsage(String),
2424
}
2525

2626
/// Gas metrics storage handler
@@ -38,7 +38,7 @@ impl GasMetricsStorage {
3838
pub fn get_profile(
3939
env: &Env,
4040
storage: &Address,
41-
function_name: &SorobanString,
41+
function_name: &String,
4242
) -> Option<GasProfile> {
4343
// Retrieve and deserialize profile
4444
None
@@ -111,26 +111,26 @@ impl GasMetricsStorage {
111111

112112
/// Record gas alert
113113
pub fn record_alert(env: &Env, storage: &Address, alert_type: &str) {
114-
let alert_key = SorobanString::from_str(env, alert_type);
114+
let alert_key = String::from_str(env, alert_type);
115115
// Increment alert count
116116
}
117117

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

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

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

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

0 commit comments

Comments
 (0)