Skip to content
Merged
200 changes: 200 additions & 0 deletions alert-failed-charges.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
sentTimestamps: Record<string, string[]>;
}

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<string, { count: number; alerts: Alert[] }> = {};
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<void> {
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<ChargeResult[]> {
let raw: string;
if (filePath) {
raw = fs.readFileSync(filePath, 'utf8');
} else {
const rl = readline.createInterface({ input: process.stdin });
raw = await new Promise<string>((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<void> {
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);
});
}
29 changes: 29 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@
"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",
"typecheck": "cd frontend && npm ci && npm exec -- tsc --noEmit",
"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'"
]
}
}
}
89 changes: 89 additions & 0 deletions tests/alert-failed-charges.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading