diff --git a/app/stores/__tests__/creditStore.performance.test.ts b/app/stores/__tests__/creditStore.performance.test.ts new file mode 100644 index 00000000..118c95d0 --- /dev/null +++ b/app/stores/__tests__/creditStore.performance.test.ts @@ -0,0 +1,23 @@ +import { useCreditStore } from '../creditStore'; + +describe('credit store performance', () => { + it('processes 100 credit applications within the local budget', () => { + useCreditStore.setState({ + accounts: {}, + nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, + now: () => 1_000, + }); + const store = useCreditStore.getState(); + const startedAt = performance.now(); + + for (let index = 0; index < 100; index += 1) { + store.issueCredit(`subscriber-${index}`, 100, 'benchmark'); + store.applyCredit(`subscriber-${index}`, `subscription-${index}`, 50); + } + + expect(performance.now() - startedAt).toBeLessThan(1_500); + }); +}); \ No newline at end of file diff --git a/app/stores/__tests__/creditStore.test.ts b/app/stores/__tests__/creditStore.test.ts index e076729c..dd74754b 100644 --- a/app/stores/__tests__/creditStore.test.ts +++ b/app/stores/__tests__/creditStore.test.ts @@ -2,7 +2,14 @@ import { useCreditStore } from '../creditStore'; let clock = 1000; const reset = () => - useCreditStore.setState({ accounts: {}, nextId: 0, now: () => clock }); + useCreditStore.setState({ + accounts: {}, + nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, + now: () => clock, + }); beforeEach(() => { clock = 1000; @@ -66,4 +73,61 @@ describe('useCreditStore', () => { clock = 1200; expect(s().getBalance('alice')).toBe(0); }); + + it('manages wallet deposits, withdrawals, and charge drawdowns', () => { + const walletId = s().createWallet('alice', 'sub_1', 'USD'); + expect(s().deposit('alice', walletId, 1000)).toMatchObject({ + walletId, + balance: 1000, + transactionId: 0, + }); + expect(s().drawdown('alice', walletId, 250)).toMatchObject({ + balance: 750, + transactionId: 1, + }); + expect(s().withdraw('alice', walletId, 100)).toMatchObject({ + balance: 650, + transactionId: 2, + }); + expect(s().getWallet(walletId)).toMatchObject({ + balance: 650, + totalDeposited: 1000, + totalDrawn: 250, + totalWithdrawn: 100, + transactions: [ + { id: 0, kind: 'deposit', amount: 1000, balanceAfter: 1000 }, + { id: 1, kind: 'drawdown', amount: 250, balanceAfter: 750 }, + { id: 2, kind: 'withdraw', amount: 100, balanceAfter: 650 }, + ], + }); + }); + + it('rejects unauthorized and overdrawn wallet operations', () => { + const walletId = s().createWallet('alice', 'sub_1', 'USD'); + s().deposit('alice', walletId, 100); + expect(s().drawdown('alice', walletId, 101)).toBeUndefined(); + expect(s().withdraw('bob', walletId, 1)).toBeUndefined(); + }); + + it('does not expose mutable account or wallet state', () => { + s().issueCredit('alice', 100, 'promo'); + const account = s().getAccount('alice'); + account.lots[0].remaining = 0; + expect(s().getBalance('alice')).toBe(100); + + const walletId = s().createWallet('alice', 'sub_1', 'USD'); + const wallet = s().getWallet(walletId); + if (wallet) wallet.balance = 99; + expect(s().getWallet(walletId)?.balance).toBe(0); + }); + + it('retains only the bounded recent transaction history', () => { + for (let index = 0; index < 130; index += 1) { + s().issueCredit('alice', 1, `grant-${index}`); + } + const history = s().getAccount('alice').transactions; + expect(history).toHaveLength(128); + expect(history[0].reason).toBe('grant-2'); + expect(history[127].reason).toBe('grant-129'); + }); }); diff --git a/app/stores/creditStore.ts b/app/stores/creditStore.ts index 4f877990..1797f82f 100644 --- a/app/stores/creditStore.ts +++ b/app/stores/creditStore.ts @@ -46,6 +46,40 @@ export interface CreditApplied { balanceAfter: number; } +export interface PrepaymentWallet { + id: number; + subscriber: string; + subscriptionId: string; + currency: string; + balance: number; + totalDeposited: number; + totalWithdrawn: number; + totalDrawn: number; + transactions: PrepaymentTransaction[]; + createdAt: number; + updatedAt: number; +} + +export type PrepaymentTxKind = 'deposit' | 'withdraw' | 'drawdown'; + +export interface PrepaymentTransaction { + id: number; + kind: PrepaymentTxKind; + amount: number; + balanceAfter: number; + timestamp: number; +} + +export interface PrepaymentSnapshot { + walletId: number; + balance: number; + transactionId: number; +} + +const MAX_HISTORY = 128; + +const isPositiveAmount = (amount: number): boolean => Number.isFinite(amount) && amount > 0; + const isExpired = (lot: CreditLot, now: number): boolean => lot.expiresAt !== undefined && lot.expiresAt <= now; @@ -58,6 +92,9 @@ const availableOf = (account: AccountCredit, now: number): number => interface CreditStoreState { accounts: Record; nextId: number; + wallets: Record; + nextWalletId: number; + walletTransactionIds: Record; now: () => number; issueCredit: (subscriber: string, amount: number, reason: string, expiresAt?: number) => void; @@ -67,6 +104,11 @@ interface CreditStoreState { expireCredits: (subscriber: string) => number; getBalance: (subscriber: string) => number; getAccount: (subscriber: string) => AccountCredit; + createWallet: (subscriber: string, subscriptionId: string, currency: string) => number; + getWallet: (walletId: number) => PrepaymentWallet | undefined; + deposit: (subscriber: string, walletId: number, amount: number) => PrepaymentSnapshot | undefined; + withdraw: (subscriber: string, walletId: number, amount: number) => PrepaymentSnapshot | undefined; + drawdown: (subscriber: string, walletId: number, amount: number) => PrepaymentSnapshot | undefined; } const blankAccount = (subscriber: string): AccountCredit => ({ @@ -103,7 +145,7 @@ export const useCreditStore = create()( acc.transactions = [ ...acc.transactions, { id: nextId(), kind, amount, timestamp: get().now(), reason, counterparty }, - ]; + ].slice(-MAX_HISTORY); }; const realizeExpiry = (acc: AccountCredit, now: number): number => { @@ -135,17 +177,47 @@ export const useCreditStore = create()( const cloneAccount = (acc: AccountCredit): AccountCredit => ({ ...acc, - lots: [...acc.lots], - transactions: [...acc.transactions], + lots: acc.lots.map((lot) => ({ ...lot })), + transactions: acc.transactions.map((transaction) => ({ ...transaction })), }); + const nextWalletTransactionId = (walletId: number): number => { + const id = get().walletTransactionIds[walletId] ?? 0; + set((state) => ({ + walletTransactionIds: { ...state.walletTransactionIds, [walletId]: id + 1 }, + })); + return id; + }; + + const recordWalletTransaction = ( + wallet: PrepaymentWallet, + kind: PrepaymentTxKind, + amount: number + ): PrepaymentSnapshot => { + const transactionId = nextWalletTransactionId(wallet.id); + wallet.transactions = [ + ...wallet.transactions, + { + id: transactionId, + kind, + amount, + balanceAfter: wallet.balance, + timestamp: get().now(), + }, + ].slice(-MAX_HISTORY); + return { walletId: wallet.id, balance: wallet.balance, transactionId }; + }; + return { accounts: {}, nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, now: () => Math.floor(Date.now() / 1000), issueCredit: (subscriber, amount, reason, expiresAt) => { - if (amount <= 0) return; + if (!isPositiveAmount(amount)) return; const now = get().now(); const acc = cloneAccount(account(subscriber)); realizeExpiry(acc, now); @@ -169,7 +241,8 @@ export const useCreditStore = create()( const now = get().now(); const acc = cloneAccount(account(subscriber)); realizeExpiry(acc, now); - const applied = consume(acc, now, Math.max(0, amountDue)); + const due = Number.isFinite(amountDue) ? Math.max(0, amountDue) : 0; + const applied = consume(acc, now, due); if (applied > 0) { acc.balance -= applied; record(acc, 'apply', -applied, 'charge_application'); @@ -178,13 +251,13 @@ export const useCreditStore = create()( return { subscriptionId, applied, - remainingDue: amountDue - applied, + remainingDue: due - applied, balanceAfter: acc.balance, }; }, transferCredit: (from, to, amount, reason) => { - if (amount <= 0 || from === to) return false; + if (!isPositiveAmount(amount) || from === to) return false; const now = get().now(); const sender = cloneAccount(account(from)); realizeExpiry(sender, now); @@ -216,7 +289,80 @@ export const useCreditStore = create()( }, getBalance: (subscriber) => availableOf(account(subscriber), get().now()), - getAccount: (subscriber) => account(subscriber), + getAccount: (subscriber) => cloneAccount(account(subscriber)), + + createWallet: (subscriber, subscriptionId, currency) => { + const id = get().nextWalletId; + const now = get().now(); + const wallet: PrepaymentWallet = { + id, + subscriber, + subscriptionId, + currency, + balance: 0, + totalDeposited: 0, + totalWithdrawn: 0, + totalDrawn: 0, + transactions: [], + createdAt: now, + updatedAt: now, + }; + set((state) => ({ + wallets: { ...state.wallets, [id]: wallet }, + nextWalletId: id + 1, + })); + return id; + }, + + getWallet: (walletId) => { + const wallet = get().wallets[walletId]; + return wallet ? { ...wallet } : undefined; + }, + + deposit: (subscriber, walletId, amount) => { + if (!isPositiveAmount(amount)) return undefined; + const wallet = get().wallets[walletId]; + if (!wallet || wallet.subscriber !== subscriber) return undefined; + const updated = { + ...wallet, + balance: wallet.balance + amount, + totalDeposited: wallet.totalDeposited + amount, + updatedAt: get().now(), + }; + const snapshot = recordWalletTransaction(updated, 'deposit', amount); + set((state) => ({ wallets: { ...state.wallets, [walletId]: updated } })); + return snapshot; + }, + + withdraw: (subscriber, walletId, amount) => { + if (!isPositiveAmount(amount)) return undefined; + const wallet = get().wallets[walletId]; + if (!wallet || wallet.subscriber !== subscriber || wallet.balance < amount) return undefined; + const updated = { + ...wallet, + balance: wallet.balance - amount, + totalWithdrawn: wallet.totalWithdrawn + amount, + updatedAt: get().now(), + }; + const snapshot = recordWalletTransaction(updated, 'withdraw', amount); + set((state) => ({ wallets: { ...state.wallets, [walletId]: updated } })); + return snapshot; + }, + + drawdown: (subscriber, walletId, amount) => { + if (!isPositiveAmount(amount)) return undefined; + const wallet = get().wallets[walletId]; + if (!wallet || wallet.subscriber !== subscriber || wallet.balance < amount) return undefined; + const updated = { + ...wallet, + balance: wallet.balance - amount, + totalDrawn: wallet.totalDrawn + amount, + updatedAt: get().now(), + }; + const snapshot = recordWalletTransaction(updated, 'drawdown', amount); + set((state) => ({ wallets: { ...state.wallets, [walletId]: updated } })); + return snapshot; + }, }; }, { @@ -225,6 +371,9 @@ export const useCreditStore = create()( partialize: (state) => ({ accounts: state.accounts, nextId: state.nextId, + wallets: state.wallets, + nextWalletId: state.nextWalletId, + walletTransactionIds: state.walletTransactionIds, }), } ) diff --git a/app/tests/integration/credit-wallet.integration.test.ts b/app/tests/integration/credit-wallet.integration.test.ts new file mode 100644 index 00000000..97b8ff46 --- /dev/null +++ b/app/tests/integration/credit-wallet.integration.test.ts @@ -0,0 +1,30 @@ +import { useCreditStore } from '../../stores/creditStore'; + +describe('credit wallet integration', () => { + beforeEach(() => { + useCreditStore.setState({ + accounts: {}, + nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, + now: () => 1_000, + }); + }); + + it('keeps wallet balance, receipts, and account credit independent', () => { + const store = useCreditStore.getState(); + const walletId = store.createWallet('alice', 'sub_1', 'USD'); + + store.issueCredit('alice', 500, 'refund'); + const deposit = store.deposit('alice', walletId, 1_000); + const drawdown = store.drawdown('alice', walletId, 250); + const applied = store.applyCredit('alice', 'sub_1', 200); + + expect(deposit?.balance).toBe(1_000); + expect(drawdown?.balance).toBe(750); + expect(applied).toMatchObject({ applied: 200, remainingDue: 0, balanceAfter: 300 }); + expect(useCreditStore.getState().getWallet(walletId)?.transactions).toHaveLength(2); + expect(useCreditStore.getState().getBalance('alice')).toBe(300); + }); +}); \ No newline at end of file diff --git a/contracts/credit/src/lib.rs b/contracts/credit/src/lib.rs index 34da4ed4..644431f9 100644 --- a/contracts/credit/src/lib.rs +++ b/contracts/credit/src/lib.rs @@ -147,10 +147,30 @@ pub struct PrepaymentWallet { pub balance: i128, pub total_deposited: i128, pub total_withdrawn: i128, + pub total_drawn: i128, + pub transactions: Vec, pub created_at: u64, pub updated_at: u64, } +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PrepaymentTxKind { + Deposit, + Withdraw, + Drawdown, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PrepaymentTransaction { + pub id: u64, + pub kind: PrepaymentTxKind, + pub amount: i128, + pub balance_after: i128, + pub timestamp: u64, +} + /// Prepayment summary returned after a deposit or withdrawal. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -167,7 +187,10 @@ enum DataKey { NextId, Account(Address), Wallet(u64), + WalletTx(u64), Counter(u64), + AccountCount, + AccountIndex(Address), } #[contract] @@ -388,6 +411,8 @@ impl SubTrackrCredit { balance: 0, total_deposited: 0, total_withdrawn: 0, + total_drawn: 0, + transactions: Vec::new(&env), created_at: now, updated_at: now, }; @@ -428,7 +453,7 @@ impl SubTrackrCredit { Ok(PrepaymentSnapshot { wallet_id, balance: wallet.balance, - transaction_id: Self::next_tx_id(&env, wallet_id), + transaction_id, }) } @@ -464,10 +489,53 @@ impl SubTrackrCredit { Ok(PrepaymentSnapshot { wallet_id, balance: wallet.balance, - transaction_id: Self::next_tx_id(&env, wallet_id), + transaction_id, + }) + } + + /// Draws funds from a prepayment wallet to settle a subscription charge. + /// The subscriber authorizes the draw and the wallet can never go negative. + pub fn drawdown( + 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); + } + wallet.balance -= amount; + wallet.total_drawn += amount; + wallet.updated_at = env.ledger().timestamp(); + let transaction_id = + Self::record_wallet_transaction(&env, &mut wallet, PrepaymentTxKind::Drawdown, amount); + env.storage() + .persistent() + .set(&DataKey::Wallet(wallet_id), &wallet); + Ok(PrepaymentSnapshot { + wallet_id, + balance: wallet.balance, + transaction_id, }) } + /// Returns a wallet when it exists. + pub fn get_wallet(env: Env, wallet_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Wallet(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() @@ -481,14 +549,24 @@ impl SubTrackrCredit { /// 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)> { + let configured_admin = Self::require_admin(&env).expect("admin required"); + if configured_admin != admin { + panic!("unauthorized"); + } admin.require_auth(); let now = env.ledger().timestamp(); let mut results: Vec<(Address, i128)> = Vec::new(&env); + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::AccountCount) + .unwrap_or(0); let mut i: u32 = 0; - while i < MAX_HISTORY { + while i < count { let key = DataKey::Counter(i as u64); if !env.storage().persistent().has(&key) { - break; + i += 1; + continue; } let subscriber: Address = env.storage().persistent().get(&key).unwrap(); let mut account = Self::account(&env, &subscriber); @@ -527,6 +605,21 @@ impl SubTrackrCredit { } fn save(env: &Env, account: &AccountCredit) { + let account_index = DataKey::AccountIndex(account.subscriber.clone()); + if !env.storage().persistent().has(&account_index) { + let index: u32 = env + .storage() + .persistent() + .get(&DataKey::AccountCount) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::Counter(index as u64), &account.subscriber); + env.storage().persistent().set(&account_index, &index); + env.storage() + .persistent() + .set(&DataKey::AccountCount, &(index + 1)); + } env.storage() .persistent() .set(&DataKey::Account(account.subscriber.clone()), account); @@ -556,9 +649,39 @@ impl SubTrackrCredit { .instance() .get(&symbol_short!("NWID")) .unwrap_or(0); + env.storage() + .instance() + .set(&symbol_short!("NWID"), &(base + 1)); base } + fn next_wallet_tx_id(env: &Env, wallet_id: u64) -> u64 { + let key = DataKey::WalletTx(wallet_id); + let id: u64 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(id + 1)); + id + } + + fn record_wallet_transaction( + env: &Env, + wallet: &mut PrepaymentWallet, + kind: PrepaymentTxKind, + amount: i128, + ) -> u64 { + let id = Self::next_wallet_tx_id(env, wallet.id); + wallet.transactions.push_back(PrepaymentTransaction { + id, + kind, + amount, + balance_after: wallet.balance, + timestamp: env.ledger().timestamp(), + }); + while wallet.transactions.len() > MAX_HISTORY { + wallet.transactions.remove(0); + } + id + } + /// Sum of unexpired lot balances. fn available(now: u64, account: &AccountCredit) -> i128 { let mut total: i128 = 0; diff --git a/contracts/credit/src/test.rs b/contracts/credit/src/test.rs index 09656353..0654a104 100644 --- a/contracts/credit/src/test.rs +++ b/contracts/credit/src/test.rs @@ -127,3 +127,74 @@ fn expiration_policy_drives_default_expiry() { set_time(&env, 1_200); // > 1_000 + 100 assert_eq!(client.get_credit_balance(&sub), 0); } + +#[test] +fn wallet_deposit_withdraw_and_drawdown_have_unique_receipts() { + let (env, client, _admin) = setup(); + let subscriber = Address::generate(&env); + let wallet_id = client.create_wallet(&subscriber, &42, &String::from_str(&env, "USD")); + + let deposited = client.deposit(&subscriber, &wallet_id, &1_000); + assert_eq!(deposited.balance, 1_000); + assert_eq!(deposited.transaction_id, 0); + + let drawn = client.drawdown(&subscriber, &wallet_id, &250); + assert_eq!(drawn.balance, 750); + assert_eq!(drawn.transaction_id, 1); + + let withdrawn = client.withdraw(&subscriber, &wallet_id, &100); + assert_eq!(withdrawn.balance, 650); + assert_eq!(withdrawn.transaction_id, 2); + + let wallet = client.get_wallet(&wallet_id).unwrap(); + assert_eq!(wallet.balance, 650); + assert_eq!(wallet.total_deposited, 1_000); + assert_eq!(wallet.total_drawn, 250); + assert_eq!(wallet.total_withdrawn, 100); + assert_eq!(wallet.transactions.len(), 3); + assert_eq!( + wallet.transactions.get(0).unwrap().kind, + PrepaymentTxKind::Deposit + ); + assert_eq!( + wallet.transactions.get(1).unwrap().kind, + PrepaymentTxKind::Drawdown + ); + assert_eq!(wallet.transactions.get(2).unwrap().balance_after, 650); +} + +#[test] +fn wallet_rejects_unauthorized_and_overdrawn_operations() { + let (env, client, _admin) = setup(); + let subscriber = Address::generate(&env); + let other = Address::generate(&env); + let wallet_id = client.create_wallet(&subscriber, &7, &String::from_str(&env, "USD")); + client.deposit(&subscriber, &wallet_id, &100); + + assert_eq!( + client.try_drawdown(&subscriber, &wallet_id, &101), + Err(Ok(CreditError::InsufficientCredit)) + ); + assert_eq!( + client.try_withdraw(&other, &wallet_id, &1), + Err(Ok(CreditError::Unauthorized)) + ); +} + +#[test] +fn cron_expires_registered_accounts() { + let (env, client, admin) = setup(); + let subscriber = Address::generate(&env); + set_time(&env, 1_000); + client.issue_credit( + &subscriber, + &100, + &String::from_str(&env, "promo"), + &Some(1_100), + ); + set_time(&env, 1_200); + + let results = client.expire_credits_with_cron(&admin); + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().1, 100); +} diff --git a/contracts/types/src/errors.rs b/contracts/types/src/errors.rs index d93a3204..f8b0773f 100644 --- a/contracts/types/src/errors.rs +++ b/contracts/types/src/errors.rs @@ -1,6 +1,4 @@ -#![no_std] - -use soroban_sdk::{contracterror, contracttype, Env, Symbol}; +use soroban_sdk::{Env, Symbol}; /// Unified core error enum for all SubTrackr contracts. /// diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index a6c8233d..fe233dbb 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] +pub mod errors; +pub use errors::CoreError; + use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; /// Billing interval in seconds. diff --git a/docs/CREDIT_SYSTEM.md b/docs/CREDIT_SYSTEM.md new file mode 100644 index 00000000..b74fa2ba --- /dev/null +++ b/docs/CREDIT_SYSTEM.md @@ -0,0 +1,60 @@ +# Credit and account balances + +SubTrackr keeps subscriber credit as expiring lots. Credit is consumed +oldest-first, and the available balance never includes expired lots. Every +issuance, application, transfer, and expiry is recorded in the account ledger; +the contract and the mobile Zustand store use the same model. + +## Mobile store + +```ts +const walletId = useCreditStore.getState().createWallet('subscriber-1', 'sub-1', 'USD'); +useCreditStore.getState().deposit('subscriber-1', walletId, 10_000); + +const charge = useCreditStore.getState().applyCredit('subscriber-1', 'sub-1', 2_500); +// charge.applied === 2_500 when enough unexpired credit exists + +const remaining = useCreditStore.getState().getBalance('subscriber-1'); +``` + +Prepayment wallets are separate from promotional/refund credit and support +`deposit`, `withdraw`, and `drawdown`. Each operation returns the new balance +and a wallet-local transaction ID. Wallet transactions are retained with their +kind, amount, resulting balance, and timestamp. Invalid, unauthorized, or +overdrawn wallet operations return `undefined` in the local store. + +## Soroban contract + +The `subtrackr-credit` contract exposes the equivalent operations: + +```text +create_wallet(subscriber, subscription_id, currency) +deposit(subscriber, wallet_id, amount) +withdraw(subscriber, wallet_id, amount) +drawdown(subscriber, wallet_id, amount) +get_wallet(wallet_id) +``` + +Credit issuance is admin-authorized. Transfers require the sender's +authorization. Wallet deposits, withdrawals, and drawdowns require the wallet +subscriber's authorization. `expire_credits_with_cron` is admin-only and +enumerates accounts that have been saved by the contract. + +Amounts should be represented in the smallest unit of the selected currency, +such as cents for USD. The UI and store use JavaScript numbers, so callers +should keep values within the precise integer range supported by their payment +rail. + +## Verification + +Run the focused checks from the repository root: + +```bash +npm run credit:test:coverage +npm run credit:benchmark +npm run contracts:test -- --package subtrackr-credit +``` + +The coverage command enforces at least 80% global coverage for the credit +store. The benchmark fails if 100 issue-and-apply workflows exceed 1,500 ms on +the local test runner. This budget includes persisted Zustand state updates. \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index b5c25788..d38a4f62 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,7 +21,7 @@ module.exports = { 'expo(nent)?|@expo(nent)?/|@expo-google-fonts/|' + '@unimodules/|react-navigation|@react-navigation/|' + '@sentry/react-native|native-base|react-native-svg|@walletconnect/' + - '))', + '))', ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'], @@ -31,7 +31,6 @@ module.exports = { '/node_modules/', '/e2e/', '/src/animations/', - '/app/', '/backend/', '/developer-portal/', '/contracts/', @@ -41,8 +40,7 @@ module.exports = { moduleNameMapper: { '^bullmq$': '/backend/shared/queue/__mocks__/bullmq.ts', '^@/(.*)$': '/src/$1', - '^@testing-library/react-native$': - '/src/__mocks__/@testing-library/react-native.js', + '^@testing-library/react-native$': '/src/__mocks__/@testing-library/react-native.js', '^@react-native-community/netinfo$': '/src/__mocks__/@react-native-community/netinfo.js', '^@react-native-async-storage/async-storage$': diff --git a/jest.credit.config.js b/jest.credit.config.js new file mode 100644 index 00000000..e873fcd4 --- /dev/null +++ b/jest.credit.config.js @@ -0,0 +1,10 @@ +module.exports = { + transform: { + '^.+\\.[jt]sx?$': ['babel-jest', { configFile: './babel.config.test.js' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.(test|spec).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + testPathIgnorePatterns: ['/node_modules/', '/e2e/', '/contracts/'], + setupFilesAfterEnv: ['/jest.setup.js'], +}; diff --git a/package.json b/package.json index 1b5e75dc..292af14e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "lint:fix": "eslint . --ext .ts,.tsx --fix", "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"", "test:shard": "jest --runInBand", + "credit:test": "jest --config jest.credit.config.js app/stores/__tests__/creditStore.test.ts app/tests/integration/credit-wallet.integration.test.ts --runInBand", + "credit:test:coverage": "jest --config jest.credit.config.js app/stores/__tests__/creditStore.test.ts app/tests/integration/credit-wallet.integration.test.ts --runInBand --coverage --collectCoverageFrom=app/stores/creditStore.ts --coverageThreshold '{\"global\":{\"branches\":80,\"functions\":80,\"lines\":80,\"statements\":80}}'", + "credit:benchmark": "jest --config jest.credit.config.js app/stores/__tests__/creditStore.performance.test.ts --runInBand", "format:check": "prettier --check \"**/*.{ts,tsx,js,json,md}\"", "typecheck": "tsc --noEmit", "test": "jest --passWithNoTests", @@ -149,5 +152,6 @@ "*.{js,json,md}": [ "prettier --write" ] - } + }, + "packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..a56c433a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +allowBuilds: + bufferutil: false + detox: false + dtrace-provider: false + es5-ext: false + keccak: false + secp256k1: false + unrs-resolver: false + utf-8-validate: false + web3: false + web3-bzz: false + web3-shh: false diff --git a/src/hooks/useFraudAnalytics.ts b/src/hooks/useFraudAnalytics.ts index cf588e97..37bb88bb 100644 --- a/src/hooks/useFraudAnalytics.ts +++ b/src/hooks/useFraudAnalytics.ts @@ -208,8 +208,16 @@ function buildSignalBreakdown(analytics: FraudAnalytics): SignalBreakdown[] { { signalType: 'usage-anomaly', count: analytics.anomalyAlerts ?? 0, avgScore: 22 }, { signalType: 'chargeback', count: analytics.chargebackPredictions ?? 0, avgScore: 38 }, { signalType: 'geolocation-anomaly', count: analytics.geoAnomalyAlerts ?? 0, avgScore: 24 }, - { signalType: 'device-mismatch', count: Math.round((analytics.flagged ?? 0) * 0.3), avgScore: 20 }, - { signalType: 'pattern-shift', count: Math.round((analytics.flagged ?? 0) * 0.2), avgScore: 26 }, + { + signalType: 'device-mismatch', + count: Math.round((analytics.flagged ?? 0) * 0.3), + avgScore: 20, + }, + { + signalType: 'pattern-shift', + count: Math.round((analytics.flagged ?? 0) * 0.2), + avgScore: 26, + }, ]; const total = raw.reduce((s, r) => s + r.count, 0); return raw.map((s) => ({ diff --git a/src/screens/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index caf18a5d..371cc9cb 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -12,32 +12,6 @@ import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { RootStackParamList } from '../navigation/types'; -import { useCancellationStore } from '../store/cancellationStore'; -import { CANCELLATION_REASONS } from '../store/cancellationStore'; - -// Local type alias for the retention offer shape -interface RetentionOffer { - id: string; - type: string; - title: string; - description: string; - expiresAt: string | Date; - abVariant?: 'A' | 'B'; -} - -const OFFER_TYPE_ICONS: Record = { - discount: 'πŸ’°', - pause: '⏸️', - downgrade: '⬇️', - trial_extension: '⏱️', - feature_unlock: 'πŸ”“', -}; - -type Props = NativeStackScreenProps; - -const CancellationFlowScreen: React.FC = ({ route, navigation }) => { - const { currentStep, setReason, setStep, acceptOffer, reset } = useCancellationStore(); - const { deleteSubscription } = useSubscriptionStore(); import { useCancellationStore, CANCELLATION_REASONS } from '../store/cancellationStore'; import { RetentionOffer } from '../../backend/services/retentionService'; diff --git a/src/services/fraudDetectionService.ts b/src/services/fraudDetectionService.ts index 5ef3da37..18a6bc64 100644 --- a/src/services/fraudDetectionService.ts +++ b/src/services/fraudDetectionService.ts @@ -59,18 +59,20 @@ export async function performFraudCheck(request: FraudCheckRequest): Promise= 30) { @@ -156,7 +158,7 @@ export async function performFraudCheck(request: FraudCheckRequest): Promise): Promise { const detections = await getAllDetections(); - + const detection: FraudDetection = { id: generateId(), transactionId: data.transactionId!, @@ -176,7 +178,7 @@ async function createDetection(data: Partial): Promise { const detections = await getAllDetections(); - const index = detections.findIndex(d => d.id === id); - + const index = detections.findIndex((d) => d.id === id); + if (index === -1) { throw new Error(`Detection with id ${id} not found`); } @@ -210,7 +212,7 @@ export async function getAllDetections(filters?: FraudFilters): Promise ({ ...d, @@ -229,13 +231,13 @@ export async function getAllDetections(filters?: FraudFilters): Promise { const detections = await getAllDetections(); - return detections.find(d => d.id === id) || null; + return detections.find((d) => d.id === id) || null; } // Fraud Alerts async function createAlert(detection: FraudDetection): Promise { const alerts = await getAllAlerts(); - + const alert: FraudAlert = { id: generateId(), detectionId: detection.id, @@ -250,7 +252,7 @@ async function createAlert(detection: FraudDetection): Promise { alerts.push(alert); await AsyncStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts)); - + return alert; } @@ -258,7 +260,7 @@ export async function getAllAlerts(): Promise { try { const data = await AsyncStorage.getItem(STORAGE_KEYS.ALERTS); if (!data) return []; - + const alerts: FraudAlert[] = JSON.parse(data); return alerts.map((a: any) => ({ ...a, @@ -273,8 +275,8 @@ export async function getAllAlerts(): Promise { export async function markAlertAsRead(id: string): Promise { const alerts = await getAllAlerts(); - const index = alerts.findIndex(a => a.id === id); - + const index = alerts.findIndex((a) => a.id === id); + if (index !== -1) { alerts[index].isRead = true; await AsyncStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts)); @@ -283,8 +285,8 @@ export async function markAlertAsRead(id: string): Promise { export async function resolveAlert(id: string, actionTaken: string): Promise { const alerts = await getAllAlerts(); - const index = alerts.findIndex(a => a.id === id); - + const index = alerts.findIndex((a) => a.id === id); + if (index !== -1) { alerts[index].isResolved = true; alerts[index].actionTaken = actionTaken; @@ -296,15 +298,14 @@ export async function resolveAlert(id: string, actionTaken: string): Promise { const detections = await getAllDetections(); - + const totalDetections = detections.length; - const blockedTransactions = detections.filter(d => d.isBlocked).length; - const confirmedFraud = detections.filter(d => d.status === FraudStatus.CONFIRMED).length; - const falsePositives = detections.filter(d => d.status === FraudStatus.FALSE_POSITIVE).length; - - const averageRiskScore = totalDetections > 0 - ? detections.reduce((sum, d) => sum + d.riskScore, 0) / totalDetections - : 0; + const blockedTransactions = detections.filter((d) => d.isBlocked).length; + const confirmedFraud = detections.filter((d) => d.status === FraudStatus.CONFIRMED).length; + const falsePositives = detections.filter((d) => d.status === FraudStatus.FALSE_POSITIVE).length; + + const averageRiskScore = + totalDetections > 0 ? detections.reduce((sum, d) => sum + d.riskScore, 0) / totalDetections : 0; // Detections by level const detectionsByLevel: Record = { @@ -313,7 +314,7 @@ export async function getFraudAnalytics(): Promise { high: 0, critical: 0, }; - detections.forEach(d => detectionsByLevel[d.riskLevel]++); + detections.forEach((d) => detectionsByLevel[d.riskLevel]++); // Detections by method const detectionsByMethod: Record = { @@ -326,7 +327,7 @@ export async function getFraudAnalytics(): Promise { network_analysis: 0, ml_model: 0, }; - detections.forEach(d => detectionsByMethod[d.detectionMethod]++); + detections.forEach((d) => detectionsByMethod[d.detectionMethod]++); // Indicator breakdown const indicatorBreakdown: Record = { @@ -341,29 +342,25 @@ export async function getFraudAnalytics(): Promise { unusual_time: 0, ip_reputation: 0, }; - detections.forEach(d => { - d.indicators.forEach(ind => indicatorBreakdown[ind.type]++); + detections.forEach((d) => { + d.indicators.forEach((ind) => indicatorBreakdown[ind.type]++); }); // Calculate prevented loss (mock calculation) const preventedLoss = detections - .filter(d => d.isBlocked && d.status !== FraudStatus.FALSE_POSITIVE) + .filter((d) => d.isBlocked && d.status !== FraudStatus.FALSE_POSITIVE) .reduce((sum, d) => sum + (d.metadata.transactionAmount || 0), 0); // Detection and false positive rates - const detectionRate = totalDetections > 0 - ? (confirmedFraud / totalDetections) * 100 - : 0; - const falsePositiveRate = totalDetections > 0 - ? (falsePositives / totalDetections) * 100 - : 0; + const detectionRate = totalDetections > 0 ? (confirmedFraud / totalDetections) * 100 : 0; + const falsePositiveRate = totalDetections > 0 ? (falsePositives / totalDetections) * 100 : 0; // Time series data (last 30 days) const timeSeriesData = generateTimeSeriesData(detections, 30); // Top risk users const userRiskMap = new Map(); - detections.forEach(d => { + detections.forEach((d) => { const existing = userRiskMap.get(d.userId) || { riskScore: 0, count: 0 }; userRiskMap.set(d.userId, { riskScore: Math.max(existing.riskScore, d.riskScore), @@ -404,7 +401,7 @@ export async function createInvestigation( priority: 'low' | 'medium' | 'high' | 'urgent' ): Promise { const investigations = await getAllInvestigations(); - + const investigation: FraudInvestigation = { id: generateId(), detectionId, @@ -420,7 +417,7 @@ export async function createInvestigation( investigations.push(investigation); await AsyncStorage.setItem(STORAGE_KEYS.INVESTIGATIONS, JSON.stringify(investigations)); - + return investigation; } @@ -429,8 +426,8 @@ export async function updateInvestigation( updates: Partial ): Promise { const investigations = await getAllInvestigations(); - const index = investigations.findIndex(i => i.id === id); - + const index = investigations.findIndex((i) => i.id === id); + if (index === -1) { throw new Error(`Investigation with id ${id} not found`); } @@ -450,7 +447,7 @@ export async function getAllInvestigations(): Promise { try { const data = await AsyncStorage.getItem(STORAGE_KEYS.INVESTIGATIONS); if (!data) return []; - + const investigations: FraudInvestigation[] = JSON.parse(data); return investigations.map((i: any) => ({ ...i, @@ -477,12 +474,11 @@ export async function generateFraudReport( const totalDetections = detections.length; const blockedAmount = detections - .filter(d => d.isBlocked) + .filter((d) => d.isBlocked) .reduce((sum, d) => sum + (d.metadata.transactionAmount || 0), 0); - const confirmedCases = detections.filter(d => d.status === FraudStatus.CONFIRMED).length; - const averageRiskScore = totalDetections > 0 - ? detections.reduce((sum, d) => sum + d.riskScore, 0) / totalDetections - : 0; + const confirmedCases = detections.filter((d) => d.status === FraudStatus.CONFIRMED).length; + const averageRiskScore = + totalDetections > 0 ? detections.reduce((sum, d) => sum + d.riskScore, 0) / totalDetections : 0; // Calculate trends const prevPeriod = calculatePreviousPeriod(period, reportType); @@ -491,20 +487,23 @@ export async function generateFraudReport( dateTo: prevPeriod.end, }); - const detectionTrend = totalDetections > prevDetections.length - ? 'increasing' - : totalDetections < prevDetections.length - ? 'decreasing' - : 'stable'; - - const prevAvgRisk = prevDetections.length > 0 - ? prevDetections.reduce((sum, d) => sum + d.riskScore, 0) / prevDetections.length - : 0; - const riskTrend = averageRiskScore > prevAvgRisk - ? 'increasing' - : averageRiskScore < prevAvgRisk - ? 'decreasing' - : 'stable'; + const detectionTrend = + totalDetections > prevDetections.length + ? 'increasing' + : totalDetections < prevDetections.length + ? 'decreasing' + : 'stable'; + + const prevAvgRisk = + prevDetections.length > 0 + ? prevDetections.reduce((sum, d) => sum + d.riskScore, 0) / prevDetections.length + : 0; + const riskTrend = + averageRiskScore > prevAvgRisk + ? 'increasing' + : averageRiskScore < prevAvgRisk + ? 'decreasing' + : 'stable'; const recommendations = generateReportRecommendations(analytics, detectionTrend, riskTrend); @@ -542,11 +541,13 @@ export async function getMonitoringStatus(): Promise { averageResponseTime: 0, }; } - + const monitoring: RealTimeMonitoring = JSON.parse(data); return { ...monitoring, - lastCheckTimestamp: monitoring.lastCheckTimestamp ? new Date(monitoring.lastCheckTimestamp) : undefined, + lastCheckTimestamp: monitoring.lastCheckTimestamp + ? new Date(monitoring.lastCheckTimestamp) + : undefined, }; } catch (error) { console.error('Failed to load monitoring status:', error); @@ -557,11 +558,11 @@ export async function getMonitoringStatus(): Promise { async function updateMonitoringStats(): Promise { const monitoring = await getMonitoringStatus(); const detections = await getAllDetections(); - + monitoring.transactionsMonitored = (monitoring.transactionsMonitored ?? 0) + 1; - monitoring.activeDetections = detections.filter(d => d.status === FraudStatus.PENDING).length; + monitoring.activeDetections = detections.filter((d) => d.status === FraudStatus.PENDING).length; monitoring.lastCheckTimestamp = new Date(); - + await AsyncStorage.setItem(STORAGE_KEYS.MONITORING, JSON.stringify(monitoring)); } @@ -578,9 +579,9 @@ async function checkVelocity( const timeWindow = 60; // minutes const threshold = 5; // max transactions const cutoff = new Date(Date.now() - timeWindow * 60 * 1000); - + const recentTransactions = detections.filter( - d => d.userId === userId && d.subscriptionId === subscriptionId && d.timestamp >= cutoff + (d) => d.userId === userId && d.subscriptionId === subscriptionId && d.timestamp >= cutoff ); return { @@ -594,17 +595,20 @@ async function checkVelocity( async function checkAmountAnomaly( userId: string, amount: number -): Promise<{ isAnomalous: boolean; severity: 'low' | 'medium' | 'high'; deviation: number; averageAmount: number }> { +): Promise<{ + isAnomalous: boolean; + severity: 'low' | 'medium' | 'high'; + deviation: number; + averageAmount: number; +}> { const detections = await getAllDetections({ userId }); - + if (detections.length < 3) { return { isAnomalous: false, severity: 'low', deviation: 1, averageAmount: amount }; } - const amounts = detections - .map(d => d.metadata.transactionAmount || 0) - .filter(a => a > 0); - + const amounts = detections.map((d) => d.metadata.transactionAmount || 0).filter((a) => a > 0); + const averageAmount = amounts.reduce((sum, a) => sum + a, 0) / amounts.length; const deviation = amount / averageAmount; @@ -621,7 +625,7 @@ async function checkLocationAnomaly( location: { country: string; city?: string } ): Promise<{ isSuspicious: boolean; reason: string }> { const detections = await getAllDetections({ userId }); - + if (detections.length === 0) { return { isSuspicious: false, reason: '' }; } @@ -636,7 +640,7 @@ async function checkLocationAnomaly( if (recentCountry !== location.country) { const timeDiff = Date.now() - detections[detections.length - 1].timestamp.getTime(); const hoursDiff = timeDiff / (1000 * 60 * 60); - + if (hoursDiff < 2) { return { isSuspicious: true, @@ -653,14 +657,14 @@ async function checkDeviceFingerprint( deviceId: string ): Promise<{ isNew: boolean }> { const detections = await getAllDetections({ userId }); - const knownDevices = new Set(detections.map(d => d.metadata.deviceId).filter(Boolean)); - + const knownDevices = new Set(detections.map((d) => d.metadata.deviceId).filter(Boolean)); + return { isNew: !knownDevices.has(deviceId) }; } function checkTimePattern(timestamp: Date): { isUnusual: boolean; reason: string } { const hour = timestamp.getHours(); - + // Flag transactions between 2 AM and 5 AM if (hour >= 2 && hour < 5) { return { @@ -678,7 +682,7 @@ async function checkIPReputation( // Mock IP reputation check // In production, integrate with IP reputation services const suspiciousIPs = ['192.168.1.100', '10.0.0.1']; // Mock blacklist - + if (suspiciousIPs.includes(ipAddress)) { return { isSuspicious: true, @@ -713,7 +717,7 @@ function generateRecommendation(riskScore: number, indicators: FraudIndicator[]) function applyFilters(detections: FraudDetection[], filters?: FraudFilters): FraudDetection[] { if (!filters) return detections; - return detections.filter(d => { + return detections.filter((d) => { if (filters.riskLevel && !filters.riskLevel.includes(d.riskLevel)) return false; if (filters.status && !filters.status.includes(d.status)) return false; if (filters.dateFrom && d.timestamp < filters.dateFrom) return false; @@ -738,7 +742,7 @@ function generateTimeSeriesData( date.setDate(date.getDate() - i); const dateStr = date.toISOString().split('T')[0]; - const dayDetections = detections.filter(d => { + const dayDetections = detections.filter((d) => { const dStr = d.timestamp.toISOString().split('T')[0]; return dStr === dateStr; }); @@ -746,8 +750,8 @@ function generateTimeSeriesData( data.push({ date: dateStr, detections: dayDetections.length, - blocked: dayDetections.filter(d => d.isBlocked).length, - confirmed: dayDetections.filter(d => d.status === FraudStatus.CONFIRMED).length, + blocked: dayDetections.filter((d) => d.isBlocked).length, + confirmed: dayDetections.filter((d) => d.status === FraudStatus.CONFIRMED).length, }); } @@ -759,7 +763,7 @@ function calculatePreviousPeriod( reportType: string ): { start: Date; end: Date } { const duration = period.end.getTime() - period.start.getTime(); - + return { start: new Date(period.start.getTime() - duration), end: new Date(period.start.getTime()), @@ -778,15 +782,21 @@ function generateReportRecommendations( } if (riskTrend === 'increasing') { - recommendations.push('Average risk score is rising. Consider implementing additional verification steps.'); + recommendations.push( + 'Average risk score is rising. Consider implementing additional verification steps.' + ); } if (analytics.falsePositiveRate && analytics.falsePositiveRate > 20) { - recommendations.push(`False positive rate is ${analytics.falsePositiveRate.toFixed(1)}%. Review and adjust fraud detection thresholds.`); + recommendations.push( + `False positive rate is ${analytics.falsePositiveRate.toFixed(1)}%. Review and adjust fraud detection thresholds.` + ); } if (analytics.preventedLoss && analytics.preventedLoss > 1000) { - recommendations.push(`Successfully prevented $${analytics.preventedLoss.toFixed(2)} in potential fraud.`); + recommendations.push( + `Successfully prevented $${analytics.preventedLoss.toFixed(2)} in potential fraud.` + ); } if (recommendations.length === 0) { diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts index b09b412b..1e580eea 100644 --- a/src/store/fraudStore.ts +++ b/src/store/fraudStore.ts @@ -484,7 +484,6 @@ const scoreSubscription = ( }; }; - const computeAnalytics = ( subscriptions: FraudSubscriptionRecord[], reviewQueue: FraudCase[] diff --git a/src/types/fraud.ts b/src/types/fraud.ts index 78ca7e1b..03fd9d47 100644 --- a/src/types/fraud.ts +++ b/src/types/fraud.ts @@ -9,7 +9,6 @@ export type FraudSignalType = | 'geolocation-anomaly'; export type FraudReviewOutcome = 'true_positive' | 'false_positive' | 'needs_follow_up'; export type FraudEvidenceSource = 'payment' | 'device' | 'location' | 'support'; - | 'device-mismatch'; // ── Legacy types used by fraudDetectionService ──────────────────────────────── @@ -320,8 +319,14 @@ export interface FraudAnalytics { preventedLoss?: number; detectionRate?: number; falsePositiveRate?: number; - timeSeriesData?: Array<{ date: string; count?: number; detections?: number; blocked: number; confirmed?: number }>; - topRiskUsers?: Array<{ userId: string; riskScore: number; detectionCount: number }>; + timeSeriesData?: { + date: string; + count?: number; + detections?: number; + blocked: number; + confirmed?: number; + }[]; + topRiskUsers?: { userId: string; riskScore: number; detectionCount: number }[]; // New dashboard fields (used by fraud store and UI) totalChecks?: number; approved?: number;