diff --git a/contracts/credit/src/lib.rs b/contracts/credit/src/lib.rs index ea5c8db8..f7264804 100644 --- a/contracts/credit/src/lib.rs +++ b/contracts/credit/src/lib.rs @@ -5,12 +5,17 @@ //! is held in lots (each optionally expiring) so it can be applied to future //! charges, transferred between accounts, and expired deterministically. //! +//! Credit notes are formal documents stored off-chain; this contract manages +//! the on-chain prepayment wallet and credit-lot mechanics, and provides an +//! expiry checker suitable for cron-driven keeper jobs. +//! //! Required behaviour (issue: credit system): //! * `AccountCredit { balance, transactions[], expiration_policy }` //! * manual and automatic issuance //! * automatic application on charging via [`SubTrackrCredit::apply_credit`] //! * transfer between accounts //! * expiration handling and a full transaction history +//! * prepayment wallet with deposit/withdraw/drawdown //! //! Balances can never go negative: application/transfer only ever move credit //! that is actually available and unexpired. @@ -33,6 +38,7 @@ pub enum CreditError { InvalidAmount = 4, InsufficientCredit = 5, SelfTransfer = 6, + WalletNotFound = 7, } #[contracttype] @@ -43,6 +49,8 @@ pub enum CreditTxKind { TransferIn, TransferOut, Expire, + Deposit, + Withdraw, } /// Default expiration applied to newly issued credit when no explicit expiry @@ -99,12 +107,38 @@ pub struct CreditApplied { pub balance_after: i128, } +/// A prepayment wallet tied to a subscription for pre-funded draws. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PrepaymentWallet { + pub id: u64, + pub subscriber: Address, + pub subscription_id: SubscriptionId, + pub currency: String, + pub balance: i128, + pub total_deposited: i128, + pub total_withdrawn: i128, + pub created_at: u64, + pub updated_at: u64, +} + +/// Prepayment summary returned after a deposit or withdrawal. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PrepaymentSnapshot { + pub wallet_id: u64, + pub balance: i128, + pub transaction_id: u64, +} + #[contracttype] #[derive(Clone)] enum DataKey { Admin, NextId, Account(Address), + Wallet(u64), + Counter(u64), } #[contract] @@ -306,6 +340,135 @@ impl SubTrackrCredit { Self::account(&env, &subscriber).transactions } + /// Creates a new prepayment wallet for the given subscription. + pub fn create_wallet( + env: Env, + subscriber: Address, + subscription_id: SubscriptionId, + currency: String, + ) -> u64 { + let admin = Self::require_admin(&env).expect("admin required"); + admin.require_auth(); + let wallet_id = Self::next_wallet_id(&env); + let now = env.ledger().timestamp(); + let wallet = PrepaymentWallet { + id: wallet_id, + subscriber: subscriber.clone(), + subscription_id, + currency, + balance: 0, + total_deposited: 0, + total_withdrawn: 0, + created_at: now, + updated_at: now, + }; + env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet); + env.events() + .publish((symbol_short!("wallet"), subscriber), wallet_id); + wallet_id + } + + /// Deposits funds into a prepayment wallet by ID. + pub fn deposit( + env: Env, + caller: Address, + wallet_id: u64, + amount: i128, + ) -> Result { + caller.require_auth(); + if amount <= 0 { + return Err(CreditError::InvalidAmount); + } + let mut wallet: PrepaymentWallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(CreditError::WalletNotFound)?; + if wallet.subscriber != caller { + return Err(CreditError::Unauthorized); + } + let now = env.ledger().timestamp(); + wallet.balance += amount; + wallet.total_deposited += amount; + wallet.updated_at = now; + env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet); + Ok(PrepaymentSnapshot { + wallet_id, + balance: wallet.balance, + transaction_id: Self::next_tx_id(&env, wallet_id), + }) + } + + /// Withdraws funds from a prepayment wallet by ID. + pub fn withdraw( + env: Env, + caller: Address, + wallet_id: u64, + amount: i128, + ) -> Result { + caller.require_auth(); + if amount <= 0 { + return Err(CreditError::InvalidAmount); + } + let mut wallet: PrepaymentWallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(CreditError::WalletNotFound)?; + if wallet.subscriber != caller { + return Err(CreditError::Unauthorized); + } + if wallet.balance < amount { + return Err(CreditError::InsufficientCredit); + } + let now = env.ledger().timestamp(); + wallet.balance -= amount; + wallet.total_withdrawn += amount; + wallet.updated_at = now; + env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet); + Ok(PrepaymentSnapshot { + wallet_id, + balance: wallet.balance, + transaction_id: Self::next_tx_id(&env, wallet_id), + }) + } + + /// Returns the current balance of a prepayment wallet. + pub fn get_wallet_balance(env: Env, _caller: Address, wallet_id: u64) -> i128 { + env.storage() + .persistent() + .get::<_, PrepaymentWallet>(&DataKey::Wallet(wallet_id)) + .map(|w| w.balance) + .unwrap_or(0) + } + + /// Batch expiry processor for cron keepers. Iterates all stored wallets, + /// applies credit lot expiry, and returns total expired amounts. Caller + /// must be admin. + pub fn expire_credits_with_cron(env: Env, admin: Address) -> Vec<(Address, i128)> { + admin.require_auth(); + let now = env.ledger().timestamp(); + let mut results: Vec<(Address, i128)> = Vec::new(&env); + let mut i: u32 = 0; + while i < MAX_HISTORY { + let key = DataKey::Counter(i); + if !env.storage().persistent().has(&key) { + break; + } + let subscriber: Address = env.storage().persistent().get(&key).unwrap(); + let mut account = Self::account(&env, &subscriber); + let before = account.balance; + Self::realize_expiry(&env, now, &mut account); + let expired = before - account.balance; + if expired > 0 { + Self::save(&env, &account); + results.push_back((subscriber, expired)); + } + i += 1; + } + results + } + // ---- internals -------------------------------------------------------- fn require_admin(env: &Env) -> Result { @@ -340,6 +503,24 @@ impl SubTrackrCredit { id } + fn next_wallet_id(env: &Env) -> u64 { + let id: u64 = env.storage().instance().get(&DataKey::Admin).map(|_| id).unwrap_or(0); + let base: u64 = env.storage().instance().get(&symbol_short!("NWID")).unwrap_or(0); + env.storage() + .instance() + .set(&symbol_short!("NWID"), &(base + 1)); + base + } + + fn next_tx_id(env: &Env, _wallet_id: u64) -> u64 { + let base: u64 = env + .storage() + .instance() + .get(&symbol_short!("NWID")) + .unwrap_or(0); + base + } + /// Sum of unexpired lot balances. fn available(now: u64, account: &AccountCredit) -> i128 { let mut total: i128 = 0; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0f32b2ee..c06154d0 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -44,6 +44,7 @@ const AdminDashboardScreen = lazyScreen(() => import('../screens/AdminDashboardS const FraudDashboard = lazyScreen(() => import('../screens/FraudDashboard')); const GroupManagementScreen = lazyScreen(() => import('../screens/GroupManagementScreen')); const TaxSettingsScreen = lazyScreen(() => import('../screens/TaxSettingsScreen')); +const CreditsAndPrepaymentsScreen = lazyScreen(() => import('../screens/CreditsAndPrepaymentsScreen')); const SupportDashboardScreen = lazyScreen(() => import('../screens/SupportDashboardScreen')); const SegmentManagementScreen = lazyScreen(() => import('../screens/SegmentManagementScreen').then((m) => ({ default: m.SegmentManagementScreen })) @@ -290,6 +291,11 @@ const SettingsStack = () => ( component={TaxSettingsScreen} options={{ title: 'Tax Settings', headerShown: true }} /> + { + const { creditNotes, prepaymentWallets, prepaymentTransactions } = useCreditStore(); + const [issueModalVisible, setIssueModalVisible] = useState(false); + const [depositModalVisible, setDepositModalVisible] = useState(false); + const [selectedWalletId, setSelectedWalletId] = useState(''); + const [amount, setAmount] = useState(''); + const [reason, setReason] = useState(CreditNoteReason.REFUND); + const [expiryDays, setExpiryDays] = useState('30'); + const [notes, setNotes] = useState(''); + + const outstandingCredit = useMemo( + () => creditNotes.reduce((sum, c) => sum + c.remainingAmount, 0), + [creditNotes] + ); + + const totalPrepaid = useMemo( + () => prepaymentWallets.reduce((sum, w) => sum + w.balance, 0), + [prepaymentWallets] + ); + + const handleIssueCredit = () => { + const parsedAmount = parseFloat(amount); + if (!parsedAmount || parsedAmount <= 0) { + Alert.alert('Error', 'Please enter a valid amount'); + return; + } + const days = parseInt(expiryDays, 10) || 30; + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + days); + + CreditNoteService.create({ + subscriptionId: '', + userId: 'current-user', + reason, + amount: parsedAmount, + currency: 'USD', + expiresAt, + notes: notes || undefined, + priority: 0, + }); + + CreditNoteService.expireExpiredNotes(); + setIssueModalVisible(false); + setAmount(''); + setNotes(''); + Alert.alert('Success', 'Credit note issued successfully'); + }; + + const handleDeposit = () => { + const parsedAmount = parseFloat(amount); + if (!parsedAmount || parsedAmount <= 0) { + Alert.alert('Error', 'Please enter a valid amount'); + return; + } + const wallet = PrepaymentWalletService.deposit(selectedWalletId, parsedAmount); + if (!wallet) { + Alert.alert('Error', 'Wallet not found or invalid amount'); + return; + } + setDepositModalVisible(false); + setAmount(''); + Alert.alert('Success', `Deposited ${parsedAmount} into prepayment wallet`); + }; + + const handleWithdraw = (walletId: string) => { + const parsedAmount = parseFloat(amount); + if (!parsedAmount || parsedAmount <= 0) { + Alert.alert('Error', 'Please enter a valid amount'); + return; + } + const wallet = PrepaymentWalletService.withdraw(walletId, parsedAmount); + if (!wallet) { + Alert.alert('Error', 'Insufficient balance or wallet not found'); + return; + } + setAmount(''); + Alert.alert('Success', `Withdrew ${parsedAmount} from prepayment wallet`); + }; + + const handleAutoApply = (subscriptionId: string) => { + const applied = CreditNoteService.autoApplyToNextInvoice(subscriptionId); + if (applied) { + Alert.alert('Applied', `Applied ${applied.remainingAmount} credit to next invoice`); + } else { + Alert.alert('No Action', 'No eligible credits or open invoices found'); + } + }; + + const getStatusColor = (status: CreditNoteStatus): string => { + switch (status) { + case CreditNoteStatus.ISSUED: + return colors.primary; + case CreditNoteStatus.PARTIALLY_APPLIED: + return colors.warning; + case CreditNoteStatus.APPLIED: + return colors.success; + case CreditNoteStatus.EXPIRED: + return colors.error; + case CreditNoteStatus.VOID: + return colors.textSecondary; + default: + return colors.textSecondary; + } + }; + + return ( + setIssueModalVisible(true)} + /> + } + testID="credits-prepayments-screen"> + + + + Outstanding Credit + ${outstandingCredit.toFixed(2)} + + + Prepaid Balance + ${totalPrepaid.toFixed(2)} + + + + + Prepayment Wallets + {prepaymentWallets.length === 0 ? ( + No prepayment wallets yet + ) : ( + prepaymentWallets.map((wallet) => ( + + + Subscription: {wallet.subscriptionId} + + Balance: ${wallet.balance.toFixed(2)} | Deposited: ${wallet.totalDeposited.toFixed(2)} + + + +