From 9e5356be8dca99b70ccd8659a44d6e6257940672 Mon Sep 17 00:00:00 2001 From: tekgodfrey Date: Fri, 28 Aug 2026 16:59:48 +0100 Subject: [PATCH 1/2] feat: Implement subscription plan templates with dynamic pricing tiers (Issue #921) --- backend/services/billing/index.ts | 15 +++ contracts/subscription/src/gas_storage.rs | 18 +-- contracts/subscription/src/lib.rs | 140 ++++++++++++++++++++++ contracts/subscription/src/quota.rs | 2 +- contracts/subscription/src/revenue.rs | 2 +- contracts/types/src/lib.rs | 14 +++ 6 files changed, 180 insertions(+), 11 deletions(-) diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index 92f0f988..ce9183e8 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -131,3 +131,18 @@ 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'; diff --git a/contracts/subscription/src/gas_storage.rs b/contracts/subscription/src/gas_storage.rs index 8c7fca5c..9f99bf61 100644 --- a/contracts/subscription/src/gas_storage.rs +++ b/contracts/subscription/src/gas_storage.rs @@ -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) @@ -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 @@ -38,7 +38,7 @@ impl GasMetricsStorage { pub fn get_profile( env: &Env, storage: &Address, - function_name: &SorobanString, + function_name: &String, ) -> Option { // Retrieve and deserialize profile None @@ -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 { - let fname = SorobanString::from_str(env, function_name); + let fname = String::from_str(env, function_name); // Retrieve last usage None } @@ -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() } diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 7a15210b..cd41e44f 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -5,6 +5,7 @@ mod gas_storage; mod quota; mod revenue; mod usage; +mod plan_templates; use soroban_sdk::{token, Address, Bytes, BytesN, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ ChargeCommitment, Interval, Invoice, MevAlert, MevProtectionConfig, Plan, StorageKey, @@ -1405,4 +1406,143 @@ impl SubTrackrSubscription { .expect("Subscription not found"); usage::check_quota(&env, &storage, subscription_id, sub.plan_id, metric) } + + // ── Plan Templates ── + + pub fn create_template( + env: Env, + proxy: Address, + storage: Address, + owner: Address, + name: String, + description: String, + base_price: i128, + token: Address, + interval: Interval, + tiers: Vec, + features: Vec, + ) -> u64 { + proxy.require_auth(); + owner.require_auth(); + plan_templates::create_template( + &env, + &storage, + &owner, + name, + description, + base_price, + token, + interval, + tiers, + features, + ) + } + + pub fn publish_template_version( + env: Env, + proxy: Address, + storage: Address, + caller: Address, + template_id: u64, + name: String, + description: String, + base_price: i128, + tiers: Vec, + features: Vec, + ) -> u64 { + proxy.require_auth(); + caller.require_auth(); + plan_templates::publish_version( + &env, + &storage, + &caller, + template_id, + name, + description, + base_price, + tiers, + features, + ) + } + + pub fn set_template_shared( + env: Env, + proxy: Address, + storage: Address, + caller: Address, + template_id: u64, + shared: bool, + ) { + proxy.require_auth(); + caller.require_auth(); + plan_templates::set_shared(&env, &storage, &caller, template_id, shared) + } + + pub fn instantiate_template( + env: Env, + proxy: Address, + storage: Address, + caller: Address, + template_id: u64, + overrides: plan_templates::TemplateOverrides, + ) -> plan_templates::ResolvedPlan { + proxy.require_auth(); + caller.require_auth(); + plan_templates::instantiate(&env, &storage, &caller, template_id, overrides) + } + + pub fn get_template( + env: Env, + proxy: Address, + storage: Address, + template_id: u64, + ) -> Option { + proxy.require_auth(); + plan_templates::get_template(&env, &storage, template_id) + } + + pub fn get_owner_templates( + env: Env, + proxy: Address, + storage: Address, + owner: Address, + ) -> Vec { + proxy.require_auth(); + plan_templates::get_owner_templates(&env, &storage, &owner) + } + + pub fn get_shared_templates(env: Env, proxy: Address, storage: Address) -> Vec { + proxy.require_auth(); + plan_templates::get_shared_templates(&env, &storage) + } + + pub fn get_template_versions( + env: Env, + proxy: Address, + storage: Address, + root_id: u64, + ) -> Vec { + proxy.require_auth(); + plan_templates::get_template_versions(&env, &storage, root_id) + } + + pub fn get_latest_template_version( + env: Env, + proxy: Address, + storage: Address, + root_id: u64, + ) -> Option { + proxy.require_auth(); + plan_templates::get_latest_version(&env, &storage, root_id) + } + + pub fn get_template_analytics( + env: Env, + proxy: Address, + storage: Address, + template_id: u64, + ) -> plan_templates::TemplateAnalytics { + proxy.require_auth(); + plan_templates::get_analytics(&env, &storage, template_id) + } } diff --git a/contracts/subscription/src/quota.rs b/contracts/subscription/src/quota.rs index 76709bd5..fef4b520 100644 --- a/contracts/subscription/src/quota.rs +++ b/contracts/subscription/src/quota.rs @@ -1,6 +1,6 @@ use crate::{storage_persistent_get, storage_persistent_set}; use soroban_sdk::{Address, Env, Vec}; -use subtrackr_types::{Quota, StorageKeyExt}; +use subtrackr_types::Quota; pub fn set_plan_quotas(env: &Env, storage: &Address, plan_id: u64, quotas: Vec) { storage_persistent_set(env, storage, StorageKeyExt::PlanQuotas(plan_id), quotas); diff --git a/contracts/subscription/src/revenue.rs b/contracts/subscription/src/revenue.rs index ce7a8057..982b2a6e 100644 --- a/contracts/subscription/src/revenue.rs +++ b/contracts/subscription/src/revenue.rs @@ -8,7 +8,7 @@ /// All storage is delegated to the shared storage contract via the /// `storage_persistent_*` helpers defined in the parent module. use soroban_sdk::{contracttype, Address, Env, Vec}; -use subtrackr_types::StorageKeyExt; +use subtrackr_types::StorageKey; use crate::{storage_persistent_get, storage_persistent_set}; diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index a6c8233d..8f5c2ecd 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -407,4 +407,18 @@ pub enum StorageKey { ChargeCommitment(u64), MevAlertCount, MevAlert(u64), + + // ── Plan Templates ── + PlanTemplate(TemplateKey), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum TemplateKey { + Template(u64), + ByOwner(Address), + Shared, + Versions(u64), + Analytics(u64), + Count, } From ef9bea2d76010a2d0af24da4d35e6c87c853e816 Mon Sep 17 00:00:00 2001 From: tekgodfrey Date: Fri, 28 Aug 2026 17:22:53 +0100 Subject: [PATCH 2/2] feat: Implement subscription trial management with conversion optimization (Issue #929) --- .../billing/__tests__/trialService.test.ts | 62 ++++++++ backend/services/billing/index.ts | 139 ++++++++++++++++++ backend/services/billing/trialService.ts | 96 ++++++++++++ contracts/subscription/src/lib.rs | 12 ++ .../src/subscription_lifecycle.rs | 16 +- contracts/subscription/src/trial.rs | 48 ++++++ contracts/types/src/lib.rs | 11 ++ 7 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 backend/services/billing/__tests__/trialService.test.ts create mode 100644 backend/services/billing/trialService.ts create mode 100644 contracts/subscription/src/trial.rs diff --git a/backend/services/billing/__tests__/trialService.test.ts b/backend/services/billing/__tests__/trialService.test.ts new file mode 100644 index 00000000..c1e5d898 --- /dev/null +++ b/backend/services/billing/__tests__/trialService.test.ts @@ -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); + }); + }); +}); diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index ce9183e8..f218e231 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -131,6 +131,141 @@ 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, @@ -146,3 +281,7 @@ export type { ResolvedPlan, TemplateAnalytics } from './planTemplateService'; + +// Trial Management +export { TrialManagementService } from './trialService'; +export type { TrialAnalytics, WebhookPayload } from './trialService'; diff --git a/backend/services/billing/trialService.ts b/backend/services/billing/trialService.ts new file mode 100644 index 00000000..236e9db8 --- /dev/null +++ b/backend/services/billing/trialService.ts @@ -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); + } + } +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index cd41e44f..c558dbb4 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -6,6 +6,7 @@ mod quota; mod revenue; mod usage; mod plan_templates; +mod trial; use soroban_sdk::{token, Address, Bytes, BytesN, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ ChargeCommitment, Interval, Invoice, MevAlert, MevProtectionConfig, Plan, StorageKey, @@ -1545,4 +1546,15 @@ impl SubTrackrSubscription { proxy.require_auth(); plan_templates::get_analytics(&env, &storage, template_id) } + + // ── Trial Management ── + pub fn set_plan_trial(env: Env, proxy: Address, storage: Address, merchant: Address, plan_id: u64, has_trial: bool, duration_seconds: u64) { + proxy.require_auth(); + trial::set_plan_trial(&env, &storage, &merchant, plan_id, has_trial, duration_seconds); + } + + pub fn get_plan_trial(env: Env, proxy: Address, storage: Address, plan_id: u64) -> Option { + proxy.require_auth(); + trial::get_plan_trial(&env, &storage, plan_id) + } } diff --git a/contracts/subscription/src/subscription_lifecycle.rs b/contracts/subscription/src/subscription_lifecycle.rs index efcda3bc..5959332a 100644 --- a/contracts/subscription/src/subscription_lifecycle.rs +++ b/contracts/subscription/src/subscription_lifecycle.rs @@ -47,14 +47,26 @@ pub fn subscribe( let now = env.ledger().timestamp(); + // Check for trial configuration + let trial_opt: Option = storage_persistent_get(env, storage, StorageKey::PlanTrial(plan_id)); + let (status, next_charge_at) = if let Some(trial) = trial_opt { + if trial.has_trial { + (SubscriptionStatus::Trialing, now + trial.duration_seconds) + } else { + (SubscriptionStatus::Active, now + plan_data.interval.seconds()) + } + } else { + (SubscriptionStatus::Active, now + plan_data.interval.seconds()) + }; + let subscription = Subscription { id: sub_count, plan_id, subscriber: subscriber.clone(), - status: SubscriptionStatus::Active, + status, started_at: now, last_charged_at: now, - next_charge_at: now + plan_data.interval.seconds(), + next_charge_at, total_paid: 0, total_gas_spent: 0, charge_count: 0, diff --git a/contracts/subscription/src/trial.rs b/contracts/subscription/src/trial.rs new file mode 100644 index 00000000..beb4bf10 --- /dev/null +++ b/contracts/subscription/src/trial.rs @@ -0,0 +1,48 @@ +use soroban_sdk::{Address, Env, String}; + +use crate::storage_persistent_get; +use crate::storage_persistent_set; +use subtrackr_types::{StorageKey, TrialConfig, Plan}; +use crate::get_admin; +use crate::enforce_rate_limit; + +/// Configure a trial for a specific plan +pub fn set_plan_trial( + env: &Env, + storage: &Address, + merchant: &Address, + plan_id: u64, + has_trial: bool, + duration_seconds: u64, +) { + if *merchant != get_admin(env, storage) { + enforce_rate_limit(env, storage, merchant, "set_plan_trial"); + } + merchant.require_auth(); + + // Verify the plan exists and belongs to the merchant + let plan: Plan = storage_persistent_get(env, storage, StorageKey::Plan(plan_id)) + .expect("Plan not found"); + assert!(plan.merchant == *merchant, "Only plan owner can modify trial config"); + + let config = TrialConfig { + has_trial, + duration_seconds, + }; + + storage_persistent_set(env, storage, StorageKey::PlanTrial(plan_id), config.clone()); + + env.events().publish( + (String::from_str(env, "plan_trial_updated"), plan_id), + (has_trial, duration_seconds), + ); +} + +/// Get trial configuration for a specific plan +pub fn get_plan_trial( + env: &Env, + storage: &Address, + plan_id: u64, +) -> Option { + storage_persistent_get(env, storage, StorageKey::PlanTrial(plan_id)) +} diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 8f5c2ecd..72679771 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -32,6 +32,7 @@ pub enum SubscriptionStatus { Paused, Cancelled, PastDue, + Trialing, } #[contracttype] @@ -130,6 +131,13 @@ pub struct Subscription { pub type Timestamp = u64; +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TrialConfig { + pub has_trial: bool, + pub duration_seconds: u64, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum UpgradeAction { @@ -410,6 +418,9 @@ pub enum StorageKey { // ── Plan Templates ── PlanTemplate(TemplateKey), + + // ── Trials ── + PlanTrial(u64), } #[contracttype]