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
128 changes: 115 additions & 13 deletions pr.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,117 @@
## Description

This PR hardens backend services, routes, escrow contract guards, and updates repository configuration.

### Addressed Issues
- closes #303
- closes #302
- closes #301
- closes #300

### Key Changes
- **Auth Routes (`src/routes/auth.routes.ts`)**: Optimized and hardened request validation schemas and error handling wrapper for auth routes.
- **Invoice Service (`src/services/invoice.service.ts`)**: Added `rejectInvoice` functionality with status transition validation, rejection reason persistence, and seller notification.
- **Contract Guard Service (`src/services/stellar/contract-guard.service.ts`)**: Fixed Soroban contract address parsing and ledger key building to prevent cache poisoning on RPC errors.
- **Git Ignore (`.gitignore`)**: Added `mimo` build and configuration artifacts ignore patterns.
### Feature 474: Secondary Market Endpoints
Builds secondary market listing and purchase endpoints for fractional invoice shares, allowing investors to list their fractional holdings for resale and other investors to purchase from those listings.

- **`POST /secondary-market/listings`**: Creates a resale listing with price and quantity.
- Validates that the seller owns sufficient confirmed shares in the invoice, accounting for existing active listings to prevent double-listing.
- Validates invoice status (only funded invoices can be listed).
- Rejects listing shares on invoices that are past maturity with HTTP 422 (`INVOICE_MATURED`).
- **`GET /secondary-market/listings`**: Returns active listings per invoice with support for filtering (`invoiceId`, `sellerWallet`, `minPrice`, `maxPrice`) and pagination (`page`, `limit`).
- **`POST /secondary-market/listings/:id/buy`**: Executes purchases of fractional invoice shares from active listings.
- Rejects over-purchases exceeding available listing quantity with HTTP 409 (`OVER_PURCHASE`).
- Rejects self-purchases and purchases on expired or non-active listings with HTTP 400.
- Atomically transfers ownership with payment in a single database transaction:
- Deducts purchased quantity from seller's confirmed holdings (`Investment`).
- Credits purchased quantity to buyer's confirmed holdings (`Investment`).
- Updates listing quantity/status (`SOLD` if fully bought).
- Records completed payment transactions (`Transaction`).
- **Cancel Listing Endpoints**:
- `DELETE /secondary-market/listings/:id` and `POST /secondary-market/listings/:id/cancel`
- Restricts cancellation exclusively to the listing owner (`sellerWallet` or `sellerId`), returning HTTP 403 (`FORBIDDEN`) for non-owners.
- **Route Mounting**:
- Mounted on `/secondary-market`, `/api/v1/secondary-market`, and `/api/v1/secondary` for complete URL compatibility.

Closes #474

---

### Feature 475: KYC Endpoints & Invoice Submission Gating
Implements KYC document submission, verification status checks, admin review transitions, and invoice submission gating as requested in Issue #475.

- **`POST /kyc/submit`**:
- Accepts seller details (`legalName`, `registrationNumber`, `jurisdiction`, etc.) and IPFS document CIDs (`documentCids`, `documentType`, `documentNumber`, `ipfsDocumentUrl`).
- Validates payload structure and stores document CIDs within verification records.
- Sets KYC status to `PENDING`.
- Rejects new submissions with HTTP 409 Conflict if a verification is already `PENDING` or `APPROVED`.
- **`GET /kyc/status`**:
- Returns current verification status (`PENDING`, `APPROVED`, `REJECTED`) for the authenticated seller/wallet.
- Returns `rejectionReason` when status is `REJECTED`, and `null` otherwise.
- **Admin `PATCH /kyc/:id/review`**:
- Allows administrators (authenticated via `x-admin-key` header or admin JWT) to update verification status to `APPROVED` or `REJECTED`.
- Requires rejection reason when rejecting verification.
- Dispatches notifications (`KYC_APPROVED` / `KYC_REJECTED`) and logs audit trail via `logKYCStatusChange`.
- Also mounted on `/admin/kyc/:id/review` and `/api/v1/admin/kyc/:id/review`.
- **Re-submission on Rejected Status**:
- Permitted when user's KYC status is `REJECTED`.
- Archives previous verification record in `KycHistoryRepository`.
- Resets status to `PENDING`, updates document CIDs, and clears previous rejection reason.
- **Invoice Submission KYC Gating**:
- Invoice submission (`POST /invoices` and `POST /api/v1/invoices`) checks seller KYC verification.
- Blocks wallets without approved KYC (`user.kycStatus === 'approved'` or `user.isKycVerified === true`) with HTTP 403 (`KYC_APPROVAL_REQUIRED`), unless `skipVerification` is enabled in configuration.
- **Backwards Compatibility**:
- All endpoints are accessible both directly on `/kyc` and under the `/api/v1/kyc` prefix.
- Legacy webhook and submission routes remain functional.

