Skip to content
Closed
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
496 changes: 493 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
"delay": "500"
},
"dependencies": {
"@types/archiver": "^8.0.0",
"archiver": "^8.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"decimal.js": "^10.6.0",
Expand Down
35 changes: 35 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import { createRoyaltiesRouter } from "./routes/royalties.routes";
import { createSubscriptionsRouter } from "./routes/subscriptions.routes";
import { createOnboardingRouter } from "./routes/onboarding.routes";
import { createIssuerReputationRouter } from "./routes/issuer-reputation.routes";
import { createIssuerAnalyticsRouter } from "./routes/issuer-analytics.routes";
import { createDocumentExportRouter } from "./routes/document-export.routes";
import { createAutoInvestRouter } from "./routes/auto-invest.routes";
import { createNetworkContextMiddleware } from "./middleware/network-context.middleware";

import type { AuthService } from "./services/auth.service";
import type { NotificationService } from "./services/notification.service";
Expand All @@ -71,6 +75,10 @@ import type { FeeScheduleService } from "./services/fee-schedule.service";
import type { IssuerReputationService } from "./services/issuer-reputation.service";
import type { InvestorRiskProfileService } from "./services/investor-risk-profile.service";
import type { InvoiceDraftService } from "./services/invoice-draft.service";
import type { IssuerAnalyticsService } from "./services/issuer-analytics.service";
import type { DocumentExportService } from "./services/document-export.service";
import type { AutoInvestRulesService } from "./services/auto-invest-rules.service";
import type { StellarNetworksConfig } from "./config/stellar-networks";

import dataSource from "./config/database";
import { getRedisClient } from "./config/redis";
Expand Down Expand Up @@ -211,6 +219,10 @@ export interface AppDependencies {
investorRiskProfileService?: InvestorRiskProfileService;
draftService?: InvoiceDraftService;
invoiceEscrowContractService?: import("./services/stellar/invoice-escrow-contract.service").InvoiceEscrowContractService;
issuerAnalyticsService?: IssuerAnalyticsService;
documentExportService?: DocumentExportService;
autoInvestRulesService?: AutoInvestRulesService;
stellarNetworksConfig?: StellarNetworksConfig;
logger?: AppLogger;
metricsEnabled?: boolean;
metricsRegistry?: MetricsRegistry;
Expand Down Expand Up @@ -265,6 +277,10 @@ export function createApp({
investorRiskProfileService,
draftService,
invoiceEscrowContractService,
issuerAnalyticsService,
documentExportService,
autoInvestRulesService,
stellarNetworksConfig,
logger: appLogger = logger,
metricsEnabled = true,
metricsRegistry = new MetricsRegistry(),
Expand Down Expand Up @@ -395,6 +411,10 @@ export function createApp({
});
}

if (stellarNetworksConfig) {
app.use(createNetworkContextMiddleware(stellarNetworksConfig));
}

// Every request with a bearer token for a suspended wallet gets a 403,
// on all authenticated routes.
app.use(
Expand Down Expand Up @@ -552,6 +572,21 @@ export function createApp({
);
}

// ---- Issuer Analytics (issue #587) ----
if (issuerAnalyticsService) {
app.use("/api/v1/issuers", createIssuerAnalyticsRouter({ issuerAnalyticsService }));
}

// ---- Document Bulk Export (issue #589) ----
if (documentExportService) {
app.use("/api/v1/invoices", createDocumentExportRouter({ documentExportService, authService }));
}

// ---- Auto-Invest Rules (issue #596) ----
if (autoInvestRulesService) {
app.use("/api/v1/auto-invest", createAutoInvestRouter({ autoInvestRulesService, authService }));
}

// ---- Issuer Reputation (issue #593) ----
if (issuerReputationService && authService) {
app.use(
Expand Down
15 changes: 15 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ export interface AppConfig {
};
/** Investor accreditation terms version (issue #473). Bumping forces re-ack. */
termsVersion: string;
documentExport: {
tempDir: string;
signingSecret: string;
urlTtlMinutes: number;
};
cache: {
redisUrl?: string;
invoicesListTtlSeconds: number;
Expand Down Expand Up @@ -478,6 +483,16 @@ export function getConfig(): AppConfig {

termsVersion: (process.env.TERMS_VERSION?.trim() || "1"),

documentExport: {
tempDir: process.env.DOCUMENT_EXPORT_TEMP_DIR || "/tmp/ss-document-exports",
signingSecret: process.env.DOCUMENT_EXPORT_SIGNING_SECRET || "change-me-in-production",
urlTtlMinutes: parsePositiveInteger(
process.env.DOCUMENT_EXPORT_URL_TTL_MINUTES,
60,
"DOCUMENT_EXPORT_URL_TTL_MINUTES"
),
},

cache: {
redisUrl: process.env.REDIS_URL || undefined,
invoicesListTtlSeconds: parsePositiveInteger(
Expand Down
44 changes: 44 additions & 0 deletions src/config/stellar-networks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Networks } from "stellar-sdk";
import type { StellarNetworkEndpoints, StellarNetworkName } from "../types/network";

/**
* Per-network Horizon/contract endpoints (issue #588), kept separate from
* `AppConfig["stellar"]` -- that field is the single network the process
* started with; this is the full mainnet/testnet table a request can select
* between via `X-Stellar-Network`.
*/
export interface StellarNetworksConfig {
mainnet: StellarNetworkEndpoints;
testnet: StellarNetworkEndpoints;
}

function requireEnv(value: string | undefined, name: string): string {
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}

export function getStellarNetworksConfig(): StellarNetworksConfig {
return {
mainnet: {
network: "mainnet",
horizonUrl: requireEnv(process.env.MAINNET_HORIZON_URL, "MAINNET_HORIZON_URL"),
networkPassphrase: Networks.PUBLIC,
contractId: process.env.MAINNET_CONTRACT_ID ?? null,
},
testnet: {
network: "testnet",
horizonUrl: requireEnv(process.env.TESTNET_HORIZON_URL, "TESTNET_HORIZON_URL"),
networkPassphrase: Networks.TESTNET,
contractId: process.env.TESTNET_CONTRACT_ID ?? null,
},
};
}

export function resolveStellarNetworkEndpoints(
config: StellarNetworksConfig,
network: StellarNetworkName,
): StellarNetworkEndpoints {
return config[network];
}
104 changes: 104 additions & 0 deletions src/controllers/auto-invest.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { AutoInvestRulesService } from "../services/auto-invest-rules.service";
import { RiskTier } from "../types/enums";

export class AutoInvestController {
constructor(private readonly autoInvestRulesService: AutoInvestRulesService) {}

createRule = async (req: AuthenticatedRequest, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } });
}

const { category, minYield, maxFaceValue, riskTier, maxPerInvoice, minWalletBalance } = req.body;

if (!minYield || !maxFaceValue || !maxPerInvoice) {
return res.status(400).json({
error: {
code: "MISSING_FIELDS",
message: "minYield, maxFaceValue and maxPerInvoice are required",
},
});
}

if (riskTier && !Object.values(RiskTier).includes(riskTier)) {
return res.status(400).json({
error: {
code: "INVALID_RISK_TIER",
message: `riskTier must be one of: ${Object.values(RiskTier).join(", ")}`,
},
});
}

const rule = await this.autoInvestRulesService.createRule({
investorId: user.id,
category: category ?? null,
minYield,
maxFaceValue,
riskTier: riskTier ?? null,
maxPerInvoice,
minWalletBalance,
});

return res.status(201).json({ success: true, data: rule });
} catch (err: unknown) {
return respondWithError(res, err);
}
};

listRules = async (req: AuthenticatedRequest, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } });
}

const rules = await this.autoInvestRulesService.listRules(user.id);
return res.status(200).json({ success: true, data: rules });
} catch (err: unknown) {
return respondWithError(res, err);
}
};

