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
1,407 changes: 911 additions & 496 deletions docs/openapi.json

Large diffs are not rendered by default.

525 changes: 523 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion scripts/verify-openapi-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,11 @@ function validateOpenAPIDrift(): boolean {
route === "/notifications" ||
// Alias mounts of documented routers.
route === "/keys" ||
route === "/swaps";
route === "/swaps" ||
route === "/investors" ||
route === "/portfolio" ||
route === "/marketplace" ||
route === "/seller";

if (isDocumented) {
logSuccess(`Registered route has spec: ${route}`);
Expand Down
5 changes: 5 additions & 0 deletions src/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const baseConfig = {
entities: [entitiesPath],
migrations: [migrationsPath],
migrationsTableName: "migrations",
// Postgres refuses `ALTER TYPE ... ADD VALUE` inside a transaction block, so
// migrations that change enums opt out by setting `transaction = false`.
// TypeORM rejects such overrides under the default "all" mode, which wraps
// the entire run in one transaction; "each" honours the per-migration flag.
migrationsTransactionMode: "each" as const,
migrationsRun: !isDevelopment,
};

Expand Down
107 changes: 107 additions & 0 deletions src/lib/ttl-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
* multiple API instances share the same warm cache. Redis is strictly best
* effort: any connection/command failure degrades to the in-process map rather
* than failing the request.
*
* The in-process map is bounded: it holds at most `maxEntries` entries, evicts
* the least recently used one when full, and periodically drops expired
* entries. Without those bounds, keys that are written once and never read
* (or keys with randomized ids) would accumulate for the lifetime of the
* process and eventually exhaust the heap.
*/

export interface TtlCacheOptions {
Expand All @@ -22,30 +28,60 @@ export interface TtlCacheOptions {
enabled?: boolean;
/** Injectable clock, for deterministic TTL assertions in tests. */
now?: () => number;
/** Maximum number of in-process entries before LRU eviction. */
maxEntries?: number;
/**
* How often expired entries are swept, in milliseconds. Sweeping is skipped
* while the cache holds nothing. Set to 0 to disable the timer and rely on
* lazy expiry plus pruning on write.
*/
sweepIntervalMs?: number;
}

interface MemoryEntry {
value: string;
expiresAt: number;
}

export const DEFAULT_MAX_ENTRIES = 5000;

/** Expired entries are swept at most this often, and at least once a minute. */
function defaultSweepIntervalMs(ttlSeconds: number): number {
return Math.max(1_000, Math.min(ttlSeconds * 1000, 60_000));
}

export class TtlCache {
readonly ttlSeconds: number;
readonly maxEntries: number;
private readonly namespace: string;
private readonly redis?: TtlCacheOptions["redisClient"];
private readonly enabled: boolean;
private readonly now: () => number;
private readonly sweepIntervalMs: number;
/** Insertion order doubles as recency order: a hit is re-inserted at the end. */
private readonly memory = new Map<string, MemoryEntry>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;

constructor(options: TtlCacheOptions) {
if (!Number.isFinite(options.ttlSeconds) || options.ttlSeconds <= 0) {
throw new Error("TtlCache requires a positive ttlSeconds.");
}
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
throw new Error("TtlCache requires a positive integer maxEntries.");
}
const sweepIntervalMs = options.sweepIntervalMs ?? defaultSweepIntervalMs(options.ttlSeconds);
if (!Number.isFinite(sweepIntervalMs) || sweepIntervalMs < 0) {
throw new Error("TtlCache requires a non-negative sweepIntervalMs.");
}

this.ttlSeconds = options.ttlSeconds;
this.maxEntries = maxEntries;
this.namespace = options.namespace ?? "cache";
this.redis = options.redisClient;
this.enabled = options.enabled !== false;
this.now = options.now ?? (() => Date.now());
this.sweepIntervalMs = sweepIntervalMs;
}

isEnabled(): boolean {
Expand All @@ -56,6 +92,11 @@ export class TtlCache {
return this.ttlSeconds;
}

/** Number of entries currently held in-process, expired ones included. */
get size(): number {
return this.memory.size;
}

async get<T>(key: string): Promise<T | null> {
if (!this.enabled) return null;
const namespaced = this.buildKey(key);
Expand All @@ -79,6 +120,9 @@ export class TtlCache {
this.memory.delete(namespaced);
return null;
}
// Reading refreshes recency so hot keys outlive a burst of one-off writes.
this.memory.delete(namespaced);
this.memory.set(namespaced, entry);
try {
return JSON.parse(entry.value) as T;
} catch {
Expand All @@ -101,7 +145,11 @@ export class TtlCache {
}
}

this.memory.delete(namespaced);
this.memory.set(namespaced, { value: serialized, expiresAt: this.now() + ttl * 1000 });
this.pruneExpired();
this.evictToCapacity();
this.ensureSweeper();
}

/** Drops a single entry from both the in-process map and Redis. */
Expand All @@ -119,14 +167,73 @@ export class TtlCache {
}
}

/** Drops every in-process entry whose key starts with `prefix`. */
invalidatePrefix(prefix: string): void {
const namespaced = this.buildKey(prefix);
for (const key of this.memory.keys()) {
if (key.startsWith(namespaced)) {
this.memory.delete(key);
}
}
}

/** Drops every entry held in-process (Redis entries expire via their TTL). */
clear(): void {
this.memory.clear();
}

/** Removes expired entries and returns how many were dropped. */
pruneExpired(): number {
const now = this.now();
let removed = 0;
for (const [key, entry] of this.memory) {
if (now > entry.expiresAt) {
this.memory.delete(key);
removed++;
}
}
return removed;
}

/** Stops the background sweeper. Safe to call more than once. */
close(): void {
if (this.sweepTimer) {
clearInterval(this.sweepTimer);
this.sweepTimer = null;
}
}

private evictToCapacity(): void {
while (this.memory.size > this.maxEntries) {
// Map iteration order is insertion order, which the read path refreshes,
// so the first key is the least recently used one.
const oldest = this.memory.keys().next();
if (oldest.done) return;
this.memory.delete(oldest.value);
}
}

private ensureSweeper(): void {
if (this.sweepTimer || this.sweepIntervalMs === 0 || !this.enabled) return;
this.sweepTimer = setInterval(() => {
if (this.memory.size === 0) {
this.close();
return;
}
this.pruneExpired();
}, this.sweepIntervalMs);
// Never hold the process open just to sweep a cache.
if (typeof this.sweepTimer.unref === "function") {
this.sweepTimer.unref();
}
}

private remember(key: string, raw: string, ttlSeconds?: number): void {
const ttl = ttlSeconds ?? this.ttlSeconds;
this.memory.delete(key);
this.memory.set(key, { value: raw, expiresAt: this.now() + ttl * 1000 });
this.evictToCapacity();
this.ensureSweeper();
}

private buildKey(key: string): string {
Expand Down
14 changes: 11 additions & 3 deletions src/migrations/1732000000000-AddInvoiceRejection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@ import { MigrationInterface, QueryRunner } from "typeorm";
export class AddInvoiceRejection1732000000000 implements MigrationInterface {
name = "AddInvoiceRejection1732000000000";

/**
* Postgres refuses `ALTER TYPE ... ADD VALUE` inside a transaction block
* (SQLSTATE 25001), and TypeORM wraps each migration in one by default.
* Running this migration without a managed transaction keeps the enum
* change in autocommit, where it is allowed. Both statements are written
* idempotently so a partially applied run can simply be retried.
*/
transaction = false as const;

public async up(queryRunner: QueryRunner): Promise<void> {
// Postgres requires ALTER TYPE ... ADD VALUE to run outside of a
// surrounding transaction in older versions; guard with IF NOT EXISTS
// (supported since PG 9.6+) so this migration is safe to re-run.
// Guard with IF NOT EXISTS (supported since PG 9.6+) so this migration is
// safe to re-run.
await queryRunner.query(`
ALTER TYPE "public"."invoices_invoicestatus_enum" ADD VALUE IF NOT EXISTS 'rejected';
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { MigrationInterface, QueryRunner } from "typeorm";
export class AddInvoiceLifecycleNotificationTypes1732500000000 implements MigrationInterface {
name = "AddInvoiceLifecycleNotificationTypes1732500000000";

/** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */
transaction = false as const;

public async up(queryRunner: QueryRunner): Promise<void> {
for (const value of [
"invoice_rejected",
Expand Down
3 changes: 3 additions & 0 deletions src/migrations/1732700000000-AddInvoiceFailedStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { MigrationInterface, QueryRunner } from "typeorm";
export class AddInvoiceFailedStatus1732700000000 implements MigrationInterface {
name = "AddInvoiceFailedStatus1732700000000";

/** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */
transaction = false as const;

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "public"."invoices_invoicestatus_enum" ADD VALUE IF NOT EXISTS 'failed';`
Expand Down
3 changes: 3 additions & 0 deletions src/migrations/1732800000000-AddUserAdminManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserAdminManagement1732800000000 implements MigrationInterface {
name = "AddUserAdminManagement1732800000000";

/** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */
transaction = false as const;

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "public"."users_usertype_enum" ADD VALUE IF NOT EXISTS 'admin';`
Expand Down
3 changes: 3 additions & 0 deletions src/migrations/1732900000000-AddNotificationDispatchDedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { MigrationInterface, QueryRunner } from "typeorm";
export class AddNotificationDispatchDedupe1732900000000 implements MigrationInterface {
name = "AddNotificationDispatchDedupe1732900000000";

/** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */
transaction = false as const;

public async up(queryRunner: QueryRunner): Promise<void> {
for (const value of ["invoice_approved", "invoice_deadline_extended", "invoice_matured"]) {
await queryRunner.query(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSubscriptionNotificationTypes1754000000000 implements MigrationInterface {
name = "AddSubscriptionNotificationTypes1754000000000";

/** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */
transaction = false as const;

public async up(queryRunner: QueryRunner): Promise<void> {
for (const value of ["subscription", "subscription_expiring"]) {
await queryRunner.query(
Expand Down
30 changes: 24 additions & 6 deletions src/services/auto-invest-rule-engine.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,17 @@ export class AutoInvestRuleEngineService {
const amount = Decimal.min(new Decimal(rule.maxPerInvoice), new Decimal(invoice.netAmount));

try {
const investor = await this.dependencies.dataSource.getRepository(User).findOneBy({ id: rule.investorId });
const investor = await this.dependencies.dataSource
.getRepository(User)
.findOneBy({ id: rule.investorId });
if (!investor) {
await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.FAILED, "Investor not found");
await this.recordExecution(
rule,
invoice,
amount,
AutoInvestExecutionStatus.FAILED,
"Investor not found"
);
return;
}

Expand All @@ -77,7 +85,7 @@ export class AutoInvestRuleEngineService {
invoice,
amount,
AutoInvestExecutionStatus.SKIPPED_INSUFFICIENT_BALANCE,
`Wallet balance ${balance.toString()} is below the rule's minimum ${rule.minWalletBalance}`,
`Wallet balance ${balance.toString()} is below the rule's minimum ${rule.minWalletBalance}`
);
return;
}
Expand All @@ -86,9 +94,19 @@ export class AutoInvestRuleEngineService {
invoiceId: invoice.id,
investorId: rule.investorId,
investmentAmount: amount.toFixed(4),
// Investments are made from the investor's own wallet; createInvestment
// requires it to check accreditation and risk suitability.
investorWallet: investor.stellarAddress,
});

await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.EXECUTED, null, investment.id);
await this.recordExecution(
rule,
invoice,
amount,
AutoInvestExecutionStatus.EXECUTED,
null,
investment.id
);

this.dependencies.logger.info("AutoInvestExecuted", {
rule_id: rule.id,
Expand Down Expand Up @@ -116,7 +134,7 @@ export class AutoInvestRuleEngineService {
amount: Decimal,
status: AutoInvestExecutionStatus,
reason: string | null,
investmentId: string | null = null,
investmentId: string | null = null
): Promise<void> {
const execution = this.dependencies.dataSource.getRepository(AutoInvestExecution).create({
ruleId: rule.id,
Expand All @@ -132,7 +150,7 @@ export class AutoInvestRuleEngineService {
}

export function createAutoInvestRuleEngineService(
dependencies: AutoInvestRuleEngineDependencies,
dependencies: AutoInvestRuleEngineDependencies
): AutoInvestRuleEngineService {
return new AutoInvestRuleEngineService(dependencies);
}
7 changes: 2 additions & 5 deletions src/services/invoice-refund.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ export class InvoiceRefundService {
return { canRefund: true };
}

async refundInvestment(
invoiceId: string,
walletAddress: string
): Promise<void> {
async refundInvestment(invoiceId: string, walletAddress: string): Promise<void> {
const invoice = await this.invoiceRepo.findOne({ where: { id: invoiceId } });

if (!invoice) {
Expand Down Expand Up @@ -81,7 +78,7 @@ export class InvoiceRefundService {
}
}

async getRefundableInvoices(_walletAddress: string): Promise<Invoice[]> {
async getRefundableInvoices(walletAddress: string): Promise<Invoice[]> {
const now = new Date();

// Scope to invoices this wallet actually invested in - without this
Expand Down
Loading
Loading