Closes #475

## Type of Change

- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [x] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📝 Documentation update
- [ ] 🎨 UI/UX improvement
- [ ] ♻️ Code refactoring
- [x] ✅ Test addition or update
- [ ] 🔧 Configuration change

## Checklist

- [x] **All GitHub Actions workflows are green** on this PR (required for merge)
- [x] Commit messages follow **Conventional Commits** (`feat:`, `fix:`, `chore:`, etc.) — enforced by CI
- [x] No secrets, API keys, `.env`, or credentials committed (see `CONTRIBUTING.md`)
- [x] My code follows the code style of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published

## Testing

### How to Test

1. Run secondary market test suite:
```bash
npx jest tests/unit/secondary-market.test.ts tests/secondary-market-notifications.test.ts
```
2. Run KYC workflow test suite:
```bash
npx jest tests/unit/kyc-workflow.test.ts tests/kyc.service.test.ts tests/kyc.controller.test.ts
```
3. Run TypeScript type check:
```bash
npm run type-check
```
4. Run ESLint:
```bash
npm run lint
```

### Test Coverage

- [x] Unit tests added/updated (`tests/unit/secondary-market.test.ts`, `tests/unit/kyc-workflow.test.ts`)
- [x] Integration tests added/updated
- [ ] E2E tests added/updated (if applicable)
- [x] Manual testing completed

## For Reviewers

