tally_sdk/
program_types.rs

1//! Program account types and structures
2
3use anchor_lang::prelude::*;
4use serde::{Deserialize, Serialize};
5
6/// Merchant tier determines platform fee rate
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[repr(u8)]
9pub enum MerchantTier {
10    /// Free tier: 2.0% platform fee (200 basis points)
11    Free = 0,
12    /// Pro tier: 1.5% platform fee (150 basis points)
13    Pro = 1,
14    /// Enterprise tier: 1.0% platform fee (100 basis points)
15    Enterprise = 2,
16}
17
18impl MerchantTier {
19    /// Returns the platform fee in basis points for this tier
20    #[must_use]
21    pub const fn fee_bps(self) -> u16 {
22        match self {
23            Self::Free => 200,       // 2.0%
24            Self::Pro => 150,        // 1.5%
25            Self::Enterprise => 100, // 1.0%
26        }
27    }
28
29    /// Create from u8 discriminant
30    #[must_use]
31    pub const fn from_discriminant(value: u8) -> Option<Self> {
32        match value {
33            0 => Some(Self::Free),
34            1 => Some(Self::Pro),
35            2 => Some(Self::Enterprise),
36            _ => None,
37        }
38    }
39}
40
41/// Merchant account stores merchant configuration and settings
42/// PDA seeds: ["merchant", authority]
43#[derive(
44    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
45)]
46pub struct Merchant {
47    /// Merchant authority (signer for merchant operations)
48    pub authority: Pubkey,
49    /// Pinned USDC mint address for all transactions
50    pub usdc_mint: Pubkey,
51    /// Merchant's USDC treasury ATA (where merchant fees are sent)
52    pub treasury_ata: Pubkey,
53    /// Platform fee in basis points (0-1000, representing 0-10%)
54    pub platform_fee_bps: u16,
55    /// Merchant tier (Free, Pro, Enterprise)
56    pub tier: u8,
57    /// PDA bump seed
58    pub bump: u8,
59}
60
61/// Plan account defines subscription plan details
62/// PDA seeds: ["plan", merchant, `plan_id`]
63#[derive(
64    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
65)]
66pub struct Plan {
67    /// Reference to the merchant PDA
68    pub merchant: Pubkey,
69    /// Deterministic plan identifier (string as bytes, padded to 32)
70    pub plan_id: [u8; 32],
71    /// Price in USDC microlamports (6 decimals)
72    pub price_usdc: u64,
73    /// Subscription period in seconds
74    pub period_secs: u64,
75    /// Grace period for renewals in seconds
76    pub grace_secs: u64,
77    /// Plan display name (string as bytes, padded to 32)
78    pub name: [u8; 32],
79    /// Whether new subscriptions can be created for this plan
80    pub active: bool,
81}
82
83/// Subscription account tracks individual user subscriptions
84/// PDA seeds: ["subscription", plan, subscriber]
85#[derive(
86    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
87)]
88pub struct Subscription {
89    /// Reference to the plan PDA
90    pub plan: Pubkey,
91    /// User's pubkey (the subscriber)
92    pub subscriber: Pubkey,
93    /// Unix timestamp for next renewal
94    pub next_renewal_ts: i64,
95    /// Whether subscription is active
96    pub active: bool,
97    /// Number of renewals processed for this subscription.
98    ///
99    /// This counter increments with each successful renewal payment and is preserved
100    /// across subscription cancellation and reactivation cycles. When a subscription
101    /// is canceled and later reactivated, this field retains its historical value
102    /// rather than resetting to zero.
103    pub renewals: u32,
104    /// Unix timestamp when subscription was created
105    pub created_ts: i64,
106    /// Last charged amount for audit purposes
107    pub last_amount: u64,
108    /// Unix timestamp when subscription was last renewed (prevents double-renewal attacks)
109    pub last_renewed_ts: i64,
110    /// Unix timestamp when free trial period ends (None if no trial)
111    ///
112    /// When present, indicates the subscription is in or was in a free trial period.
113    /// During the trial, no payment is required. After `trial_ends_at`, the first
114    /// renewal will process the initial payment.
115    pub trial_ends_at: Option<i64>,
116    /// Whether subscription is currently in free trial period
117    ///
118    /// When true, the subscription is active but no payment has been made yet.
119    /// The first payment will occur at `next_renewal_ts` (when trial ends).
120    pub in_trial: bool,
121    /// PDA bump seed
122    pub bump: u8,
123}
124
125/// Arguments for initializing a merchant
126#[derive(
127    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
128)]
129pub struct InitMerchantArgs {
130    /// USDC mint address
131    pub usdc_mint: Pubkey,
132    /// Treasury ATA for receiving merchant fees
133    pub treasury_ata: Pubkey,
134    /// Platform fee in basis points (0-1000)
135    pub platform_fee_bps: u16,
136}
137
138/// Arguments for creating a subscription plan
139#[derive(
140    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
141)]
142pub struct CreatePlanArgs {
143    /// Unique plan identifier (will be padded to 32 bytes)
144    pub plan_id: String,
145    /// Padded `plan_id` bytes for PDA seeds (must match program constraint calculation)
146    pub plan_id_bytes: [u8; 32],
147    /// Price in USDC microlamports (6 decimals)
148    pub price_usdc: u64,
149    /// Subscription period in seconds
150    pub period_secs: u64,
151    /// Grace period for renewals in seconds
152    pub grace_secs: u64,
153    /// Plan display name (will be padded to 32 bytes)
154    pub name: String,
155}
156
157/// Arguments for starting a subscription
158#[derive(
159    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
160)]
161pub struct StartSubscriptionArgs {
162    /// Allowance periods multiplier (default 3)
163    pub allowance_periods: u8,
164}
165
166/// Arguments for renewing a subscription
167#[derive(
168    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
169)]
170pub struct RenewSubscriptionArgs {
171    // No args needed - renewal driven by keeper
172}
173
174/// Arguments for updating a subscription plan
175#[derive(
176    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
177)]
178pub struct UpdatePlanArgs {
179    /// New plan display name (will be padded to 32 bytes)
180    pub name: Option<String>,
181    /// Whether plan accepts new subscriptions
182    pub active: Option<bool>,
183    /// New price in USDC microlamports (affects only new subscriptions)
184    pub price_usdc: Option<u64>,
185    /// New subscription period in seconds (with validation)
186    pub period_secs: Option<u64>,
187    /// New grace period for renewals in seconds (with validation)
188    pub grace_secs: Option<u64>,
189}
190
191/// Arguments for canceling a subscription
192#[derive(
193    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
194)]
195pub struct CancelSubscriptionArgs;
196
197/// Arguments for admin fee withdrawal
198#[derive(
199    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
200)]
201pub struct AdminWithdrawFeesArgs {
202    /// Amount to withdraw in USDC microlamports
203    pub amount: u64,
204}
205
206/// Global configuration account for program constants and settings
207/// PDA seeds: `["config"]`
208#[derive(
209    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
210)]
211pub struct Config {
212    /// Platform authority pubkey for admin operations
213    pub platform_authority: Pubkey,
214    /// Pending authority for two-step authority transfer
215    pub pending_authority: Option<Pubkey>,
216    /// Maximum platform fee in basis points (e.g., 1000 = 10%)
217    pub max_platform_fee_bps: u16,
218    /// Minimum platform fee in basis points (e.g., 50 = 0.5%)
219    pub min_platform_fee_bps: u16,
220    /// Minimum subscription period in seconds (e.g., 86400 = 24 hours)
221    pub min_period_seconds: u64,
222    /// Default allowance periods multiplier (e.g., 3)
223    pub default_allowance_periods: u8,
224    /// Allowed token mint address (e.g., official USDC mint)
225    /// This prevents merchants from using fake or arbitrary tokens
226    pub allowed_mint: Pubkey,
227    /// Maximum withdrawal amount per transaction in USDC microlamports
228    /// Prevents accidental or malicious drainage of entire treasury
229    pub max_withdrawal_amount: u64,
230    /// Maximum grace period in seconds (e.g., 604800 = 7 days)
231    /// Prevents excessively long grace periods that increase merchant payment risk
232    pub max_grace_period_seconds: u64,
233    /// Emergency pause state - when true, all user-facing operations are disabled
234    /// This allows the platform authority to halt operations in case of security incidents
235    pub paused: bool,
236    /// Keeper fee in basis points (e.g., 25 = 0.25%)
237    /// This fee is paid to the transaction caller (keeper) to incentivize decentralized renewal network
238    /// Capped at 100 basis points (1%) to prevent excessive keeper fees
239    pub keeper_fee_bps: u16,
240    /// PDA bump seed
241    pub bump: u8,
242}
243
244/// Arguments for initializing global program configuration
245#[derive(
246    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
247)]
248pub struct InitConfigArgs {
249    /// Platform authority pubkey for admin operations
250    pub platform_authority: Pubkey,
251    /// Maximum platform fee in basis points (e.g., 1000 = 10%)
252    pub max_platform_fee_bps: u16,
253    /// Basis points divisor (e.g., 10000 for percentage calculations)
254    pub fee_basis_points_divisor: u16,
255    /// Minimum subscription period in seconds (e.g., 86400 = 24 hours)
256    pub min_period_seconds: u64,
257    /// Default allowance periods multiplier (e.g., 3)
258    pub default_allowance_periods: u8,
259}
260
261impl Plan {
262    /// Convert `plan_id` bytes to string, trimming null bytes
263    #[must_use]
264    pub fn plan_id_str(&self) -> String {
265        String::from_utf8_lossy(&self.plan_id)
266            .trim_end_matches('\0')
267            .to_string()
268    }
269
270    /// Convert name bytes to string, trimming null bytes
271    #[must_use]
272    pub fn name_str(&self) -> String {
273        String::from_utf8_lossy(&self.name)
274            .trim_end_matches('\0')
275            .to_string()
276    }
277
278    // Compatibility methods for tally-actions migration
279
280    /// Get plan ID as string, removing null padding (alias for `plan_id_str`)
281    #[must_use]
282    pub fn plan_id_string(&self) -> String {
283        self.plan_id_str()
284    }
285
286    /// Get plan name as string, removing null padding (alias for `name_str`)
287    #[must_use]
288    pub fn name_string(&self) -> String {
289        self.name_str()
290    }
291
292    /// Get plan price in USDC (human readable, with 6 decimals)
293    #[must_use]
294    #[allow(clippy::cast_precision_loss)]
295    pub fn price_usdc_formatted(&self) -> f64 {
296        self.price_usdc as f64 / 1_000_000.0
297    }
298
299    /// Get period in human readable format
300    #[must_use]
301    pub fn period_formatted(&self) -> String {
302        let days = self.period_secs / 86400;
303        if days == 1 {
304            "1 day".to_string()
305        } else if days == 7 {
306            "1 week".to_string()
307        } else if days == 30 {
308            "1 month".to_string()
309        } else if days == 365 {
310            "1 year".to_string()
311        } else {
312            format!("{days} days")
313        }
314    }
315}
316
317impl CreatePlanArgs {
318    /// Convert `plan_id` string to padded 32-byte array
319    #[must_use]
320    pub fn plan_id_bytes(&self) -> [u8; 32] {
321        let mut bytes = [0u8; 32];
322        let id_bytes = self.plan_id.as_bytes();
323        let len = id_bytes.len().min(32);
324        bytes[..len].copy_from_slice(&id_bytes[..len]);
325        bytes
326    }
327
328    /// Convert name string to padded 32-byte array
329    #[must_use]
330    pub fn name_bytes(&self) -> [u8; 32] {
331        let mut bytes = [0u8; 32];
332        let name_bytes = self.name.as_bytes();
333        let len = name_bytes.len().min(32);
334        bytes[..len].copy_from_slice(&name_bytes[..len]);
335        bytes
336    }
337}
338
339impl UpdatePlanArgs {
340    /// Create a new `UpdatePlanArgs` with all fields None
341    #[must_use]
342    pub const fn new() -> Self {
343        Self {
344            name: None,
345            active: None,
346            price_usdc: None,
347            period_secs: None,
348            grace_secs: None,
349        }
350    }
351
352    /// Set the plan name
353    #[must_use]
354    pub fn with_name(mut self, name: String) -> Self {
355        self.name = Some(name);
356        self
357    }
358
359    /// Set the plan active status
360    #[must_use]
361    pub const fn with_active(mut self, active: bool) -> Self {
362        self.active = Some(active);
363        self
364    }
365
366    /// Set the plan price
367    #[must_use]
368    pub const fn with_price_usdc(mut self, price_usdc: u64) -> Self {
369        self.price_usdc = Some(price_usdc);
370        self
371    }
372
373    /// Set the plan period
374    #[must_use]
375    pub const fn with_period_secs(mut self, period_secs: u64) -> Self {
376        self.period_secs = Some(period_secs);
377        self
378    }
379
380    /// Set the plan grace period
381    #[must_use]
382    pub const fn with_grace_secs(mut self, grace_secs: u64) -> Self {
383        self.grace_secs = Some(grace_secs);
384        self
385    }
386
387    /// Check if any fields are set for update
388    #[must_use]
389    pub const fn has_updates(&self) -> bool {
390        self.name.is_some()
391            || self.active.is_some()
392            || self.price_usdc.is_some()
393            || self.period_secs.is_some()
394            || self.grace_secs.is_some()
395    }
396
397    /// Convert name string to padded 32-byte array if present
398    #[must_use]
399    pub fn name_bytes(&self) -> Option<[u8; 32]> {
400        self.name.as_ref().map(|name| {
401            let mut bytes = [0u8; 32];
402            let name_bytes = name.as_bytes();
403            let len = name_bytes.len().min(32);
404            bytes[..len].copy_from_slice(&name_bytes[..len]);
405            bytes
406        })
407    }
408}
409
410impl Default for UpdatePlanArgs {
411    fn default() -> Self {
412        Self::new()
413    }
414}
415
416/// Arguments for closing a subscription
417#[derive(
418    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
419)]
420pub struct CloseSubscriptionArgs {
421    // No args needed for closing
422}
423
424/// Arguments for initiating authority transfer
425#[derive(
426    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
427)]
428pub struct TransferAuthorityArgs {
429    /// The new authority to transfer to
430    pub new_authority: Pubkey,
431}
432
433/// Arguments for accepting authority transfer
434#[derive(
435    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
436)]
437pub struct AcceptAuthorityArgs {
438    // No arguments needed - signer validation is sufficient
439}
440
441/// Arguments for canceling authority transfer
442#[derive(
443    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
444)]
445pub struct CancelAuthorityTransferArgs {
446    // No arguments needed - signer validation is sufficient
447}
448
449/// Arguments for pausing the program
450#[derive(
451    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
452)]
453pub struct PauseArgs {}
454
455/// Arguments for unpausing the program
456#[derive(
457    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
458)]
459pub struct UnpauseArgs {}
460
461/// Arguments for updating global program configuration
462#[derive(
463    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
464)]
465pub struct UpdateConfigArgs {
466    /// Keeper fee in basis points
467    pub keeper_fee_bps: Option<u16>,
468    /// Maximum withdrawal amount
469    pub max_withdrawal_amount: Option<u64>,
470    /// Maximum grace period in seconds
471    pub max_grace_period_seconds: Option<u64>,
472    /// Minimum platform fee in basis points
473    pub min_platform_fee_bps: Option<u16>,
474    /// Maximum platform fee in basis points
475    pub max_platform_fee_bps: Option<u16>,
476    /// Minimum period in seconds
477    pub min_period_seconds: Option<u64>,
478    /// Default allowance periods
479    pub default_allowance_periods: Option<u8>,
480}
481
482/// Arguments for updating merchant tier
483#[derive(
484    Clone, Debug, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
485)]
486pub struct UpdateMerchantTierArgs {
487    /// New tier for the merchant (as discriminant: 0=Free, 1=Pro, 2=Enterprise)
488    pub new_tier: u8,
489}
490
491/// Arguments for updating a subscription plan's pricing and terms
492///
493/// All fields are optional - at least one must be provided.
494/// Only the merchant authority can update plan terms.
495#[derive(
496    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, AnchorSerialize, AnchorDeserialize,
497)]
498pub struct UpdatePlanTermsArgs {
499    /// Price in USDC microlamports (6 decimals)
500    /// Must be > 0 if provided
501    pub price_usdc: Option<u64>,
502    /// Subscription period in seconds
503    /// Must be >= `config.min_period_seconds` if provided
504    pub period_secs: Option<u64>,
505    /// Grace period for renewals in seconds
506    /// Must be <= period AND <= `config.max_grace_period_seconds` if provided
507    pub grace_secs: Option<u64>,
508    /// Plan display name
509    /// Must not be empty if provided
510    pub name: Option<String>,
511}
512
513impl UpdatePlanTermsArgs {
514    /// Create a new `UpdatePlanTermsArgs` with all fields None
515    #[must_use]
516    pub const fn new() -> Self {
517        Self {
518            price_usdc: None,
519            period_secs: None,
520            grace_secs: None,
521            name: None,
522        }
523    }
524
525    /// Set the plan price
526    #[must_use]
527    pub const fn with_price_usdc(mut self, price_usdc: u64) -> Self {
528        self.price_usdc = Some(price_usdc);
529        self
530    }
531
532    /// Set the plan period
533    #[must_use]
534    pub const fn with_period_secs(mut self, period_secs: u64) -> Self {
535        self.period_secs = Some(period_secs);
536        self
537    }
538
539    /// Set the plan grace period
540    #[must_use]
541    pub const fn with_grace_secs(mut self, grace_secs: u64) -> Self {
542        self.grace_secs = Some(grace_secs);
543        self
544    }
545
546    /// Set the plan name
547    #[must_use]
548    pub fn with_name(mut self, name: String) -> Self {
549        self.name = Some(name);
550        self
551    }
552
553    /// Check if any fields are set for update
554    #[must_use]
555    pub const fn has_updates(&self) -> bool {
556        self.price_usdc.is_some()
557            || self.period_secs.is_some()
558            || self.grace_secs.is_some()
559            || self.name.is_some()
560    }
561
562    /// Convert name string to padded 32-byte array if present
563    #[must_use]
564    pub fn name_bytes(&self) -> Option<[u8; 32]> {
565        self.name.as_ref().map(|name| {
566            let mut bytes = [0u8; 32];
567            let name_bytes = name.as_bytes();
568            let len = name_bytes.len().min(32);
569            bytes[..len].copy_from_slice(&name_bytes[..len]);
570            bytes
571        })
572    }
573}