mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
feat:( telebirr ) add web redirect checkout
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
"@sendgrid/mail": "^8.1.0",
|
"@sendgrid/mail": "^8.1.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@@ -44,8 +45,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0"
|
||||||
"@prisma/client": "^6.19.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/eslint-config": "workspace:*",
|
"@edr/eslint-config": "workspace:*",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"@nestjs/schematics": "^11.1.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^11.1.19",
|
"@nestjs/testing": "^11.1.19",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto';
|
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { RolesGuard } from '../../common/roles.guard';
|
import { RolesGuard } from '../../common/roles.guard';
|
||||||
import { Roles } from '../../common/roles.decorator';
|
import { Roles } from '../../common/roles.decorator';
|
||||||
@@ -62,4 +63,113 @@ export class PaymentsController {
|
|||||||
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
||||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||||
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
||||||
|
|
||||||
|
@Get('checkout')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Browser checkout redirect',
|
||||||
|
description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'bookingId', required: true })
|
||||||
|
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
|
||||||
|
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
|
||||||
|
@ApiProduces('text/html')
|
||||||
|
async checkout(
|
||||||
|
@Query('bookingId') bookingId: string,
|
||||||
|
@Query('method') method: PaymentMethodTypeEnum,
|
||||||
|
@Query('platform') platform: PaymentPlatformDto = 'web',
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (!bookingId) {
|
||||||
|
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId'));
|
||||||
|
}
|
||||||
|
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||||
|
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.service.initiatePayment({ bookingId, method, platform });
|
||||||
|
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
||||||
|
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRedirectHtml(url: string): string {
|
||||||
|
const escaped = url.replace(/"/g, '"');
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||||
|
<title>Redirecting to payment…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
p { color: #555; margin: 0 0 16px; }
|
||||||
|
a { color: #1a73e8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Redirecting to payment provider…</p>
|
||||||
|
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||||
|
</div>
|
||||||
|
<script>window.location.href = "${escaped}";</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatusHtml(status: string, intentId: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment status</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||||
|
small { color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="status">${status}</div>
|
||||||
|
<small>Intent: ${intentId}</small>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorHtml(message: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
p { color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="error">Payment could not be initiated</div>
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user