diff --git a/.github/workflows/disaster-recovery.yml b/.github/workflows/disaster-recovery.yml new file mode 100644 index 00000000..e8b61862 --- /dev/null +++ b/.github/workflows/disaster-recovery.yml @@ -0,0 +1,73 @@ +name: Disaster Recovery Automation + +# Automated disaster-recovery routine: +# 1. Runs a full DR drill (backup → verify → restore + monitor health check) +# 2. Creates a DR backup (with pre-check) +# 3. Captures and uploads DR status + backup artefacts +# +# Wired to a schedule so backups/status are taken on a routine cadence +# independent of human action, plus a manual dispatch for on-demand runs. + +on: + schedule: + # Daily at 03:17 UTC + - cron: '17 3 * * *' + workflow_dispatch: + +env: + NODE_VERSION: '20' + +jobs: + dr-routine: + name: DR Backup + Status + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run DR drill (backup → verify → restore + health) + run: node scripts/dr-test.js + continue-on-error: true + + - name: Create DR backup (with pre-check) + run: ./scripts/dr-backup.sh --pre-check --region "${DR_REGION:-us-east-1}" --env "${DR_ENVIRONMENT:-production}" + env: + DR_REGION: us-east-1 + DR_ENVIRONMENT: production + + - name: Capture DR status (JSON) + run: | + STATUS_FILE="dr-status-${{ github.run_id }}.json" + ./scripts/dr-status.sh --json > "$STATUS_FILE" 2>&1 || true + echo "STATUS_FILE=$STATUS_FILE" >> "$GITHUB_ENV" + id: status + + - name: Upload DR backup artefact + if: always() + uses: actions/upload-artifact@v7 + with: + name: dr-backups-${{ github.run_id }} + path: | + .dr-backups/*.tar.gz + .dr-recovery-log.jsonl + ${{ env.STATUS_FILE }} + + - name: Notify on degraded/critical DR health + if: always() + run: | + if ! ./scripts/dr-status.sh --short; then + echo "::warning::DR system is in a degraded/critical state — review the DR status artefact." + else + echo "DR system is healthy." + fi diff --git a/app/stores/__tests__/creditStore.test.ts b/app/stores/__tests__/creditStore.test.ts index e076729c..2bd22314 100644 --- a/app/stores/__tests__/creditStore.test.ts +++ b/app/stores/__tests__/creditStore.test.ts @@ -2,7 +2,7 @@ import { useCreditStore } from '../creditStore'; let clock = 1000; const reset = () => - useCreditStore.setState({ accounts: {}, nextId: 0, now: () => clock }); + useCreditStore.setState({ accounts: {}, wallets: {}, nextId: 0, now: () => clock }); beforeEach(() => { clock = 1000; @@ -66,4 +66,54 @@ describe('useCreditStore', () => { clock = 1200; expect(s().getBalance('alice')).toBe(0); }); + + it('deposits credit into an account balance', () => { + s().depositCredit('alice', 200, 'prepaid top-up'); + expect(s().getBalance('alice')).toBe(200); + expect( + s() + .getAccount('alice') + .transactions.some((t) => t.kind === 'deposit' && t.amount === 200) + ).toBe(true); + }); + + it('withdraws available credit and rejects overdrafts', () => { + s().issueCredit('alice', 300, 'promo'); + expect(s().withdrawCredit('alice', 100, 'cash-out')).toBe(true); + expect(s().getBalance('alice')).toBe(200); + expect(s().withdrawCredit('alice', 500, 'cash-out')).toBe(false); + expect(s().getBalance('alice')).toBe(200); + }); + + it('computes a consolidated account balance summary', () => { + useCreditStore.getState().wallets = { + 'w-1': { + id: 'w-1', + subscriber: 'alice', + currency: 'USD', + balance: 75, + totalDeposited: 100, + totalWithdrawn: 25, + }, + }; + + s().issueCredit('alice', 250, 'refund'); + s().applyCredit('alice', 'sub_1', 50); + + const balance = s().getAccountBalance('alice'); + expect(balance.subscriber).toBe('alice'); + expect(balance.availableCredit).toBe(200); + expect(balance.totalIssued).toBe(250); + expect(balance.totalApplied).toBe(50); + expect(balance.prepaymentBalance).toBe(75); + expect(balance.netBalance).toBe(275); + }); + + it('returns account balances for all known subscribers', () => { + s().issueCredit('alice', 100, 'promo'); + s().issueCredit('bob', 200, 'promo'); + const balances = s().getAccountBalances(); + expect(balances).toHaveLength(2); + expect(balances.map((b) => b.subscriber).sort()).toEqual(['alice', 'bob']); + }); }); diff --git a/app/stores/creditStore.ts b/app/stores/creditStore.ts index 4f877990..e9e51ef3 100644 --- a/app/stores/creditStore.ts +++ b/app/stores/creditStore.ts @@ -10,7 +10,14 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { asyncStorageAdapter } from '../../src/utils/storage'; -export type CreditTxKind = 'issue' | 'apply' | 'transfer_in' | 'transfer_out' | 'expire'; +export type CreditTxKind = + | 'issue' + | 'apply' + | 'transfer_in' + | 'transfer_out' + | 'expire' + | 'deposit' + | 'withdraw'; export type ExpirationPolicy = { kind: 'never' } | { kind: 'after_secs'; seconds: number }; @@ -46,6 +53,26 @@ export interface CreditApplied { balanceAfter: number; } +/** + * Consolidated account-balance summary for a subscriber, combining on-book + * credit (available/issued/expired/applied/transferred) with any prepayment + * wallet funds. Used for high-level balance display and reconciliation. + */ +export interface AccountBalance { + subscriber: string; + availableCredit: number; + totalIssued: number; + totalApplied: number; + totalExpired: number; + totalTransferredIn: number; + totalTransferredOut: number; + totalDeposited: number; + totalWithdrawn: number; + prepaymentBalance: number; + netBalance: number; + nextExpirationAt?: number; +} + const isExpired = (lot: CreditLot, now: number): boolean => lot.expiresAt !== undefined && lot.expiresAt <= now; @@ -57,6 +84,7 @@ const availableOf = (account: AccountCredit, now: number): number => interface CreditStoreState { accounts: Record; + wallets: Record; nextId: number; now: () => number; @@ -64,9 +92,23 @@ interface CreditStoreState { setExpirationPolicy: (subscriber: string, policy: ExpirationPolicy) => void; applyCredit: (subscriber: string, subscriptionId: string, amountDue: number) => CreditApplied; transferCredit: (from: string, to: string, amount: number, reason: string) => boolean; + depositCredit: (subscriber: string, amount: number, reason: string) => void; + withdrawCredit: (subscriber: string, amount: number, reason: string) => boolean; expireCredits: (subscriber: string) => number; getBalance: (subscriber: string) => number; getAccount: (subscriber: string) => AccountCredit; + getAccountBalance: (subscriber: string) => AccountBalance; + getAccountBalances: () => AccountBalance[]; +} + +/** Prepayment wallet tracked alongside credit accounts. */ +export interface CreditWallet { + id: string; + subscriber: string; + currency: string; + balance: number; + totalDeposited: number; + totalWithdrawn: number; } const blankAccount = (subscriber: string): AccountCredit => ({ @@ -141,6 +183,7 @@ export const useCreditStore = create()( return { accounts: {}, + wallets: {}, nextId: 0, now: () => Math.floor(Date.now() / 1000), @@ -217,6 +260,81 @@ export const useCreditStore = create()( getBalance: (subscriber) => availableOf(account(subscriber), get().now()), getAccount: (subscriber) => account(subscriber), + + depositCredit: (subscriber, amount, reason) => { + if (amount <= 0) return; + const now = get().now(); + const acc = cloneAccount(account(subscriber)); + realizeExpiry(acc, now); + acc.balance += amount; + acc.lots.push({ id: nextId(), remaining: amount, issuedAt: now }); + record(acc, 'deposit', amount, reason); + commit(acc); + }, + + withdrawCredit: (subscriber, amount, reason) => { + if (amount <= 0) return false; + const now = get().now(); + const acc = cloneAccount(account(subscriber)); + realizeExpiry(acc, now); + if (availableOf(acc, now) < amount) return false; + const moved = consume(acc, now, amount); + acc.balance -= moved; + record(acc, 'withdraw', -moved, reason); + commit(acc); + return true; + }, + + getAccountBalance: (subscriber): AccountBalance => { + const now = get().now(); + const acc = account(subscriber); + const availableCredit = availableOf(acc, now); + const totalApplied = acc.transactions + .filter((t) => t.kind === 'apply') + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + const totalExpired = acc.transactions + .filter((t) => t.kind === 'expire') + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + const totalTransferredIn = acc.transactions + .filter((t) => t.kind === 'transfer_in') + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + const totalTransferredOut = acc.transactions + .filter((t) => t.kind === 'transfer_out') + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + const totalIssued = acc.transactions + .filter((t) => t.kind === 'issue' || t.kind === 'deposit') + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + + const wallets = Object.values(get().wallets).filter( + (w) => w.subscriber === subscriber + ); + const totalDeposited = wallets.reduce((s, w) => s + w.totalDeposited, 0); + const totalWithdrawn = wallets.reduce((s, w) => s + w.totalWithdrawn, 0); + const prepaymentBalance = wallets.reduce((s, w) => s + w.balance, 0); + + const expiringLots = acc.lots + .filter((lot) => lot.remaining > 0 && lot.expiresAt !== undefined && lot.expiresAt > now) + .sort((a, b) => (a.expiresAt ?? 0) - (b.expiresAt ?? 0)); + + return { + subscriber, + availableCredit, + totalIssued, + totalApplied, + totalExpired, + totalTransferredIn, + totalTransferredOut, + totalDeposited, + totalWithdrawn, + prepaymentBalance, + netBalance: availableCredit + prepaymentBalance, + nextExpirationAt: expiringLots[0]?.expiresAt, + }; + }, + + getAccountBalances: () => + [...new Set([...Object.keys(get().accounts), ...Object.keys(get().wallets)])] + .map((sub) => get().getAccountBalance(sub)), }; }, { @@ -224,6 +342,7 @@ export const useCreditStore = create()( storage: createJSONStorage(() => asyncStorageAdapter), partialize: (state) => ({ accounts: state.accounts, + wallets: state.wallets, nextId: state.nextId, }), } diff --git a/chaos/__tests__/failure-injection.test.ts b/chaos/__tests__/failure-injection.test.ts new file mode 100644 index 00000000..f339fab4 --- /dev/null +++ b/chaos/__tests__/failure-injection.test.ts @@ -0,0 +1,28 @@ +import { + injectFailure, + runFailureInjectionExperiment, +} from '../experiments/failure-injection'; + +describe('Failure Injection Experiment', () => { + it('injects failure into marked steps', async () => { + const result = await injectFailure([ + { name: 'charge', inject: true }, + { name: 'notify', inject: false }, + ]); + expect(result.ok).toBe(false); + expect(result.failedSteps).toEqual(['charge']); + }); + + it('succeeds when no steps are marked', async () => { + const result = await injectFailure([{ name: 'charge', inject: false }]); + expect(result.ok).toBe(true); + expect(result.failedSteps).toEqual([]); + }); + + it('runFailureInjectionExperiment passes', async () => { + const result = await runFailureInjectionExperiment(); + expect(result.experiment).toBe('failure-injection'); + expect(result.passed).toBe(true); + expect(result.recovery).toBe('failure-contained-and-recovered'); + }); +}); diff --git a/chaos/__tests__/network-partition.test.ts b/chaos/__tests__/network-partition.test.ts new file mode 100644 index 00000000..57c9bcca --- /dev/null +++ b/chaos/__tests__/network-partition.test.ts @@ -0,0 +1,32 @@ +import { + simulateNetworkPartition, + runNetworkPartitionExperiment, + PartitionNode, +} from '../experiments/network-partition'; + +describe('Network Partition Experiment', () => { + it('reports unreachable nodes during partition', async () => { + const nodes: PartitionNode[] = [ + { name: 'a', reachable: true, value: 'ok' }, + { name: 'b', reachable: false, value: 'ok' }, + ]; + const result = await simulateNetworkPartition(nodes); + expect(result.find((r) => r.name === 'b')?.ok).toBe(false); + expect(result.find((r) => r.name === 'a')?.ok).toBe(true); + }); + + it('recovers once partition heals', async () => { + const nodes: PartitionNode[] = [ + { name: 'a', reachable: false, value: 'ok' }, + ]; + const recovered = await simulateNetworkPartition(nodes, true); + expect(recovered[0].ok).toBe(true); + }); + + it('runNetworkPartitionExperiment passes', async () => { + const result = await runNetworkPartitionExperiment(); + expect(result.experiment).toBe('network-partition'); + expect(result.passed).toBe(true); + expect(result.recovery).toBe('partition-healed'); + }); +}); diff --git a/chaos/__tests__/service-degradation.test.ts b/chaos/__tests__/service-degradation.test.ts new file mode 100644 index 00000000..e78a63e2 --- /dev/null +++ b/chaos/__tests__/service-degradation.test.ts @@ -0,0 +1,30 @@ +import { + isServiceHealthy, + simulateServiceDegradation, + runServiceDegradationExperiment, +} from '../experiments/service-degradation'; + +describe('Service Degradation Experiment', () => { + it('flags a service healthy below threshold', () => { + expect(isServiceHealthy({ name: 's', errorRate: 0.02 })).toBe(true); + expect(isServiceHealthy({ name: 's', errorRate: 0.9 })).toBe(false); + }); + + it('opens circuit and engages fallback when degraded', async () => { + const state = await simulateServiceDegradation(false); + expect(state.circuitOpen).toBe(true); + expect(state.fallback).toBe(true); + }); + + it('closes circuit when healthy', async () => { + const state = await simulateServiceDegradation(true); + expect(state.circuitOpen).toBe(false); + }); + + it('runServiceDegradationExperiment passes', async () => { + const result = await runServiceDegradationExperiment(); + expect(result.experiment).toBe('service-degradation'); + expect(result.passed).toBe(true); + expect(result.recovery).toBe('circuit-recovered'); + }); +}); diff --git a/chaos/experiments/failure-injection.ts b/chaos/experiments/failure-injection.ts new file mode 100644 index 00000000..8f7cde9d --- /dev/null +++ b/chaos/experiments/failure-injection.ts @@ -0,0 +1,65 @@ +/** + * failure-injection.ts — Failure injection chaos experiment. + * + * Injects a deterministic failure into a request pipeline and verifies that: + * 1. the failure is contained (an error is returned, not a crash/panic), and + * 2. the pipeline recovers on the next attempt (retry succeeds). + */ + +import type { ChaosResult } from './network-partition'; + +/** A pipeline step that can be made to fail. */ +export interface PipelineStep { + name: string; + /** When true, this step raises a contained failure. */ + inject: boolean; +} + +/** + * Injects failures at the marked steps. Steps that are not failed produce an + * "ok"; failed steps produce an error. The overall pipeline returns `ok: false` + * if any step failed, but never throws (failure is contained). + */ +export async function injectFailure(steps: PipelineStep[]): Promise<{ + ok: boolean; + failedSteps: string[]; +}> { + // Simulate processing latency. + await new Promise((resolve) => setTimeout(resolve, 5)); + + const failedSteps = steps.filter((s) => s.inject).map((s) => s.name); + return { ok: failedSteps.length === 0, failedSteps }; +} + +/** + * Runs the failure-injection chaos experiment and verifies containment + + * recovery. + */ +export async function runFailureInjectionExperiment(): Promise { + const start = Date.now(); + + const steps: PipelineStep[] = [ + { name: 'authenticate', inject: false }, + { name: 'charge', inject: true }, + { name: 'notify', inject: false }, + ]; + + const injected = await injectFailure(steps); + const contained = injected.failedSteps.length === 1 && injected.failedSteps[0] === 'charge'; + + // After the failure is removed, the pipeline succeeds again. + const recovered = await injectFailure(steps.map((s) => ({ ...s, inject: false }))); + const recoveredOk = recovered.ok; + + const passed = contained && recoveredOk; + + return { + experiment: 'failure-injection', + passed, + duration: Date.now() - start, + recovery: passed ? 'failure-contained-and-recovered' : undefined, + error: passed + ? undefined + : `contained=${contained}, recovered=${recoveredOk}, failed=${JSON.stringify(injected.failedSteps)}`, + }; +} diff --git a/chaos/experiments/network-partition.ts b/chaos/experiments/network-partition.ts new file mode 100644 index 00000000..5176d8c7 --- /dev/null +++ b/chaos/experiments/network-partition.ts @@ -0,0 +1,80 @@ +/** + * network-partition.ts — Network partition chaos experiment. + * + * Simulates a network partition between services and verifies that the system + * degrades gracefully (timeouts / circuit breakers) and recovers when the + * partition heals. + * + * This module is also the canonical home of the shared `ChaosResult` type used + * across every experiment. + */ + +export interface ChaosResult { + experiment: string; + passed: boolean; + duration: number; + recovery?: string; + error?: string; +} + +/** Simulated service node participating in the partition. */ +export interface PartitionNode { + name: string; + /** Whether the node is reachable from the orchestrator. */ + reachable: boolean; + /** Payload the node would return when reachable. */ + value: T; +} + +/** + * Simulates a network partition by making the nodes listed in `partitioned` + * unreachable. Returns a result set of `{{ name, ok, value }}` per node. + */ +export async function simulateNetworkPartition( + nodes: PartitionNode[], + partitionHealed = false +): Promise<{ name: string; ok: boolean; value: T | null; error?: string }[]> { + // Simulate a transient packet-loss window before resolving. + await new Promise((resolve) => setTimeout(resolve, 5)); + + return nodes.map((node) => { + const isPartitioned = !partitionHealed ? !node.reachable : node.reachable; + if (!isPartitioned) { + return { name: node.name, ok: false, value: null, error: `${node.name} unreachable` }; + } + return { name: node.name, ok: true, value: node.value }; + }); +} + +/** + * Runs the network-partition chaos experiment: a wire dependency is partitioned + * and must fail closed (error), then the partition heals and it must recover. + */ +export async function runNetworkPartitionExperiment(): Promise { + const start = Date.now(); + + const nodes: PartitionNode[] = [ + { name: 'api-gateway', reachable: true, value: 'ok' }, + { name: 'billing-worker', reachable: false, value: 'ok' }, + { name: 'notification-service', reachable: true, value: 'ok' }, + ]; + + const duringPartition = await simulateNetworkPartition(nodes); + const degraded = duringPartition.filter((r) => !r.ok); + const degradesGracefully = degraded.length === 1 && degraded[0].name === 'billing-worker'; + + const afterHeal = await simulateNetworkPartition(nodes, true); + const recovered = afterHeal.every((r) => r.ok); + + const passed = degradesGracefully && recovered; + + return { + experiment: 'network-partition', + passed, + duration: Date.now() - start, + recovery: passed ? 'partition-healed' : undefined, + error: passed + ? undefined + : `degraded=${degradesGracefully}, recovered=${recovered}`, + }; +} diff --git a/chaos/experiments/service-degradation.ts b/chaos/experiments/service-degradation.ts new file mode 100644 index 00000000..dc1af9df --- /dev/null +++ b/chaos/experiments/service-degradation.ts @@ -0,0 +1,72 @@ +/** + * service-degradation.ts — Service degradation chaos experiment. + * + * Simulates a service that starts failing (elevated error rate) and verifies + * that the circuit breaker opens and the fallback path engages, then that the + * circuit closes again once the service recovers. + */ + +import type { ChaosResult } from './network-partition'; + +/** Health of a service over a single probe window. */ +export interface ServiceHealth { + name: string; + /** Fraction of requests failing, 0..1. */ + errorRate: number; +} + +/** + * Decides whether the service is "healthy" (closed circuit) given an error-rate + * threshold. A degraded service trips the circuit breaker. + */ +export function isServiceHealthy(health: ServiceHealth, threshold = 0.5): boolean { + return health.errorRate < threshold; +} + +/** + * Simulates the circuit-breaker lifecycle for a service: if the service is + * degraded, the breaker opens and the caller activates the fallback; when the + * service recovers the breaker closes again. + */ +export async function simulateServiceDegradation( + healthy: boolean +): Promise<{ circuitOpen: boolean; fallback: boolean }> { + // Simulate probe latency. + await new Promise((resolve) => setTimeout(resolve, 5)); + + if (healthy) { + return { circuitOpen: false, fallback: false }; + } + + // Degraded service: circuit opens and fallback engages. + return { circuitOpen: true, fallback: true }; +} + +/** + * Runs the service-degradation chaos experiment. + */ +export async function runServiceDegradationExperiment(): Promise { + const start = Date.now(); + + const baseline: ServiceHealth = { name: 'billing-gateway', errorRate: 0.02 }; + const degraded: ServiceHealth = { name: 'billing-gateway', errorRate: 0.9 }; + + const baselineHealthy = isServiceHealthy(baseline); + const degradedHealthy = !isServiceHealthy(degraded); + + const degradedState = await simulateServiceDegradation(degradedHealthy); + const recoveredState = await simulateServiceDegradation(true); + + const passed = + baselineHealthy && !degradedHealthy && degradedState.circuitOpen && !recoveredState.circuitOpen; + + return { + experiment: 'service-degradation', + passed, + duration: Date.now() - start, + recovery: passed ? 'circuit-recovered' : undefined, + error: passed + ? undefined + : `degradedState=${JSON.stringify(degradedState)}, recoveredState=${JSON.stringify(recoveredState)}`, + }; +} diff --git a/contracts/credit/src/lib.rs b/contracts/credit/src/lib.rs index 34da4ed4..62e5b114 100644 --- a/contracts/credit/src/lib.rs +++ b/contracts/credit/src/lib.rs @@ -160,6 +160,19 @@ pub struct PrepaymentSnapshot { pub transaction_id: u64, } +/// Consolidated balance summary for a subscriber combining on-book credit and +/// prepayment-wallet funds. Returned by [`SubTrackrCredit::get_account_balance_summary`]. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AccountBalanceSummary { + pub subscriber: Address, + pub credit_balance: i128, + pub wallet_balance: i128, + pub net_balance: i128, + pub expiration_policy: ExpirationPolicy, + pub next_expiration_at: Option, +} + #[contracttype] #[derive(Clone)] enum DataKey { @@ -477,6 +490,55 @@ impl SubTrackrCredit { .unwrap_or(0) } + /// Returns a consolidated account-balance summary for a subscriber, + /// combining on-book credit with prepayment-wallet funds. Read-only view. + pub fn get_account_balance_summary(env: Env, subscriber: Address) -> AccountBalanceSummary { + let now = env.ledger().timestamp(); + let account = Self::account(&env, &subscriber); + let credit_balance = Self::available(now, &account); + + // Sum prepayment wallets owned by the subscriber. + let mut wallet_balance: i128 = 0; + let mut i: u64 = 0; + while i < 1024 { + let key = DataKey::Wallet(i); + if !env.storage().persistent().has(&key) { + break; + } + if let Some(wallet): Option = env.storage().persistent().get(&key) { + if wallet.subscriber == subscriber { + wallet_balance += wallet.balance; + } + } + i += 1; + } + + // First non-expired lot expiry (earliest future expiry). + let mut next_expiration_at: Option = None; + let mut j: u32 = 0; + while j < account.lots.len() { + let lot = account.lots.get(j).unwrap(); + if lot.remaining > 0 && !Self::is_expired(now, &lot) { + if let Some(exp) = lot.expires_at { + next_expiration_at = Some(match next_expiration_at { + Some(cur) if cur < exp => cur, + _ => exp, + }); + } + } + j += 1; + } + + AccountBalanceSummary { + subscriber: subscriber.clone(), + credit_balance, + wallet_balance, + net_balance: credit_balance + wallet_balance, + expiration_policy: account.expiration_policy, + next_expiration_at, + } + } + /// Batch expiry processor for cron keepers. Iterates all stored wallets, /// applies credit lot expiry, and returns total expired amounts. Caller /// must be admin. diff --git a/contracts/credit/src/test.rs b/contracts/credit/src/test.rs index 09656353..b8cd5c0d 100644 --- a/contracts/credit/src/test.rs +++ b/contracts/credit/src/test.rs @@ -127,3 +127,33 @@ fn expiration_policy_drives_default_expiry() { set_time(&env, 1_200); // > 1_000 + 100 assert_eq!(client.get_credit_balance(&sub), 0); } + +#[test] +fn returns_consolidated_account_balance_summary() { + let (env, client, _admin) = setup(); + let sub = Address::generate(&env); + let reason = String::from_str(&env, "promo"); + let currency = String::from_str(&env, "USD"); + + set_time(&env, 1_000); + client.issue_credit(&sub, &250, &reason, &Some(2_000)); + + // Create a prepayment wallet and deposit funds. + let wallet_id = client.create_wallet(&sub, &1, ¤cy); + let deposit = client.deposit(&sub, &wallet_id, &75); + assert_eq!(deposit.balance, 75); + + let summary = client.get_account_balance_summary(&sub); + assert_eq!(summary.credit_balance, 250); + assert_eq!(summary.wallet_balance, 75); + assert_eq!(summary.net_balance, 325); + assert_eq!(summary.next_expiration_at, Some(2_000)); + + // Expiry removes credit from the summary but keeps wallet balance. + set_time(&env, 2_500); + client.expire_credits(&sub); + let after = client.get_account_balance_summary(&sub); + assert_eq!(after.credit_balance, 0); + assert_eq!(after.wallet_balance, 75); + assert_eq!(after.net_balance, 75); +} diff --git a/developer-portal/components/ApiPlayground.tsx b/developer-portal/components/ApiPlayground.tsx index 7de54249..c32d927b 100644 --- a/developer-portal/components/ApiPlayground.tsx +++ b/developer-portal/components/ApiPlayground.tsx @@ -11,7 +11,7 @@ import { interface Endpoint { id: string; - method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; path: string; name: string; hasBody?: boolean; @@ -39,7 +39,46 @@ const ENDPOINTS: Endpoint[] = [ 2 ), }, + { id: 'get_sub', method: 'GET', path: '/v1/subscriptions/:id', name: 'Get Subscription' }, + { + id: 'cancel_sub', + method: 'POST', + path: '/v1/subscriptions/:id/cancel', + name: 'Cancel Subscription', + hasBody: true, + defaultBody: JSON.stringify({ reason: 'user_requested', atPeriodEnd: true }, null, 2), + }, + { id: 'list_plans', method: 'GET', path: '/v1/plans', name: 'List Plans' }, { id: 'list_pay', method: 'GET', path: '/v1/payments', name: 'List Payments' }, + { + id: 'list_invoices', + method: 'GET', + path: '/v1/invoices', + name: 'List Invoices', + }, + { id: 'list_webhooks', method: 'GET', path: '/v1/webhooks', name: 'List Webhooks' }, + { + id: 'create_webhook', + method: 'POST', + path: '/v1/webhooks', + name: 'Create Webhook', + hasBody: true, + defaultBody: JSON.stringify( + { + url: 'https://your-app.com/webhook', + events: ['subscription.created', 'payment.completed'], + }, + null, + 2 + ), + }, + { + id: 'usage_analytics', + method: 'GET', + path: '/v1/analytics/usage', + name: 'Usage Analytics', + }, + { id: 'list_themes', method: 'GET', path: '/v1/themes', name: 'List Themes' }, ]; const LANGUAGES = ['cURL', 'JavaScript', 'Python', 'Go']; @@ -131,8 +170,29 @@ func main() { if (selectedEndpoint.id === 'list_sub') { mockResponse = { success: true, - data: [{ id: 'sub_123', name: 'Netflix', price: 15.99, status: 'active' }], - pagination: { page: 1, limit: 20, total: 1 }, + data: [ + { + id: 'sub_123', + name: 'Netflix', + category: 'streaming', + price: 15.99, + currency: 'USD', + billingCycle: 'monthly', + status: 'active', + nextBillingDate: '2026-09-01T00:00:00Z', + }, + { + id: 'sub_124', + name: 'Spotify', + category: 'music', + price: 9.99, + currency: 'USD', + billingCycle: 'monthly', + status: 'active', + nextBillingDate: '2026-09-04T00:00:00Z', + }, + ], + pagination: { page: 1, limit: 20, total: 2, hasNext: false }, }; } else if (selectedEndpoint.id === 'create_sub') { try { @@ -154,6 +214,120 @@ func main() { }; mockStatus = 400; } + } else if (selectedEndpoint.id === 'get_sub') { + mockResponse = { + success: true, + data: { + id: 'sub_123', + name: 'Netflix', + category: 'streaming', + price: 15.99, + currency: 'USD', + billingCycle: 'monthly', + status: 'active', + nextBillingDate: '2026-09-01T00:00:00Z', + }, + }; + } else if (selectedEndpoint.id === 'cancel_sub') { + mockResponse = { + success: true, + data: { + id: 'sub_123', + status: 'cancelled', + cancelAtPeriodEnd: true, + effectiveAt: '2026-09-30T00:00:00Z', + }, + }; + } else if (selectedEndpoint.id === 'list_plans') { + mockResponse = { + success: true, + data: [ + { id: 'plan_free', name: 'Free', price: 0, currency: 'USD', interval: 'monthly' }, + { id: 'plan_pro', name: 'Pro', price: 19, currency: 'USD', interval: 'monthly' }, + { id: 'plan_ent', name: 'Enterprise', price: 99, currency: 'USD', interval: 'monthly' }, + ], + }; + } else if (selectedEndpoint.id === 'list_pay') { + mockResponse = { + success: true, + data: [ + { + id: 'pay_1', + subscriptionId: 'sub_123', + amount: 15.99, + currency: 'USD', + status: 'succeeded', + createdAt: '2026-08-01T00:00:00Z', + }, + ], + pagination: { page: 1, limit: 20, total: 1, hasNext: false }, + }; + } else if (selectedEndpoint.id === 'list_invoices') { + mockResponse = { + success: true, + data: [ + { + id: 'inv_1', + subscriptionId: 'sub_123', + total: 15.99, + currency: 'USD', + status: 'paid', + dueAt: '2026-08-01T00:00:00Z', + }, + { + id: 'inv_2', + subscriptionId: 'sub_124', + total: 9.99, + currency: 'USD', + status: 'open', + dueAt: '2026-09-01T00:00:00Z', + }, + ], + }; + } else if (selectedEndpoint.id === 'list_webhooks') { + mockResponse = { + success: true, + data: [ + { + id: 'wh_1', + url: 'https://your-app.com/webhook', + events: ['subscription.created'], + status: 'enabled', + }, + ], + }; + } else if (selectedEndpoint.id === 'create_webhook') { + try { + const body = JSON.parse(requestBody); + mockResponse = { + success: true, + data: { id: 'wh_new', ...body, status: 'enabled', createdAt: new Date().toISOString() }, + }; + mockStatus = 201; + } catch (e) { + mockResponse = { + success: false, + error: { code: 'INVALID_REQUEST', message: 'Invalid JSON body' }, + }; + mockStatus = 400; + } + } else if (selectedEndpoint.id === 'usage_analytics') { + mockResponse = { + success: true, + data: { + requests: { total: 1234, window: 'daily' }, + credits: { used: 340, remaining: 660 }, + rateLimit: { short: 78, long: 340 }, + }, + }; + } else if (selectedEndpoint.id === 'list_themes') { + mockResponse = { + success: true, + data: [ + { id: 'theme_1', name: 'Midnight', primaryColor: '#6C5CE7', status: 'active' }, + { id: 'theme_2', name: 'Ocean', primaryColor: '#0984E3', status: 'draft' }, + ], + }; } else { mockResponse = { success: true, data: [] }; } diff --git a/developer-portal/docs/openapi.json b/developer-portal/docs/openapi.json index 3ac5d5cc..b121ff71 100644 --- a/developer-portal/docs/openapi.json +++ b/developer-portal/docs/openapi.json @@ -3,10 +3,13 @@ "info": { "title": "SubTrackr API", "version": "1.0.0", - "description": "API specification for SubTrackr subscription management, notifications, and payments.", + "description": "API specification for SubTrackr subscription management, billing, webhooks, and payments.\n\nBase paths are versioned under `/v1` and authenticated with a Bearer token (`sk_test_...` for sandbox, `sk_live_...` for production).", "contact": { "name": "SubTrackr Support", "email": "support@subtrackr.io" + }, + "license": { + "name": "MIT" } }, "servers": [ @@ -14,16 +17,31 @@ "url": "https://api.subtrackr.io/v1", "description": "Production Server" }, + { + "url": "https://sandbox.api.subtrackr.io/v1", + "description": "Sandbox Server" + }, { "url": "http://localhost:3000/v1", "description": "Local Development Server" } ], + "tags": [ + { "name": "Auth", "description": "Authentication" }, + { "name": "Subscriptions", "description": "Manage subscriptions" }, + { "name": "Plans", "description": "Plans and pricing" }, + { "name": "Payments", "description": "Payment records" }, + { "name": "Invoices", "description": "Invoices and billing" }, + { "name": "Webhooks", "description": "Webhook configuration and delivery" }, + { "name": "Analytics", "description": "Usage and subscription analytics" }, + { "name": "Themes", "description": "Theme management for merchants" } + ], "paths": { "/auth/login": { "post": { "summary": "Authenticate user", "operationId": "loginUser", + "tags": ["Auth"], "requestBody": { "required": true, "content": { @@ -34,7 +52,8 @@ "email": { "type": "string", "format": "email" }, "password": { "type": "string" } }, - "required": ["email", "password"] + "required": ["email", "password"], + "example": { "email": "dev@example.com", "password": "secret" } } } } @@ -53,7 +72,8 @@ } } } - } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } } } }, @@ -61,20 +81,328 @@ "get": { "summary": "List subscriptions", "operationId": "listSubscriptions", + "tags": ["Subscriptions"], "security": [{ "BearerAuth": [] }], + "parameters": [ + { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "cancelled", "paused"] } }, + { "name": "category", "in": "query", "schema": { "type": "string" } }, + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } } + ], "responses": { "200": { - "description": "List of active user subscriptions" + "description": "List of subscriptions", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SubscriptionList" } + } + } } } }, "post": { "summary": "Create subscription", "operationId": "createSubscription", + "tags": ["Subscriptions"], "security": [{ "BearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SubscriptionInput" } + } + } + }, "responses": { "201": { - "description": "Subscription created" + "description": "Subscription created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Subscription" } + } + } + }, + "400": { "description": "Invalid request body" } + } + } + }, + "/subscriptions/{id}": { + "get": { + "summary": "Get subscription", + "operationId": "getSubscription", + "tags": ["Subscriptions"], + "security": [{ "BearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { + "description": "Subscription", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Subscription" } + } + } + }, + "404": { "description": "Subscription not found" } + } + }, + "put": { + "summary": "Update subscription", + "operationId": "updateSubscription", + "tags": ["Subscriptions"], + "security": [{ "BearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SubscriptionInput" } + } + } + }, + "responses": { + "200": { + "description": "Updated subscription", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Subscription" } + } + } + } + } + }, + "delete": { + "summary": "Delete subscription", + "operationId": "deleteSubscription", + "tags": ["Subscriptions"], + "security": [{ "BearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "204": { "description": "Subscription deleted" } + } + } + }, + "/subscriptions/{id}/cancel": { + "post": { + "summary": "Cancel subscription", + "operationId": "cancelSubscription", + "tags": ["Subscriptions"], + "security": [{ "BearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "atPeriodEnd": { "type": "boolean", "default": true } + } + } + } + } + }, + "responses": { + "200": { + "description": "Subscription cancelled", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Subscription" } + } + } + } + } + } + }, + "/plans": { + "get": { + "summary": "List plans", + "operationId": "listPlans", + "tags": ["Plans"], + "security": [{ "ApiKeyAuth": [] }], + "responses": { + "200": { + "description": "List of plans", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Plan" } + } + } + } + } + } + } + } + } + }, + "/payments": { + "get": { + "summary": "List payments", + "operationId": "listPayments", + "tags": ["Payments"], + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { + "description": "List of payments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Payment" } + }, + "pagination": { "$ref": "#/components/schemas/Pagination" } + } + } + } + } + } + } + } + }, + "/invoices": { + "get": { + "summary": "List invoices", + "operationId": "listInvoices", + "tags": ["Invoices"], + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { + "description": "List of invoices", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Invoice" } + } + } + } + } + } + } + } + } + }, + "/webhooks": { + "get": { + "summary": "List webhooks", + "operationId": "listWebhooks", + "tags": ["Webhooks"], + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { + "description": "List of webhook endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } + } + } + } + } + } + } + }, + "post": { + "summary": "Create webhook", + "operationId": "createWebhook", + "tags": ["Webhooks"], + "security": [{ "BearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WebhookInput" } + } + } + }, + "responses": { + "201": { + "description": "Webhook created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { "$ref": "#/components/schemas/Webhook" } + } + } + } + } + } + } + } + }, + "/analytics/usage": { + "get": { + "summary": "Get usage analytics", + "operationId": "getUsageAnalytics", + "tags": ["Analytics"], + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { + "description": "Usage analytics", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "object", + "properties": { + "requests": { "type": "object" }, + "credits": { "type": "object" }, + "rateLimit": { "type": "object" } + } + } + } + } + } + } + } + } + } + }, + "/themes": { + "get": { + "summary": "List themes", + "operationId": "listThemes", + "tags": ["Themes"], + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { + "description": "List of themes", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Theme" } + } + } + } + } + } } } } @@ -85,13 +413,140 @@ "BearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT" + "bearerFormat": "JWT", + "description": "Bearer token (sk_test_... or sk_live_...)" }, "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }, + "schemas": { + "Subscription": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "category": { "type": "string" }, + "price": { "type": "number" }, + "currency": { "type": "string" }, + "billingCycle": { "type": "string", "enum": ["monthly", "yearly"] }, + "status": { "type": "string", "enum": ["active", "paused", "cancelled"] }, + "nextBillingDate": { "type": "string", "format": "date-time" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "SubscriptionInput": { + "type": "object", + "required": ["name", "price", "currency", "billingCycle"], + "properties": { + "name": { "type": "string" }, + "category": { "type": "string" }, + "price": { "type": "number", "exclusiveMinimum": 0 }, + "currency": { "type": "string" }, + "billingCycle": { "type": "string", "enum": ["monthly", "yearly"] }, + "startDate": { "type": "string", "format": "date-time" } + } + }, + "SubscriptionList": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { "type": "array", "items": { "$ref": "#/components/schemas/Subscription" } }, + "pagination": { "$ref": "#/components/schemas/Pagination" } + } + }, + "Pagination": { + "type": "object", + "properties": { + "page": { "type": "integer" }, + "limit": { "type": "integer" }, + "total": { "type": "integer" }, + "hasNext": { "type": "boolean" } + } + }, + "Plan": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "price": { "type": "number" }, + "currency": { "type": "string" }, + "interval": { "type": "string", "enum": ["monthly", "yearly"] } + } + }, + "Payment": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "subscriptionId": { "type": "string" }, + "amount": { "type": "number" }, + "currency": { "type": "string" }, + "status": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "Invoice": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "subscriptionId": { "type": "string" }, + "total": { "type": "number" }, + "currency": { "type": "string" }, + "status": { "type": "string", "enum": ["draft", "open", "paid", "partial"] }, + "dueAt": { "type": "string", "format": "date-time" } + } + }, + "Webhook": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "events": { "type": "array", "items": { "type": "string" } }, + "status": { "type": "string", "enum": ["enabled", "disabled"] }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "WebhookInput": { + "type": "object", + "required": ["url", "events"], + "properties": { + "url": { "type": "string", "format": "uri" }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": ["subscription.created", "subscription.updated", "subscription.cancelled", "payment.completed", "payment.failed", "invoice.generated"] + } + } + } + }, + "Theme": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "primaryColor": { "type": "string" }, + "status": { "type": "string", "enum": ["active", "draft"] } + } + } + }, + "responses": { + "Unauthorized": { + "description": "Missing or invalid authorization", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "const": false }, + "error": { "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } } } + } + } + } + } + } } } } diff --git a/docs/DISASTER_RECOVERY_RUNBOOK.md b/docs/DISASTER_RECOVERY_RUNBOOK.md index 7c4ae200..218c02be 100644 --- a/docs/DISASTER_RECOVERY_RUNBOOK.md +++ b/docs/DISASTER_RECOVERY_RUNBOOK.md @@ -203,16 +203,28 @@ console.log('Drill passed:', result.passed, 'RTO compliant:', result.rtoComplian ### CI Integration -Add to `package.json`: +The DR routine is automated by the `.github/workflows/disaster-recovery.yml` GitHub Actions +workflow. On every run it: + +1. Executes a full DR drill (`node scripts/dr-test.js` — backup → verify → restore + health). +2. Creates a DR backup with a pre-check (`./scripts/dr-backup.sh --pre-check`). +3. Captures DR status as JSON (`./scripts/dr-status.sh --json`) and uploads it as a build artefact. + +Schedule: +- **Automated daily** at `03:17 UTC` (cron `17 3 * * *`) — backups/status on a routine cadence with no human action required. +- **Manual** via `workflow_dispatch` for on-demand runs. + +Add the local (non-CI) equivalents to `package.json` for ad-hoc checks: ```json "dr:drill": "jest backend/dr/__tests__/DisasterRecoveryService.test.ts --no-coverage", +"dr:backup": "bash scripts/dr-backup.sh --pre-check", "chaos": "jest chaos/__tests__/ --no-coverage" ``` -Recommended schedule: +Recommended full schedule: - **CI per PR**: Chaos experiments (network partition, service degradation, failure injection, geo partition, backup consistency) -- **Daily**: DR drill +- **Daily (automated)**: DR drill + backup + status via `disaster-recovery.yml` - **Pre-release**: Full DR drill + chaos suite --- diff --git a/docs/ZUSTAND_STATE_MIGRATION.md b/docs/ZUSTAND_STATE_MIGRATION.md new file mode 100644 index 00000000..8a1da7c0 --- /dev/null +++ b/docs/ZUSTAND_STATE_MIGRATION.md @@ -0,0 +1,74 @@ +# Zustand State Migration (Slices Pattern) + +The app state was previously spread across several independent singleton stores +(`authStore`, `userStore`, `settingsStore`, `networkStore`, `transactionStore`), each +persisting its own key. This made cross-store coordination awkward and state-fetching +scattered. + +## The new pattern + +A single root store — `useAppStore` — is composed from **domain slices** living in +`src/store/slices/`: + +| Slice file | Domain | +| ---------------------- | ------------------------------------------ | +| `authSlice.ts` | Auth token, `isAuthenticated` | +| `userSlice.ts` | User profile, subscription tier, consent | +| `settingsSlice.ts` | Currency, notifications, exchange rates | +| `networkSlice.ts` | Active network, provider, health weights | +| `transactionSlice.ts` | Recent transactions | + +Each slice is defined independently as a `SliceCreator` (see +`src/store/slices/state.ts` for `AppState` and the `SliceCreator` helper) and is +composed in `src/store/slices/index.ts`: + +```ts +export const useAppStore = create()( + persist( + (set, get) => ({ + ...createAuthSlice(set, get), + ...createUserSlice(set, get), + ...createSettingsSlice(set, get), + ...createNetworkSlice(set, get), + ...createTransactionSlice(set, get), + }), + { name: 'subtrackr-app-store', version: 1, storage, partialize } + ) +); +``` + +## Consuming state + +Get whole slices or individual fields with a selector: + +```ts +const token = useAppStore(selectAuthToken); +const { user } = useAppStore(); +``` + +Prefer the exported cross-slice selectors (`selectAuthToken`, `selectUser`, …) for +fine-grained subscriptions that avoid re-renders on unrelated state changes. + +## Backwards compatibility + +The legacy stores (`src/store/authStore.ts`, `userStore.ts`, `settingsStore.ts`, +`networkStore.ts`, `transactionStore.ts`) now **alias** `useAppStore` and re-export the +same hooks/selectors they always did. Existing consumers that call `.getState()` / +`.setState()` or destructure a store hook continue to work unchanged — no consumer edits +are required. + +## Persistence + +The combined store persists a single JSON blob under the key `subtrackr-app-store` +(version 1). `partialize` whitelists the persisted fields so ephemeral state such as +`isLoading`/`error` is excluded — matching the behavior of the original singleton stores. +See the migration note in the issue for why the single-key change is acceptable. + +## Adding a new slice + +1. Create `src/store/slices/Slice.ts` exporting `createSlice(set, get)` + and a `Slice` interface. +2. Add its state fields to `AppState` in `state.ts`. +3. Spread it into the store in `src/store/slices/index.ts`. +4. (Optional) add it to `partialize` if it should persist. +5. Add tests under `src/store/__tests__/` (see `slices.test.ts` for reference). diff --git a/src/store/__tests__/slices.test.ts b/src/store/__tests__/slices.test.ts new file mode 100644 index 00000000..012b1cdc --- /dev/null +++ b/src/store/__tests__/slices.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { + useAppStore, + selectAuthToken, + selectIsAuthenticated, + selectUserId, + selectPreferredCurrency, + selectCurrentNetwork, + selectTransactions, +} from '../slices'; +import { SubscriptionTier } from '../../types/subscription'; +import { TransactionStatus } from '../../types/transaction'; +import type { UserProfile } from '../../types/api'; + +/** + * The slices test verifies that: + * - each slice is composed into the combined useAppStore; + * - cross-slice state is independently accessible and settable; + * - actions from different slices do not interfere (modularity); + * - per-slice selectors return the expected fields. + */ +describe('zustand slices pattern (useAppStore)', () => { + beforeEach(() => { + useAppStore.setState({ + token: null, + userId: null, + isAuthenticated: false, + isLoading: false, + error: null, + user: null, + subscriptionTier: SubscriptionTier.FREE, + consent: { + analytics: false, + marketing: false, + notifications: true, + hasAcceptedPolicy: false, + }, + preferredCurrency: 'USD', + notificationsEnabled: true, + exchangeRates: null, + healthScoreWeights: null, + currentNetwork: null, + availableNetworks: [], + transactions: [], + }); + }); + + it('composes auth slice state + actions', () => { + useAppStore.getState().signIn('tok-123', { id: 'u1', email: 'a@b.c' }); + + expect(useAppStore.getState().token).toBe('tok-123'); + expect(useAppStore.getState().userId).toBe('u1'); + expect(useAppStore.getState().isAuthenticated).toBe(true); + expect(selectAuthToken(useAppStore.getState())).toBe('tok-123'); + expect(selectIsAuthenticated(useAppStore.getState())).toBe(true); + expect(selectUserId(useAppStore.getState())).toBe('u1'); + }); + + it('signs out and clears auth state', () => { + useAppStore.getState().signIn('tok', { id: 'u1', email: 'a@b.c' }); + useAppStore.getState().signOut(); + + expect(useAppStore.getState().isAuthenticated).toBe(false); + expect(useAppStore.getState().token).toBeNull(); + expect(selectIsAuthenticated(useAppStore.getState())).toBe(false); + }); + + it('maintains the user slice independently from auth', () => { + useAppStore.getState().setUser({ + id: 'u1', + email: 'a@b.c', + name: 'Alice', + } as UserProfile); + + expect(useAppStore.getState().user).not.toBeNull(); + expect(useAppStore.getState().user?.id).toBe('u1'); + }); + + it('updates subscription tier via setSubscriptionTier', () => { + useAppStore.getState().setSubscriptionTier(SubscriptionTier.PRO); + expect(useAppStore.getState().subscriptionTier).toBe(SubscriptionTier.PRO); + }); + + it('settings slice persists preferred currency and triggers actions', () => { + useAppStore.getState().setPreferredCurrency('EUR'); + + expect(useAppStore.getState().preferredCurrency).toBe('EUR'); + expect(selectPreferredCurrency(useAppStore.getState())).toBe('EUR'); + }); + + it('network slice can be set', () => { + useAppStore.setState({ currentNetwork: { id: 'mainnet', name: 'Mainnet' } as never }); + expect(selectCurrentNetwork(useAppStore.getState())).not.toBeNull(); + }); + + it('transaction slice adds and queries transactions', () => { + const tx = useAppStore.getState().addTransaction({ + subscriptionId: 'sub-1', + amount: 10, + currency: 'USD', + status: TransactionStatus.SUCCESS, + } as never); + + expect(useAppStore.getState().transactions).toHaveLength(1); + expect(selectTransactions(useAppStore.getState())).toHaveLength(1); + expect(useAppStore.getState().getBySubscription('sub-1')).toHaveLength(1); + expect(tx.id).toBeDefined(); + }); + + it('clears transaction history', () => { + useAppStore.getState().addTransaction({ + subscriptionId: 'sub-1', + amount: 1, + currency: 'USD', + status: TransactionStatus.SUCCESS, + } as never); + + useAppStore.getState().clearHistory(); + expect(useAppStore.getState().transactions).toHaveLength(0); + }); +}); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 42adc4cc..2c1c0e1b 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,208 +1,29 @@ /** - * authStore.ts — Authentication state with Zustand v5 persist middleware + * authStore.ts — Authentication state (slices pattern). * - * Schema v1: { token, userId, isAuthenticated } - * Schema v2: flattens nested `user` object (migration from hypothetical v0 shape) + * This file now delegates to the combined `useAppStore` (see slices/index.ts) + * and exposes the legacy `useAuthStore` hook + selectors so all existing + * consumers continue to work unchanged. The slice itself lives in + * `slices/authSlice.ts`. * * Persisted (whitelisted): token, userId, isAuthenticated * Ephemeral (skipped): isLoading, error - * - * Edge cases: - * - Corrupted / truncated storage → resets to defaults + console.warn - * - null / undefined token → treated as signed-out */ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { asyncStorageAdapter } from '../utils/storage'; +import { useAppStore, AuthSlice } from './slices'; -// ───────────────────────────────────────────────────────────────────────────── -// Types -// ───────────────────────────────────────────────────────────────────────────── +/** + * Legacy hook — full auth state + actions. + */ +export const useAuthStore = useAppStore; +export type AuthState = AuthSlice; export interface AuthUser { id: string; email: string; displayName?: string; } -interface AuthState { - // Persisted fields - token: string | null; - userId: string | null; - isAuthenticated: boolean; - - // Ephemeral (never persisted) - isLoading: boolean; - error: string | null; - - // Actions - signIn: (token: string, user: AuthUser) => void; - signOut: () => void; - setToken: (token: string | null) => void; - setLoading: (loading: boolean) => void; - clearError: () => void; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Persisted slice type (whitelist only) -// ───────────────────────────────────────────────────────────────────────────── - -type PersistedAuthSlice = Pick; - -// ───────────────────────────────────────────────────────────────────────────── -// Schema defaults -// ───────────────────────────────────────────────────────────────────────────── - -const DEFAULT_STATE: PersistedAuthSlice = { - token: null, - userId: null, - isAuthenticated: false, -}; - -// ───────────────────────────────────────────────────────────────────────────── -// Schema migration -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Migrate persisted auth state between schema versions. - * - * v0 → v1: no-op (initial schema) - * v1 → v2: flatten nested `user` object → flat `userId` - */ -function migrateAuthState(persisted: unknown, fromVersion: number): PersistedAuthSlice { - if (!persisted || typeof persisted !== 'object') { - console.warn('[authStore] Corrupted persisted state — resetting to defaults.'); - return { ...DEFAULT_STATE }; - } - - const raw = persisted as Record; - - // v0 → v1: nothing to do, just normalize - if (fromVersion < 1) { - return { - token: typeof raw.token === 'string' ? raw.token : null, - userId: typeof raw.userId === 'string' ? raw.userId : null, - isAuthenticated: raw.isAuthenticated === true, - }; - } - - // v1 → v2: flatten nested `user` shape, e.g. { user: { id, email } } - if (fromVersion < 2) { - const userId = - typeof raw.userId === 'string' - ? raw.userId - : typeof raw.user === 'object' && raw.user !== null - ? (((raw.user as Record).id as string | null) ?? null) - : null; - - return { - token: typeof raw.token === 'string' ? raw.token : null, - userId, - isAuthenticated: raw.isAuthenticated === true && raw.token != null, - }; - } - - return { - token: typeof raw.token === 'string' ? raw.token : null, - userId: typeof raw.userId === 'string' ? raw.userId : null, - isAuthenticated: raw.isAuthenticated === true, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Store -// ───────────────────────────────────────────────────────────────────────────── - -const STORAGE_KEY = '@subtrackr/auth_token'; -const STORE_VERSION = 2; - -export const useAuthStore = create()( - persist( - (set) => ({ - ...DEFAULT_STATE, - isLoading: false, - error: null, - - signIn: (token, user) => { - set({ - token, - userId: user.id, - isAuthenticated: true, - isLoading: false, - error: null, - }); - }, - - signOut: () => { - set({ - token: null, - userId: null, - isAuthenticated: false, - isLoading: false, - error: null, - }); - }, - - setToken: (token) => { - set({ token, isAuthenticated: token != null }); - }, - - setLoading: (loading) => set({ isLoading: loading }), - - clearError: () => set({ error: null }), - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => asyncStorageAdapter), - - // Only persist these fields; isLoading and error are ephemeral - partialize: (state): PersistedAuthSlice => ({ - token: state.token, - userId: state.userId, - isAuthenticated: state.isAuthenticated, - }), - - migrate: (persistedState, version) => - migrateAuthState(persistedState, version) as PersistedAuthSlice, - - merge: (persistedState, currentState) => ({ - ...currentState, - ...migrateAuthState(persistedState, STORE_VERSION), - }), - - onRehydrateStorage: () => (state, error) => { - if (error) { - console.warn('[authStore] Hydration error — resetting auth state:', error); - useAuthStore.setState({ - ...DEFAULT_STATE, - isLoading: false, - error: null, - }); - return; - } - - // Sanity-check: if token is missing but isAuthenticated is true, fix it - if (state && state.isAuthenticated && !state.token) { - console.warn('[authStore] Inconsistent auth state detected — signing out.'); - useAuthStore.setState({ - token: null, - userId: null, - isAuthenticated: false, - isLoading: false, - error: null, - }); - } - }, - } - ) -); - -// ───────────────────────────────────────────────────────────────────────────── -// Selectors -// ───────────────────────────────────────────────────────────────────────────── - export const selectIsAuthenticated = (s: AuthState) => s.isAuthenticated; export const selectAuthToken = (s: AuthState) => s.token; export const selectUserId = (s: AuthState) => s.userId; diff --git a/src/store/index.ts b/src/store/index.ts index 3933746c..47707a03 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,5 +1,7 @@ export { useTrialStore } from './trialStore'; export { useSubscriptionStore } from './subscriptionStore'; +// Combined slices pattern store (Issue #915) +export { useAppStore } from './slices'; export { useInvoiceStore } from './invoiceStore'; export { useCreditStore } from './creditStore'; export { useTransactionQueueStore } from './transactionQueueStore'; diff --git a/src/store/networkStore.ts b/src/store/networkStore.ts index 2e67d5be..1954f006 100644 --- a/src/store/networkStore.ts +++ b/src/store/networkStore.ts @@ -1,92 +1,20 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { asyncStorageAdapter } from '../utils/storage'; +/** + * networkStore.ts — Network selection state (slices pattern). + * + * Delegates to the combined `useAppStore` (see slices/index.ts). The legacy + * `useNetworkStore` hook is preserved for compatibility. + */ + +import { useAppStore, NetworkSlice } from './slices'; import { Network, ALL_NETWORKS, getNetworkById } from '../config/networks'; import { networkService } from '../services/networkService'; -interface NetworkState { - currentNetwork: Network | null; - availableNetworks: Network[]; - isLoading: boolean; - error: string | null; - - initialize: () => Promise; - setNetwork: (networkId: string) => Promise; - checkHealth: ( - networkId: string - ) => Promise<{ healthy: boolean; latency?: number; error?: string }>; - refreshNetworks: () => Promise; -} - -export const useNetworkStore = create()( - persist( - (set) => ({ - currentNetwork: null, - availableNetworks: ALL_NETWORKS, - isLoading: false, - error: null, - - initialize: async () => { - set({ isLoading: true, error: null }); - try { - const network = await networkService.getSelectedNetwork(); - set({ currentNetwork: network, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to initialize network', - isLoading: false, - }); - } - }, +export type NetworkState = NetworkSlice; - setNetwork: async (networkId: string) => { - set({ isLoading: true, error: null }); - try { - const success = await networkService.setSelectedNetwork(networkId); - if (success) { - const network = getNetworkById(networkId); - set({ currentNetwork: network, isLoading: false }); - } else { - set({ error: 'Failed to set network', isLoading: false }); - } - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to set network', - isLoading: false, - }); - } - }, +export const useNetworkStore = useAppStore; - checkHealth: async (networkId: string) => { - try { - return await networkService.checkNetworkHealth(networkId); - } catch (error) { - return { - healthy: false, - error: error instanceof Error ? error.message : 'Health check failed', - }; - } - }, +export const selectCurrentNetwork = (s: NetworkState) => s.currentNetwork; +export const selectAvailableNetworks = (s: NetworkState) => s.availableNetworks; - refreshNetworks: async () => { - set({ isLoading: true, error: null }); - try { - const networks = await networkService.getAvailableNetworks(); - set({ availableNetworks: networks, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to refresh networks', - isLoading: false, - }); - } - }, - }), - { - name: 'subtrackr-network-store', - storage: createJSONStorage(() => asyncStorageAdapter), - partialize: (state) => ({ - currentNetwork: state.currentNetwork, - }), - } - ) -); +export type { Network }; +export { ALL_NETWORKS, getNetworkById, networkService }; diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index c8d35ce3..c81d1034 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -1,68 +1,16 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { asyncStorageAdapter } from '../utils/storage'; +/** + * settingsStore.ts — Settings state (slices pattern). + * + * Delegates to the combined `useAppStore` (see slices/index.ts). The legacy + * `useSettingsStore` hook is preserved for compatibility. + */ + +import { useAppStore, SettingsSlice } from './slices'; import { currencyService, ExchangeRates } from '../services/currencyService'; -interface SettingsState { - preferredCurrency: string; - notificationsEnabled: boolean; - exchangeRates: ExchangeRates | null; - healthScoreWeights: Record | null; - isLoading: boolean; +export type SettingsState = SettingsSlice; - // Actions - setPreferredCurrency: (currency: string) => void; - setNotificationsEnabled: (enabled: boolean) => void; - setHealthScoreWeights: (weights: Record) => void; - updateExchangeRates: () => Promise; - initializeSettings: () => Promise; -} +export const useSettingsStore = useAppStore; -export const useSettingsStore = create()( - persist( - (set, get) => ({ - preferredCurrency: 'USD', - notificationsEnabled: true, - exchangeRates: null, - healthScoreWeights: null, - isLoading: false, - - setPreferredCurrency: (currency) => { - set({ preferredCurrency: currency }); - void get().updateExchangeRates(); - }, - - setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), - - setHealthScoreWeights: (weights) => set({ healthScoreWeights: weights }), - - updateExchangeRates: async () => { - set({ isLoading: true }); - const rates = await currencyService.fetchRates('USD'); - set({ exchangeRates: rates, isLoading: false }); - }, - - initializeSettings: async () => { - const { exchangeRates } = get(); - if (!exchangeRates || currencyService.isCacheExpired(exchangeRates.timestamp)) { - await get().updateExchangeRates(); - } - }, - }), - { - name: 'subtrackr-settings-store', - storage: createJSONStorage(() => asyncStorageAdapter), - onRehydrateStorage: () => (_state, error) => { - if (error) { - console.warn('[settingsStore] Hydration error — resetting to defaults:', error); - useSettingsStore.setState({ - preferredCurrency: 'USD', - notificationsEnabled: true, - exchangeRates: null, - isLoading: false, - }); - } - }, - } - ) -); +export type { ExchangeRates }; +export { currencyService }; diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts new file mode 100644 index 00000000..4ee04ca1 --- /dev/null +++ b/src/store/slices/authSlice.ts @@ -0,0 +1,68 @@ +/** + * authSlice.ts — Authentication slice for the slices-pattern store. + * + * This isolates all authentication state + actions so it can be composed into + * the combined `useAppStore`. The public `useAuthStore` (see ../authStore.ts) + * now re-exports the combined slice so consumers are unaffected. + */ + +import { SliceCreator } from './types'; +import type { AppState } from './state'; + +export type AuthStoreState = AppState & AuthSlice; + +export interface AuthUser { + id: string; + email: string; + displayName?: string; +} + +export interface AuthSlice { + token: string | null; + userId: string | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + + signIn: (token: string, user: AuthUser) => void; + signOut: () => void; + setToken: (token: string | null) => void; + setLoading: (loading: boolean) => void; + clearError: () => void; +} + +export const createAuthSlice: SliceCreator = (set) => ({ + token: null, + userId: null, + isAuthenticated: false, + isLoading: false, + error: null, + + signIn: (token, user) => { + set({ + token, + userId: user.id, + isAuthenticated: true, + isLoading: false, + error: null, + }); + }, + + signOut: () => { + set({ + token: null, + userId: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + }, + + setToken: (token) => { + set({ token, isAuthenticated: token != null }); + }, + + setLoading: (loading) => set({ isLoading: loading }), + + clearError: () => set({ error: null }), +}); diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts new file mode 100644 index 00000000..dbca4baa --- /dev/null +++ b/src/store/slices/index.ts @@ -0,0 +1,91 @@ +/** + * slices/index.ts — Combined root store (slices pattern). + * + * Composes all domain slices into a single `useAppStore`. Each slice is + * defined independently in its own file (authSlice, userSlice, settingsSlice, + * networkSlice, transactionSlice, ...) — giving modularity and type-safety — + * and then composed here. + * + * Persistence: the combined store persists a single JSON blob under one key. + * `partialize` whitelists the persisted fields so ephemeral state (isLoading, + * error) is excluded — matching the behaviour of the original singleton + * stores. Existing legacy stores re-read their slice from this store, so no + * consumers are broken. + */ + +import { create } from 'zustand'; +import { createJSONStorage, persist, StateStorage } from 'zustand/middleware'; +import { asyncStorageAdapter, localStorageAdapter } from '../../utils/storage'; + +import { createAuthSlice } from './authSlice'; +import { createUserSlice } from './userSlice'; +import { createSettingsSlice } from './settingsSlice'; +import { createNetworkSlice } from './networkSlice'; +import { createTransactionSlice } from './transactionSlice'; +import type { AppState } from './state'; + +export type { AppState } from './state'; +export type { AuthUser, AuthSlice, AuthStoreState } from './authSlice'; +export type { UserSlice, UserStoreState, ConsentState } from './userSlice'; +export type { SettingsSlice, SettingsStoreState } from './settingsSlice'; +export type { NetworkSlice, NetworkStoreState } from './networkSlice'; +export type { TransactionSlice, TransactionStoreState } from './transactionSlice'; + +// ───────────────────────────────────────────────────────────────────────────── +// Storage selection: pick the correct adapter for the runtime environment. +// Mobile uses AsyncStorage; web/developer-portal use localStorage. +// ───────────────────────────────────────────────────────────────────────────── + +const isWeb = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; + +const storage: StateStorage = isWeb ? localStorageAdapter : asyncStorageAdapter; + +/** + * `useAppStore` — single source of truth for all app slices. + * + * Consumers may subscribe to a slice with a selector, e.g. + * `const token = useAppStore(selectAuthToken)`. + */ +export const useAppStore = create()( + persist( + (set, get) => ({ + ...createAuthSlice(set, get), + ...createUserSlice(set, get), + ...createSettingsSlice(set, get), + ...createNetworkSlice(set, get), + ...createTransactionSlice(set, get), + }), + { + name: 'subtrackr-app-store', + version: 1, + storage: createJSONStorage(() => storage), + partialize: (state): Partial => ({ + token: state.token, + userId: state.userId, + isAuthenticated: state.isAuthenticated, + user: state.user, + subscriptionTier: state.subscriptionTier, + consent: state.consent, + preferredCurrency: state.preferredCurrency, + notificationsEnabled: state.notificationsEnabled, + exchangeRates: state.exchangeRates, + healthScoreWeights: state.healthScoreWeights, + currentNetwork: state.currentNetwork, + transactions: state.transactions, + }), + } + ) +); + +// ───────────────────────────────────────────────────────────────────────────── +// Cross-slice selectors +// ───────────────────────────────────────────────────────────────────────────── + +export const selectAuthToken = (s: AppState) => s.token; +export const selectIsAuthenticated = (s: AppState) => s.isAuthenticated; +export const selectUserId = (s: AppState) => s.userId; +export const selectUser = (s: AppState) => s.user; +export const selectPreferredCurrency = (s: AppState) => s.preferredCurrency; +export const selectCurrentNetwork = (s: AppState) => s.currentNetwork; +export const selectTransactions = (s: AppState) => s.transactions; +export const selectSubscriptionTier = (s: AppState) => s.subscriptionTier; diff --git a/src/store/slices/networkSlice.ts b/src/store/slices/networkSlice.ts new file mode 100644 index 00000000..d6a15a8a --- /dev/null +++ b/src/store/slices/networkSlice.ts @@ -0,0 +1,86 @@ +/** + * networkSlice.ts — Network slice for the slices-pattern store. + */ + +import { SliceCreator } from './types'; +import type { AppState } from './state'; +import { Network, ALL_NETWORKS, getNetworkById } from '../../config/networks'; +import { networkService } from '../../services/networkService'; + +export interface NetworkSlice { + currentNetwork: Network | null; + availableNetworks: Network[]; + isLoading: boolean; + error: string | null; + + initialize: () => Promise; + setNetwork: (networkId: string) => Promise; + checkHealth: ( + networkId: string + ) => Promise<{ healthy: boolean; latency?: number; error?: string }>; + refreshNetworks: () => Promise; +} + +export type NetworkStoreState = AppState; + +export const createNetworkSlice: SliceCreator = (set) => ({ + currentNetwork: null, + availableNetworks: ALL_NETWORKS, + isLoading: false, + error: null, + + initialize: async () => { + set({ isLoading: true, error: null }); + try { + const network = await networkService.getSelectedNetwork(); + set({ currentNetwork: network, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to initialize network', + isLoading: false, + }); + } + }, + + setNetwork: async (networkId: string) => { + set({ isLoading: true, error: null }); + try { + const success = await networkService.setSelectedNetwork(networkId); + if (success) { + const network = getNetworkById(networkId); + set({ currentNetwork: network, isLoading: false }); + } else { + set({ error: 'Failed to set network', isLoading: false }); + } + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to set network', + isLoading: false, + }); + } + }, + + checkHealth: async (networkId: string) => { + try { + return await networkService.checkNetworkHealth(networkId); + } catch (error) { + return { + healthy: false, + error: error instanceof Error ? error.message : 'Health check failed', + }; + } + }, + + refreshNetworks: async () => { + set({ isLoading: true, error: null }); + try { + const networks = await networkService.getAvailableNetworks(); + set({ availableNetworks: networks, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to refresh networks', + isLoading: false, + }); + } + }, +}); diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts new file mode 100644 index 00000000..d37874cc --- /dev/null +++ b/src/store/slices/settingsSlice.ts @@ -0,0 +1,53 @@ +/** + * settingsSlice.ts — Settings slice for the slices-pattern store. + */ + +import { SliceCreator } from './types'; +import type { AppState } from './state'; +import { currencyService, ExchangeRates } from '../../services/currencyService'; + +export interface SettingsSlice { + preferredCurrency: string; + notificationsEnabled: boolean; + exchangeRates: ExchangeRates | null; + healthScoreWeights: Record | null; + isLoading: boolean; + + setPreferredCurrency: (currency: string) => void; + setNotificationsEnabled: (enabled: boolean) => void; + setHealthScoreWeights: (weights: Record) => void; + updateExchangeRates: () => Promise; + initializeSettings: () => Promise; +} + +export type SettingsStoreState = AppState; + +export const createSettingsSlice: SliceCreator = (set, get) => ({ + preferredCurrency: 'USD', + notificationsEnabled: true, + exchangeRates: null, + healthScoreWeights: null, + isLoading: false, + + setPreferredCurrency: (currency) => { + set({ preferredCurrency: currency }); + void get().updateExchangeRates(); + }, + + setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), + + setHealthScoreWeights: (weights) => set({ healthScoreWeights: weights }), + + updateExchangeRates: async () => { + set({ isLoading: true }); + const rates = await currencyService.fetchRates('USD'); + set({ exchangeRates: rates, isLoading: false }); + }, + + initializeSettings: async () => { + const { exchangeRates } = get(); + if (!exchangeRates || currencyService.isCacheExpired(exchangeRates.timestamp)) { + await get().updateExchangeRates(); + } + }, +}); diff --git a/src/store/slices/state.ts b/src/store/slices/state.ts new file mode 100644 index 00000000..5f323961 --- /dev/null +++ b/src/store/slices/state.ts @@ -0,0 +1,25 @@ +/** + * state.ts — Combined app state type used by every slice creator. + * + * Importing this from slices avoids circular type imports: the creators import + * AppState here, and index.ts composes the creators into the root store. + */ + +import { StateCreator } from 'zustand'; +import { AuthSlice } from './authSlice'; +import { UserSlice } from './userSlice'; +import { SettingsSlice } from './settingsSlice'; +import { NetworkSlice } from './networkSlice'; +import { TransactionSlice } from './transactionSlice'; + +/** + * The full combined store state — every slice spread together. + */ +export interface AppState + extends AuthSlice, UserSlice, SettingsSlice, NetworkSlice, TransactionSlice {} + +/** + * SliceCreator with full cross-slice access: the 4th generic is AppState so a + * slice may read/write other slices (e.g. user slice reading auth slice). + */ +export type SliceCreator = StateCreator; diff --git a/src/store/slices/transactionSlice.ts b/src/store/slices/transactionSlice.ts new file mode 100644 index 00000000..6fa0faa5 --- /dev/null +++ b/src/store/slices/transactionSlice.ts @@ -0,0 +1,55 @@ +/** + * transactionSlice.ts — Transaction history slice for the slices-pattern store. + */ + +import { SliceCreator } from './types'; +import type { AppState } from './state'; +import { Transaction, TransactionStatus } from '../../types/transaction'; + +const MAX_RECORDS = 500; + +export interface TransactionSlice { + transactions: Transaction[]; + + addTransaction: (tx: Omit) => Transaction; + updateTransactionStatus: (id: string, status: TransactionStatus, failureReason?: string) => void; + getBySubscription: (subscriptionId: string) => Transaction[]; + getByStatus: (status: TransactionStatus) => Transaction[]; + clearHistory: () => void; +} + +export type TransactionStoreState = AppState; + +export const createTransactionSlice: SliceCreator = (set, get) => ({ + transactions: [], + + addTransaction: (tx) => { + const newTx: Transaction = { + ...tx, + id: `txhist_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + date: new Date().toISOString(), + }; + + set((state) => { + const next = [newTx, ...state.transactions]; + return { transactions: next.slice(0, MAX_RECORDS) }; + }); + + return newTx; + }, + + updateTransactionStatus: (id, status, failureReason) => { + set((state) => ({ + transactions: state.transactions.map((tx) => + tx.id === id ? { ...tx, status, ...(failureReason ? { failureReason } : {}) } : tx + ), + })); + }, + + getBySubscription: (subscriptionId) => + get().transactions.filter((tx) => tx.subscriptionId === subscriptionId), + + getByStatus: (status) => get().transactions.filter((tx) => tx.status === status), + + clearHistory: () => set({ transactions: [] }), +}); diff --git a/src/store/slices/types.ts b/src/store/slices/types.ts new file mode 100644 index 00000000..a2c5e15d --- /dev/null +++ b/src/store/slices/types.ts @@ -0,0 +1,25 @@ +/** + * types.ts — Shared composition types for the Zustand slices pattern. + * + * This module defines the slice creator type used across all store slices so + * each slice can be expressed independently and then composed into a single + * combined `useAppStore` (see `slices/index.ts`). + * + * The slices pattern (https://docs.pmnd.rs/zustand/guides/slices-pattern) + * gives us modularity: each domain owns its state + actions in one file, and + * the root store simply spreads the slices together. + */ + +export { SliceCreator, AppState } from './state'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared ephemeral-state helpers +// ───────────────────────────────────────────────────────────────────────────── + +export interface LoadingState { + isLoading: boolean; +} + +export interface ErrorState { + error: string | null; +} diff --git a/src/store/slices/userSlice.ts b/src/store/slices/userSlice.ts new file mode 100644 index 00000000..5ed69e30 --- /dev/null +++ b/src/store/slices/userSlice.ts @@ -0,0 +1,69 @@ +/** + * userSlice.ts — User profile + consent slice for the slices-pattern store. + */ + +import { SliceCreator } from './types'; +import type { AppState } from './state'; +import { UserProfile } from '../../types/api'; +import { SubscriptionTier } from '../../types/subscription'; + +export interface ConsentState { + analytics: boolean; + marketing: boolean; + notifications: boolean; + hasAcceptedPolicy: boolean; +} + +export interface UserSlice { + user: UserProfile | null; + subscriptionTier: SubscriptionTier; + consent: ConsentState; + + setUser: (user: UserProfile | null) => void; + setSubscriptionTier: (subscriptionTier: SubscriptionTier) => void; + setConsent: (consent: Partial) => void; + acceptAll: () => void; + resetConsent: () => void; +} + +export type UserStoreState = AppState; + +export const createUserSlice: SliceCreator = (set) => ({ + user: null, + subscriptionTier: SubscriptionTier.FREE, + consent: { + analytics: false, + marketing: false, + notifications: true, + hasAcceptedPolicy: false, + }, + + setUser: (user) => + set((state) => ({ + user, + subscriptionTier: user + ? (user.subscriptionTier ?? state.subscriptionTier) + : SubscriptionTier.FREE, + })), + + setSubscriptionTier: (subscriptionTier) => set(() => ({ subscriptionTier })), + setConsent: (newConsent) => set((state) => ({ consent: { ...state.consent, ...newConsent } })), + acceptAll: () => + set(() => ({ + consent: { + analytics: true, + marketing: true, + notifications: true, + hasAcceptedPolicy: true, + }, + })), + resetConsent: () => + set(() => ({ + consent: { + analytics: false, + marketing: false, + notifications: false, + hasAcceptedPolicy: false, + }, + })), +}); diff --git a/src/store/transactionStore.ts b/src/store/transactionStore.ts index 693b481c..639a95c2 100644 --- a/src/store/transactionStore.ts +++ b/src/store/transactionStore.ts @@ -1,62 +1,17 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +/** + * transactionStore.ts — Transaction history state (slices pattern). + * + * Delegates to the combined `useAppStore` (see slices/index.ts). The legacy + * `useTransactionStore` hook is preserved for compatibility. + */ + +import { useAppStore, TransactionSlice } from './slices'; import { Transaction, TransactionStatus } from '../types/transaction'; -const STORAGE_KEY = 'subtrackr-transaction-history'; -const MAX_RECORDS = 500; +export type TransactionState = TransactionSlice; -interface TransactionState { - transactions: Transaction[]; +export const useTransactionStore = useAppStore; - // Actions - addTransaction: (tx: Omit) => Transaction; - updateTransactionStatus: (id: string, status: TransactionStatus, failureReason?: string) => void; - getBySubscription: (subscriptionId: string) => Transaction[]; - getByStatus: (status: TransactionStatus) => Transaction[]; - clearHistory: () => void; -} +export const selectTransactions = (s: TransactionState) => s.transactions; -export const useTransactionStore = create()( - persist( - (set, get) => ({ - transactions: [], - - addTransaction: (tx) => { - const newTx: Transaction = { - ...tx, - id: `txhist_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - date: new Date().toISOString(), - }; - - set((state) => { - const next = [newTx, ...state.transactions]; - // Prune oldest beyond limit - return { transactions: next.slice(0, MAX_RECORDS) }; - }); - - return newTx; - }, - - updateTransactionStatus: (id, status, failureReason) => { - set((state) => ({ - transactions: state.transactions.map((tx) => - tx.id === id ? { ...tx, status, ...(failureReason ? { failureReason } : {}) } : tx - ), - })); - }, - - getBySubscription: (subscriptionId) => - get().transactions.filter((tx) => tx.subscriptionId === subscriptionId), - - getByStatus: (status) => get().transactions.filter((tx) => tx.status === status), - - clearHistory: () => set({ transactions: [] }), - }), - { - name: STORAGE_KEY, - version: 1, - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +export type { Transaction, TransactionStatus }; diff --git a/src/store/userStore.ts b/src/store/userStore.ts index 10ff5f0a..05e24ba5 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -1,87 +1,32 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { asyncStorageAdapter } from '../utils/storage'; +/** + * userStore.ts — User profile + consent state (slices pattern). + * + * Delegates to the combined `useAppStore` (see slices/index.ts). The legacy + * `useUserStore` hook is preserved so all existing consumers keep their exact + * behaviour (`useUserStore()`, `.getState()`, `.setState()` all work). + */ + +import { useAppStore, UserSlice } from './slices'; import { UserProfile } from '../types/api'; import { SubscriptionTier } from '../types/subscription'; -interface ConsentState { +export type UserState = UserSlice; + +export interface ConsentState { analytics: boolean; marketing: boolean; notifications: boolean; hasAcceptedPolicy: boolean; } -interface UserState { - user: UserProfile | null; - subscriptionTier: SubscriptionTier; - consent: ConsentState; - setUser: (user: UserProfile | null) => void; - setSubscriptionTier: (subscriptionTier: SubscriptionTier) => void; - setConsent: (consent: Partial) => void; - acceptAll: () => void; - resetConsent: () => void; -} +/** + * Legacy hook — backed by the combined app store. + */ +export const useUserStore = useAppStore; + +export const selectUser = (s: UserState) => s.user; +export const selectSubscriptionTier = (s: UserState) => s.subscriptionTier; +export const selectConsent = (s: UserState) => s.consent; -export const useUserStore = create()( - persist( - (set) => ({ - user: null, - subscriptionTier: SubscriptionTier.FREE, - consent: { - analytics: false, - marketing: false, - notifications: true, // Default to true for core functionality - hasAcceptedPolicy: false, - }, - setUser: (user) => - set((state) => ({ - user, - subscriptionTier: user - ? (user.subscriptionTier ?? state.subscriptionTier) - : SubscriptionTier.FREE, - })), - setSubscriptionTier: (subscriptionTier) => set(() => ({ subscriptionTier })), - setConsent: (newConsent) => - set((state) => ({ - consent: { ...state.consent, ...newConsent }, - })), - acceptAll: () => - set(() => ({ - consent: { - analytics: true, - marketing: true, - notifications: true, - hasAcceptedPolicy: true, - }, - })), - resetConsent: () => - set(() => ({ - consent: { - analytics: false, - marketing: false, - notifications: false, - hasAcceptedPolicy: false, - }, - })), - }), - { - name: 'subtrackr-user-store', - storage: createJSONStorage(() => asyncStorageAdapter), - onRehydrateStorage: () => (_state, error) => { - if (error) { - console.warn('[userStore] Hydration error — resetting to defaults:', error); - useUserStore.setState({ - user: null, - subscriptionTier: SubscriptionTier.FREE, - consent: { - analytics: false, - marketing: false, - notifications: true, - hasAcceptedPolicy: false, - }, - }); - } - }, - } - ) -); +export type { UserProfile }; +export { SubscriptionTier }; diff --git a/src/types/credit.ts b/src/types/credit.ts index e6794a40..34000953 100644 --- a/src/types/credit.ts +++ b/src/types/credit.ts @@ -122,3 +122,121 @@ export interface CreditApplicationResult { remainingDue: number; autoApplied: boolean; } + +// ───────────────────────────────────────────────────────────────────────────── +// Credit Note & Prepayment Wallet model +// +// Used by src/store/creditStore.ts. Credit notes are formal document-style +// credits (e.g. goodwill adjustments, refunds) that can be applied to open +// invoices; prepayment wallets hold customer prepaid balances that are drawn +// down automatically at billing close. +// ───────────────────────────────────────────────────────────────────────────── + +export enum CreditNoteStatus { + DRAFT = 'DRAFT', + ISSUED = 'ISSUED', + PARTIALLY_APPLIED = 'PARTIALLY_APPLIED', + APPLIED = 'APPLIED', + VOID = 'VOID', + EXPIRED = 'EXPIRED', +} + +export enum CreditNoteReason { + ADJUSTMENT = 'ADJUSTMENT', + REFUND = 'REFUND', + GOODWILL = 'GOODWILL', + COMPENSATION = 'COMPENSATION', + PROMOTION = 'PROMOTION', + DUPLICATE_CHARGE = 'DUPLICATE_CHARGE', +} + +export interface CreditNote { + id: string; + subscriptionId: string; + userId: string; + reason: CreditNoteReason; + amount: number; + remainingAmount: number; + currency: string; + status: CreditNoteStatus; + issuedAt: Date; + expiresAt: Date; + appliedAt?: Date; + appliedToInvoiceIds: string[]; + notes?: string; + priority: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreditNoteApplication { + id: string; + creditNoteId: string; + invoiceId: string; + amount: number; + status: CreditNoteStatus; + appliedAt: Date; +} + +export interface PrepaymentWallet { + id: string; + subscriptionId: string; + userId: string; + currency: string; + balance: number; + totalDeposited: number; + totalWithdrawn: number; + createdAt: Date; + updatedAt: Date; +} + +export type PrepaymentTransactionType = 'deposit' | 'withdraw' | 'drawdown'; + +export interface PrepaymentTransaction { + id: string; + walletId: string; + type: PrepaymentTransactionType; + amount: number; + balanceAfter: number; + invoiceId?: string; + timestamp: Date; +} + +export interface CreditNoteReportBucket { + issued: CreditNote[]; + applied: CreditNote[]; + expired: CreditNote[]; + outstanding: CreditNote[]; +} + +export interface CreditNoteReport { + generatedAt: Date; + totalIssued: number; + totalApplied: number; + totalExpired: number; + totalOutstanding: number; + creditNotes: CreditNoteReportBucket; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Account balance summary +// +// Aggregated view of a subscriber's credit account used for high-level +// balance display and reconciliation. +// ───────────────────────────────────────────────────────────────────────────── + +export interface CreditAccountBalance { + accountId: string; + currency: string; + availableBalance: number; + totalPurchased: number; + totalApplied: number; + totalExpired: number; + totalTransferredIn: number; + totalTransferredOut: number; + pendingExpiry: number; + nextExpirationAt: Date | null; + createdAt: Date; + updatedAt: Date; +} +