Skip to content

Commit 7f50c68

Browse files
feat(#915,#918,#911,#909): slices store refactor, credit system, DR automation, API docs
- #915: compose domain stores into a single useAppStore slices pattern (src/store/slices/), keep legacy stores as aliases, add tests + docs. - #918: add credit-note/prepayment/account types, deposit/withdraw/balance actions in app store, get_account_balance_summary Soroban method, tests. - #911: add network-partition/service-degradation/failure-injection chaos experiments + tests, and a scheduled .github/workflows/disaster-recovery.yml; document the automated DR routine in the runbook. - #909: expand ApiPlayground to 12 interactive endpoints and rewrite openapi.json to a full OpenAPI 3.1.0 spec.
1 parent efcd3b8 commit 7f50c68

31 files changed

Lines changed: 2154 additions & 488 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Disaster Recovery Automation
2+
3+
# Automated disaster-recovery routine:
4+
# 1. Runs a full DR drill (backup → verify → restore + monitor health check)
5+
# 2. Creates a DR backup (with pre-check)
6+
# 3. Captures and uploads DR status + backup artefacts
7+
#
8+
# Wired to a schedule so backups/status are taken on a routine cadence
9+
# independent of human action, plus a manual dispatch for on-demand runs.
10+
11+
on:
12+
schedule:
13+
# Daily at 03:17 UTC
14+
- cron: '17 3 * * *'
15+
workflow_dispatch:
16+
17+
env:
18+
NODE_VERSION: '20'
19+
20+
jobs:
21+
dr-routine:
22+
name: DR Backup + Status
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout code
26+
uses: actions/checkout@v7
27+
with:
28+
fetch-depth: 0
29+
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v7
32+
with:
33+
node-version: ${{ env.NODE_VERSION }}
34+
cache: 'npm'
35+
36+
- name: Install dependencies
37+
run: npm ci --legacy-peer-deps
38+
39+
- name: Run DR drill (backup → verify → restore + health)
40+
run: node scripts/dr-test.js
41+
continue-on-error: true
42+
43+
- name: Create DR backup (with pre-check)
44+
run: ./scripts/dr-backup.sh --pre-check --region "${DR_REGION:-us-east-1}" --env "${DR_ENVIRONMENT:-production}"
45+
env:
46+
DR_REGION: us-east-1
47+
DR_ENVIRONMENT: production
48+
49+
- name: Capture DR status (JSON)
50+
run: |
51+
STATUS_FILE="dr-status-${{ github.run_id }}.json"
52+
./scripts/dr-status.sh --json > "$STATUS_FILE" 2>&1 || true
53+
echo "STATUS_FILE=$STATUS_FILE" >> "$GITHUB_ENV"
54+
id: status
55+
56+
- name: Upload DR backup artefact
57+
if: always()
58+
uses: actions/upload-artifact@v7
59+
with:
60+
name: dr-backups-${{ github.run_id }}
61+
path: |
62+
.dr-backups/*.tar.gz
63+
.dr-recovery-log.jsonl
64+
${{ env.STATUS_FILE }}
65+
66+
- name: Notify on degraded/critical DR health
67+
if: always()
68+
run: |
69+
if ! ./scripts/dr-status.sh --short; then
70+
echo "::warning::DR system is in a degraded/critical state — review the DR status artefact."
71+
else
72+
echo "DR system is healthy."
73+
fi

app/stores/__tests__/creditStore.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useCreditStore } from '../creditStore';
22

33
let clock = 1000;
44
const reset = () =>
5-
useCreditStore.setState({ accounts: {}, nextId: 0, now: () => clock });
5+
useCreditStore.setState({ accounts: {}, wallets: {}, nextId: 0, now: () => clock });
66

77
beforeEach(() => {
88
clock = 1000;
@@ -66,4 +66,54 @@ describe('useCreditStore', () => {
6666
clock = 1200;
6767
expect(s().getBalance('alice')).toBe(0);
6868
});
69+
70+
it('deposits credit into an account balance', () => {
71+
s().depositCredit('alice', 200, 'prepaid top-up');
72+
expect(s().getBalance('alice')).toBe(200);
73+
expect(
74+
s()
75+
.getAccount('alice')
76+
.transactions.some((t) => t.kind === 'deposit' && t.amount === 200)
77+
).toBe(true);
78+
});
79+
80+
it('withdraws available credit and rejects overdrafts', () => {
81+
s().issueCredit('alice', 300, 'promo');
82+
expect(s().withdrawCredit('alice', 100, 'cash-out')).toBe(true);
83+
expect(s().getBalance('alice')).toBe(200);
84+
expect(s().withdrawCredit('alice', 500, 'cash-out')).toBe(false);
85+
expect(s().getBalance('alice')).toBe(200);
86+
});
87+
88+
it('computes a consolidated account balance summary', () => {
89+
useCreditStore.getState().wallets = {
90+
'w-1': {
91+
id: 'w-1',
92+
subscriber: 'alice',
93+
currency: 'USD',
94+
balance: 75,
95+
totalDeposited: 100,
96+
totalWithdrawn: 25,
97+
},
98+
};
99+
100+
s().issueCredit('alice', 250, 'refund');
101+
s().applyCredit('alice', 'sub_1', 50);
102+
103+
const balance = s().getAccountBalance('alice');
104+
expect(balance.subscriber).toBe('alice');
105+
expect(balance.availableCredit).toBe(200);
106+
expect(balance.totalIssued).toBe(250);
107+
expect(balance.totalApplied).toBe(50);
108+
expect(balance.prepaymentBalance).toBe(75);
109+
expect(balance.netBalance).toBe(275);
110+
});
111+
112+
it('returns account balances for all known subscribers', () => {
113+
s().issueCredit('alice', 100, 'promo');
114+
s().issueCredit('bob', 200, 'promo');
115+
const balances = s().getAccountBalances();
116+
expect(balances).toHaveLength(2);
117+
expect(balances.map((b) => b.subscriber).sort()).toEqual(['alice', 'bob']);
118+
});
69119
});

app/stores/creditStore.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ import { create } from 'zustand';
1010
import { persist, createJSONStorage } from 'zustand/middleware';
1111
import { asyncStorageAdapter } from '../../src/utils/storage';
1212

13-
export type CreditTxKind = 'issue' | 'apply' | 'transfer_in' | 'transfer_out' | 'expire';
13+
export type CreditTxKind =
14+
| 'issue'
15+
| 'apply'
16+
| 'transfer_in'
17+
| 'transfer_out'
18+
| 'expire'
19+
| 'deposit'
20+
| 'withdraw';
1421

1522
export type ExpirationPolicy = { kind: 'never' } | { kind: 'after_secs'; seconds: number };
1623

@@ -46,6 +53,26 @@ export interface CreditApplied {
4653
balanceAfter: number;
4754
}
4855

56+
/**
57+
* Consolidated account-balance summary for a subscriber, combining on-book
58+
* credit (available/issued/expired/applied/transferred) with any prepayment
59+
* wallet funds. Used for high-level balance display and reconciliation.
60+
*/
61+
export interface AccountBalance {
62+
subscriber: string;
63+
availableCredit: number;
64+
totalIssued: number;
65+
totalApplied: number;
66+
totalExpired: number;
67+
totalTransferredIn: number;
68+
totalTransferredOut: number;
69+
totalDeposited: number;
70+
totalWithdrawn: number;
71+
prepaymentBalance: number;
72+
netBalance: number;
73+
nextExpirationAt?: number;
74+
}
75+
4976
const isExpired = (lot: CreditLot, now: number): boolean =>
5077
lot.expiresAt !== undefined && lot.expiresAt <= now;
5178

@@ -57,16 +84,31 @@ const availableOf = (account: AccountCredit, now: number): number =>
5784

5885
interface CreditStoreState {
5986
accounts: Record<string, AccountCredit>;
87+
wallets: Record<string, CreditWallet>;
6088
nextId: number;
6189
now: () => number;
6290

6391
issueCredit: (subscriber: string, amount: number, reason: string, expiresAt?: number) => void;
6492
setExpirationPolicy: (subscriber: string, policy: ExpirationPolicy) => void;
6593
applyCredit: (subscriber: string, subscriptionId: string, amountDue: number) => CreditApplied;
6694
transferCredit: (from: string, to: string, amount: number, reason: string) => boolean;
95+
depositCredit: (subscriber: string, amount: number, reason: string) => void;
96+
withdrawCredit: (subscriber: string, amount: number, reason: string) => boolean;
6797
expireCredits: (subscriber: string) => number;
6898
getBalance: (subscriber: string) => number;
6999
getAccount: (subscriber: string) => AccountCredit;
100+
getAccountBalance: (subscriber: string) => AccountBalance;
101+
getAccountBalances: () => AccountBalance[];
102+
}
103+
104+
/** Prepayment wallet tracked alongside credit accounts. */
105+
export interface CreditWallet {
106+
id: string;
107+
subscriber: string;
108+
currency: string;
109+
balance: number;
110+
totalDeposited: number;
111+
totalWithdrawn: number;
70112
}
71113

72114
const blankAccount = (subscriber: string): AccountCredit => ({
@@ -141,6 +183,7 @@ export const useCreditStore = create<CreditStoreState>()(
141183

142184
return {
143185
accounts: {},
186+
wallets: {},
144187
nextId: 0,
145188
now: () => Math.floor(Date.now() / 1000),
146189

@@ -217,13 +260,89 @@ export const useCreditStore = create<CreditStoreState>()(
217260

218261
getBalance: (subscriber) => availableOf(account(subscriber), get().now()),
219262
getAccount: (subscriber) => account(subscriber),
263+
264+
depositCredit: (subscriber, amount, reason) => {
265+
if (amount <= 0) return;
266+
const now = get().now();
267+
const acc = cloneAccount(account(subscriber));
268+
realizeExpiry(acc, now);
269+
acc.balance += amount;
270+
acc.lots.push({ id: nextId(), remaining: amount, issuedAt: now });
271+
record(acc, 'deposit', amount, reason);
272+
commit(acc);
273+
},
274+
275+
withdrawCredit: (subscriber, amount, reason) => {
276+
if (amount <= 0) return false;
277+
const now = get().now();
278+
const acc = cloneAccount(account(subscriber));
279+
realizeExpiry(acc, now);
280+
if (availableOf(acc, now) < amount) return false;
281+
const moved = consume(acc, now, amount);
282+
acc.balance -= moved;
283+
record(acc, 'withdraw', -moved, reason);
284+
commit(acc);
285+
return true;
286+
},
287+
288+
getAccountBalance: (subscriber): AccountBalance => {
289+
const now = get().now();
290+
const acc = account(subscriber);
291+
const availableCredit = availableOf(acc, now);
292+
const totalApplied = acc.transactions
293+
.filter((t) => t.kind === 'apply')
294+
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
295+
const totalExpired = acc.transactions
296+
.filter((t) => t.kind === 'expire')
297+
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
298+
const totalTransferredIn = acc.transactions
299+
.filter((t) => t.kind === 'transfer_in')
300+
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
301+
const totalTransferredOut = acc.transactions
302+
.filter((t) => t.kind === 'transfer_out')
303+
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
304+
const totalIssued = acc.transactions
305+
.filter((t) => t.kind === 'issue' || t.kind === 'deposit')
306+
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
307+
308+
const wallets = Object.values(get().wallets).filter(
309+
(w) => w.subscriber === subscriber
310+
);
311+
const totalDeposited = wallets.reduce((s, w) => s + w.totalDeposited, 0);
312+
const totalWithdrawn = wallets.reduce((s, w) => s + w.totalWithdrawn, 0);
313+
const prepaymentBalance = wallets.reduce((s, w) => s + w.balance, 0);
314+
315+
const expiringLots = acc.lots
316+
.filter((lot) => lot.remaining > 0 && lot.expiresAt !== undefined && lot.expiresAt > now)
317+
.sort((a, b) => (a.expiresAt ?? 0) - (b.expiresAt ?? 0));
318+
319+
return {
320+
subscriber,
321+
availableCredit,
322+
totalIssued,
323+
totalApplied,
324+
totalExpired,
325+
totalTransferredIn,
326+
totalTransferredOut,
327+
totalDeposited,
328+
totalWithdrawn,
329+
prepaymentBalance,
330+
netBalance: availableCredit + prepaymentBalance,
331+
nextExpirationAt: expiringLots[0]?.expiresAt,
332+
};
333+
},
334+
335+
getAccountBalances: () =>
336+
[...new Set([...Object.keys(get().accounts), ...Object.keys(get().wallets)])]
337+
.map((sub) => get().getAccountBalance(sub)),
220338
};
221339
},
222340
{
223341
name: 'subtrackr-credit-store',
224342
storage: createJSONStorage(() => asyncStorageAdapter),
225343
partialize: (state) => ({
226344
accounts: state.accounts,
345+
wallets: state.wallets,
227346
nextId: state.nextId,
228347
}),
229348
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {
2+
injectFailure,
3+
runFailureInjectionExperiment,
4+
} from '../experiments/failure-injection';
5+
6+
describe('Failure Injection Experiment', () => {
7+
it('injects failure into marked steps', async () => {
8+
const result = await injectFailure([
9+
{ name: 'charge', inject: true },
10+
{ name: 'notify', inject: false },
11+
]);
12+
expect(result.ok).toBe(false);
13+
expect(result.failedSteps).toEqual(['charge']);
14+
});
15+
16+
it('succeeds when no steps are marked', async () => {
17+
const result = await injectFailure([{ name: 'charge', inject: false }]);
18+
expect(result.ok).toBe(true);
19+
expect(result.failedSteps).toEqual([]);
20+
});
21+
22+
it('runFailureInjectionExperiment passes', async () => {
23+
const result = await runFailureInjectionExperiment();
24+
expect(result.experiment).toBe('failure-injection');
25+
expect(result.passed).toBe(true);
26+
expect(result.recovery).toBe('failure-contained-and-recovered');
27+
});
28+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
simulateNetworkPartition,
3+
runNetworkPartitionExperiment,
4+
PartitionNode,
5+
} from '../experiments/network-partition';
6+
7+
describe('Network Partition Experiment', () => {
8+
it('reports unreachable nodes during partition', async () => {
9+
const nodes: PartitionNode[] = [
10+
{ name: 'a', reachable: true, value: 'ok' },
11+
{ name: 'b', reachable: false, value: 'ok' },
12+
];
13+
const result = await simulateNetworkPartition(nodes);
14+
expect(result.find((r) => r.name === 'b')?.ok).toBe(false);
15+
expect(result.find((r) => r.name === 'a')?.ok).toBe(true);
16+
});
17+
18+
it('recovers once partition heals', async () => {
19+
const nodes: PartitionNode[] = [
20+
{ name: 'a', reachable: false, value: 'ok' },
21+
];
22+
const recovered = await simulateNetworkPartition(nodes, true);
23+
expect(recovered[0].ok).toBe(true);
24+
});
25+
26+
it('runNetworkPartitionExperiment passes', async () => {
27+
const result = await runNetworkPartitionExperiment();
28+
expect(result.experiment).toBe('network-partition');
29+
expect(result.passed).toBe(true);
30+
expect(result.recovery).toBe('partition-healed');
31+
});
32+
});

0 commit comments

Comments
 (0)