- [x] Code quality and readability
- [x] Test coverage
- [x] Security implications
- [ ] Performance impact
- [ ] Breaking changes
15 changes: 10 additions & 5 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export function createApp({

if (kycService) {
app.use("/api/v1/kyc", createKycWebhookRouter(kycService));
app.use("/kyc", createKycWebhookRouter(kycService));
}

app.use(express.json());
Expand Down Expand Up @@ -451,6 +452,7 @@ export function createApp({

if (kycService) {
app.use("/api/v1/kyc", createKycRouter(kycService, authService));
app.use("/kyc", createKycRouter(kycService, authService));
}

if (notificationService) {
Expand Down Expand Up @@ -537,10 +539,13 @@ export function createApp({
}

if (secondaryMarketService && authService) {
app.use(
"/api/v1/secondary",
createSecondaryMarketRouter({ secondaryMarketService, authService })
);
const secondaryMarketRouter = createSecondaryMarketRouter({
secondaryMarketService,
authService,
});
app.use("/api/v1/secondary-market", secondaryMarketRouter);
app.use("/secondary-market", secondaryMarketRouter);
app.use("/api/v1/secondary", secondaryMarketRouter);
}

if (watchlistService && authService) {
Expand Down Expand Up @@ -621,6 +626,7 @@ export function createApp({
authService,
aclService,
invoiceService,
kycService,
adminUserService,
extensionService,
metricsService: adminMetricsService,
Expand All @@ -629,7 +635,6 @@ export function createApp({
invoiceEscrowContractService,
feeScheduleService,
waitlistService,
notificationService
})
);
}
Expand Down
173 changes: 153 additions & 20 deletions src/controllers/kyc.controller.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,130 @@
import { Request, Response } from "express";
import { Request, Response, NextFunction } from "express";
import { KycService } from "../services/kyc.service";
import { AuthenticatedRequest } from "../types/auth";

function getErrorStatus(error: unknown): number {
if (typeof error === "object" && error !== null) {
const candidate = error as Record<string, unknown>;
if (typeof candidate.statusCode === "number") return candidate.statusCode;
if (typeof candidate.status === "number") return candidate.status;
}
return 500;
}

function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) return error.message;
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof (error as Record<string, unknown>).message === "string"
) {
return (error as Record<string, unknown>).message as string;
}
return fallback;
}

export function createKycController(service: KycService) {
return {
submit: async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const verification = await service.submitKycVerification(req.user.id, req.body);
return res.status(201).json({ success: true, data: verification });
submit: async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const walletOrId = req.user.stellarAddress || req.user.id;
const verification = await service.submitKyc(walletOrId, req.body);
return res.status(201).json({
success: true,
status: verification.status,
data: verification,
});
} catch (error) {
if (next) return next(error);
const status = getErrorStatus(error);
return res.status(status).json({
success: false,
error: getErrorMessage(error, "Submission failed"),
});
}
},
submitDocument: async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const verification = await service.submitKycVerificationByWallet(
req.user.stellarAddress,
req.body,
);
return res.status(201).json({ success: true, data: verification });

getStatus: async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const walletOrId = req.user.stellarAddress || req.user.id;
const statusResult = await service.getKycStatus(walletOrId);
return res.status(200).json({
success: true,
status: statusResult.status,
rejectionReason: statusResult.rejectionReason,
data: statusResult,
});
} catch (error) {
if (next) return next(error);
const status = getErrorStatus(error);
return res.status(status).json({
success: false,
error: getErrorMessage(error, "Status check failed"),
});
}
},

review: async (req: Request | AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
if (!id) {
return res.status(400).json({
error: { code: "ID_REQUIRED", message: "Verification id is required" },
});
}

const authUser = (req as AuthenticatedRequest).user;
const reviewerId = authUser?.id || (req.headers["x-reviewer-id"] as string) || "admin";
const reviewerWallet = authUser?.stellarAddress || "admin";

const verification = await service.reviewKyc(id, {
...req.body,
reviewerId,
reviewerWallet,
});

const docs = verification.documents as Record<string, unknown> | null;
const rejectionReason =
typeof docs?.rejectionReason === "string" ? docs.rejectionReason : null;

return res.status(200).json({
success: true,
status: verification.status,
rejectionReason,
data: verification,
});
} catch (error) {
if (next) return next(error);
const status = getErrorStatus(error);
return res.status(status).json({
success: false,
error: getErrorMessage(error, "Review failed"),
});
}
},

submitDocument: async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const verification = await service.submitKycVerificationByWallet(
req.user.stellarAddress,
req.body
);
return res.status(201).json({ success: true, data: verification });
} catch (error) {
if (next) return next(error);
const status = getErrorStatus(error);
return res.status(status).json({
success: false,
error: getErrorMessage(error, "Submission failed"),
});
}
},

webhook: async (req: Request, res: Response) => {
if (!Buffer.isBuffer(req.body)) {
return res
Expand All @@ -40,15 +148,40 @@ export function createKycController(service: KycService) {
await service.processWebhook(payload as Parameters<KycService["processWebhook"]>[0]);
return res.status(204).send();
},
resubmit: async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const verification = await service.resubmitKycVerification(req.user.stellarAddress, req.body);
return res.status(201).json({ success: true, data: verification });

resubmit: async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const walletOrId = req.user.stellarAddress || req.user.id;
const verification = await service.submitKyc(walletOrId, req.body);
return res.status(201).json({
success: true,
status: verification.status,
data: verification,
});
} catch (error) {
if (next) return next(error);
const status = getErrorStatus(error);
return res.status(status).json({
success: false,
error: getErrorMessage(error, "Resubmission failed"),
});
}
},
getHistory: async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const history = await service.getKycHistory(req.user.stellarAddress);
return res.status(200).json({ success: true, data: history });

getHistory: async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
const history = await service.getKycHistory(req.user.stellarAddress);
return res.status(200).json({ success: true, data: history });
} catch (error) {
if (next) return next(error);
const status = getErrorStatus(error);
return res.status(status).json({
success: false,
error: getErrorMessage(error, "History fetch failed"),
});
}
},
};
}
Loading
Loading