Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,8 @@ This project participates in the [Drips Wave Program](https://drips.network/wave
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide.

**Do not start coding until assigned to an issue by a maintainer.**

## Handsoff notes

<!-- handsoff-issue-912 -->
- #912: Implement advanced filter DSL for invoice queries
85 changes: 85 additions & 0 deletions src/__tests__/budgetTracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { BudgetTracker } from '../budgetTracker';

describe('BudgetTracker', () => {
it('tracks cumulative spend and remaining budget', () => {
const tracker = new BudgetTracker({ limit: 100 });
tracker.track(30);
tracker.track(20);
expect(tracker.getSpent()).toBe(50);
expect(tracker.getRemaining()).toBe(50);
expect(tracker.isLimitReached()).toBe(false);
});

it('emits a warning when spend approaches the limit', () => {
const tracker = new BudgetTracker({ limit: 100, warnThreshold: 0.8 });
const listener = jest.fn();
tracker.on('warning', listener);

tracker.track(79);
expect(listener).not.toHaveBeenCalled();

tracker.track(1);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
event: 'warning',
spent: 80,
limit: 100,
remaining: 20,
});
});

it('emits the warning only once', () => {
const tracker = new BudgetTracker({ limit: 100 });
const listener = jest.fn();
tracker.on('warning', listener);

tracker.track(85);
tracker.track(5);
expect(listener).toHaveBeenCalledTimes(1);
});

it('emits limit-reached when spend meets or exceeds the limit', () => {
const tracker = new BudgetTracker({ limit: 100 });
const listener = jest.fn();
tracker.on('limit-reached', listener);

tracker.track(100);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
event: 'limit-reached',
spent: 100,
limit: 100,
remaining: 0,
});
expect(tracker.isLimitReached()).toBe(true);
});

it('emits limit-reached only once and clamps remaining at zero', () => {
const tracker = new BudgetTracker({ limit: 100 });
const listener = jest.fn();
tracker.on('limit-reached', listener);

tracker.track(120);
tracker.track(10);
expect(listener).toHaveBeenCalledTimes(1);
expect(tracker.getRemaining()).toBe(0);
});

it('supports unsubscribing from events', () => {
const tracker = new BudgetTracker({ limit: 100 });
const listener = jest.fn();
const unsubscribe = tracker.on('warning', listener);

unsubscribe();
tracker.track(90);
expect(listener).not.toHaveBeenCalled();
});

it('rejects invalid configuration and amounts', () => {
expect(() => new BudgetTracker({ limit: 0 })).toThrow();
expect(() => new BudgetTracker({ limit: 100, warnThreshold: 1 })).toThrow();

const tracker = new BudgetTracker({ limit: 100 });
expect(() => tracker.track(-1)).toThrow();
});
});
81 changes: 81 additions & 0 deletions src/approvalWorkflowSequencer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,87 @@ export interface ApprovalWorkflowOptions {
applySignatures?: SignatureApplier;
}

export interface PaymentForwardingRule {
id: string;
/** Optional source account filter; matches any source when omitted. */
source?: string;
/** Optional destination account filter; matches any destination when omitted. */
destination?: string;
/** Optional asset code filter; matches any asset when omitted. */
assetCode?: string;
/** Optional inclusive minimum amount filter. */
minAmount?: number;
/** Optional inclusive maximum amount filter. */
maxAmount?: number;
/** Account that receives the forwarded payment. */
forwardTo: string;
}

export interface PaymentForwardingRequest {
source: string;
destination: string;
assetCode: string;
amount: number;
}

export interface PaymentForwardingDecision {
forwarded: boolean;
ruleId?: string;
forwardTo?: string;
}