updateRule = async (req: AuthenticatedRequest, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } });
}

const rule = await this.autoInvestRulesService.updateRule(user.id, req.params.ruleId as string, req.body);
return res.status(200).json({ success: true, data: rule });
} catch (err: unknown) {
return respondWithError(res, err);
}
};

deleteRule = async (req: AuthenticatedRequest, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } });
}

await this.autoInvestRulesService.deleteRule(user.id, req.params.ruleId as string);
return res.status(204).send();
} catch (err: unknown) {
return respondWithError(res, err);
}
};
}

function respondWithError(res: Response, err: unknown) {
const statusCode =
(err as { statusCode?: number }).statusCode || (err as { status?: number }).status || 500;
return res.status(statusCode).json({
error: {
code: (err as { code?: string }).code || "INTERNAL_ERROR",
message: (err as { message?: string }).message || "Internal server error",
},
});
}
75 changes: 75 additions & 0 deletions src/controllers/document-export.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { DocumentExportService } from "../services/document-export.service";

export class DocumentExportController {
constructor(private readonly documentExportService: DocumentExportService) {}

requestExport = async (req: AuthenticatedRequest, res: Response) => {
try {
const invoiceId = req.params.id as string;
const user = req.user;
if (!user) {
return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } });
}

const { jobId } = await this.documentExportService.requestExport(invoiceId, user.id);

