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
91 changes: 91 additions & 0 deletions backend/src/controllers/loanController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,97 @@
},
);


Check failure on line 61 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `⏎export·const·buildCancelLoanTx·=·async·(⏎··req,⏎··res,⏎··next,⏎` with `export·const·buildCancelLoanTx·=·async·(req,·res,·next`
export const buildCancelLoanTx = async (
req,
res,
next,
) => {
try {
const { loanId } = req.params;

const borrower =

Check failure on line 70 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Delete `⏎·····`
req.user.publicKey;

const loan =

Check failure on line 73 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Delete `⏎·····`
await loanService.getLoanById(loanId);

if (!loan) {
return res.status(404).json({
message: 'Loan not found',

Check failure on line 78 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `'Loan·not·found'` with `"Loan·not·found"`
});
}

if (

Check failure on line 82 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `⏎······!['PENDING',·'OPEN'].includes(⏎········loan.status,⏎······)⏎····` with `!["PENDING",·"OPEN"].includes(loan.status)`
!['PENDING', 'OPEN'].includes(
loan.status,
)
) {
return res.status(400).json({
message:

Check failure on line 88 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `⏎··········'Loan·cannot·be·cancelled'` with `·"Loan·cannot·be·cancelled"`
'Loan cannot be cancelled',
});
}

const transaction =

Check failure on line 93 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Delete `⏎·····`
await sorobanService.buildCancelLoanTx(
borrower,

Check failure on line 95 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Delete `··`
loanId,

Check failure on line 96 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Delete `··`
);

Check failure on line 97 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Delete `··`

return res.json({
success: true,
transaction,
});
} catch (error) {
next(error);
}
};

export const buildRejectLoanTx = async (
req,
res,
next,
) => {
try {
const { loanId } = req.params;

const { reason } =
rejectLoanSchema.parse(req.body);

const loan =
await loanService.getLoanById(loanId);

if (!loan) {
return res.status(404).json({
message: 'Loan not found',
});
}

if (
loan.status !== 'PENDING'
) {
return res.status(400).json({
message:
'Loan cannot be rejected',
});
}

const transaction =
await sorobanService.buildRejectLoanTx(
req.user.publicKey,
loanId,
reason,
);

return res.json({
success: true,
transaction,
});
} catch (error) {
next(error);
}
};
/**
* POST /api/loans/:loanId/mark-defaulted (TEST/DEV ONLY)
* Helper endpoint to mark a loan as defaulted for test setup.
Expand Down
23 changes: 22 additions & 1 deletion backend/src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,29 @@ import {
import { getPendingGovernance } from "../controllers/adminGovernanceController.js";
import { query } from "../db/connection.js";

const router = Router();
import { buildRejectLoanTx } from "../controllers/loanController.ts";

import {
authenticateJWT,
} from '../middleware/jwtAuth';

import {
requireRoles,
} from '../middleware/auth';

import auditLog from '../middleware/auditLog';

const router = express.Router();

router.post(
'/loans/:loanId/build-reject',
authenticateJWT,
requireRoles('admin'),
auditLog('LOAN_REJECT_BUILD'),
buildRejectLoanTx,
);

export default router;
/**
* @swagger
* /admin/loan-disputes:
Expand Down
63 changes: 63 additions & 0 deletions backend/src/routes/loanRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ import {
liquidateLoanSchema,
borrowerLoansQuerySchema,
} from "../schemas/loanSchemas.js";
import {
authenticateJWT,
} from '../middleware/jwtAuth';

import { requireJwtAuth } from "../middleware/jwtAuth.js";

import { buildCancelLoanTx } from "../controllers/loanController.js";


const router = Router();

Expand Down Expand Up @@ -78,6 +86,61 @@ if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {

router.get("/config", getLoanConfigEndpoint);

/**
* @swagger
* /loans/{loanId}/build-cancel:
* post:
* summary: Build cancel loan transaction
* tags:
* - Loans
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: loanId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Transaction built successfully
*/

/**
* @swagger
* /admin/loans/{loanId}/build-reject:
* post:
* summary: Build reject loan transaction
* tags:
* - Admin Loans
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - reason
* properties:
* reason:
* type: string
* minLength: 5
* maxLength: 500
* responses:
* 200:
* description: Reject transaction built
*/


router.post(
'/:loanId/build-cancel',
authenticateJWT,
requireLoanOwner,
buildCancelLoanTx,
);

router.post(
"/amortization-preview",
requireJwtAuth,
Expand Down
10 changes: 10 additions & 0 deletions backend/src/schemas/loanSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { z } from "zod";
import { stellarAddressSchema } from "./stellarSchemas.js";

export const rejectLoanSchema = z.object({
reason: z
.string()
.min(5, 'Reason must be at least 5 characters')
.max(500, 'Reason cannot exceed 500 characters'),
});

export type RejectLoanInput =
z.infer<typeof rejectLoanSchema>;

export const positiveAmountSchema = z
.number()
.int()
Expand Down
81 changes: 81 additions & 0 deletions backend/src/services/__tests__/loanEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
describe('POST /loans/:loanId/build-cancel', () => {
it('should build cancel transaction', async () => {
const response = await request(app)
.post(
'/loans/loan-123/build-cancel',
)
.set(
'Authorization',
`Bearer ${token}`,
);

expect(response.status).toBe(200);

expect(
response.body.transaction,
).toBeDefined();
});
});


describe(
'POST /admin/loans/:loanId/build-reject',
() => {
it(
'should build reject transaction',
async () => {
const response = await request(app)
.post(
'/admin/loans/loan-123/build-reject',
)
.set(
'Authorization',
`Bearer ${adminToken}`,
)
.send({
reason:
'Insufficient collateral',
});

expect(
response.status,
).toBe(200);
},
);
},
);

it(
'should reject non-cancellable loans',
async () => {
const response = await request(app)
.post(
'/loans/completed-loan/build-cancel',
)
.set(
'Authorization',
`Bearer ${token}`,
);

expect(response.status).toBe(400);
},
);

it(
'should fail if reason too short',
async () => {
const response = await request(app)
.post(
'/admin/loans/loan-1/build-reject',
)
.set(
'Authorization',
`Bearer ${adminToken}`,
)
.send({
reason: 'bad',
});

expect(response.status).toBe(400);
},
);
34 changes: 34 additions & 0 deletions backend/src/services/sorobanService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getStellarRpcUrl,
} from "../config/stellar.js";


/**
* Service for building and submitting Soroban contract transactions.
* Handles the transaction lifecycle: build → (frontend signs) → submit.
Expand All @@ -33,6 +34,38 @@ class SorobanService {
return result.connected ? "ok" : "error";
}

async buildCancelLoanTx(
borrower: string,
loanId: string,
) {
const contract = this.getLoanManagerContract();

const tx = await contract.call(
'cancel_loan',
borrower,
loanId,
);

return this.serializeTransaction(tx);
}

async buildRejectLoanTx(
adminPublicKey: string,
loanId: string,
reason: string,
) {
const contract = this.getLoanManagerContract();

const tx = await contract.call(
'reject_loan',
adminPublicKey,
loanId,
reason,
);

return this.serializeTransaction(tx);
}

private getNetworkPassphrase(): string {
return getStellarNetworkPassphrase();
}
Expand Down Expand Up @@ -1213,6 +1246,7 @@ class SorobanService {
latePenalty: process.env.SCORE_DELTA_LATE ?? "5",
});
}

}

export const sorobanService = new SorobanService();
Loading
Loading