export class PaymentForwardingRulesEngine {
private readonly rules: PaymentForwardingRule[] = [];

constructor(rules: readonly PaymentForwardingRule[] = []) {
for (const rule of rules) {
this.addRule(rule);
}
}

addRule(rule: PaymentForwardingRule): void {
if (!rule.id) {
throw new Error("Payment forwarding rule requires an id");
}
if (!rule.forwardTo) {
throw new Error(`Payment forwarding rule requires a forwardTo account: ${rule.id}`);
}
this.rules.push(rule);
emitSdkEvent("paymentForwardingRuleAdded", { ruleId: rule.id });
}

getRules(): readonly PaymentForwardingRule[] {
return this.rules;
}

evaluate(request: PaymentForwardingRequest): PaymentForwardingDecision {
const rule = this.rules.find((candidate) => this.matches(candidate, request));
if (!rule) {
emitSdkEvent("paymentForwardingSkipped", {
source: request.source,
destination: request.destination,
});
return { forwarded: false };
}

emitSdkEvent("paymentForwarded", {
ruleId: rule.id,
forwardTo: rule.forwardTo,
amount: request.amount,
});
return { forwarded: true, ruleId: rule.id, forwardTo: rule.forwardTo };
}

private matches(rule: PaymentForwardingRule, request: PaymentForwardingRequest): boolean {
if (rule.source !== undefined && rule.source !== request.source) return false;
if (rule.destination !== undefined && rule.destination !== request.destination) return false;
if (rule.assetCode !== undefined && rule.assetCode !== request.assetCode) return false;
if (rule.minAmount !== undefined && request.amount < rule.minAmount) return false;
if (rule.maxAmount !== undefined && request.amount > rule.maxAmount) return false;
return true;
}
}

export class ApprovalSession {
private readonly signatures = new Map<string, string>();
private readonly signerWeights = new Map<string, number>();
Expand Down
102 changes: 102 additions & 0 deletions src/budgetTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
export interface BudgetTrackerOptions {
/** Maximum allowed cumulative spend. */
limit: number;
/** Fraction of the limit at which a warning is emitted (0 < threshold < 1). Defaults to 0.8. */
warnThreshold?: number;
}

export type BudgetTrackerEvent = 'warning' | 'limit-reached';

export type BudgetTrackerListener = (payload: {
event: BudgetTrackerEvent;
spent: number;
limit: number;
remaining: number;
}) => void;

/**
* Tracks cumulative spend against a configured budget limit and emits
* events when spend approaches or exceeds the limit.
*/
export class BudgetTracker {
private readonly limit: number;
private readonly warnThreshold: number;
private spent = 0;
private warned = false;
private reached = false;
private readonly listeners = new Map<BudgetTrackerEvent, Set<BudgetTrackerListener>>();

constructor(options: BudgetTrackerOptions) {
if (!Number.isFinite(options.limit) || options.limit <= 0) {
throw new Error('BudgetTracker: limit must be a positive finite number');
}
const warnThreshold = options.warnThreshold ?? 0.8;
if (!Number.isFinite(warnThreshold) || warnThreshold <= 0 || warnThreshold >= 1) {
throw new Error('BudgetTracker: warnThreshold must be between 0 and 1 (exclusive)');
}
this.limit = options.limit;
this.warnThreshold = warnThreshold;
}

/** Record additional spend and emit threshold events as needed. */
track(amount: number): void {
if (!Number.isFinite(amount) || amount < 0) {
throw new Error('BudgetTracker: amount must be a non-negative finite number');
}
this.spent += amount;

if (!this.warned && this.spent >= this.limit * this.warnThreshold && this.spent < this.limit) {
this.warned = true;
this.emit('warning');
}

if (!this.reached && this.spent >= this.limit) {
this.reached = true;
this.emit('limit-reached');
}
}

/** Current cumulative spend. */
getSpent(): number {
return this.spent;
}

/** Remaining budget (never negative). */
getRemaining(): number {
return Math.max(0, this.limit - this.spent);
}

/** Whether spend has reached or exceeded the limit. */
isLimitReached(): boolean {
return this.reached;
}

/** Subscribe to a budget event. Returns an unsubscribe function. */
on(event: BudgetTrackerEvent, listener: BudgetTrackerListener): () => void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(listener);
return () => {
set?.delete(listener);
};
}

private emit(event: BudgetTrackerEvent): void {
const set = this.listeners.get(event);
if (!set) {
return;
}
const payload = {
event,
spent: this.spent,
limit: this.limit,
remaining: this.getRemaining(),
};
for (const listener of set) {
listener(payload);
}
}
}
Loading