Skip to content

Commit d4e0b91

Browse files
authored
Merge pull request #657 from Eromosele0110/issue-555-credit-prepayment
feat: implement credit note and prepayment wallet management (#555)
2 parents fa997d9 + b0836cc commit d4e0b91

8 files changed

Lines changed: 1433 additions & 0 deletions

File tree

contracts/credit/src/lib.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
//! is held in lots (each optionally expiring) so it can be applied to future
66
//! charges, transferred between accounts, and expired deterministically.
77
//!
8+
//! Credit notes are formal documents stored off-chain; this contract manages
9+
//! the on-chain prepayment wallet and credit-lot mechanics, and provides an
10+
//! expiry checker suitable for cron-driven keeper jobs.
11+
//!
812
//! Required behaviour (issue: credit system):
913
//! * `AccountCredit { balance, transactions[], expiration_policy }`
1014
//! * manual and automatic issuance
1115
//! * automatic application on charging via [`SubTrackrCredit::apply_credit`]
1216
//! * transfer between accounts
1317
//! * expiration handling and a full transaction history
18+
//! * prepayment wallet with deposit/withdraw/drawdown
1419
//!
1520
//! Balances can never go negative: application/transfer only ever move credit
1621
//! that is actually available and unexpired.
@@ -33,6 +38,7 @@ pub enum CreditError {
3338
InvalidAmount = 4,
3439
InsufficientCredit = 5,
3540
SelfTransfer = 6,
41+
WalletNotFound = 7,
3642
}
3743

3844
#[contracttype]
@@ -43,6 +49,8 @@ pub enum CreditTxKind {
4349
TransferIn,
4450
TransferOut,
4551
Expire,
52+
Deposit,
53+
Withdraw,
4654
}
4755

4856
/// Default expiration applied to newly issued credit when no explicit expiry
@@ -99,12 +107,38 @@ pub struct CreditApplied {
99107
pub balance_after: i128,
100108
}
101109

110+
/// A prepayment wallet tied to a subscription for pre-funded draws.
111+
#[contracttype]
112+
#[derive(Clone, Debug, PartialEq)]
113+
pub struct PrepaymentWallet {
114+
pub id: u64,
115+
pub subscriber: Address,
116+
pub subscription_id: SubscriptionId,
117+
pub currency: String,
118+
pub balance: i128,
119+
pub total_deposited: i128,
120+
pub total_withdrawn: i128,
121+
pub created_at: u64,
122+
pub updated_at: u64,
123+
}
124+
125+
/// Prepayment summary returned after a deposit or withdrawal.
126+
#[contracttype]
127+
#[derive(Clone, Debug, PartialEq)]
128+
pub struct PrepaymentSnapshot {
129+
pub wallet_id: u64,
130+
pub balance: i128,
131+
pub transaction_id: u64,
132+
}
133+
102134
#[contracttype]
103135
#[derive(Clone)]
104136
enum DataKey {
105137
Admin,
106138
NextId,
107139
Account(Address),
140+
Wallet(u64),
141+
Counter(u64),
108142
}
109143

110144
#[contract]
@@ -306,6 +340,135 @@ impl SubTrackrCredit {
306340
Self::account(&env, &subscriber).transactions
307341
}
308342

343+
/// Creates a new prepayment wallet for the given subscription.
344+
pub fn create_wallet(
345+
env: Env,
346+
subscriber: Address,
347+
subscription_id: SubscriptionId,
348+
currency: String,
349+
) -> u64 {
350+
let admin = Self::require_admin(&env).expect("admin required");
351+
admin.require_auth();
352+
let wallet_id = Self::next_wallet_id(&env);
353+
let now = env.ledger().timestamp();
354+
let wallet = PrepaymentWallet {
355+
id: wallet_id,
356+
subscriber: subscriber.clone(),
357+
subscription_id,
358+
currency,
359+
balance: 0,
360+
total_deposited: 0,
361+
total_withdrawn: 0,
362+
created_at: now,
363+
updated_at: now,
364+
};
365+
env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet);
366+
env.events()
367+
.publish((symbol_short!("wallet"), subscriber), wallet_id);
368+
wallet_id
369+
}
370+
371+
/// Deposits funds into a prepayment wallet by ID.
372+
pub fn deposit(
373+
env: Env,
374+
caller: Address,
375+
wallet_id: u64,
376+
amount: i128,
377+
) -> Result<PrepaymentSnapshot, CreditError> {
378+
caller.require_auth();
379+
if amount <= 0 {
380+
return Err(CreditError::InvalidAmount);
381+
}
382+
let mut wallet: PrepaymentWallet = env
383+
.storage()
384+
.persistent()
385+
.get(&DataKey::Wallet(wallet_id))
386+
.ok_or(CreditError::WalletNotFound)?;
387+
if wallet.subscriber != caller {
388+
return Err(CreditError::Unauthorized);
389+
}
390+
let now = env.ledger().timestamp();
391+
wallet.balance += amount;
392+
wallet.total_deposited += amount;
393+
wallet.updated_at = now;
394+
env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet);
395+
Ok(PrepaymentSnapshot {
396+
wallet_id,
397+
balance: wallet.balance,
398+
transaction_id: Self::next_tx_id(&env, wallet_id),
399+
})
400+
}
401+
402+
/// Withdraws funds from a prepayment wallet by ID.
403+
pub fn withdraw(
404+
env: Env,
405+
caller: Address,
406+
wallet_id: u64,
407+
amount: i128,
408+
) -> Result<PrepaymentSnapshot, CreditError> {
409+
caller.require_auth();
410+
if amount <= 0 {
411+
return Err(CreditError::InvalidAmount);
412+
}
413+
let mut wallet: PrepaymentWallet = env
414+
.storage()
415+
.persistent()
416+
.get(&DataKey::Wallet(wallet_id))
417+
.ok_or(CreditError::WalletNotFound)?;
418+
if wallet.subscriber != caller {
419+
return Err(CreditError::Unauthorized);
420+
}
421+
if wallet.balance < amount {
422+
return Err(CreditError::InsufficientCredit);
423+
}
424+
let now = env.ledger().timestamp();
425+
wallet.balance -= amount;
426+
wallet.total_withdrawn += amount;
427+
wallet.updated_at = now;
428+
env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet);
429+
Ok(PrepaymentSnapshot {
430+
wallet_id,
431+
balance: wallet.balance,
432+
transaction_id: Self::next_tx_id(&env, wallet_id),
433+
})
434+
}
435+
436+
/// Returns the current balance of a prepayment wallet.
437+
pub fn get_wallet_balance(env: Env, _caller: Address, wallet_id: u64) -> i128 {
438+
env.storage()
439+
.persistent()
440+
.get::<_, PrepaymentWallet>(&DataKey::Wallet(wallet_id))
441+
.map(|w| w.balance)
442+
.unwrap_or(0)
443+
}
444+
445+
/// Batch expiry processor for cron keepers. Iterates all stored wallets,
446+
/// applies credit lot expiry, and returns total expired amounts. Caller
447+
/// must be admin.
448+
pub fn expire_credits_with_cron(env: Env, admin: Address) -> Vec<(Address, i128)> {
449+
admin.require_auth();
450+
let now = env.ledger().timestamp();
451+
let mut results: Vec<(Address, i128)> = Vec::new(&env);
452+
let mut i: u32 = 0;
453+
while i < MAX_HISTORY {
454+
let key = DataKey::Counter(i);
455+
if !env.storage().persistent().has(&key) {
456+
break;
457+
}
458+
let subscriber: Address = env.storage().persistent().get(&key).unwrap();
459+
let mut account = Self::account(&env, &subscriber);
460+
let before = account.balance;
461+
Self::realize_expiry(&env, now, &mut account);
462+
let expired = before - account.balance;
463+
if expired > 0 {
464+
Self::save(&env, &account);
465+
results.push_back((subscriber, expired));
466+
}
467+
i += 1;
468+
}
469+
results
470+
}
471+
309472
// ---- internals --------------------------------------------------------
310473

