mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -75,7 +75,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||
PaymentsModule,
|
||||
TicketsModule,
|
||||
PassengersModule,
|
||||
NotificationsModule.register(),
|
||||
NotificationsModule,
|
||||
LoyaltyModule,
|
||||
WalletModule,
|
||||
PromosModule,
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SendEmail {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
to: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sourceId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sourceName?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
subject: string;
|
||||
body: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
html?: string;
|
||||
templateKey?: string;
|
||||
context?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
body?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
context?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SendMessage {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
to: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export class BulkMessagesDto {
|
||||
@ApiProperty({ type: [SendMessage] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SendMessage)
|
||||
messages: SendMessage[];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { SendEmail } from './dtos/email.dto';
|
||||
import { SendMessage } from './dtos/sms.dto';
|
||||
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@Controller('notifications')
|
||||
@@ -56,6 +56,15 @@ export class NotificationsController {
|
||||
return this.smsClient.sendSms(dto);
|
||||
}
|
||||
|
||||
@Post('send/sms/bulk')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
|
||||
@ApiBody({ type: BulkMessagesDto })
|
||||
sendBulkSms(@Body() dto: BulkMessagesDto) {
|
||||
return this.smsClient.sendBulkMessages(dto);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
@@ -8,47 +7,29 @@ import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
|
||||
const rmqClientsModule = ClientsModule.registerAsync([
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ClientsModule.register([
|
||||
{
|
||||
name: 'EMAIL_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
|
||||
urls: [process.env.RABBITMQ_URL as string],
|
||||
queue: process.env.EMAIL_QUEUE ?? 'email_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'SMS_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
|
||||
urls: [process.env.RABBITMQ_URL as string],
|
||||
queue: process.env.SMS_QUEUE ?? 'sms_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@Module({})
|
||||
export class NotificationsModule {
|
||||
static register(): DynamicModule {
|
||||
const rmqEnabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
return {
|
||||
module: NotificationsModule,
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
...(rmqEnabled ? [rmqClientsModule] : []),
|
||||
]),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
@@ -56,16 +37,9 @@ export class NotificationsModule {
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
...(rmqEnabled
|
||||
? [EmailClientService, SmsClientService]
|
||||
: [
|
||||
{ provide: 'EMAIL_SERVICE', useValue: null },
|
||||
{ provide: 'SMS_SERVICE', useValue: null },
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
]),
|
||||
],
|
||||
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class NotificationsService {
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
|
||||
['PUSH', this.pushAdapter as NotificationChannel],
|
||||
]);
|
||||
@@ -107,7 +107,7 @@ export class NotificationsService {
|
||||
await this.emailClient.sendEmail({
|
||||
to: passenger.user.email,
|
||||
subject: this.sanitize(dto.title),
|
||||
body: this.sanitize(dto.body),
|
||||
text: this.sanitize(dto.body),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
|
||||
|
||||
@@ -8,7 +13,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
|
||||
constructor(
|
||||
@Inject('SMS_SERVICE')
|
||||
private readonly smsClient: ClientProxy,
|
||||
private smsClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
@@ -17,8 +22,12 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
if (!this.enabled) return;
|
||||
this.smsClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to SMS service'))
|
||||
.catch((err) => this.logger.error('Error connecting to SMS service', err));
|
||||
.then(() => {
|
||||
this.logger.log('connected to SMS service');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error happened at SMS service', err);
|
||||
});
|
||||
}
|
||||
|
||||
async sendSms(dto: SendMessage) {
|
||||
|
||||
@@ -79,6 +79,28 @@ export class PaymentsController {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("waafi/return")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
|
||||
"UI to display. The frontend success page forwards the Waafi query params here. Gated by " +
|
||||
"WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).",
|
||||
})
|
||||
@ApiQuery({ name: "referenceId", required: true })
|
||||
@ApiQuery({ name: "state", required: true })
|
||||
@ApiQuery({ name: "transactionId", required: false })
|
||||
waafiReturn(
|
||||
@Query("referenceId") referenceId: string,
|
||||
@Query("state") state: string,
|
||||
@Query("transactionId") transactionId: string,
|
||||
) {
|
||||
return this.service.confirmWaafiReturnDemo({
|
||||
referenceId,
|
||||
state,
|
||||
transactionId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
||||
|
||||
@@ -44,6 +44,8 @@ export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly walletDemoAutoSucceed = true;
|
||||
|
||||
private readonly waafiDemoTrustReturn = true;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
@@ -192,6 +194,41 @@ export class PaymentsService {
|
||||
return { returnUrl, failureUrl };
|
||||
}
|
||||
|
||||
async confirmWaafiReturnDemo(params: {
|
||||
referenceId?: string;
|
||||
state?: string;
|
||||
transactionId?: string;
|
||||
}): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
|
||||
if (!this.waafiDemoTrustReturn) {
|
||||
return { confirmed: false, reason: "demo-disabled" };
|
||||
}
|
||||
if ((params.state ?? "").toUpperCase() !== "APPROVED") {
|
||||
return { confirmed: false, reason: `not-approved (${params.state})` };
|
||||
}
|
||||
if (!params.referenceId) {
|
||||
return { confirmed: false, reason: "missing-referenceId" };
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findFirst({
|
||||
where: { merchantOrderId: params.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(
|
||||
`waafi demo return: no local intent for referenceId ${params.referenceId}`,
|
||||
);
|
||||
return { confirmed: false, reason: "intent-not-found" };
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
|
||||
);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: params.transactionId,
|
||||
});
|
||||
return { confirmed: true, bookingId: intent.bookingId };
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
@@ -453,6 +490,30 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
|
||||
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
|
||||
* whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
|
||||
* booking still confirms.
|
||||
*/
|
||||
private sanitizePaidAt(value?: Date): Date {
|
||||
const now = new Date();
|
||||
if (!value) return now;
|
||||
const t = value.getTime();
|
||||
const oneDayMs = 86_400_000;
|
||||
if (
|
||||
Number.isNaN(t) ||
|
||||
t > now.getTime() + oneDayMs ||
|
||||
t < Date.UTC(2000, 0, 1)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
|
||||
);
|
||||
return now;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
@@ -477,7 +538,7 @@ export class PaymentsService {
|
||||
});
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
const paidAt = this.sanitizePaidAt(input.paidAt);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
|
||||
@@ -238,12 +238,21 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700" />
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="relative pb-16">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900" />
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -256,7 +265,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="relative pb-16 -mt-8">
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -274,8 +283,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="relative -mt-8">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -287,6 +295,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
@@ -307,12 +316,21 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700" />
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="relative pb-16">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900" />
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -325,7 +343,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="relative pb-16 -mt-8">
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -343,8 +361,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="relative -mt-8">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -356,6 +373,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
@@ -378,12 +396,21 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700" />
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="relative pb-16">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900" />
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -396,7 +423,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="relative pb-16 -mt-8">
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -414,8 +441,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="relative -mt-8">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -428,6 +454,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -30,7 +30,13 @@
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
@apply bg-[rgb(20_113_76)] bg-opacity-10 dark:bg-[rgb(20_113_76)] dark:bg-opacity-20;
|
||||
background-color: rgba(20, 113, 76, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@@ -146,6 +152,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@@ -167,11 +184,6 @@
|
||||
animation: slide-in-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@@ -85,3 +85,4 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -86,6 +86,21 @@ export class WaafiProvider implements PaymentProvider {
|
||||
requestBody,
|
||||
);
|
||||
|
||||
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
|
||||
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
|
||||
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
|
||||
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
|
||||
// wrongly resolved off a "no info" response.
|
||||
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
|
||||
this.logger.debug(
|
||||
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
|
||||
const transactionId = response.params?.transactionId;
|
||||
const mapped = this.mapStatus(rawState);
|
||||
|
||||
Reference in New Issue
Block a user