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
203 changes: 202 additions & 1 deletion docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@
"example": "AAAA..."
}
},
"required": ["status", "contractId"]
"required": [
"status",
"contractId"
]
},
"ErrorResponse": {
"type": "object",
Expand All @@ -50,6 +53,70 @@
"type": "string"
}
}
},
"BatchPublishRequest": {
"type": "object",
"required": [
"invoiceIds"
],
"properties": {
"invoiceIds": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Ids of the draft invoices to publish."
}
}
},
"BatchPublishResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"published": {
"type": "array",
"items": {
"type": "object"
},
"description": "The invoices that were published."
},
"count": {
"type": "integer",
"example": 3
}
}
}
}
},
"RevokeKYCRequest": {
"type": "object",
"required": [
"userId",
"reviewerId"
],
"properties": {
"userId": {
"type": "string",
"description": "User whose approval is being withdrawn."
},
"reviewerId": {
"type": "string",
"description": "Admin performing the revocation."
},
"revocationReason": {
"type": "string",
"description": "Recorded in the KYC audit log."
}
}
}
}
},
Expand Down Expand Up @@ -144,6 +211,140 @@
}
}
}
},
"/api/v1/invoices/batch-publish": {
"post": {
"summary": "Batch publish draft invoices",
"description": "Publishes several of the authenticated seller's draft invoices to the marketplace in a single atomic transaction. Every invoice is validated first; if any one of them cannot be published the whole batch is rejected and no invoice changes state. The 400 response lists every rejected invoice and why.",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BatchPublishRequest"
}
}
}
},
"responses": {
"200": {
"description": "All invoices published",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BatchPublishResponse"
}
}
}
},
"400": {
"description": "Batch rejected; no invoice was changed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "KYC approval required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Rate limit exceeded",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/admin/revoke-kyc": {
"post": {
"summary": "Revoke a user's KYC approval",
"description": "Withdraws a previously granted KYC approval, returning the user to the pending state so they can be reviewed again. Only an approved user can be revoked. Requires the admin API key and an allow-listed source IP.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RevokeKYCRequest"
}
}
}
},
"responses": {
"200": {
"description": "KYC revoked"
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "User not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "User is not currently approved",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
}
}
}
31 changes: 28 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { createInvestmentRouter } from "./routes/investment.routes";
import { createSettlementRouter } from "./routes/settlement.routes";
import { createMarketplaceRouter } from "./routes/marketplace.routes";
import { createAdminRouter } from "./routes/admin/admin.routes";
import { createKycRouter, createKycWebhookRouter } from "./routes/kyc.routes";
import { createContractGuardService } from "./services/stellar/contract-guard.service";

import type { AuthService } from "./services/auth.service";
import type { NotificationService } from "./services/notification.service";
Expand Down Expand Up @@ -193,12 +193,37 @@ export function createApp({
app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config }));
}

// The emergency pause guard only has something to check when a Soroban
// contract and an RPC endpoint are both configured; otherwise the routers
// mount without it and behave exactly as before.
const pauseGuardContractId = config?.sorobanEscrow.contractId ?? null;
const pauseGuardRpcUrl = config?.sorobanEscrow.rpcUrl ?? null;
const contractGuardService =
pauseGuardRpcUrl && pauseGuardContractId
? createContractGuardService({ rpcUrl: pauseGuardRpcUrl })
: undefined;

if (investmentService) {
app.use("/api/v1/investments", createInvestmentRouter({ investmentService, authService }));
app.use(
"/api/v1/investments",
createInvestmentRouter({
investmentService,
authService,
contractGuardService,
contractId: pauseGuardContractId,
}),
);
}

if (settlementService) {
app.use("/api/v1/settlements", createSettlementRouter({ settlementService }));
app.use(
"/api/v1/settlements",
createSettlementRouter({
settlementService,
contractGuardService,
contractId: pauseGuardContractId,
}),
);
}

if (marketplaceService) {
Expand Down
3 changes: 3 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface AppConfig {
enabled: boolean;
contractId: string | null;
fundingMode: "wallet_xdr";
/** Soroban JSON-RPC endpoint used to read on-chain contract state. */
rpcUrl: string | null;
};
ipfs: {
apiUrl: string;
Expand Down Expand Up @@ -256,6 +258,7 @@ export function getConfig(): AppConfig {
enabled: parseBoolean(process.env.SOROBAN_ESCROW_ENABLED, false, "SOROBAN_ESCROW_ENABLED"),
contractId: process.env.SOROBAN_ESCROW_CONTRACT_ID ?? null,
fundingMode: "wallet_xdr",
rpcUrl: process.env.SOROBAN_RPC_URL ?? null,
},

ipfs: {
Expand Down
38 changes: 38 additions & 0 deletions src/controllers/invoice.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export interface PublishInvoiceRequest extends AuthenticatedRequest {
};
}

export interface BatchPublishInvoicesRequest extends AuthenticatedRequest {
body: {
invoiceIds: string[];
};
}

export function createInvoiceController(invoiceService: InvoiceService) {
return {
async createInvoice(
Expand Down Expand Up @@ -299,6 +305,38 @@ export function createInvoiceController(invoiceService: InvoiceService) {
}
},

async batchPublishInvoices(
req: BatchPublishInvoicesRequest,
res: Response,
next: NextFunction,
): Promise<void> {
try {
if (!req.user) {
throw new HttpError(401, "Authentication required");
}

const result = await invoiceService.publishInvoicesBatch({
invoiceIds: req.body.invoiceIds,
sellerId: req.user.id,
});

res.status(200).json({
success: true,
data: result,
});
} catch (error) {
if (error instanceof ServiceError) {
// Per-invoice rejections are the point of the endpoint: the seller
// needs to see every problem at once, so they are passed through as
// error details rather than collapsed into a message.
next(new HttpError(error.statusCode, error.message, error.details));
return;
}

next(error);
}
},

async uploadDocument(
req: UploadDocumentRequest,
res: Response,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/invoice-lifecycle-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import type { InvoiceStatus } from "../types/enums";

export type InvoiceTransitionReason =
| "seller_published"
| "seller_batch_published"
| "fully_funded"
| "admin_settled"
| "admin_rejected";
| "admin_settled";

export interface InvoiceTransitionLogInput {
invoiceId: string;
Expand Down
Loading
Loading