311474
fn require_admin(env: &Env) -> Result<Address, CreditError> {
@@ -340,6 +503,24 @@ impl SubTrackrCredit {
340503
id
341504
}
342505

506+
fn next_wallet_id(env: &Env) -> u64 {
507+
let id: u64 = env.storage().instance().get(&DataKey::Admin).map(|_| id).unwrap_or(0);
508+
let base: u64 = env.storage().instance().get(&symbol_short!("NWID")).unwrap_or(0);
509+
env.storage()
510+
.instance()
511+
.set(&symbol_short!("NWID"), &(base + 1));
512+
base
513+
}
514+
515+
fn next_tx_id(env: &Env, _wallet_id: u64) -> u64 {
516+
let base: u64 = env
517+
.storage()
518+
.instance()
519+
.get(&symbol_short!("NWID"))
520+
.unwrap_or(0);
521+
base
522+
}
523+
343524
/// Sum of unexpired lot balances.
344525
fn available(now: u64, account: &AccountCredit) -> i128 {
345526
let mut total: i128 = 0;

src/navigation/AppNavigator.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const AdminDashboardScreen = lazyScreen(() => import('../screens/AdminDashboardS
4646
const FraudDashboard = lazyScreen(() => import('../screens/FraudDashboard'));
4747
const GroupManagementScreen = lazyScreen(() => import('../screens/GroupManagementScreen'));
4848
const TaxSettingsScreen = lazyScreen(() => import('../screens/TaxSettingsScreen'));
49+
const CreditsAndPrepaymentsScreen = lazyScreen(() => import('../screens/CreditsAndPrepaymentsScreen'));
4950
const TaxComplianceScreen = lazyScreen(() => import('../screens/TaxComplianceScreen'));
5051
const SupportDashboardScreen = lazyScreen(() => import('../screens/SupportDashboardScreen'));
5152
const SegmentManagementScreen = lazyScreen(() =>
@@ -333,6 +334,9 @@ const SettingsStack = () => (
333334
options={{ title: 'Tax Settings', headerShown: true }}
334335
/>
335336
<Stack.Screen
337+
name="CreditsAndPrepayments"
338+
component={CreditsAndPrepaymentsScreen}
339+
options={{ title: 'Credits & Prepayments', headerShown: true }}
336340
name="TaxCompliance"
337341
component={TaxComplianceScreen}
338342
options={{ title: 'Tax Compliance', headerShown: true }}

src/navigation/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type RootStackParamList = {
3434
FraudDashboard: undefined;
3535
GroupManagement: undefined;
3636
TaxSettings: undefined;
37+
CreditsAndPrepayments: undefined;
3738
TaxCompliance: undefined;
3839
SupportDashboard: undefined;
3940
UsageDashboard: undefined;

0 commit comments

Comments
 (0)