diff --git a/alert-failed-charges.ts b/alert-failed-charges.ts new file mode 100644 index 0000000..cfc00c0 --- /dev/null +++ b/alert-failed-charges.ts @@ -0,0 +1,200 @@ +import { Config, loadConfig } from './config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; + +export interface ChargeResult { + id: string; + subscriberId: string; + amount: number; + currency: string; + status: 'succeeded' | 'failed'; + failureReason?: string; + failureCode?: string; + createdAt: string; +} + +export interface Alert { + subscriberId: string; + reason: string; + chargeId: string; + amount: number; + currency: string; + createdAt: string; +} + +interface DedupState { + lastSent: Record; + sentTimestamps: Record; +} + +function getKey(alert: Alert): string { + return `${alert.subscriberId}:${alert.reason}`; +} + +function loadState(filePath?: string): DedupState { + const empty: DedupState = { lastSent: { }, sentTimestamps: { } }; + if (!filePath) return empty; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return { + lastSent: parsed.lastSent || {}, + sentTimestamps: parsed.sentTimestamps || {}, + }; + } catch (err: any) { + // If file doesn't exist or is invalid, start fresh. + return empty; + } +} + +function saveState(filePath: string, state: DedupState): void { + fs.writeFileSync(filePath, JSON.stringify(state, null, 2)); +} + +function isWithinWindow(timestamp: string, now: Date, windowMs: number): boolean { + const t = new Date(timestamp).getTime(); + return now.getTime() - t <= windowMs; +} + +function shouldSendAlert(state: DedupState, alert: Alert, config: Config, now: Date): boolean { + const key = getKey(alert); + const last = state.lastSent[key]; + if (last && isWithinWindow(last, now, config.dedupWindowMs)) { + return false; // Deduplicated + } + + // Rate-limit per subscriber + const sentTimes = state.sentTimestamps[alert.subscriberId] || []; + const recentTimes = sentTimes.filter(t => isWithinWindow(t, now, config.dedupWindowMs)); + if (recentTimes.length >= config.maxAlertsPerSubscriber) { + return false; + } + + return true; +} + +function markAlertSent(state: DedupState, alert: Alert, now: Date): void { + const key = getKey(alert); + state.lastSent[key] = now.toISOString(); + + if (!state.sentTimestamps[alert.subscriberId]) { + state.sentTimestamps[alert.subscriberId] = []; + } + state.sentTimestamps[alert.subscriberId].push(now.toISOString()); +} + +/** + * Builds webhook payload grouped by reason. + */ +export function buildPayload(alerts: Alert[]): unknown { + const groups: Record = {}; + for (const alert of alerts) { + if (!groups[alert.reason]) { + groups[alert.reason] = { count: 0, alerts: [] }; + } + groups[alert.reason].count++; + groups[alert.reason].alerts.push(alert); + } + return { + event: 'failed_charges', + timestamp: new Date().toISOString(), + groups, + }; +} + +/** + * Sends the webhook payload. If no URL is configured, logs it. + */ +async function sendWebhook(url: string, payload: unknown): Promise { + if (!url) { + console.log('Webhook URL not set. Payload:'); + console.log(JSON.stringify(payload, null, 2)); + return; + } + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Webhook failed with status ${response.status}`); + } +} + +/** + * Reads batch results from a file or stdin. + */ +async function readInput(filePath?: string): Promise { + let raw: string; + if (filePath) { + raw = fs.readFileSync(filePath, 'utf8'); + } else { + const rl = readline.createInterface({ input: process.stdin }); + raw = await new Promise((resolve) => { + let data = ''; + rl.on('line', (line) => { data += line; }); + rl.on('close', () => resolve(data)); + }); + } + return JSON.parse(raw) as ChargeResult[]; +} + +/** + * Classifies failed charges into alerts grouped by reason. + */ +export function classifyFailures(results: ChargeResult[]): Alert[] { + const alerts: Alert[] = []; + for (const result of results) { + if (result.status !== 'failed') continue; + const reason = result.failureReason || result.failureCode || 'unknown'; + alerts.push({ + subscriberId: result.subscriberId, + reason, + chargeId: result.id, + amount: result.amount, + currency: result.currency, + createdAt: result.createdAt, + }); + } + return alerts; +} + +/** + * Main entry point. + */ +export async function main(): Promise { + const config = loadConfig(); + const results = await readInput(config.inputFile); + const alerts = classifyFailures(results); + + const state = loadState(config.stateFilePath); + const now = new Date(); + + const toSend: Alert[] = []; + for (const alert of alerts) { + if (shouldSendAlert(state, alert, config, now)) { + toSend.push(alert); + markAlertSent(state, alert, now); + } + } + + if (toSend.length > 0) { + const payload = buildPayload(toSend); + await sendWebhook(config.webhookUrl, payload); + } + + if (config.stateFilePath) { + saveState(config.stateFilePath, state); + } +} + +// Run only when executed directly +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} \ No newline at end of file diff --git a/config.ts b/config.ts index 2fe3cfb..fe59bf5 100644 --- a/config.ts +++ b/config.ts @@ -1,3 +1,32 @@ +/** + * Configuration for the alert-failed-charges script. + * Reads environment variables. + */ +export interface Config { + /** Webhook URL to POST alerts to. If empty, alerts are logged instead. */ + webhookUrl: string; + /** Dedup window in milliseconds. Alerts for the same subscriber+reason are suppressed within this window. */ + dedupWindowMs: number; + /** Maximum number of alerts sent per subscriber within the dedup window. */ + maxAlertsPerSubscriber: number; + /** Path to state file for dedup persistence. If not set, dedup is in-memory only. */ + stateFilePath?: string; + /** Path to the batch results JSON file. If not set, reads from stdin. */ + inputFile?: string; +} + +/** + * Loads configuration from environment variables with defaults. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + return { + webhookUrl: env.WEBHOOK_URL || '', + dedupWindowMs: Number(env.DEDUP_WINDOW_MS || 3600000), + maxAlertsPerSubscriber: Number(env.MAX_ALERTS_PER_SUBSCRIBER || 5), + stateFilePath: env.STATE_FILE_PATH, + inputFile: env.INPUT_FILE, + }; +} import * as dotenv from 'dotenv'; import { Keypair } from 'stellar-sdk'; diff --git a/package.json b/package.json index 41e5071..40eeb1e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "prepare": "husky", "build:frontend": "cd frontend && npm ci && npm run build", + "typecheck": "cd frontend && npm ci && npm exec-- tsc --noEmit", "typecheck": "cd frontend && ./node_modules/.bin/tsc --noEmit", "typecheck:scripts": "cd scripts && npx tsc --noEmit", "test:scripts": "cd scripts && npm install --prefer-offline && npm test", @@ -12,15 +13,19 @@ "audit:contrast": "npm run typecheck", "backend:typecheck": "cd contract && cargo check", "backend:test": "cd contract && cargo test", - "generate:types": "bash scripts/generate-types.sh" + "generate:types": "bash scripts/generate-types.sh", + "test": "tsx --test tests/*.test.ts" }, "devDependencies": { + "@types/node": "^20.16.0", "husky": "^9.1.7", - "lint-staged": "^15.5.2" + "lint-staged": "^15.5.2", + "tsx": "^4.19.0", + "typescript": "^5.6.3" }, "lint-staged": { - "frontend/src/**/*.{ts,tsx}": [ + "frontend/src/**/*.ts,.tsx": [ "bash -c 'cd frontend && npx tsc --noEmit'" ] } -} +} \ No newline at end of file diff --git a/tests/alert-failed-charges.test.ts b/tests/alert-failed-charges.test.ts new file mode 100644 index 0000000..9e8e95f --- /dev/null +++ b/tests/alert-failed-charges.test.ts @@ -0,0 +1,89 @@ +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { classifyFailures, buildPayload, shouldSendAlert, markAlertSent, main } from '../alert-failed-charges'; +import { loadConfig } from '../config'; + +function loadFixture(name: string): any { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8')); +} + +describe('classifyFailures', () => { + it('groups failed charges by reason', () => { + const results = [ + { id: 'ch_1', subscriberId: 'sub_1', amount: 10, currency: 'usd', status: 'failed', failureReason: 'insufficient_funds', createdAt: '2024-01-01T00:00:00Z' }, + { id: 'ch_2', subscriberId: 'sub_2', amount: 20, currency: 'usd', status: 'failed', failureReason: 'card_declined', createdAt: '2024-01-01T00:00:00Z' }, + { id: 'ch_3', subscriberId: 'sub_1', amount: 30, currency: 'usd', status: 'failed', failureReason: 'insufficient_funds', createdAt: '2024-01-01T00:00:00Z' }, + { id: 'ch_4', subscriberId: 'sub_3', amount: 40, currency: 'usd', status: 'succeeded', createdAt: '2024-01-01T00:00:00Z' }, + ]; + const alerts = classifyFailures(results); + assert.equal(alerts.length, 3); + const reasons = alerts.map(a => a.reason); + assert.deepEqual(reasons.sort(), ['card_declined', 'insufficient_funds', 'insufficient_funds']); + }); +}); + +describe('buildPayload', () => { + it('groups alerts by reason', () => { + const alerts = [ + { subscriberId: 'a', reason: 'x', chargeId: 'ch1', amount: 1, currency: 'usd', createdAt: '2024-01-01T00:00:00Z' }, + { subscriberId: 'b', reason: 'x', chargeId: 'ch2', amount: 2, currency: 'usd', createdAt: '2024-01-01T00:00:00Z' }, + { subscriberId: 'c', reason: 'y', chargeId: 'ch3', amount: 3, currency: 'usd', createdAt: '2024-01-01T00:00:00Z' }, + ]; + const payload = buildPayload(alerts) as any; + assert.equal(payload.event, 'failed_charges'); + assert.equal(payload.groups.x.count, 2); + assert.equal(payload.groups.y.count, 1); + }); +}); + +describe('shouldSendAlert', () => { + const config = { webhookUrl: 'http://example.com', dedupWindowMs: 3600000, maxAlertsPerSubscriber: 2 } as any; + const now = new Date('2024-01-01T12:00:00Z'); + const alert = { subscriberId: 'sub', reason: 'x', chargeId: 'ch', amount: 1, currency: 'usd', createdAt: '2024-01-01T00:00:00Z' }; + + it('allows first alert', () => { + const state = { lastSent: {}, sentTimestamps: {} }; + assert.equal(shouldSendAlert(state, alert, config, now), true); + }); + + it('deduplicates same subscriber+reason within window', () => { + const state = { lastSent: { 'sub:x': now.toISOString() }, sentTimestamps: { sub: [now.toISOString()] } }; + assert.equal(shouldSendAlert(state, alert, config, now), false); + }); + + it('rate-limits subscriber after max alerts', () => { + const earlier = new Date(now.getTime() - 60000).toISOString(); + const state = { + lastSent: { 'sub:x': earlier }, + sentTimestamps: { + sub: [earlier, earlier], // already 2 within window + }, + }; + assert.equal(shouldSendAlert(state, alert, config, now), false); + }); +}); + +describe('main with fixture', () => { + it('processes fixture and sends webhook', async () => { + const fixture = loadFixture('batch-results.json'); + const fetchMock = mock.fn(async () => new Response(null, { status: 200 })); + global.fetch = fetchMock; + + const originalEnv = process.env; + process.env = { ...originalEnv, INPUT_FILE: path.join(__dirname, 'fixtures', 'batch-results.json'), WEBHOOK_URL: 'http://test' }; + try { + await main(); + } finally { + process.env = originalEnv; + delete (global as any).fetch; + } + + assert.equal(fetchMock.mock.calls.length, 1); + const [url, options] = fetchMock.mock.calls[0].arguments; + assert.equal(url, 'http://test'); + const payload = JSON.parse((options as RequestInit).body as string); + assert.ok(payload.groups); + }); +}); \ No newline at end of file diff --git a/tests/fixtures/batch-results.json b/tests/fixtures/batch-results.json new file mode 100644 index 0000000..814451c --- /dev/null +++ b/tests/fixtures/batch-results.json @@ -0,0 +1,40 @@ +[ + { + "id": "ch_123", + "subscriberId": "sub_1", + "amount": 1000, + "currency": "usd", + "status": "failed", + "failureReason": "insufficient_funds", + "failureCode": "insufficient_funds", + "createdAt": "2024-01-01T00:00:00Z" + }, + { + "id": "ch_456", + "subscriberId": "sub_2", + "amount": 2000, + "currency": "usd", + "status": "failed", + "failureReason": "card_declined", + "failureCode": "card_declined", + "createdAt": "2024-01-01T00:00:00Z" + }, + { + "id": "ch_789", + "subscriberId": "sub_1", + "amount": 1500, + "currency": "usd", + "status": "failed", + "failureReason": "insufficient_funds", + "failureCode": "insufficient_funds", + "createdAt": "2024-01-01T00:00:00Z" + }, + { + "id": "ch_101", + "subscriberId": "sub_3", + "amount": 500, + "currency": "usd", + "status": "succeeded", + "createdAt": "2024-01-01T00:00:00Z" + } +] \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8f0569e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibrCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["alert-failed-charges.ts", "config.ts", "tests/**/*.ts"] +} \ No newline at end of file