mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1327 from Tria-plc/alpha
fix: ( supplementary-charges ) pay in the selected method's currency,…
This commit is contained in:
@@ -58,6 +58,11 @@ export class InternalPaymentsController {
|
|||||||
if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) {
|
if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) {
|
||||||
return this.paymentsService.billQueryExcessBaggage(request.referenceId);
|
return this.paymentsService.billQueryExcessBaggage(request.referenceId);
|
||||||
}
|
}
|
||||||
|
if (request.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
|
||||||
|
return this.paymentsService.billQuerySupplementaryCharge(
|
||||||
|
request.referenceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.paymentsService.billQuery(request.referenceId);
|
return this.paymentsService.billQuery(request.referenceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ class WaiveSupplementaryChargeDto {
|
|||||||
class PaySupplementaryChargeDto {
|
class PaySupplementaryChargeDto {
|
||||||
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
||||||
@ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto;
|
@ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' +
|
||||||
|
'SMSes a one-time password to it) and EBIRR (the wallet pushes a USSD PIN prompt to it).',
|
||||||
|
example: '77123456',
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() payerAccount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConfirmSupplementaryOtpDto {
|
||||||
|
@ApiProperty({ description: 'One-time password the payer received by SMS (CAC Bank).', example: '4530' })
|
||||||
|
@IsString() otp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags("Payment")
|
@ApiTags("Payment")
|
||||||
@@ -420,6 +432,52 @@ export class PaymentsController {
|
|||||||
return this.supplementaryService.getByToken(token);
|
return this.supplementaryService.getByToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('supplementary/by-token/:token/amount')
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Quote a supplementary charge in a payment method’s settlement currency (public)',
|
||||||
|
description:
|
||||||
|
'Returns what the given method would debit, converted from the charge’s stored ETB amount ' +
|
||||||
|
'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' +
|
||||||
|
'wallets in ETB). The self-pay page quotes this before the payer commits; paying recomputes ' +
|
||||||
|
'it identically.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'method', required: true, example: 'WAAFI' })
|
||||||
|
quoteSupplementaryAmount(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Query('method') method: string,
|
||||||
|
) {
|
||||||
|
return this.supplementaryService.quoteAmount(token, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('supplementary/by-token/:token/status')
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Poll a supplementary charge’s settlement status (public)',
|
||||||
|
description:
|
||||||
|
'Reports the charge’s current status without the payability gate on the by-token lookup, ' +
|
||||||
|
'so a page can watch for settlement that happens out of band (a CBE bill paid at a branch, ' +
|
||||||
|
'or a redirect payment confirmed by webhook).',
|
||||||
|
})
|
||||||
|
getSupplementaryStatus(@Param('token') token: string) {
|
||||||
|
return this.supplementaryService.getStatus(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('supplementary/by-token/:token/confirm')
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Confirm an OTP-debit balance payment (CAC Bank, public — self-pay)',
|
||||||
|
description:
|
||||||
|
'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' +
|
||||||
|
'400 and the payment stays open for retry.',
|
||||||
|
})
|
||||||
|
confirmSupplementaryOtp(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Body() dto: ConfirmSupplementaryOtpDto,
|
||||||
|
) {
|
||||||
|
return this.supplementaryService.confirmOtp(token, dto.otp);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('supplementary/by-token/:token/pay')
|
@Post('supplementary/by-token/:token/pay')
|
||||||
@SetMetadata('isPublic', true)
|
@SetMetadata('isPublic', true)
|
||||||
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
|
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
|
||||||
@@ -437,6 +495,7 @@ export class PaymentsController {
|
|||||||
dto.method,
|
dto.method,
|
||||||
dto.platform,
|
dto.platform,
|
||||||
resolveAllowedOrigin(origin, referer, frontendBaseUrl),
|
resolveAllowedOrigin(origin, referer, frontendBaseUrl),
|
||||||
|
dto.payerAccount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ describe("PaymentsService", () => {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
supplementaryCharge: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
currencyExchangeRate: {
|
currencyExchangeRate: {
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -758,4 +762,76 @@ describe("PaymentsService", () => {
|
|||||||
).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" });
|
).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* The live hop CBE makes while a teller is on the line, for a balance bill. Same
|
||||||
|
* double-payment guard as bookings and baggage: anything other than stillPayable=true makes
|
||||||
|
* CBE refuse the debit.
|
||||||
|
*/
|
||||||
|
describe("billQuerySupplementaryCharge", () => {
|
||||||
|
const payable = {
|
||||||
|
id: "sc-1",
|
||||||
|
amountMinor: 100_000,
|
||||||
|
status: "PENDING",
|
||||||
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
booking: {
|
||||||
|
bookingRef: "BAL-001",
|
||||||
|
seats: [{ leg: 1, passengerName: "Abebe Kebede" }],
|
||||||
|
passenger: { user: { fullName: "Account Holder" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports a pending charge as payable, in ETB, with the passenger name", async () => {
|
||||||
|
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(payable);
|
||||||
|
const result = await service.billQuerySupplementaryCharge("sc-1");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
stillPayable: true,
|
||||||
|
currency: "ETB",
|
||||||
|
currentAmountMinor: 1000,
|
||||||
|
payerName: "Abebe Kebede",
|
||||||
|
});
|
||||||
|
expect(result.paymentReason).toContain("BAL-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an already paid charge", async () => {
|
||||||
|
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "PAID" });
|
||||||
|
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
|
||||||
|
stillPayable: false,
|
||||||
|
reason: "ALREADY_PAID",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a waived charge", async () => {
|
||||||
|
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "WAIVED" });
|
||||||
|
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
|
||||||
|
stillPayable: false,
|
||||||
|
reason: "CANCELLED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses within the settle margin so a debit cannot land after expiry", async () => {
|
||||||
|
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({
|
||||||
|
...payable,
|
||||||
|
expiresAt: new Date(Date.now() + 5_000),
|
||||||
|
});
|
||||||
|
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
|
||||||
|
stillPayable: false,
|
||||||
|
reason: "EXPIRED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a null expiry as an open-ended debt, still payable", async () => {
|
||||||
|
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, expiresAt: null });
|
||||||
|
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
|
||||||
|
stillPayable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports NOT_FOUND for a bill whose charge is gone", async () => {
|
||||||
|
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toEqual({
|
||||||
|
stillPayable: false,
|
||||||
|
reason: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -611,6 +611,72 @@ export class PaymentsService {
|
|||||||
return { ...base, stillPayable: true, reason: null };
|
return { ...base, stillPayable: true, reason: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bill-query for a supplementary charge — the same live "still payable?" hop as bookings,
|
||||||
|
* against `SupplementaryCharge`. This is the double-payment guard for balance bills: once the
|
||||||
|
* charge is paid, waived or lapsed, CBE is told to refuse the debit.
|
||||||
|
*
|
||||||
|
* The charge's own 72-hour `expiresAt` is the deadline. It is nullable — a charge raised with
|
||||||
|
* no expiry is an open-ended debt and stays payable indefinitely, which is the intended reading
|
||||||
|
* of a null here rather than an immediate refusal.
|
||||||
|
*/
|
||||||
|
async billQuerySupplementaryCharge(
|
||||||
|
chargeId: string,
|
||||||
|
): Promise<BillQueryResponseDto> {
|
||||||
|
const charge = await this.prisma.supplementaryCharge.findUnique({
|
||||||
|
where: { id: chargeId },
|
||||||
|
include: {
|
||||||
|
booking: {
|
||||||
|
include: { seats: true, passenger: { include: { user: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// A bill reference we issued whose charge has since been deleted — a data problem, not a
|
||||||
|
// customer-facing cancellation.
|
||||||
|
if (!charge) return { stillPayable: false, reason: "NOT_FOUND" };
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
payerName:
|
||||||
|
charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ??
|
||||||
|
charge.booking?.seats?.[0]?.passengerName ??
|
||||||
|
charge.booking?.passenger?.user?.fullName ??
|
||||||
|
null,
|
||||||
|
// The charge is raised in ETB and CBE settles ETB only, so no conversion applies.
|
||||||
|
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
|
||||||
|
charge.amountMinor,
|
||||||
|
"ETB",
|
||||||
|
),
|
||||||
|
currency: "ETB",
|
||||||
|
// Rendered beside the amount on CBE's confirmation screen. The booking ref is on the
|
||||||
|
// passenger's ticket, so they can match the two before confirming.
|
||||||
|
paymentReason: `Outstanding balance — booking ${
|
||||||
|
charge.booking?.bookingRef ?? ""
|
||||||
|
}`.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (charge.status === "PAID") {
|
||||||
|
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||||
|
}
|
||||||
|
// Staff wrote the balance off; from the payer's side the debt is gone.
|
||||||
|
if (charge.status === "WAIVED") {
|
||||||
|
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||||
|
}
|
||||||
|
// Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline
|
||||||
|
// that the sweep expires the intent before the capture is registered.
|
||||||
|
if (
|
||||||
|
charge.status === "EXPIRED" ||
|
||||||
|
(charge.expiresAt &&
|
||||||
|
charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 <
|
||||||
|
Date.now())
|
||||||
|
) {
|
||||||
|
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
||||||
|
}
|
||||||
|
if (charge.status !== "PENDING") {
|
||||||
|
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
|
||||||
|
}
|
||||||
|
return { ...base, stillPayable: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
|
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
|
||||||
* origin-segment time and that stop's own check-in window, falling back to the route default.
|
* origin-segment time and that stop's own check-in window, falling back to the route default.
|
||||||
|
|||||||
@@ -4,11 +4,35 @@ import { AuditService } from '../../common/audit.service';
|
|||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { EmailClientService } from '../notifications/email-client.service';
|
import { EmailClientService } from '../notifications/email-client.service';
|
||||||
import { PaymentClientService } from './payment-client.service';
|
import { PaymentClientService } from './payment-client.service';
|
||||||
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import {
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService as PaymentServiceEnum,
|
||||||
|
ProviderMethod,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from '@edr/types';
|
||||||
import { PaymentPlatformDto } from './payments.dto';
|
import { PaymentPlatformDto } from './payments.dto';
|
||||||
|
import { PaymentMethodType } from '@prisma/client';
|
||||||
|
|
||||||
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WALLET is an internal balance debit handled inside this app, not a provider — the payment
|
||||||
|
* microservice rejects it as one. Supplementary charges have no wallet path, so it is refused up
|
||||||
|
* front with a message the payer can act on rather than a 502 from the gateway layer.
|
||||||
|
*/
|
||||||
|
const UNSUPPORTED_METHODS = new Set<string>([PaymentMethodType.WALLET]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time
|
||||||
|
* password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could
|
||||||
|
* collect the number later (mirrors PaymentsService and ExcessBaggageService).
|
||||||
|
*/
|
||||||
|
const METHODS_REQUIRING_PAYER_ACCOUNT = new Set<string>([
|
||||||
|
PaymentMethodType.CAC_BANK,
|
||||||
|
PaymentMethodType.EBIRR,
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupplementaryChargesService {
|
export class SupplementaryChargesService {
|
||||||
private readonly logger = new Logger(SupplementaryChargesService.name);
|
private readonly logger = new Logger(SupplementaryChargesService.name);
|
||||||
@@ -19,6 +43,7 @@ export class SupplementaryChargesService {
|
|||||||
private smsClient: SmsClientService,
|
private smsClient: SmsClientService,
|
||||||
private emailClient: EmailClientService,
|
private emailClient: EmailClientService,
|
||||||
private paymentClient: PaymentClientService,
|
private paymentClient: PaymentClientService,
|
||||||
|
private currencyService: CurrencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: {
|
async create(dto: {
|
||||||
@@ -117,14 +142,161 @@ export class SupplementaryChargesService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the payer is actually charged when settling this charge with `method`.
|
||||||
|
*
|
||||||
|
* The charge is raised in ETB, but the selected method settles in its own currency — WAAFI and
|
||||||
|
* D-Money in DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row.
|
||||||
|
* The payment microservice is currency-agnostic and hands whatever it is given straight to the
|
||||||
|
* gateway, so the ETB->settlement conversion has to happen here or the provider is asked to
|
||||||
|
* debit an ETB number labelled as its own currency.
|
||||||
|
*
|
||||||
|
* Both the quote shown to the payer and the amount sent to the provider come through this one
|
||||||
|
* method, so the price on the button and the price debited cannot drift apart.
|
||||||
|
*/
|
||||||
|
private async resolveChargeAmount(
|
||||||
|
charge: { amountMinor: number; currency: string },
|
||||||
|
method: string,
|
||||||
|
): Promise<{ amount: number; currency: string }> {
|
||||||
|
if (UNSUPPORTED_METHODS.has(method)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${method} is not available for balance payments`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||||
|
where: { type: method as PaymentMethodType },
|
||||||
|
});
|
||||||
|
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted.
|
||||||
|
const chargeCurrency =
|
||||||
|
method === PaymentMethodType.CBE_BILL
|
||||||
|
? 'ETB'
|
||||||
|
: (paymentMethod?.currency ?? charge.currency).toUpperCase();
|
||||||
|
|
||||||
|
// Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents.
|
||||||
|
const amount = await this.currencyService.convertMinorToChargeMajor(
|
||||||
|
charge.amountMinor,
|
||||||
|
charge.currency,
|
||||||
|
chargeCurrency,
|
||||||
|
);
|
||||||
|
return { amount, currency: chargeCurrency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price quote for the pay page: what `method` would debit, in that method's settlement
|
||||||
|
* currency. The payer sees this before committing, and pay() recomputes it the same way.
|
||||||
|
*/
|
||||||
|
async quoteAmount(token: string, method: string) {
|
||||||
|
const charge = await this.getByToken(token);
|
||||||
|
const { amount, currency } = await this.resolveChargeAmount(charge, method);
|
||||||
|
return { chargeId: charge.id, method, currency, amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid,
|
||||||
|
* expired or waived charge — reporting those states is the entire point. A CBE bill can settle
|
||||||
|
* long after the payer closed the tab, and redirect methods only converge when the settlement
|
||||||
|
* event lands, so the page needs something it can watch.
|
||||||
|
*/
|
||||||
|
async getStatus(token: string) {
|
||||||
|
const charge = await this.prisma.supplementaryCharge.findUnique({
|
||||||
|
where: { paymentToken: token },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
paidAt: true,
|
||||||
|
amountMinor: true,
|
||||||
|
currency: true,
|
||||||
|
expiresAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!charge) throw new NotFoundException('Payment link not found');
|
||||||
|
return {
|
||||||
|
chargeId: charge.id,
|
||||||
|
status: charge.status,
|
||||||
|
paid: charge.status === 'PAID',
|
||||||
|
paidAt: charge.paidAt,
|
||||||
|
amountMinor: charge.amountMinor,
|
||||||
|
currency: charge.currency,
|
||||||
|
expiresAt: charge.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full_Name for CBE's confirmation screen — mandatory in its envelope. The traveller the balance
|
||||||
|
* is owed against: lead passenger on the booking, falling back to the account holder.
|
||||||
|
*/
|
||||||
|
private async resolvePayerName(bookingId: string): Promise<string | null> {
|
||||||
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: { seats: true, passenger: { include: { user: true } } },
|
||||||
|
});
|
||||||
|
if (!booking) return null;
|
||||||
|
return (
|
||||||
|
booking.seats?.find((s: any) => s.leg === 1)?.passengerName ??
|
||||||
|
booking.seats?.[0]?.passengerName ??
|
||||||
|
booking.passenger?.user?.fullName ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the
|
||||||
|
* payerAccount given at pay(); this forwards it to the payment service and marks the charge paid
|
||||||
|
* when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays open,
|
||||||
|
* so the payer can simply re-enter it.
|
||||||
|
*
|
||||||
|
* Deliberately reads the charge directly rather than through getByToken: the bank is already
|
||||||
|
* holding a debit against this payer, and refusing to submit their OTP because the link TTL
|
||||||
|
* lapsed while they read the SMS would strand a payment that is mid-flight.
|
||||||
|
*/
|
||||||
|
async confirmOtp(token: string, otp: string) {
|
||||||
|
const charge = await this.prisma.supplementaryCharge.findUnique({
|
||||||
|
where: { paymentToken: token },
|
||||||
|
});
|
||||||
|
if (!charge) throw new NotFoundException('Payment link not found');
|
||||||
|
if (charge.status === 'PAID') {
|
||||||
|
return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await this.paymentClient.getIntentByReference(
|
||||||
|
PaymentReferenceType.SUPPLEMENTARY_CHARGE,
|
||||||
|
charge.id,
|
||||||
|
);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new NotFoundException('No active payment to confirm for this charge');
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.paymentClient.confirmOtp(
|
||||||
|
snapshot.intentId,
|
||||||
|
otp,
|
||||||
|
);
|
||||||
|
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
|
await this.markPaid(charge.id, confirmed.providerTxnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chargeId: charge.id,
|
||||||
|
status: confirmed.status,
|
||||||
|
alreadyPaid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async pay(
|
async pay(
|
||||||
token: string,
|
token: string,
|
||||||
method: string,
|
method: string,
|
||||||
platform?: PaymentPlatformDto,
|
platform?: PaymentPlatformDto,
|
||||||
requestOrigin?: string | null,
|
requestOrigin?: string | null,
|
||||||
|
payerAccount?: string,
|
||||||
) {
|
) {
|
||||||
const charge = await this.getByToken(token); // validates status/expiry
|
const charge = await this.getByToken(token); // validates status/expiry
|
||||||
|
|
||||||
|
if (METHODS_REQUIRING_PAYER_ACCOUNT.has(method) && !payerAccount?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`payerAccount (mobile number) is required for ${method}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const paymentMethod = method as ProviderMethod;
|
const paymentMethod = method as ProviderMethod;
|
||||||
// Self-pay links are opened on whichever portal domain the recipient used
|
// Self-pay links are opened on whichever portal domain the recipient used
|
||||||
// (bookingedr.et vs passenger.edrsc.com), so the return pages must live on
|
// (bookingedr.et vs passenger.edrsc.com), so the return pages must live on
|
||||||
@@ -135,15 +307,38 @@ export class SupplementaryChargesService {
|
|||||||
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
|
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
|
||||||
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
|
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
|
||||||
|
|
||||||
|
const { amount, currency } = await this.resolveChargeAmount(charge, method);
|
||||||
|
|
||||||
|
// CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's
|
||||||
|
// system until someone pays it. It needs a real deadline and a payer name (Full_Name is
|
||||||
|
// mandatory in CBE's envelope) rather than the redirect flow's session semantics.
|
||||||
|
//
|
||||||
|
// Unlike an excess baggage charge (30-minute link TTL), this charge already carries a 72-hour
|
||||||
|
// deadline of its own, which is a sane bill lifetime — so it is passed straight through with
|
||||||
|
// no extension. That deadline is what stops the reconciliation sweep from expiring the intent
|
||||||
|
// early (CBE_IMPLEMENTATION_PLAN.md §6.4). A charge with no expiry at all yields no intent
|
||||||
|
// expiry either, which is correct: an open-ended debt backs an open-ended bill.
|
||||||
|
let payerName: string | undefined;
|
||||||
|
let expiresAt: string | undefined;
|
||||||
|
if (method === PaymentMethodType.CBE_BILL) {
|
||||||
|
expiresAt = charge.expiresAt?.toISOString();
|
||||||
|
payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.PASSENGER,
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
|
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
|
||||||
referenceId: charge.id,
|
referenceId: charge.id,
|
||||||
orderRef: `SC-${charge.id.substring(0, 8)}`,
|
orderRef: `SC-${charge.id.substring(0, 8)}`,
|
||||||
amountMinor: charge.amountMinor / 100,
|
// `amountMinor` is the contract's name but its value is MAJOR units — the provider layer
|
||||||
currency: charge.currency,
|
// charges it verbatim at the currency's own precision (see PaymentIntentSnapshot).
|
||||||
|
amountMinor: amount,
|
||||||
|
currency,
|
||||||
provider: paymentMethod,
|
provider: paymentMethod,
|
||||||
platform,
|
platform,
|
||||||
|
payerAccount: payerAccount?.trim() || undefined,
|
||||||
|
payerName,
|
||||||
|
expiresAt,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
failureUrl,
|
failureUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { PaymentMethodType } from '@prisma/client';
|
||||||
|
import { SupplementaryChargesService } from './supplementary-charges.service';
|
||||||
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A supplementary charge is raised in ETB, but each payment method settles in its own currency and
|
||||||
|
* the payment microservice forwards whatever it is given straight to the gateway. These cover the
|
||||||
|
* ETB->settlement conversion, plus the two methods that could not complete at all before: CAC Bank
|
||||||
|
* (OTP debit) and CBE (inbound bill).
|
||||||
|
*/
|
||||||
|
describe('SupplementaryChargesService — payment methods', () => {
|
||||||
|
const CHARGE_ID = 'sc-1';
|
||||||
|
const TOKEN = 'tok-1';
|
||||||
|
|
||||||
|
let prisma: Record<string, any>;
|
||||||
|
let paymentClient: Record<string, jest.Mock>;
|
||||||
|
let service: SupplementaryChargesService;
|
||||||
|
let charge: any;
|
||||||
|
|
||||||
|
const build = (rate?: { rate: number }) => {
|
||||||
|
charge = {
|
||||||
|
id: CHARGE_ID,
|
||||||
|
bookingId: 'booking-1',
|
||||||
|
amountMinor: 100_000,
|
||||||
|
currency: 'ETB',
|
||||||
|
status: 'PENDING',
|
||||||
|
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||||
|
booking: { bookingRef: 'BAL-001' },
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
supplementaryCharge: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(charge),
|
||||||
|
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
seats: [{ leg: 1, passengerName: 'Abebe Kebede' }],
|
||||||
|
passenger: { user: { fullName: 'Account Holder' } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
paymentMethod: { findUnique: jest.fn() },
|
||||||
|
currencyExchangeRate: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(rate ?? null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
paymentClient = {
|
||||||
|
initiate: jest.fn().mockResolvedValue({
|
||||||
|
intentId: 'intent-1',
|
||||||
|
status: 'REQUIRES_ACTION',
|
||||||
|
clientAction: { type: 'REDIRECT', url: 'https://gw.test/pay' },
|
||||||
|
}),
|
||||||
|
getIntentByReference: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }),
|
||||||
|
confirmOtp: jest.fn().mockResolvedValue({
|
||||||
|
intentId: 'intent-1',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
providerTxnId: 'CAC-77',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service = new SupplementaryChargesService(
|
||||||
|
prisma as any,
|
||||||
|
{ log: jest.fn() } as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
paymentClient as any,
|
||||||
|
new CurrencyService(prisma as any),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMethod = (type: string, currency: string) =>
|
||||||
|
prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency });
|
||||||
|
|
||||||
|
describe('currency', () => {
|
||||||
|
it('charges an Ethiopian wallet in ETB, unconverted', async () => {
|
||||||
|
build();
|
||||||
|
withMethod(PaymentMethodType.TELEBIRR, 'ETB');
|
||||||
|
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR);
|
||||||
|
expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 });
|
||||||
|
expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts to DJF and rounds to whole francs', async () => {
|
||||||
|
build({ rate: 3.25 });
|
||||||
|
withMethod(PaymentMethodType.DMONEY, 'DJF');
|
||||||
|
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.DMONEY);
|
||||||
|
expect(quote).toMatchObject({ currency: 'DJF', amount: 3250 });
|
||||||
|
expect(Number.isInteger(quote.amount)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the provider the converted amount, not the stored ETB total', async () => {
|
||||||
|
build({ rate: 0.018 });
|
||||||
|
withMethod(PaymentMethodType.CARD, 'USD');
|
||||||
|
await service.pay(TOKEN, PaymentMethodType.CARD, 'web' as any, null);
|
||||||
|
expect(paymentClient.initiate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
referenceType: 'SUPPLEMENTARY_CHARGE',
|
||||||
|
amountMinor: 18,
|
||||||
|
currency: 'USD',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes and charges the same figure', async () => {
|
||||||
|
build({ rate: 3.25 });
|
||||||
|
withMethod(PaymentMethodType.WAAFI, 'DJF');
|
||||||
|
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI);
|
||||||
|
await service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null);
|
||||||
|
const sent = paymentClient.initiate.mock.calls[0][0];
|
||||||
|
expect(quote.amount).toBe(sent.amountMinor);
|
||||||
|
expect(quote.currency).toBe(sent.currency);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses WALLET, which has no supplementary-charge path', async () => {
|
||||||
|
build();
|
||||||
|
await expect(
|
||||||
|
service.quoteAmount(TOKEN, PaymentMethodType.WALLET),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(paymentClient.initiate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when no exchange rate is configured', async () => {
|
||||||
|
build();
|
||||||
|
withMethod(PaymentMethodType.WAAFI, 'DJF');
|
||||||
|
await expect(
|
||||||
|
service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(paymentClient.initiate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CAC Bank OTP debit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
build({ rate: 3.25 });
|
||||||
|
withMethod(PaymentMethodType.CAC_BANK, 'DJF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects pay() without a payer mobile', async () => {
|
||||||
|
await expect(
|
||||||
|
service.pay(TOKEN, PaymentMethodType.CAC_BANK, 'web' as any, null),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(paymentClient.initiate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the trimmed payer mobile', async () => {
|
||||||
|
await service.pay(
|
||||||
|
TOKEN,
|
||||||
|
PaymentMethodType.CAC_BANK,
|
||||||
|
'web' as any,
|
||||||
|
null,
|
||||||
|
' 77123456 ',
|
||||||
|
);
|
||||||
|
expect(paymentClient.initiate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ payerAccount: '77123456', currency: 'DJF' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits the OTP against the active intent and marks the charge paid', async () => {
|
||||||
|
const result = await service.confirmOtp(TOKEN, '4530');
|
||||||
|
expect(paymentClient.getIntentByReference).toHaveBeenCalledWith(
|
||||||
|
'SUPPLEMENTARY_CHARGE',
|
||||||
|
CHARGE_ID,
|
||||||
|
);
|
||||||
|
expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530');
|
||||||
|
expect(prisma.supplementaryCharge.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'PAID',
|
||||||
|
providerTxnId: 'CAC-77',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the charge unpaid when the OTP does not settle', async () => {
|
||||||
|
paymentClient.confirmOtp.mockResolvedValue({
|
||||||
|
intentId: 'intent-1',
|
||||||
|
status: 'REQUIRES_ACTION',
|
||||||
|
});
|
||||||
|
await service.confirmOtp(TOKEN, '0000');
|
||||||
|
expect(prisma.supplementaryCharge.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent once already paid', async () => {
|
||||||
|
prisma.supplementaryCharge.findUnique.mockResolvedValue({
|
||||||
|
...charge,
|
||||||
|
status: 'PAID',
|
||||||
|
});
|
||||||
|
await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({
|
||||||
|
alreadyPaid: true,
|
||||||
|
});
|
||||||
|
expect(paymentClient.confirmOtp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CBE bill', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
build({ rate: 3.25 });
|
||||||
|
withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces ETB regardless of the PaymentMethod row', async () => {
|
||||||
|
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL);
|
||||||
|
expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the charge own 72h deadline as the intent expiry', async () => {
|
||||||
|
await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null);
|
||||||
|
const sent = paymentClient.initiate.mock.calls[0][0];
|
||||||
|
expect(sent.currency).toBe('ETB');
|
||||||
|
expect(sent.expiresAt).toBe(charge.expiresAt.toISOString());
|
||||||
|
// Comfortably longer than a browser-session TTL, so the sweep cannot kill the bill early.
|
||||||
|
expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan(
|
||||||
|
Date.now() + 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the lead passenger as Full_Name, which CBE requires', async () => {
|
||||||
|
await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null);
|
||||||
|
expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe(
|
||||||
|
'Abebe Kebede',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the intent expiry unset for an open-ended charge', async () => {
|
||||||
|
charge.expiresAt = null;
|
||||||
|
await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null);
|
||||||
|
expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a paid charge through getStatus without the payability gate', async () => {
|
||||||
|
prisma.supplementaryCharge.findUnique.mockResolvedValue({
|
||||||
|
...charge,
|
||||||
|
status: 'PAID',
|
||||||
|
paidAt: new Date(),
|
||||||
|
});
|
||||||
|
await expect(service.getStatus(TOKEN)).resolves.toMatchObject({
|
||||||
|
status: 'PAID',
|
||||||
|
paid: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
@@ -8,7 +8,10 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
|
|||||||
import { PaymentMethod } from "@/types";
|
import { PaymentMethod } from "@/types";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
KeyRound,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Wallet,
|
Wallet,
|
||||||
Landmark,
|
Landmark,
|
||||||
@@ -23,6 +26,28 @@ const getIconForMethod = (methodId: string) => {
|
|||||||
return Smartphone;
|
return Smartphone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// WALLET is an internal balance debit with no supplementary-charge path — the API refuses it, so
|
||||||
|
// it is never offered here.
|
||||||
|
const UNSUPPORTED_METHODS = ["WALLET"];
|
||||||
|
|
||||||
|
// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time
|
||||||
|
// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could
|
||||||
|
// collect the number afterwards, so it is asked for up front.
|
||||||
|
const requiresPayerMobile = (method: string | null) =>
|
||||||
|
method === "CAC_BANK" || method === "EBIRR";
|
||||||
|
|
||||||
|
// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding,
|
||||||
|
// so the quote renders exactly the figure the provider will debit.
|
||||||
|
const formatAmount = (amount: number, currency: string) =>
|
||||||
|
amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2);
|
||||||
|
|
||||||
|
interface AmountQuote {
|
||||||
|
chargeId: string;
|
||||||
|
method: string;
|
||||||
|
currency: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PayBalancePage() {
|
export default function PayBalancePage() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,6 +55,26 @@ export default function PayBalancePage() {
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC —
|
||||||
|
// the OTP the bank SMSes to it.
|
||||||
|
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
|
||||||
|
const [payerMobile, setPayerMobile] = useState("");
|
||||||
|
const [phoneError, setPhoneError] = useState<string | null>(null);
|
||||||
|
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [otpMessage, setOtpMessage] = useState<string | null>(null);
|
||||||
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
|
const [pushMessage, setPushMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a
|
||||||
|
// branch/app later, so the page shows the number and watches for settlement.
|
||||||
|
const [billAction, setBillAction] = useState<{
|
||||||
|
billReference: string;
|
||||||
|
instructions?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [billCopied, setBillCopied] = useState(false);
|
||||||
|
|
||||||
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
|
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
|
||||||
queryKey: ["supplementary-charge", token],
|
queryKey: ["supplementary-charge", token],
|
||||||
queryFn: () => apiClient.get<any>(`/payments/supplementary/by-token/${token}`),
|
queryFn: () => apiClient.get<any>(`/payments/supplementary/by-token/${token}`),
|
||||||
@@ -45,18 +90,102 @@ export default function PayBalancePage() {
|
|||||||
enabled: !!charge,
|
enabled: !!charge,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const availableMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
paymentMethods.filter(
|
||||||
|
(m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type),
|
||||||
|
),
|
||||||
|
[paymentMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The charge is raised in ETB; this is what it costs before a method is chosen.
|
||||||
|
const chargeCurrency = charge?.currency ?? "ETB";
|
||||||
|
const chargeAmount = useMemo(
|
||||||
|
() => Number(charge?.amountMinor ?? 0) / 100,
|
||||||
|
[charge],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets
|
||||||
|
// in ETB), so the price has to be re-quoted server-side whenever the selection changes — the
|
||||||
|
// stored ETB amount is not what a Djiboutian wallet would debit.
|
||||||
|
const {
|
||||||
|
data: quote,
|
||||||
|
isFetching: fetchingQuote,
|
||||||
|
error: quoteError,
|
||||||
|
} = useQuery<AmountQuote>({
|
||||||
|
queryKey: ["supplementaryAmount", token, selectedMethod],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get<AmountQuote>(
|
||||||
|
`/payments/supplementary/by-token/${token}/amount?method=${selectedMethod}`,
|
||||||
|
),
|
||||||
|
enabled: !!token && !!selectedMethod,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A quote is only usable once it belongs to the method currently selected — otherwise it is a
|
||||||
|
// leftover from the previous selection and would price the payment in the wrong currency.
|
||||||
|
const quoteReady = !fetchingQuote && quote?.method === selectedMethod;
|
||||||
|
const displayCurrency = selectedMethod ? (quote?.currency ?? "") : chargeCurrency;
|
||||||
|
const displayAmount = selectedMethod ? quote?.amount : chargeAmount;
|
||||||
|
const amountLabel =
|
||||||
|
quoteReady && displayAmount != null
|
||||||
|
? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}`
|
||||||
|
: !selectedMethod && displayAmount != null
|
||||||
|
? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Never let Pay fire against a price the payer has not been shown.
|
||||||
|
const awaitingQuote = !!selectedMethod && !quoteReady;
|
||||||
|
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
mutationFn: (method: string) =>
|
mutationFn: (vars: { method: string; payerAccount?: string }) =>
|
||||||
apiClient.post<any>(`/payments/supplementary/by-token/${token}/pay`, {
|
apiClient.post<any>(`/payments/supplementary/by-token/${token}/pay`, {
|
||||||
method,
|
method: vars.method,
|
||||||
platform: "web",
|
platform: "web",
|
||||||
|
...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}),
|
||||||
}),
|
}),
|
||||||
onSuccess: (data: any) => {
|
onSuccess: (data: any) => {
|
||||||
if (data?.clientAction?.type === "REDIRECT") {
|
const action = data?.clientAction;
|
||||||
window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
|
|
||||||
|
if (action?.type === "REDIRECT") {
|
||||||
|
window.location.href = resolvePaymentRedirectUrl(action.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Immediate success (e.g. wallet)
|
|
||||||
|
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm.
|
||||||
|
if (action?.type === "COLLECT_OTP") {
|
||||||
|
setOtpMessage(action.message ?? "Enter the OTP sent to your phone");
|
||||||
|
setOtpCode("");
|
||||||
|
setOtpError(null);
|
||||||
|
setOtpModalOpen(true);
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number.
|
||||||
|
if (action?.type === "SHOW_BILL_REFERENCE") {
|
||||||
|
setBillAction({
|
||||||
|
billReference: action.billReference,
|
||||||
|
instructions: action.instructions,
|
||||||
|
expiresAt: action.expiresAt,
|
||||||
|
});
|
||||||
|
setBillCopied(false);
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to.
|
||||||
|
if (action?.type === "AWAIT_PUSH") {
|
||||||
|
setPushMessage(
|
||||||
|
action.message ??
|
||||||
|
`Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`,
|
||||||
|
);
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate success
|
||||||
router.push(`/pay-balance/${token}/success`);
|
router.push(`/pay-balance/${token}/success`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -67,11 +196,90 @@ export default function PayBalancePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePay = () => {
|
// CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP —
|
||||||
|
// keep the modal open so the payer can re-enter it (the intent stays open).
|
||||||
|
const otpMutation = useMutation({
|
||||||
|
mutationFn: (otp: string) =>
|
||||||
|
apiClient.post<any>(`/payments/supplementary/by-token/${token}/confirm`, { otp }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
router.push(`/pay-balance/${token}/success`);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setOtpError(
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
err?.message ??
|
||||||
|
"Invalid or expired OTP. Please try again.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startPayment = (mobile?: string) => {
|
||||||
if (!selectedMethod) return;
|
if (!selectedMethod) return;
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setPaymentError(null);
|
setPaymentError(null);
|
||||||
payMutation.mutate(selectedMethod);
|
payMutation.mutate({
|
||||||
|
method: selectedMethod,
|
||||||
|
payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
if (!selectedMethod || awaitingQuote) return;
|
||||||
|
setPaymentError(null);
|
||||||
|
|
||||||
|
if (requiresPayerMobile(selectedMethod)) {
|
||||||
|
// Prefill with the number the charge was raised against, but leave it editable — the
|
||||||
|
// handset paying is often not the one the booking was made under.
|
||||||
|
if (!payerMobile.trim() && charge?.booking?.contactPhone) {
|
||||||
|
setPayerMobile(charge.booking.contactPhone);
|
||||||
|
}
|
||||||
|
setPhoneError(null);
|
||||||
|
setPhoneModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPayment();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPhone = () => {
|
||||||
|
if (!payerMobile.trim()) {
|
||||||
|
setPhoneError("Please enter your mobile number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhoneModalOpen(false);
|
||||||
|
startPayment(payerMobile);
|
||||||
|
};
|
||||||
|
|
||||||
|
// While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens
|
||||||
|
// server-side — a CBE teller, or the provider's webhook — so the browser has no other signal.
|
||||||
|
// Success is only ever claimed from this, never from a client-side guess.
|
||||||
|
const watching = !!billAction || !!pushMessage;
|
||||||
|
const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({
|
||||||
|
queryKey: ["supplementaryStatus", token],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get<{ status: string; paid: boolean }>(
|
||||||
|
`/payments/supplementary/by-token/${token}/status`,
|
||||||
|
),
|
||||||
|
enabled: !!token && watching,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watching && liveStatus?.paid) {
|
||||||
|
router.push(`/pay-balance/${token}/success`);
|
||||||
|
}
|
||||||
|
}, [watching, liveStatus?.paid, router, token]);
|
||||||
|
|
||||||
|
const copyBillReference = async () => {
|
||||||
|
if (!billAction) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(billAction.billReference);
|
||||||
|
setBillCopied(true);
|
||||||
|
setTimeout(() => setBillCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
/* clipboard unavailable — the number is still shown on screen */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingCharge) {
|
if (loadingCharge) {
|
||||||
@@ -95,8 +303,6 @@ export default function PayBalancePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountDisplay = (charge.amountMinor / 100).toFixed(2);
|
|
||||||
const currency = charge.currency ?? "ETB";
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
||||||
<div className="w-full max-w-md space-y-4">
|
<div className="w-full max-w-md space-y-4">
|
||||||
@@ -122,8 +328,25 @@ export default function PayBalancePage() {
|
|||||||
)}
|
)}
|
||||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||||
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
|
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
|
||||||
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
|
{amountLabel ? (
|
||||||
|
<span className="text-2xl font-bold text-primary">{amountLabel}</span>
|
||||||
|
) : quoteError ? (
|
||||||
|
<span className="text-2xl font-bold text-gray-400">—</span>
|
||||||
|
) : (
|
||||||
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{quoteError && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||||
|
{(quoteError as any)?.response?.data?.message ??
|
||||||
|
"This payment method is unavailable right now. Please choose another."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment methods */}
|
{/* Payment methods */}
|
||||||
@@ -136,7 +359,7 @@ export default function PayBalancePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{paymentMethods.filter((m) => m.enabled).map((method) => {
|
{availableMethods.map((method) => {
|
||||||
const Icon = getIconForMethod(method.type);
|
const Icon = getIconForMethod(method.type);
|
||||||
const isSelected = selectedMethod === method.type;
|
const isSelected = selectedMethod === method.type;
|
||||||
return (
|
return (
|
||||||
@@ -173,19 +396,180 @@ export default function PayBalancePage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
disabled={!selectedMethod || isProcessing}
|
disabled={!selectedMethod || isProcessing || awaitingQuote}
|
||||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||||
</span>
|
</span>
|
||||||
|
) : quoteError ? (
|
||||||
|
"Choose another payment method"
|
||||||
|
) : awaitingQuote ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
`Pay ${currency} ${amountDisplay}`
|
`Pay ${amountLabel ?? ""}`.trim()
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
|
||||||
|
|
||||||
|
{/* CBE bill — show the number; confirmation only ever comes from the status poll */}
|
||||||
|
{billAction && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Landmark className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{billAction.instructions ??
|
||||||
|
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
|
||||||
|
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
|
||||||
|
{billAction.billReference}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={copyBillReference}
|
||||||
|
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
|
||||||
|
title="Copy bill number"
|
||||||
|
>
|
||||||
|
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||||
|
{billCopied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
|
||||||
|
<p>
|
||||||
|
Amount: <span className="font-semibold">ETB {formatAmount(chargeAmount, "ETB")}</span>
|
||||||
|
</p>
|
||||||
|
{billAction.expiresAt && (
|
||||||
|
<p>
|
||||||
|
Pay before:{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{new Date(billAction.expiresAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||||
|
Waiting for payment confirmation — this page updates automatically once CBE
|
||||||
|
confirms your payment.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillAction(null)}
|
||||||
|
className="btn-secondary w-full py-2.5 mt-4"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
|
||||||
|
{pushMessage && (
|
||||||
|
<div className="card flex items-start gap-3">
|
||||||
|
<Smartphone className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">Check your phone</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">{pushMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
|
||||||
|
{phoneModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Smartphone className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{selectedMethod === "EBIRR"
|
||||||
|
? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you."
|
||||||
|
: "CAC Bank will send a one-time password to this number to authorize the payment."}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoFocus
|
||||||
|
value={payerMobile}
|
||||||
|
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }}
|
||||||
|
placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"}
|
||||||
|
className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||||
|
/>
|
||||||
|
{phoneError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {phoneError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button onClick={() => setPhoneModalOpen(false)} className="btn-secondary flex-1 py-2.5">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitPhone}
|
||||||
|
disabled={!payerMobile.trim()}
|
||||||
|
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CAC Bank OTP entry */}
|
||||||
|
{otpModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<KeyRound className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{otpMessage}</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoFocus
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
|
||||||
|
placeholder="Enter code"
|
||||||
|
maxLength={10}
|
||||||
|
className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||||
|
/>
|
||||||
|
{otpError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {otpError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setOtpModalOpen(false)}
|
||||||
|
disabled={otpMutation.isPending}
|
||||||
|
className="btn-secondary flex-1 py-2.5"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
|
||||||
|
disabled={otpMutation.isPending || !otpCode.trim()}
|
||||||
|
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{otpMutation.isPending ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Confirm payment"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user