diff --git a/README.md b/README.md index 762d7fe..81f52ea 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ new StellarSplitClient(config: StellarSplitClientConfig) | Class | Description | |-------|-------------| -| `MultiTenantClient` | Manage a pool of `StellarSplitClient` instances keyed by tenant ID, with `getClient`, `evict`, and `evictAll` | +| `MultiTenantClient` | Manage a pool of `StellarSplitClient` instances keyed by tenant ID, with `getClient`, `evict`, `evictAll`, `stats`, and O(1) LRU/TTL/health-check eviction. Construct with a tenant→config factory plus `{ maxClients, ttlMs, healthCheckIntervalMs }`, or with the options only and pass a config to `getClient(tenantId, config)` | ### Profiling @@ -177,6 +177,60 @@ Prune stale or over-broad ledger keys from Soroban transactions before submissio See [docs/FOOTPRINT_OPTIMIZER.md](./docs/FOOTPRINT_OPTIMIZER.md). +### Payment Aggregator (multi-invoice allocation) + +Allocate a single budget across many invoices. Choose `"equal"`, `"proportional"` +(weighted by how far each invoice still is from its target) or `"custom"` weights +that sum to 100. Every allocation is capped at the invoice's remaining amount, so +overpayment is impossible. + +```typescript +import { aggregatePayments, createInvoiceRemainingFetcher } from "@stellar-split/sdk"; + +const allocations = await aggregatePayments(parseAmount("100"), [1n, 2n, 3n], "proportional", { + // Any object exposing getInvoice(id) works — a StellarSplitClient is ideal. + fetchRemaining: createInvoiceRemainingFetcher(client), +}); + +for (const { invoiceId, amount, percentOfBudget } of allocations) { + console.log(`Invoice ${invoiceId}: ${formatAmount(amount)} (${percentOfBudget}%)`); +} +``` + +| Function | Description | +|----------|-------------| +| `aggregatePayments(budget, invoiceIds, strategy, options?)` | Compute the optimal allocation; returns one `PaymentAllocation` per invoice (`{ invoiceId, amount, percentOfBudget }`) | +| `remainingForInvoice(invoice)` | Remaining (still unfunded) amount of an invoice, clamped at zero | +| `createInvoiceRemainingFetcher(source)` | Build a remaining-amount fetcher from any `getInvoice(id)` source | +| `registerInvoiceRemainingFetcher(fetcher)` | Set a process-wide fallback fetcher so `aggregatePayments` can be called without options | + +Behavior notes: an empty invoice list returns `[]`; duplicate invoice IDs and a +negative budget throw `ValidationError`; `"custom"` weights must sum to 100; +when the invoices collectively need less than the budget, the surplus stays +unallocated and the percentages sum to less than 100. + +### Deadline Helpers + +`bigint`-based deadline helpers (Unix seconds), matching the on-chain `u64` +representation. The legacy `number`-returning `deadlineFromDays` remains +available from the `@stellar-split/sdk/utils` entry point. + +| Function | Returns | Description | +|----------|---------|-------------| +| `deadlineFromDays(days)` | `bigint` | Unix timestamp `days` days from now (rounded up to the next whole second) | +| `deadlineFromDate(date)` | `bigint` | Convert a `Date` to a Unix timestamp in seconds | +| `isDeadlineValid(deadline)` | `boolean` | `true` when the deadline is at least 1 hour in the future | +| `timeUntilDeadline(deadline)` | `DeadlineRemaining` | `{ days, hours, minutes, seconds, expired }`, all zeros once expired | +| `formatDeadline(deadline, locale?)` | `string` | Human-readable date string, localized (rendered in UTC) | + +```typescript +import { deadlineFromDays, isDeadlineValid, timeUntilDeadline } from "@stellar-split/sdk"; + +const deadline = deadlineFromDays(7); +isDeadlineValid(deadline); // true +timeUntilDeadline(deadline); // { days: 7, hours: 0, minutes: 0, seconds: 0, expired: false } +``` + ### Utilities | Function | Description | @@ -184,7 +238,7 @@ See [docs/FOOTPRINT_OPTIMIZER.md](./docs/FOOTPRINT_OPTIMIZER.md). | `formatAmount(stroops)` | Format stroops as USDC string (7 decimals) | | `parseAmount(value)` | Parse USDC string to stroops | | `isValidAddress(address)` | Validate a Stellar G... address | -| `deadlineFromDays(days)` | Unix timestamp N days from now | +| `deadlineFromDays(days)` | Unix timestamp N days from now (`number`; the root export returns a `bigint` — see [Deadline Helpers](#deadline-helpers)) | | `isExpired(deadline)` | Check if a deadline has passed | | `truncateAddress(address)` | Truncate for display: "GABC...XYZ" | diff --git a/src/deadline.ts b/src/deadline.ts new file mode 100644 index 0000000..e6fe1ce --- /dev/null +++ b/src/deadline.ts @@ -0,0 +1,171 @@ +/** + * Deadline helpers for StellarSplit invoices. + * + * Complements {@link DeadlineEngine} (countdowns, business hours and expiry + * callbacks) with small, pure helpers for *computing*, *validating* and + * *formatting* invoice deadlines as `bigint` Unix timestamps in seconds — the + * same `u64` representation the Soroban contract stores. + * + * Note: `@stellar-split/sdk/utils` still exposes a legacy + * `deadlineFromDays(days): number` helper. The helpers in this module are the + * `bigint`-based API re-exported from the package root. + * + * @example + * import { deadlineFromDays, isDeadlineValid, timeUntilDeadline } from "@stellar-split/sdk"; + * + * const deadline = deadlineFromDays(7); // 7 days from now, as bigint + * isDeadlineValid(deadline); // true (more than 1 hour away) + * timeUntilDeadline(deadline); // { days: 7, hours: 0, … } + */ + +import { ValidationError } from "./errors.js"; + +/** Number of seconds in one day. */ +const SECONDS_PER_DAY = 86_400; +/** Number of seconds in one hour. */ +const SECONDS_PER_HOUR = 3_600; +/** Minimum lead time (in seconds) for a deadline to be accepted. */ +const MIN_VALID_LEAD_SECONDS = 3_600; + +/** Remaining time until a deadline, broken down into calendar units. */ +export interface DeadlineRemaining { + /** Whole days remaining (0 once expired). */ + days: number; + /** Whole hours remaining after `days` (0-23). */ + hours: number; + /** Whole minutes remaining after `hours` (0-59). */ + minutes: number; + /** Whole seconds remaining after `minutes` (0-59). */ + seconds: number; + /** `true` when the deadline is now or in the past. */ + expired: boolean; +} + +/** Current wall-clock time as a whole Unix second. */ +function nowSeconds(): bigint { + return BigInt(Math.floor(Date.now() / 1000)); +} + +/** + * Compute a Unix timestamp `n` days from now. + * + * Any positive `n`, however small, yields a timestamp strictly in the future + * because the duration is rounded **up** to the next whole second. + * + * @param n - Number of days from now. May be fractional or negative. + * @returns A Unix timestamp in seconds, as a `bigint`. + * @throws {ValidationError} If `n` is not a finite number. + * + * @example + * deadlineFromDays(7); // now (seconds) + 604_800n + * deadlineFromDays(0); // now, rounded up to the current second + */ +export function deadlineFromDays(n: number): bigint { + if (!Number.isFinite(n)) { + throw new ValidationError("deadlineFromDays requires a finite number of days", { + days: n, + }); + } + + return nowSeconds() + BigInt(Math.ceil(n * SECONDS_PER_DAY)); +} + +/** + * Convert a `Date` to a Unix timestamp in seconds. + * + * Sub-second precision is truncated, matching the on-chain second resolution. + * + * @param date - The date to convert. + * @returns A Unix timestamp in seconds, as a `bigint`. + * @throws {ValidationError} If `date` is invalid (for example `new Date("nope")`). + * + * @example + * deadlineFromDate(new Date(0)); // 0n + */ +export function deadlineFromDate(date: Date): bigint { + const ms = date.getTime(); + + if (!Number.isFinite(ms)) { + throw new ValidationError("deadlineFromDate requires a valid Date", { + date: date.toString(), + }); + } + + return BigInt(Math.floor(ms / 1000)); +} + +/** + * Report whether `deadline` is at least one hour in the future. + * + * @param deadline - Unix timestamp in seconds. + * @returns `true` when `deadline - now >= 1 hour`. + * + * @example + * isDeadlineValid(deadlineFromDays(1)); // true + * isDeadlineValid(BigInt(Math.floor(Date.now() / 1000))); // false + */ +export function isDeadlineValid(deadline: bigint): boolean { + return deadline - nowSeconds() >= BigInt(MIN_VALID_LEAD_SECONDS); +} + +/** + * Break the remaining time until `deadline` into calendar units. + * + * Expired deadlines return all-zero units with `expired: true` rather than + * negative values, so the result can be rendered directly. + * + * @param deadline - Unix timestamp in seconds. + * @returns A {@link DeadlineRemaining} breakdown. + * + * @example + * timeUntilDeadline(deadlineFromDays(1)); // { days: 1, hours: 0, minutes: 0, seconds: 0, expired: false } + * timeUntilDeadline(0n); // { days: 0, …, expired: true } + */ +export function timeUntilDeadline(deadline: bigint): DeadlineRemaining { + const diff = deadline - nowSeconds(); + + if (diff <= 0n) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + } + + return { + days: Number(diff / BigInt(SECONDS_PER_DAY)), + hours: Number((diff % BigInt(SECONDS_PER_DAY)) / BigInt(SECONDS_PER_HOUR)), + minutes: Number((diff % BigInt(SECONDS_PER_HOUR)) / 60n), + seconds: Number(diff % 60n), + expired: false, + }; +} + +/** + * Format a deadline as a human-readable, locale-aware date string. + * + * Output is always rendered in UTC so the same deadline formats identically on + * every machine and CI runner; only the language/format of the string changes + * with `locale`. + * + * @param deadline - Unix timestamp in seconds. + * @param locale - Optional BCP-47 locale tag (for example `"en-US"`, `"de-DE"`). + * Defaults to the runtime locale. + * @returns A localized date/time string, for example `"Sep 25, 2026, 12:00 PM"`. + * @throws {ValidationError} If `deadline` cannot be represented as a `Date`. + * + * @example + * formatDeadline(deadlineFromDays(7)); // e.g. "Oct 2, 2026, 11:00 AM" + * formatDeadline(deadlineFromDays(7), "de-DE"); // e.g. "02.10.2026, 11:00" + */ +export function formatDeadline(deadline: bigint, locale?: string): string { + const ms = Number(deadline) * 1000; + + if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) { + throw new ValidationError("formatDeadline received a deadline outside the supported Date range", { + deadline: deadline.toString(), + }); + } + + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(ms)); +} diff --git a/src/index.ts b/src/index.ts index d3c162e..48d672b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1053,6 +1053,39 @@ export type { HistoricalInvoiceSample, } from "./forecast.js"; +// --------------------------------------------------------------------------- +// #852 — Deadline helpers +// --------------------------------------------------------------------------- + +export { + deadlineFromDays, + deadlineFromDate, + isDeadlineValid, + timeUntilDeadline, + formatDeadline, +} from "./deadline.js"; +export type { DeadlineRemaining } from "./deadline.js"; + +// --------------------------------------------------------------------------- +// #853 — Payment aggregator (multi-invoice budget allocation) +// --------------------------------------------------------------------------- + +export { + aggregatePayments, + createInvoiceRemainingFetcher, + registerInvoiceRemainingFetcher, + remainingForInvoice, +} from "./paymentAllocation.js"; +export type { + AggregatePaymentsOptions, + AmountLookup, + InvoiceRemainingFetcher, + InvoiceSource, + PaymentAllocation, + SplitStrategy, +} from "./paymentAllocation.js"; + + // --------------------------------------------------------------------------- // Split ratio validator // --------------------------------------------------------------------------- diff --git a/src/multiTenant.ts b/src/multiTenant.ts index f4d4dfa..67cebdb 100644 --- a/src/multiTenant.ts +++ b/src/multiTenant.ts @@ -1,6 +1,7 @@ import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; import type { StellarSplitClientConfig } from "./client.js"; import { StellarSplitClient } from "./client.js"; +import { ValidationError } from "./errors.js"; // --------------------------------------------------------------------------- // Disposable helpers (unchanged from original) @@ -120,7 +121,7 @@ export class MultiTenantClient { // on every hit so the most-recently-used entry is always at the end. private readonly pool = new Map(); - private readonly clientFactory: (tenantId: string) => StellarSplitClientConfig; + private readonly clientFactory: ((tenantId: string) => StellarSplitClientConfig) | null; private readonly maxClients: number; private readonly ttlMs: number; private readonly healthCheckIntervalMs: number; @@ -134,11 +135,35 @@ export class MultiTenantClient { // Background health-check timer private _healthTimer: ReturnType | null = null; + /** + * Create a tenant pool. + * + * Two call styles are supported: pass a factory plus options (the original + * signature), or pass only {@link PoolOptions} and supply each tenant's config + * to `getClient(tenantId, config)`. + * + * @example + * // Options only — config comes from getClient() + * const pool = new MultiTenantClient({ maxClients: 50, ttlMs: 60_000 }); + * pool.getClient("tenant-a", tenantConfig); + * + * @example + * // Factory + options + * const pool = new MultiTenantClient((id) => configFor(id), { maxClients: 50 }); + */ constructor( - clientFactory: (tenantId: string) => StellarSplitClientConfig, + clientFactoryOrOptions: + | ((tenantId: string) => StellarSplitClientConfig) + | PoolOptions = {}, options: PoolOptions = {} ) { - this.clientFactory = clientFactory; + if (typeof clientFactoryOrOptions === "function") { + this.clientFactory = clientFactoryOrOptions; + } else { + this.clientFactory = null; + options = clientFactoryOrOptions; + } + this.maxClients = options.maxClients ?? Infinity; this.ttlMs = options.ttlMs ?? Infinity; this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 0; @@ -196,7 +221,15 @@ export class MultiTenantClient { this._evict(lruKey, lruEntry); } - const resolvedConfig = config ?? this.clientFactory(tenantId); + const resolvedConfig = config ?? this.clientFactory?.(tenantId); + + if (!resolvedConfig) { + throw new ValidationError( + `No configuration available for tenant "${tenantId}". Pass a config to getClient() or a client factory to the MultiTenantClient constructor.`, + { tenantId } + ); + } + const client = new StellarSplitClient(resolvedConfig); const rpcUrl = Array.isArray(resolvedConfig.rpcUrl) ? resolvedConfig.rpcUrl[0] ?? "" diff --git a/src/paymentAllocation.ts b/src/paymentAllocation.ts new file mode 100644 index 0000000..0e5ce2b --- /dev/null +++ b/src/paymentAllocation.ts @@ -0,0 +1,459 @@ +/** + * Payment aggregator — allocate one budget across many invoices. + * + * Given a total budget in stroops and a list of invoice IDs, {@link aggregatePayments} + * computes how much should be paid to each invoice using one of three + * strategies: + * + * - `equal` — split the budget evenly across the invoices. + * - `proportional` — weight each invoice by how far it still is from its + * target (its remaining amount, or `remaining / target` when targets are + * supplied), so invoices furthest from being funded receive the most. + * - `custom` — use caller-supplied weights that sum to 100. + * + * Every allocation is capped at the invoice's remaining amount, so the + * aggregator can never propose an overpayment. Rounding dust is redistributed + * deterministically (largest-remainder method) so the allocations always sum to + * `min(budget, total remaining)`. + * + * The remaining amount of each invoice is resolved from (in order) an explicit + * `remaining` map/record, a per-call `fetchRemaining` function, an + * `invoiceSource` (any object exposing `getInvoice`), or a fetcher registered + * through {@link registerInvoiceRemainingFetcher}. + * + * @example + * import { aggregatePayments } from "@stellar-split/sdk"; + * + * const allocations = await aggregatePayments(1_000_000_000n, [1n, 2n, 3n], "proportional", { + * invoiceSource: client, // StellarSplitClient + * }); + * + * for (const { invoiceId, amount, percentOfBudget } of allocations) { + * console.log(`Invoice ${invoiceId}: ${amount} (${percentOfBudget}% of budget)`); + * } + */ + +import { ValidationError } from "./errors.js"; +import type { Invoice } from "./types.js"; + +/** Budget-allocation strategies supported by {@link aggregatePayments}. */ +export type SplitStrategy = "equal" | "proportional" | "custom"; + +/** A single invoice's share of the budget. */ +export interface PaymentAllocation { + /** Invoice the payment should be sent to. */ + invoiceId: bigint; + /** Amount to pay, in stroops. Never exceeds the invoice's remaining amount. */ + amount: bigint; + /** `amount` as a percentage of the total budget, rounded to 2 decimals. */ + percentOfBudget: number; +} + +/** Resolves the remaining (still unfunded) amount, in stroops, for an invoice. */ +export type InvoiceRemainingFetcher = (invoiceId: bigint) => Promise; + +/** Minimal invoice source contract — satisfied by `StellarSplitClient`. */ +export interface InvoiceSource { + getInvoice(invoiceId: string): Promise; +} + +/** A bigint/number amount keyed by invoice ID (decimal string). */ +export type AmountLookup = ReadonlyMap | Record; + +/** Options for {@link aggregatePayments}. */ +export interface AggregatePaymentsOptions { + /** + * Weights for the `custom` strategy, one per invoice ID, in the same order. + * Must be non-negative and sum to exactly 100. + */ + weights?: readonly number[]; + + /** Explicit remaining amounts, keyed by invoice ID. */ + remaining?: AmountLookup; + + /** + * Per-invoice target (total) amounts, keyed by invoice ID. When supplied, the + * `proportional` strategy weights each invoice by the *fraction* of its + * target still unfunded instead of by the raw remaining amount. + */ + targets?: AmountLookup; + + /** Async resolver called once per invoice for its remaining amount. */ + fetchRemaining?: InvoiceRemainingFetcher; + + /** Invoice source used to derive remaining amounts from on-chain data. */ + invoiceSource?: InvoiceSource; +} + +/** Fixed-point scale used internally to keep proportional weights integral. */ +const WEIGHT_SCALE = 1_000_000n; +/** Fixed-point scale used to express percentages with 2 decimals. */ +const PERCENT_SCALE = 10_000n; +/** Allowed floating-point tolerance when validating custom weights. */ +const WEIGHT_SUM_TOLERANCE = 1e-6; +/** Guard against pathological weight distributions looping forever. */ +const MAX_ALLOCATION_PASSES = 1_024; + +/** Default remaining-amount fetcher, set via {@link registerInvoiceRemainingFetcher}. */ +let defaultRemainingFetcher: InvoiceRemainingFetcher | null = null; + +/** + * Register a process-wide remaining-amount fetcher. + * + * Useful when an application always talks to a single contract: register the + * fetcher once and call {@link aggregatePayments} without per-call options. + * Pass `null` to clear the registration. + * + * @param fetcher - Fetcher to use as the fallback source, or `null` to clear it. + */ +export function registerInvoiceRemainingFetcher( + fetcher: InvoiceRemainingFetcher | null, +): void { + defaultRemainingFetcher = fetcher; +} + +/** + * Compute how much an invoice still needs to be fully funded. + * + * The target is the sum of all recipient amounts; negative results (an + * over-funded invoice) are clamped to `0n`. + * + * @param invoice - Invoice to inspect. + * @returns Remaining amount in stroops, never negative. + */ +export function remainingForInvoice(invoice: Invoice): bigint { + const target = invoice.recipients.reduce( + (total, recipient) => total + recipient.amount, + 0n, + ); + const remaining = target - invoice.funded; + + return remaining > 0n ? remaining : 0n; +} + +/** + * Build an {@link InvoiceRemainingFetcher} from any {@link InvoiceSource}. + * + * @param source - Object exposing `getInvoice(id)` (for example a `StellarSplitClient`). + * @returns A fetcher that derives the remaining amount from the fetched invoice. + * + * @example + * const fetcher = createInvoiceRemainingFetcher(client); + * const allocations = await aggregatePayments(budget, ids, "equal", { fetchRemaining: fetcher }); + */ +export function createInvoiceRemainingFetcher( + source: InvoiceSource, +): InvoiceRemainingFetcher { + return async (invoiceId: bigint) => + remainingForInvoice(await source.getInvoice(invoiceId.toString())); +} + +function assertValidStrategy(strategy: SplitStrategy): void { + if (strategy !== "equal" && strategy !== "proportional" && strategy !== "custom") { + throw new ValidationError( + `Unknown payment allocation strategy "${String(strategy)}". Expected "equal", "proportional" or "custom".`, + { strategy }, + ); + } +} + +function assertNoDuplicates(invoiceIds: readonly bigint[]): void { + const seen = new Set(); + + for (const invoiceId of invoiceIds) { + const key = invoiceId.toString(); + + if (seen.has(key)) { + throw new ValidationError( + `Duplicate invoice ID ${key} in aggregatePayments input.`, + { invoiceId: key }, + ); + } + + seen.add(key); + } +} + +function lookupAmount(source: AmountLookup | undefined, invoiceId: bigint): bigint | undefined { + if (!source) { + return undefined; + } + + if (source instanceof Map) { + return source.get(invoiceId); + } + + const raw = (source as Record)[invoiceId.toString()]; + + if (raw === undefined) { + return undefined; + } + + return typeof raw === "bigint" ? raw : BigInt(Math.trunc(raw)); +} + +/** Validate `weights` and return them scaled to integers, ready for allocation. */ +function resolveCustomWeights( + weights: readonly number[] | undefined, + invoiceCount: number, +): bigint[] { + if (!weights) { + throw new ValidationError( + 'The "custom" strategy requires a `weights` array with one entry per invoice.', + ); + } + + if (weights.length !== invoiceCount) { + throw new ValidationError( + `Custom weights must contain one entry per invoice (expected ${invoiceCount}, received ${weights.length}).`, + { expected: invoiceCount, received: weights.length }, + ); + } + + let sum = 0; + + for (const weight of weights) { + if (!Number.isFinite(weight) || weight < 0) { + throw new ValidationError( + `Custom weights must be finite, non-negative numbers (received ${String(weight)}).`, + { weight }, + ); + } + + sum += weight; + } + + if (Math.abs(sum - 100) > WEIGHT_SUM_TOLERANCE) { + throw new ValidationError(`Custom weights must sum to 100 (received ${sum}).`, { + sum, + }); + } + + // Scale to integers so the allocation math stays exact. `Math.round` preserves + // the proportional relationship to 6 decimal places. + return weights.map((weight) => BigInt(Math.round(weight * Number(WEIGHT_SCALE)))); +} + +async function resolveRemainingAmounts( + invoiceIds: readonly bigint[], + options: AggregatePaymentsOptions, +): Promise { + const fetcher = + options.fetchRemaining ?? + (options.invoiceSource + ? createInvoiceRemainingFetcher(options.invoiceSource) + : defaultRemainingFetcher); + + const amounts: bigint[] = []; + + for (const invoiceId of invoiceIds) { + let remaining = lookupAmount(options.remaining, invoiceId); + + if (remaining === undefined && fetcher) { + remaining = await fetcher(invoiceId); + } + + if (remaining === undefined) { + throw new ValidationError( + "No source of invoice remaining amounts available. Pass `remaining`, `fetchRemaining` or `invoiceSource`, or call registerInvoiceRemainingFetcher().", + { invoiceId: invoiceId.toString() }, + ); + } + + amounts.push(remaining > 0n ? remaining : 0n); + } + + return amounts; +} + +/** Derive integer weights for the requested strategy. */ +function resolveWeights( + strategy: SplitStrategy, + remaining: readonly bigint[], + options: AggregatePaymentsOptions, + invoiceIds: readonly bigint[], +): bigint[] { + if (strategy === "equal") { + return remaining.map(() => 1n); + } + + if (strategy === "custom") { + return resolveCustomWeights(options.weights, invoiceIds.length); + } + + // proportional: the default weight is the raw remaining amount; with targets + // the weight becomes the fraction of the target still unfunded, i.e. how far + // the invoice is from its target. + return remaining.map((amount, index) => { + const target = lookupAmount(options.targets, invoiceIds[index]!); + + if (target === undefined) { + return amount; + } + + if (target <= 0n) { + return 0n; + } + + return (amount * WEIGHT_SCALE) / target; + }); +} + +/** + * Water-filling allocator: hand out `pool` stroops across the `remaining` + * bounds in proportion to `weights`, capping each entry at its remaining amount + * and redistributing any excess to entries that still have room. + */ +function allocateWithCaps( + weights: readonly bigint[], + remaining: readonly bigint[], + pool: bigint, +): bigint[] { + const allocations = remaining.map(() => 0n); + let active = remaining + .map((amount, index) => (amount > 0n ? index : -1)) + .filter((index) => index >= 0); + + let passes = 0; + + while (pool > 0n && active.length > 0 && passes < MAX_ALLOCATION_PASSES) { + passes += 1; + + const weightSum = active.reduce((total, index) => total + weights[index]!, 0n); + const proRata = weightSum > 0n; + const evenDivisor = BigInt(active.length); + const leftovers: Array<{ index: number; remainder: bigint }> = []; + + let distributed = 0n; + + for (const index of active) { + const numerator = proRata ? pool * weights[index]! : pool; + const divisor = proRata ? weightSum : evenDivisor; + let share = numerator / divisor; + const capacity = remaining[index]! - allocations[index]!; + + if (share > capacity) { + share = capacity; + } + + if (share > 0n) { + allocations[index]! += share; + distributed += share; + } + + leftovers.push({ index, remainder: numerator % divisor }); + } + + pool -= distributed; + active = active.filter((index) => allocations[index]! < remaining[index]!); + + if (distributed > 0n || pool === 0n || active.length === 0) { + continue; + } + + // Rounding dust: hand out one stroop at a time to the entries with the + // largest fractional remainder (zero-weight entries are skipped). + leftovers.sort((a, b) => { + if (a.remainder === b.remainder) { + return a.index - b.index; + } + + return a.remainder < b.remainder ? 1 : -1; + }); + + for (const { index } of leftovers) { + if (pool === 0n) { + break; + } + + if (allocations[index]! >= remaining[index]!) { + continue; + } + + if (proRata && weights[index] === 0n) { + continue; + } + + allocations[index]! += 1n; + pool -= 1n; + } + } + + return allocations; +} + +/** + * Allocate `budget` stroops across `invoiceIds` using the requested strategy. + * + * Behavior: + * - An empty `invoiceIds` list returns `[]` (no error). + * - The total allocated never exceeds `budget`, and no single allocation + * exceeds that invoice's remaining amount. + * - When the invoices collectively need less than `budget`, the surplus is left + * unallocated and `percentOfBudget` values sum to less than 100. + * - Invoice IDs must be unique; duplicates throw to avoid double funding. + * + * @param budget - Total amount available to spend, in stroops. + * @param invoiceIds - Invoice IDs to allocate across. + * @param strategy - `"equal"`, `"proportional"` or `"custom"`. + * @param options - Remaining-amount sources and, for `"custom"`, the weights. + * @returns One {@link PaymentAllocation} per input invoice ID, in input order. + * @throws {ValidationError} On an unknown strategy, a negative budget, duplicate + * invoice IDs, invalid custom weights, or a missing remaining-amount source. + * + * @example + * // Split 300 stroops evenly across three invoices that each still need 100. + * await aggregatePayments(300n, [1n, 2n, 3n], "equal", { + * remaining: { "1": 100n, "2": 100n, "3": 100n }, + * }); + * // → [{ invoiceId: 1n, amount: 100n, percentOfBudget: 33.33 }, …] + */ +export async function aggregatePayments( + budget: bigint, + invoiceIds: readonly bigint[], + strategy: SplitStrategy, + options: AggregatePaymentsOptions = {}, +): Promise { + assertValidStrategy(strategy); + + if (budget < 0n) { + throw new ValidationError("aggregatePayments budget must not be negative", { + budget: budget.toString(), + }); + } + + if (invoiceIds.length === 0) { + return []; + } + + assertNoDuplicates(invoiceIds); + + if (strategy === "custom") { + // Validate weights even when there is nothing to distribute so callers get + // immediate feedback on a malformed configuration. + resolveCustomWeights(options.weights, invoiceIds.length); + } + + const remaining = await resolveRemainingAmounts(invoiceIds, options); + const totalRemaining = remaining.reduce((total, amount) => total + amount, 0n); + const distributable = budget < totalRemaining ? budget : totalRemaining; + + if (distributable === 0n) { + return invoiceIds.map((invoiceId) => ({ invoiceId, amount: 0n, percentOfBudget: 0 })); + } + + const weights = resolveWeights(strategy, remaining, options, invoiceIds); + const allocations = allocateWithCaps(weights, remaining, distributable); + + return invoiceIds.map((invoiceId, index) => { + const amount = allocations[index]!; + + return { + invoiceId, + amount, + percentOfBudget: Number((amount * PERCENT_SCALE) / budget) / 100, + }; + }); +} + + diff --git a/test/deadline.test.ts b/test/deadline.test.ts new file mode 100644 index 0000000..fcad77c --- /dev/null +++ b/test/deadline.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + deadlineFromDays, + deadlineFromDate, + isDeadlineValid, + timeUntilDeadline, + formatDeadline, +} from "../src/deadline.js"; +import { ValidationError } from "../src/errors.js"; + +/** 2026-01-01T00:00:00Z — deterministic "now" for every test below. */ +const NOW_MS = Date.UTC(2026, 0, 1, 0, 0, 0); +const NOW_S = BigInt(Math.floor(NOW_MS / 1000)); +const HOUR = 3_600n; +const DAY = 86_400n; + +describe("deadlineFromDays", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns a bigint exactly n days from now", () => { + const deadline = deadlineFromDays(7); + + expect(typeof deadline).toBe("bigint"); + expect(deadline).toBe(NOW_S + 7n * DAY); + }); + + it("rounds partial days up so any positive duration is in the future", () => { + expect(deadlineFromDays(0.5)).toBe(NOW_S + 43_200n); + expect(deadlineFromDays(0.000001)).toBe(NOW_S + 1n); + expect(deadlineFromDays(0.000001)).toBeGreaterThan(NOW_S); + }); + + it("supports zero and negative day counts", () => { + expect(deadlineFromDays(0)).toBe(NOW_S); + expect(deadlineFromDays(-1)).toBe(NOW_S - DAY); + }); + + it("throws for non-finite input", () => { + expect(() => deadlineFromDays(Number.NaN)).toThrow(ValidationError); + expect(() => deadlineFromDays(Number.POSITIVE_INFINITY)).toThrow(ValidationError); + }); +}); + +describe("deadlineFromDate", () => { + it("converts a Date to a Unix timestamp in seconds", () => { + expect(deadlineFromDate(new Date(NOW_MS))).toBe(NOW_S); + expect(deadlineFromDate(new Date(0))).toBe(0n); + }); + + it("truncates sub-second precision", () => { + expect(deadlineFromDate(new Date(NOW_MS + 999))).toBe(NOW_S); + }); + + it("throws for an invalid Date", () => { + expect(() => deadlineFromDate(new Date("not-a-date"))).toThrow(ValidationError); + }); +}); + +describe("isDeadlineValid", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("rejects a deadline in the past", () => { + expect(isDeadlineValid(NOW_S - 1n)).toBe(false); + }); + + it("rejects a same-day deadline less than an hour away", () => { + expect(isDeadlineValid(NOW_S)).toBe(false); + expect(isDeadlineValid(NOW_S + HOUR - 1n)).toBe(false); + }); + + it("accepts a deadline exactly one hour away (inclusive minimum)", () => { + expect(isDeadlineValid(NOW_S + HOUR)).toBe(true); + }); + + it("accepts a far-future deadline", () => { + expect(isDeadlineValid(deadlineFromDays(30))).toBe(true); + }); +}); + +describe("timeUntilDeadline", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("breaks a far-future deadline into days, hours, minutes and seconds", () => { + const remaining = timeUntilDeadline(NOW_S + 10n * DAY + 5n * HOUR + 120n + 7n); + + expect(remaining).toEqual({ + days: 10, + hours: 5, + minutes: 2, + seconds: 7, + expired: false, + }); + }); + + it("handles a same-day deadline", () => { + expect(timeUntilDeadline(NOW_S + 90n)).toEqual({ + days: 0, + hours: 0, + minutes: 1, + seconds: 30, + expired: false, + }); + }); + + it("reports expired deadlines as all zeros", () => { + expect(timeUntilDeadline(NOW_S - 60n)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + expired: true, + }); + + expect(timeUntilDeadline(NOW_S)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + expired: true, + }); + }); + + it("never returns negative units for deadlines far in the past", () => { + const remaining = timeUntilDeadline(1n); + + expect(remaining.expired).toBe(true); + expect(remaining.days).toBe(0); + expect(remaining.hours).toBe(0); + expect(remaining.minutes).toBe(0); + expect(remaining.seconds).toBe(0); + }); +}); + +describe("formatDeadline", () => { + const NEW_YEAR = BigInt(Math.floor(Date.UTC(2026, 0, 1, 0, 0, 0) / 1000)); + + it("formats a deadline as a human-readable date string", () => { + expect(formatDeadline(NEW_YEAR, "en-US")).toBe("Jan 1, 2026, 12:00 AM"); + }); + + it("respects the requested locale", () => { + const en = formatDeadline(NEW_YEAR, "en-US"); + const de = formatDeadline(NEW_YEAR, "de-DE"); + + expect(de).toContain("01.01.2026"); + expect(de).not.toBe(en); + }); + + it("falls back to the runtime locale when none is given", () => { + expect(typeof formatDeadline(NEW_YEAR)).toBe("string"); + expect(formatDeadline(NEW_YEAR)).toContain("2026"); + }); + + it("formats far-future deadlines correctly", () => { + const deadline = deadlineFromDate(new Date(Date.UTC(2032, 8, 25, 12, 30, 0))); + + expect(formatDeadline(deadline, "en-US")).toBe("Sep 25, 2032, 12:30 PM"); + }); + + it("throws when the deadline is outside the supported Date range", () => { + expect(() => formatDeadline(10n ** 30n)).toThrow(ValidationError); + }); +}); diff --git a/test/multiTenant.test.ts b/test/multiTenant.test.ts index 60407f1..a81b21f 100644 --- a/test/multiTenant.test.ts +++ b/test/multiTenant.test.ts @@ -439,3 +439,51 @@ describe("MultiTenantClient — pool.stats()", () => { expect(defaultFactory).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// #846 — options-only construction +// --------------------------------------------------------------------------- + +describe("MultiTenantClient — options-only construction", () => { + it("accepts PoolOptions as the only constructor argument", () => { + const pool = new MultiTenantClient({ maxClients: 2, ttlMs: 30_000 }); + + const first = pool.getClient("tenant-a", makeFactory()("tenant-a")); + const second = pool.getClient("tenant-a", makeFactory()("tenant-a")); + + expect(first).toBeInstanceOf(StellarSplitClient); + expect(second).toBe(first); + expect(pool.stats()).toMatchObject({ size: 1, hits: 1, misses: 1 }); + }); + + it("enforces maxClients when the pool has no factory", () => { + const pool = new MultiTenantClient({ maxClients: 2 }); + const configFor = (tenantId: string): StellarSplitClientConfig => ({ + rpcUrl: `https://${tenantId}.example.com`, + networkPassphrase: "Test Network", + contractId: makeContractId(), + }); + + pool.getClient("tenant-a", configFor("tenant-a")); + pool.getClient("tenant-b", configFor("tenant-b")); + pool.getClient("tenant-c", configFor("tenant-c")); + + expect(pool.stats()).toMatchObject({ size: 2, evictions: 1 }); + }); + + it("keeps honouring a client factory when one is supplied", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory, { maxClients: 5 }); + + pool.getClient("tenant-a"); + + expect(factory).toHaveBeenCalledWith("tenant-a"); + }); + + it("throws a ValidationError when neither a factory nor a config is available", () => { + const pool = new MultiTenantClient({ maxClients: 1 }); + + expect(() => pool.getClient("tenant-a")).toThrow(/No configuration available for tenant "tenant-a"/); + }); +}); + diff --git a/test/paymentAllocation.test.ts b/test/paymentAllocation.test.ts new file mode 100644 index 0000000..1ce9a54 --- /dev/null +++ b/test/paymentAllocation.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { + aggregatePayments, + createInvoiceRemainingFetcher, + registerInvoiceRemainingFetcher, + remainingForInvoice, +} from "../src/paymentAllocation.js"; +import { ValidationError } from "../src/errors.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(id: string, amounts: bigint[], funded = 0n): Invoice { + return { + id, + creator: "GCREATOR", + recipients: amounts.map((amount, index) => ({ + address: `GRECIPIENT${index}`, + amount, + })), + token: "USDC", + deadline: 1_000_000, + funded, + status: "Pending", + payments: [], + } as unknown as Invoice; +} + +const amountsOf = (allocations: Array<{ amount: bigint }>) => + allocations.map((allocation) => allocation.amount); + +afterEach(() => { + registerInvoiceRemainingFetcher(null); + vi.useRealTimers(); +}); + +describe("aggregatePayments — equal strategy", () => { + it("splits the budget evenly when every invoice has enough room", async () => { + const allocations = await aggregatePayments(300n, [1n, 2n, 3n], "equal", { + remaining: { "1": 100n, "2": 100n, "3": 100n }, + }); + + expect(amountsOf(allocations)).toEqual([100n, 100n, 100n]); + expect(allocations[0]!.percentOfBudget).toBe(33.33); + }); + + it("caps at each invoice's remaining amount and redistributes the surplus", async () => { + const allocations = await aggregatePayments(300n, [1n, 2n, 3n], "equal", { + remaining: { "1": 50n, "2": 500n, "3": 500n }, + }); + + expect(amountsOf(allocations)).toEqual([50n, 125n, 125n]); + }); + + it("never allocates more than the invoices collectively still need", async () => { + const allocations = await aggregatePayments(1_000n, [1n, 2n], "equal", { + remaining: { "1": 10n, "2": 20n }, + }); + + expect(amountsOf(allocations)).toEqual([10n, 20n]); + expect(allocations.reduce((total, a) => total + a.percentOfBudget, 0)).toBe(3); + }); + + it("distributes rounding dust deterministically so the budget is fully used", async () => { + const allocations = await aggregatePayments(100n, [1n, 2n, 3n], "equal", { + remaining: { "1": 100n, "2": 100n, "3": 100n }, + }); + + expect(allocations.reduce((total, a) => total + a.amount, 0n)).toBe(100n); + expect(amountsOf(allocations)).toEqual([34n, 33n, 33n]); + }); + + it("handles a single invoice", async () => { + const allocations = await aggregatePayments(1_000n, [42n], "equal", { + remaining: { "42": 400n }, + }); + + expect(allocations).toHaveLength(1); + expect(allocations[0]).toMatchObject({ invoiceId: 42n, amount: 400n }); + expect(allocations[0]!.percentOfBudget).toBe(40); + }); + + it("handles an empty invoice list gracefully", async () => { + await expect(aggregatePayments(500n, [], "equal")).resolves.toEqual([]); + }); + + it("skips invoices that are already fully funded", async () => { + const allocations = await aggregatePayments(100n, [1n, 2n], "equal", { + remaining: { "1": 0n, "2": 100n }, + }); + + expect(amountsOf(allocations)).toEqual([0n, 100n]); + expect(allocations[0]!.percentOfBudget).toBe(0); + }); +}); + +describe("aggregatePayments — proportional strategy", () => { + it("allocates more to invoices further from their target", async () => { + const allocations = await aggregatePayments(1_000n, [1n, 2n], "proportional", { + remaining: { "1": 100n, "2": 900n }, + }); + + expect(amountsOf(allocations)).toEqual([100n, 900n]); + }); + + it("weights by raw remaining amounts when the budget is smaller than the total", async () => { + const allocations = await aggregatePayments(200n, [1n, 2n], "proportional", { + remaining: { "1": 300n, "2": 100n }, + }); + + expect(amountsOf(allocations)).toEqual([150n, 50n]); + }); + + it("weights by fraction-of-target-remaining when targets are supplied", async () => { + const budget = 600n; + const remaining = { "1": 200n, "2": 800n }; + const withTargets = await aggregatePayments(budget, [1n, 2n], "proportional", { + remaining, + targets: { "1": 400n, "2": 20_000n }, + }); + + // Invoice 1 is 50% unfunded while invoice 2 is only 4% unfunded, so the + // fraction-based weighting overrides the raw remaining amounts. + const rawProportional = await aggregatePayments(budget, [1n, 2n], "proportional", { + remaining, + }); + + expect(withTargets[0]!.amount).toBe(200n); + expect(withTargets[0]!.amount + withTargets[1]!.amount).toBe(budget); + expect(withTargets[0]!.amount).toBeGreaterThan(rawProportional[0]!.amount); + expect(withTargets[1]!.amount).toBeLessThan(rawProportional[1]!.amount); + }); + + it("treats invoices with a zero target as unfunded-adjacent (zero weight)", async () => { + const allocations = await aggregatePayments(100n, [1n, 2n], "proportional", { + remaining: { "1": 100n, "2": 100n }, + targets: { "1": 0n, "2": 100n }, + }); + + expect(amountsOf(allocations)).toEqual([0n, 100n]); + }); +}); + + +describe("aggregatePayments — custom strategy", () => { + it("allocates according to the supplied weights", async () => { + const allocations = await aggregatePayments(1_000n, [1n, 2n], "custom", { + remaining: { "1": 10_000n, "2": 10_000n }, + weights: [70, 30], + }); + + expect(amountsOf(allocations)).toEqual([700n, 300n]); + expect(allocations.map((a) => a.percentOfBudget)).toEqual([70, 30]); + }); + + it("still caps allocations at the remaining amount", async () => { + const allocations = await aggregatePayments(1_000n, [1n, 2n], "custom", { + remaining: { "1": 10n, "2": 10_000n }, + weights: [50, 50], + }); + + expect(amountsOf(allocations)).toEqual([10n, 990n]); + }); + + it("accepts weights that sum to 100 within floating-point tolerance", async () => { + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { + remaining: { "1": 1_000n, "2": 1_000n }, + weights: [33.3333333, 66.6666667], + }), + ).resolves.toHaveLength(2); + }); + + it("rejects weights that do not sum to 100", async () => { + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { + remaining: { "1": 1_000n, "2": 1_000n }, + weights: [60, 30], + }), + ).rejects.toThrow(/must sum to 100/); + }); + + it("rejects a missing weights array", async () => { + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { remaining: { "1": 1n, "2": 1n } }), + ).rejects.toThrow(ValidationError); + }); + + it("rejects weights whose length does not match the invoice list", async () => { + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { + remaining: { "1": 1_000n, "2": 1_000n }, + weights: [100], + }), + ).rejects.toThrow(/one entry per invoice/); + }); + + it("rejects negative and non-finite weights", async () => { + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { + remaining: { "1": 1_000n, "2": 1_000n }, + weights: [120, -20], + }), + ).rejects.toThrow(/non-negative/); + + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { + remaining: { "1": 1_000n, "2": 1_000n }, + weights: [Number.NaN, 100], + }), + ).rejects.toThrow(/non-negative/); + }); + + it("validates weights before resolving invoice data", async () => { + await expect( + aggregatePayments(100n, [1n, 2n], "custom", { weights: [50, 40] }), + ).rejects.toThrow(/must sum to 100/); + }); +}); + +describe("aggregatePayments — validation", () => { + it("rejects an unknown strategy", async () => { + await expect( + aggregatePayments(100n, [1n], "weighted" as never, { remaining: { "1": 100n } }), + ).rejects.toThrow(/Unknown payment allocation strategy/); + }); + + it("rejects a negative budget", async () => { + await expect( + aggregatePayments(-1n, [1n], "equal", { remaining: { "1": 100n } }), + ).rejects.toThrow(/must not be negative/); + }); + + it("rejects duplicate invoice IDs", async () => { + await expect( + aggregatePayments(100n, [1n, 1n], "equal", { remaining: { "1": 100n } }), + ).rejects.toThrow(/Duplicate invoice ID/); + }); + + it("throws when no remaining-amount source is available", async () => { + await expect(aggregatePayments(100n, [1n], "equal")).rejects.toThrow( + /No source of invoice remaining amounts/, + ); + }); + + it("returns zeroed allocations for a zero budget", async () => { + const allocations = await aggregatePayments(0n, [1n, 2n], "equal", { + remaining: { "1": 100n, "2": 100n }, + }); + + expect(allocations).toEqual([ + { invoiceId: 1n, amount: 0n, percentOfBudget: 0 }, + { invoiceId: 2n, amount: 0n, percentOfBudget: 0 }, + ]); + }); + + it("returns allocations in the order the invoice IDs were given", async () => { + const allocations = await aggregatePayments(30n, [9n, 3n, 6n], "equal", { + remaining: { "9": 10n, "3": 10n, "6": 10n }, + }); + + expect(allocations.map((a) => a.invoiceId)).toEqual([9n, 3n, 6n]); + }); +}); + + +describe("aggregatePayments — remaining amount sources", () => { + it("uses a per-call fetchRemaining function", async () => { + const fetchRemaining = vi.fn(async (invoiceId: bigint) => invoiceId * 100n); + + const allocations = await aggregatePayments(300n, [1n, 2n], "equal", { fetchRemaining }); + + expect(fetchRemaining).toHaveBeenCalledWith(1n); + expect(fetchRemaining).toHaveBeenCalledWith(2n); + // Invoice 1 only needs 100 more, so the surplus goes to invoice 2. + expect(amountsOf(allocations)).toEqual([100n, 200n]); + }); + + it("derives remaining amounts from an invoiceSource", async () => { + const invoices = new Map([ + ["1", makeInvoice("1", [100n], 40n)], + ["2", makeInvoice("2", [500n], 0n)], + ]); + const invoiceSource = { + getInvoice: vi.fn(async (id: string) => invoices.get(id)!), + }; + + const allocations = await aggregatePayments(1_000n, [1n, 2n], "equal", { invoiceSource }); + + // Remaining amounts are 60 and 500, so both invoices are fully funded. + expect(amountsOf(allocations)).toEqual([60n, 500n]); + }); + + it("falls back to a registered default fetcher", async () => { + registerInvoiceRemainingFetcher(async () => 25n); + + const allocations = await aggregatePayments(100n, [1n, 2n], "equal"); + + expect(amountsOf(allocations)).toEqual([25n, 25n]); + }); + + it("prefers per-call options over the registered default fetcher", async () => { + registerInvoiceRemainingFetcher(async () => 1n); + + const allocations = await aggregatePayments(100n, [1n], "equal", { + remaining: { "1": 80n }, + }); + + expect(amountsOf(allocations)).toEqual([80n]); + }); + + it("accepts number values in a remaining record", async () => { + const allocations = await aggregatePayments(100n, [1n, 2n], "equal", { + remaining: { "1": 30, "2": 70 }, + }); + + expect(amountsOf(allocations)).toEqual([30n, 70n]); + }); + + it("accepts a Map of remaining amounts", async () => { + const allocations = await aggregatePayments(100n, [1n, 2n], "equal", { + remaining: new Map([ + [1n, 40n], + [2n, 60n], + ]), + }); + + expect(amountsOf(allocations)).toEqual([40n, 60n]); + }); + + it("treats negative remaining amounts as already fully funded", async () => { + const allocations = await aggregatePayments(100n, [1n, 2n], "equal", { + remaining: { "1": -5n, "2": 100n }, + }); + + expect(amountsOf(allocations)).toEqual([0n, 100n]); + }); +}); + +describe("remainingForInvoice", () => { + it("subtracts the funded amount from the sum of recipient amounts", () => { + expect(remainingForInvoice(makeInvoice("1", [60n, 40n], 25n))).toBe(75n); + }); + + it("clamps over-funded invoices to zero", () => { + expect(remainingForInvoice(makeInvoice("1", [100n], 150n))).toBe(0n); + }); + + it("returns zero for invoices with no recipients", () => { + expect(remainingForInvoice(makeInvoice("1", []))).toBe(0n); + }); +}); + +describe("createInvoiceRemainingFetcher", () => { + it("builds a fetcher from any getInvoice-compatible source", async () => { + const getInvoice = vi.fn(async () => makeInvoice("7", [1_000n], 250n)); + const fetcher = createInvoiceRemainingFetcher({ getInvoice }); + + await expect(fetcher(7n)).resolves.toBe(750n); + expect(getInvoice).toHaveBeenCalledWith("7"); + }); +}); diff --git a/test/property-deadline.test.ts b/test/property-deadline.test.ts index 4eecc7b..6c49b95 100644 --- a/test/property-deadline.test.ts +++ b/test/property-deadline.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import * as fc from "fast-check"; import { deadlineFromDays, isExpired } from "../src/utils.js"; +import { + deadlineFromDays as deadlineFromDaysBigInt, + isDeadlineValid, + timeUntilDeadline, +} from "../src/deadline.js"; describe("deadlineFromDays (property-based)", () => { it("returns a timestamp in the future for positive day counts", () => { @@ -106,3 +111,69 @@ describe("deadlineFromDays (property-based)", () => { expect(Math.abs(deadline - now)).toBeLessThanOrEqual(2); }); }); + +describe("bigint deadline helpers (property-based)", () => { + const nowSeconds = () => BigInt(Math.floor(Date.now() / 1000)); + + it("deadlineFromDays(n) is always in the future for n > 0", () => { + fc.assert( + fc.property( + fc.double({ min: 1e-9, max: 3_650, noNaN: true, noDefaultInfinity: true }), + (days) => { + expect(deadlineFromDaysBigInt(days)).toBeGreaterThan(nowSeconds()); + }, + ), + { numRuns: 500, verbose: true }, + ); + }); + + it("isDeadlineValid matches the one-hour rule for arbitrary timestamps", () => { + fc.assert( + fc.property(fc.bigInt({ min: 0n, max: 4_102_444_800n }), (deadline) => { + expect(isDeadlineValid(deadline)).toBe(deadline - nowSeconds() >= 3_600n); + }), + { numRuns: 500, verbose: true }, + ); + }); + + it("timeUntilDeadline never reports negative units and flags expiry consistently", () => { + fc.assert( + fc.property(fc.bigInt({ min: 0n, max: 4_102_444_800n }), (deadline) => { + const remaining = timeUntilDeadline(deadline); + const diff = deadline - nowSeconds(); + + expect(remaining.expired).toBe(diff <= 0n); + expect(remaining.days).toBeGreaterThanOrEqual(0); + expect(remaining.hours).toBeGreaterThanOrEqual(0); + expect(remaining.hours).toBeLessThanOrEqual(23); + expect(remaining.minutes).toBeGreaterThanOrEqual(0); + expect(remaining.minutes).toBeLessThanOrEqual(59); + expect(remaining.seconds).toBeGreaterThanOrEqual(0); + expect(remaining.seconds).toBeLessThanOrEqual(59); + }), + { numRuns: 500, verbose: true }, + ); + }); + + it("timeUntilDeadline units reconstruct the remaining duration", () => { + fc.assert( + fc.property(fc.bigInt({ min: 1n, max: 4_102_444_800n }), (total) => { + const deadline = nowSeconds() + total; + const remaining = timeUntilDeadline(deadline); + const reconstructed = + BigInt(remaining.days) * 86_400n + + BigInt(remaining.hours) * 3_600n + + BigInt(remaining.minutes) * 60n + + BigInt(remaining.seconds); + const diff = deadline - nowSeconds(); + + expect(remaining.expired).toBe(false); + expect(reconstructed).toBeLessThanOrEqual(diff); + // Allow for the wall clock rolling to the next second mid-assertion. + expect(diff - reconstructed).toBeLessThanOrEqual(1n); + }), + { numRuns: 500, verbose: true }, + ); + }); +}); + diff --git a/test/property-payment-allocation.test.ts b/test/property-payment-allocation.test.ts new file mode 100644 index 0000000..ac6fe82 --- /dev/null +++ b/test/property-payment-allocation.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { aggregatePayments } from "../src/paymentAllocation.js"; +import type { SplitStrategy } from "../src/paymentAllocation.js"; + +const STRATEGIES: SplitStrategy[] = ["equal", "proportional", "custom"]; + +/** Arbitrary: 1-8 invoices, each with a remaining amount between 1 and 10^6. */ +const scenario = fc + .array(fc.bigInt({ min: 1n, max: 1_000_000n }), { minLength: 1, maxLength: 8 }) + .chain((remaining) => + fc + .record({ + budget: fc.bigInt({ min: 0n, max: 5_000_000n }), + strategy: fc.constantFrom(...STRATEGIES), + }) + .map((record) => ({ + ...record, + remaining, + invoiceIds: remaining.map((_, index) => BigInt(index + 1)), + remainingLookup: Object.fromEntries( + remaining.map((amount, index) => [String(index + 1), amount]), + ), + })), + ); + +const amountsOf = (allocations: Array<{ amount: bigint }>) => + allocations.map((allocation) => allocation.amount); + +const weightsFor = (input: { invoiceIds: bigint[]; strategy: SplitStrategy }) => + input.strategy === "custom" + ? input.invoiceIds.map(() => 100 / input.invoiceIds.length) + : undefined; + +async function allocate( + input: { budget: bigint; invoiceIds: bigint[]; strategy: SplitStrategy; remainingLookup: Record }, + extra: { weights?: number[] } = {}, +) { + return aggregatePayments(input.budget, input.invoiceIds, input.strategy, { + remaining: input.remainingLookup, + ...(weightsFor(input) ? { weights: weightsFor(input) } : {}), + ...extra, + }); +} + +const totalOf = (allocations: Array<{ amount: bigint }>) => + allocations.reduce((sum, allocation) => sum + allocation.amount, 0n); + +describe("aggregatePayments (property-based)", () => { + it("never allocates more than the budget", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (input) => { + const allocations = await allocate(input); + + expect(totalOf(allocations)).toBeLessThanOrEqual(input.budget); + }), + { numRuns: 500 }, + ); + }); + + it("never allocates more than an invoice still needs (no overpayment)", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (input) => { + const allocations = await allocate(input); + + allocations.forEach((allocation, index) => { + expect(allocation.amount).toBeLessThanOrEqual(input.remaining[index]!); + expect(allocation.amount).toBeGreaterThanOrEqual(0n); + }); + }), + { numRuns: 500 }, + ); + }); + + it("fully distributes min(budget, total remaining)", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (input) => { + const totalRemaining = input.remaining.reduce((sum, amount) => sum + amount, 0n); + const expected = input.budget < totalRemaining ? input.budget : totalRemaining; + + expect(totalOf(await allocate(input))).toBe(expected); + }), + { numRuns: 500 }, + ); + }); + + it("returns one allocation per invoice, in input order", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (input) => { + const allocations = await allocate(input); + + expect(allocations.map((allocation) => allocation.invoiceId)).toEqual(input.invoiceIds); + }), + { numRuns: 500 }, + ); + }); + + it("keeps percentOfBudget within [0, 100] for a positive budget", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (input) => { + const budget = input.budget === 0n ? 1n : input.budget; + const allocations = await aggregatePayments(budget, input.invoiceIds, input.strategy, { + remaining: input.remainingLookup, + ...(weightsFor(input) ? { weights: weightsFor(input) } : {}), + }); + + for (const allocation of allocations) { + expect(allocation.percentOfBudget).toBeGreaterThanOrEqual(0); + expect(allocation.percentOfBudget).toBeLessThanOrEqual(100); + expect(Number.isFinite(allocation.percentOfBudget)).toBe(true); + } + }), + { numRuns: 500 }, + ); + }); + + it("accepts custom weights that sum to 100 for any invoice count", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.bigInt({ min: 1n, max: 1_000_000n }), { minLength: 1, maxLength: 8 }), + fc.bigInt({ min: 0n, max: 5_000_000n }), + async (remaining, budget) => { + const invoiceIds = remaining.map((_, index) => BigInt(index + 1)); + const weights = remaining.map(() => 100 / remaining.length); + const totalRemaining = remaining.reduce((sum, amount) => sum + amount, 0n); + const expected = budget < totalRemaining ? budget : totalRemaining; + const allocations = await aggregatePayments(budget, invoiceIds, "custom", { + remaining: Object.fromEntries( + remaining.map((amount, index) => [String(index + 1), amount]), + ), + weights, + }); + + expect(totalOf(allocations)).toBe(expected); + expect(amountsOf(allocations)).toHaveLength(remaining.length); + }, + ), + { numRuns: 500, verbose: true }, + ); + }); + + it("rejects custom weights that do not sum to 100", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.integer({ min: 0, max: 60 }), { minLength: 1, maxLength: 5 }), + fc.integer({ min: 1, max: 99 }), + async (weights, offset) => { + const drifting = [ + ...weights.slice(0, -1), + weights[weights.length - 1]! + offset, + ]; + const sum = drifting.reduce((total, weight) => total + weight, 0); + + fc.pre(sum !== 100); + + await expect( + aggregatePayments( + 1_000n, + drifting.map((_, index) => BigInt(index + 1)), + "custom", + { + remaining: Object.fromEntries( + drifting.map((_, index) => [String(index + 1), 1_000n]), + ), + weights: drifting, + }, + ), + ).rejects.toThrow(/must sum to 100/); + }, + ), + { numRuns: 500, verbose: true }, + ); + }); +}); +