return res.status(202).json({
success: true,
data: { jobId, status: "processing" },
});
} catch (err: unknown) {
return respondWithError(res, err);
}
};

getExportStatus = async (req: Request, res: Response) => {
try {
const jobId = req.params.jobId as string;
const job = this.documentExportService.getJobStatus(jobId);
if (!job) {
return res.status(404).json({ error: { code: "export_job_not_found", message: "Export job not found" } });
}

const data: Record<string, unknown> = { jobId: job.id, status: job.status };
if (job.status === "completed" && job.downloadToken) {
data.downloadUrl = `/api/v1/invoices/${job.invoiceId}/documents/export/${job.id}/download?token=${job.downloadToken}`;
data.expiresAt = new Date(job.downloadTokenExpiresAt!).toISOString();
}
if (job.status === "failed") {
data.error = job.error;
}

return res.status(200).json({ success: true, data });
} catch (err: unknown) {
return respondWithError(res, err);
}
};

downloadExport = async (req: Request, res: Response) => {
try {
const jobId = req.params.jobId as string;
const token = req.query.token as string | undefined;
if (!token) {
return res.status(400).json({ error: { code: "missing_token", message: "token query param is required" } });
}

const zipPath = this.documentExportService.getDownloadPath(jobId, token);
return res.download(zipPath, `${jobId}.zip`);
} catch (err: unknown) {
return respondWithError(res, err);
}
};
}

function respondWithError(res: Response, err: unknown) {
const statusCode =
(err as { statusCode?: number }).statusCode || (err as { status?: number }).status || 500;
return res.status(statusCode).json({
error: {
code: (err as { code?: string }).code || "INTERNAL_ERROR",
message: (err as { message?: string }).message || "Internal server error",
},
});
}
1 change: 0 additions & 1 deletion src/controllers/investor-risk-profile.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { InvestorRiskProfileService, RiskProfileInput } from "../services/investor-risk-profile.service";
import { AppError } from "../utils/http-error";
import { logger } from "../observability/logger";
import type { AuthenticatedRequest } from "../types/auth";

export class InvestorRiskProfileController {
Expand Down
3 changes: 1 addition & 2 deletions src/controllers/invoice-draft.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { Response, NextFunction } from "express";
import { InvoiceDraftService, DraftData } from "../services/invoice-draft.service";
import { AppError } from "../utils/http-error";
import { logger } from "../observability/logger";
import type { AuthenticatedRequest } from "../types/auth";

export class InvoiceDraftController {
Expand Down
40 changes: 40 additions & 0 deletions src/controllers/issuer-analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Request, Response } from "express";
import { IssuerAnalyticsService, isValidAnalyticsRange } from "../services/issuer-analytics.service";

export class IssuerAnalyticsController {
constructor(private readonly issuerAnalyticsService: IssuerAnalyticsService) {}

getAnalytics = async (req: Request, res: Response) => {
try {
const wallet = req.params.wallet as string;
const rangeParam = (req.query.range as string | undefined) ?? "30d";

if (!isValidAnalyticsRange(rangeParam)) {
return res.status(400).json({
error: {
code: "INVALID_RANGE",
message: `range must be one of: 30d, 90d, all. Got "${rangeParam}".`,
},
});
}

const analytics = await this.issuerAnalyticsService.getAnalyticsByWallet(wallet, rangeParam);

return res.status(200).json({
success: true,
data: analytics,
});
} catch (err: unknown) {
const statusCode =
(err as { statusCode?: number }).statusCode ||
(err as { status?: number }).status ||
500;
return res.status(statusCode).json({
error: {
code: (err as { code?: string }).code || "INTERNAL_ERROR",
message: (err as { message?: string }).message || "Internal server error",
},
});
}
};
}
Loading
Loading