mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
729 lines
23 KiB
TypeScript
729 lines
23 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
} from "@nestjs/common";
|
|
import { PrismaService } from "../../common/prisma.service";
|
|
import { SeatsService } from "../seats/seats.service";
|
|
import { TicketsService } from "../tickets/tickets.service";
|
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
|
import {
|
|
Prisma,
|
|
PaymentIntentStatus,
|
|
PaymentMethodType,
|
|
PaymentRegion,
|
|
} from "@prisma/client";
|
|
import {
|
|
InitiatePaymentDto,
|
|
RefundDto,
|
|
AddPaymentMethodDto,
|
|
InitiateResponseDto,
|
|
IntentStatusDto,
|
|
PaymentRegionEnum,
|
|
} from "./payments.dto";
|
|
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
|
import { PaymentClientService } from "./payment-client.service";
|
|
import {
|
|
PaymentService as PaymentServiceEnum,
|
|
PaymentReferenceType,
|
|
PaymentIntentSnapshot,
|
|
ProviderMethod,
|
|
ClientAction,
|
|
ProviderPaymentStatus,
|
|
} from "@edr/types";
|
|
|
|
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|
PaymentIntentStatus.REQUIRES_ACTION,
|
|
PaymentIntentStatus.PROCESSING,
|
|
PaymentIntentStatus.SUCCEEDED,
|
|
];
|
|
|
|
@Injectable()
|
|
export class PaymentsService {
|
|
private readonly logger = new Logger(PaymentsService.name);
|
|
private readonly walletDemoAutoSucceed = true;
|
|
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private seatsService: SeatsService,
|
|
private ticketsService: TicketsService,
|
|
private eventEmitter: EventEmitter2,
|
|
private paymentClient: PaymentClientService,
|
|
) {}
|
|
|
|
async getAll(filters: {
|
|
search?: string;
|
|
status?: string;
|
|
method?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const where: any = {};
|
|
if (search) {
|
|
where.OR = [
|
|
{ id: { contains: search, mode: "insensitive" } },
|
|
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
|
|
];
|
|
}
|
|
if (status) {
|
|
where.status = status;
|
|
}
|
|
if (method) {
|
|
where.method = method;
|
|
}
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.paymentIntent.findMany({
|
|
where,
|
|
include: { booking: true },
|
|
skip,
|
|
take: pageSize,
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
this.prisma.paymentIntent.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
items: items.map((item) => ({
|
|
id: item.id,
|
|
reference: item.id.substring(0, 8),
|
|
bookingId: item.bookingId,
|
|
booking: { bookingRef: item.booking?.bookingRef },
|
|
amountMinor: item.amountMinor,
|
|
currency: item.currency,
|
|
method: item.method,
|
|
status: item.status,
|
|
createdAt: item.createdAt,
|
|
paidAt: item.paidAt,
|
|
})),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
|
const booking = await this.prisma.booking.findUnique({
|
|
where: { id: dto.bookingId },
|
|
include: { seats: true },
|
|
});
|
|
if (!booking) throw new NotFoundException("Booking not found");
|
|
if (booking.status !== "PENDING_PAYMENT") {
|
|
throw new BadRequestException("Booking not payable");
|
|
}
|
|
|
|
const method = dto.method as PaymentMethodType;
|
|
|
|
// WALLET is an internal balance debit — it never leaves this app.
|
|
if (method === PaymentMethodType.WALLET) {
|
|
const existing = await this.prisma.paymentIntent.findUnique({
|
|
where: { bookingId: dto.bookingId },
|
|
});
|
|
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
|
return this.formatIntentResponse(existing);
|
|
}
|
|
return this.initiateWalletPayment(booking);
|
|
}
|
|
|
|
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
|
const snapshot = await this.paymentClient.initiate({
|
|
service: PaymentServiceEnum.PASSENGER,
|
|
referenceType: PaymentReferenceType.BOOKING,
|
|
referenceId: booking.id,
|
|
orderRef: booking.bookingRef,
|
|
amountMinor: booking.totalMinor,
|
|
currency: booking.currency,
|
|
provider: method as unknown as ProviderMethod,
|
|
platform: dto.platform,
|
|
returnUrl,
|
|
failureUrl,
|
|
});
|
|
|
|
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
|
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
// Already-paid order re-initiated: converge the booking now (idempotent).
|
|
await this.finalizePaymentSuccess({
|
|
intentId: intent.id,
|
|
providerTxnId: snapshot.providerTxnId,
|
|
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
|
});
|
|
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
|
where: { id: intent.id },
|
|
});
|
|
}
|
|
return this.formatIntentResponse(intent);
|
|
}
|
|
private resolveReturnUrls(method: PaymentMethodType): {
|
|
returnUrl?: string;
|
|
failureUrl?: string;
|
|
} {
|
|
const perMethod: Partial<
|
|
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
|
|
> = {
|
|
[PaymentMethodType.TELEBIRR]: {
|
|
returnUrl: process.env.TELEBIRR_RETURN_URL,
|
|
},
|
|
[PaymentMethodType.WAAFI]: {
|
|
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
|
|
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
|
|
},
|
|
[PaymentMethodType.DMONEY]: {
|
|
returnUrl: process.env.DMONEY_RETURN_URL,
|
|
},
|
|
[PaymentMethodType.CBE_BIRR]: {
|
|
returnUrl: process.env.CBE_RETURN_URL,
|
|
},
|
|
[PaymentMethodType.EBIRR]: {
|
|
returnUrl: process.env.EBIRR_RETURN_URL,
|
|
},
|
|
[PaymentMethodType.CARD]: {
|
|
returnUrl: process.env.CARD_RETURN_URL,
|
|
},
|
|
};
|
|
|
|
const m = perMethod[method] ?? {};
|
|
const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
|
|
const failureUrl =
|
|
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
|
|
return { returnUrl, failureUrl };
|
|
}
|
|
|
|
private async syncIntentProjection(
|
|
bookingId: string,
|
|
snapshot: PaymentIntentSnapshot,
|
|
) {
|
|
const status =
|
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
|
? PaymentIntentStatus.PROCESSING
|
|
: (snapshot.status as unknown as PaymentIntentStatus);
|
|
const data = {
|
|
status,
|
|
method: snapshot.provider as unknown as PaymentMethodType,
|
|
merchantOrderId: snapshot.merchantOrderId,
|
|
clientAction: snapshot.clientAction
|
|
? (snapshot.clientAction as unknown as Prisma.InputJsonValue)
|
|
: Prisma.DbNull,
|
|
providerTxnId: snapshot.providerTxnId ?? null,
|
|
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
|
failureCode: snapshot.failureCode ?? null,
|
|
failureMessage: snapshot.failureMessage ?? null,
|
|
};
|
|
return this.prisma.paymentIntent.upsert({
|
|
where: { bookingId },
|
|
update: data,
|
|
create: {
|
|
bookingId,
|
|
amountMinor: snapshot.amountMinor,
|
|
currency: snapshot.currency,
|
|
...data,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async initiateWalletPayment(
|
|
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
|
): Promise<InitiateResponseDto> {
|
|
// DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check,
|
|
// no debit — and run the exact same finalize path a real successful payment uses
|
|
// (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works.
|
|
if (this.walletDemoAutoSucceed) {
|
|
this.logger.warn(
|
|
`WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`,
|
|
);
|
|
const demoIntent = await this.prisma.paymentIntent.upsert({
|
|
where: { bookingId: booking.id },
|
|
update: {
|
|
status: PaymentIntentStatus.PROCESSING,
|
|
failureCode: null,
|
|
method: PaymentMethodType.WALLET,
|
|
},
|
|
create: {
|
|
bookingId: booking.id,
|
|
amountMinor: booking.totalMinor,
|
|
method: PaymentMethodType.WALLET,
|
|
status: PaymentIntentStatus.PROCESSING,
|
|
providerRef: `WALLET-DEMO-${Date.now()}`,
|
|
},
|
|
});
|
|
await this.finalizePaymentSuccess({ intentId: demoIntent.id });
|
|
const settled = await this.prisma.paymentIntent.findUniqueOrThrow({
|
|
where: { id: demoIntent.id },
|
|
});
|
|
return this.formatIntentResponse(settled);
|
|
}
|
|
|
|
const debitResult = await this.prisma.$transaction(async (tx) => {
|
|
const wallet = await tx.walletAccount.findUnique({
|
|
where: { passengerId: booking.passengerId },
|
|
});
|
|
if (!wallet || wallet.balanceMinor < booking.totalMinor) {
|
|
return { success: false };
|
|
}
|
|
const newBalance = wallet.balanceMinor - booking.totalMinor;
|
|
await tx.walletAccount.update({
|
|
where: { passengerId: booking.passengerId },
|
|
data: { balanceMinor: newBalance },
|
|
});
|
|
await tx.walletLedgerEntry.create({
|
|
data: {
|
|
walletId: wallet.id,
|
|
type: "DEBIT",
|
|
amountMinor: booking.totalMinor,
|
|
balanceAfterMinor: newBalance,
|
|
description: `Train Ticket - ${booking.bookingRef}`,
|
|
relatedBookingId: booking.id,
|
|
},
|
|
});
|
|
return { success: true };
|
|
});
|
|
|
|
if (!debitResult.success) {
|
|
const failed = await this.prisma.paymentIntent.upsert({
|
|
where: { bookingId: booking.id },
|
|
update: {
|
|
status: PaymentIntentStatus.FAILED,
|
|
failureCode: "INSUFFICIENT_BALANCE",
|
|
},
|
|
create: {
|
|
bookingId: booking.id,
|
|
amountMinor: booking.totalMinor,
|
|
method: PaymentMethodType.WALLET,
|
|
status: PaymentIntentStatus.FAILED,
|
|
failureCode: "INSUFFICIENT_BALANCE",
|
|
},
|
|
});
|
|
return this.formatIntentResponse(failed);
|
|
}
|
|
|
|
const intent = await this.prisma.paymentIntent.upsert({
|
|
where: { bookingId: booking.id },
|
|
update: { status: PaymentIntentStatus.PROCESSING },
|
|
create: {
|
|
bookingId: booking.id,
|
|
amountMinor: booking.totalMinor,
|
|
method: PaymentMethodType.WALLET,
|
|
status: PaymentIntentStatus.PROCESSING,
|
|
providerRef: `WALLET-${Date.now()}`,
|
|
},
|
|
});
|
|
await this.finalizePaymentSuccess({ intentId: intent.id });
|
|
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
|
where: { id: intent.id },
|
|
});
|
|
return this.formatIntentResponse(refreshed);
|
|
}
|
|
|
|
private formatIntentResponse(
|
|
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
|
): InitiateResponseDto {
|
|
const clientAction =
|
|
intent.clientAction && typeof intent.clientAction === "object"
|
|
? (intent.clientAction as unknown as ClientAction)
|
|
: undefined;
|
|
return {
|
|
intentId: intent.id,
|
|
status: intent.status,
|
|
clientAction,
|
|
merchantOrderId: intent.merchantOrderId ?? undefined,
|
|
};
|
|
}
|
|
|
|
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
|
const local = await this.prisma.paymentIntent.findUnique({
|
|
where: { bookingId },
|
|
});
|
|
|
|
// WALLET payments never leave this app — no remote intent exists for them.
|
|
if (local?.method === PaymentMethodType.WALLET) {
|
|
return this.formatIntentStatus(local);
|
|
}
|
|
|
|
// Pull/reconcile through the payment microservice (it refreshes stale intents from the
|
|
// provider itself). Falls back to the legacy local path when the service is unreachable
|
|
// or only a pre-cutover local intent exists.
|
|
let snapshot: PaymentIntentSnapshot | null = null;
|
|
try {
|
|
snapshot = await this.paymentClient.getIntentByReference(
|
|
PaymentReferenceType.BOOKING,
|
|
bookingId,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
this.logger.warn(
|
|
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
|
);
|
|
}
|
|
|
|
if (!snapshot) {
|
|
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
|
|
// status. The payment service owns provider refresh for everything initiated after
|
|
// the cutover; webhooks/mark-paid converge the rest.
|
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
|
return this.formatIntentStatus(local);
|
|
}
|
|
|
|
let intent = await this.syncIntentProjection(bookingId, snapshot);
|
|
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
// Poll observed success before (or instead of) the mark-paid event — converge now.
|
|
await this.finalizePaymentSuccess({
|
|
intentId: intent.id,
|
|
providerTxnId: snapshot.providerTxnId,
|
|
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
|
});
|
|
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
|
where: { id: intent.id },
|
|
});
|
|
}
|
|
return this.formatIntentStatus(intent);
|
|
}
|
|
|
|
private formatIntentStatus(
|
|
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
|
): IntentStatusDto {
|
|
const base = this.formatIntentResponse(intent);
|
|
return {
|
|
...base,
|
|
paidAt: intent.paidAt?.toISOString(),
|
|
failureCode: intent.failureCode ?? undefined,
|
|
failureMessage: intent.failureMessage ?? undefined,
|
|
};
|
|
}
|
|
|
|
async refund(dto: RefundDto) {
|
|
const intent = await this.prisma.paymentIntent.findUnique({
|
|
where: { bookingId: dto.bookingId },
|
|
});
|
|
if (!intent || intent.status !== "SUCCEEDED")
|
|
throw new BadRequestException("No successful payment to refund");
|
|
await this.prisma.paymentIntent.update({
|
|
where: { bookingId: dto.bookingId },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
const booking = await this.prisma.booking.findUnique({
|
|
where: { id: dto.bookingId },
|
|
include: { seats: true },
|
|
});
|
|
if (booking) {
|
|
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
|
await this.prisma.booking.update({
|
|
where: { id: dto.bookingId },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
}
|
|
return { refunded: true, bookingRef: booking?.bookingRef };
|
|
}
|
|
|
|
addPaymentMethod(dto: AddPaymentMethodDto) {
|
|
const data = {
|
|
type: dto.type as unknown as PaymentMethodType,
|
|
displayName: dto.displayName,
|
|
region: dto.region as unknown as PaymentRegion,
|
|
currency: dto.currency ?? "ETB",
|
|
providerId: dto.providerId,
|
|
enabled: dto.enabled ?? true,
|
|
sortOrder: dto.sortOrder ?? 0,
|
|
};
|
|
return this.prisma.paymentMethod.upsert({
|
|
where: { type: data.type },
|
|
update: data,
|
|
create: data,
|
|
});
|
|
}
|
|
|
|
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
|
return this.prisma.paymentMethod.findMany({
|
|
where: {
|
|
enabled: true,
|
|
...(region
|
|
? {
|
|
region: {
|
|
in: [
|
|
region,
|
|
PaymentRegionEnum.GLOBAL,
|
|
] as unknown as PaymentRegion[],
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
|
});
|
|
}
|
|
|
|
async finalizePaymentSuccess(input: {
|
|
intentId: string;
|
|
providerTxnId?: string;
|
|
paidAt?: Date;
|
|
}): Promise<{ alreadyFinalized: boolean }> {
|
|
const intent = await this.prisma.paymentIntent.findUnique({
|
|
where: { id: input.intentId },
|
|
});
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
|
return { alreadyFinalized: true };
|
|
}
|
|
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
|
throw new BadRequestException(
|
|
"PaymentIntent is cancelled; cannot finalize",
|
|
);
|
|
}
|
|
|
|
const booking = await this.prisma.booking.findUnique({
|
|
where: { id: intent.bookingId },
|
|
include: { seats: true },
|
|
});
|
|
if (!booking) throw new NotFoundException("Booking not found");
|
|
|
|
const paidAt = input.paidAt ?? new Date();
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.paymentIntent.update({
|
|
where: { id: intent.id },
|
|
data: {
|
|
status: PaymentIntentStatus.SUCCEEDED,
|
|
providerTxnId:
|
|
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
|
paidAt,
|
|
},
|
|
});
|
|
await tx.booking.update({
|
|
where: { id: booking.id },
|
|
data: { status: "CONFIRMED" },
|
|
});
|
|
});
|
|
|
|
try {
|
|
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
await this.createJourneySegments(booking);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
await this.ticketsService.generate(booking.id);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.awardLoyaltyPoints(
|
|
booking.passengerId,
|
|
booking.totalMinor,
|
|
booking.id,
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
this.eventEmitter.emit("payment.succeeded", { booking });
|
|
return { alreadyFinalized: false };
|
|
}
|
|
|
|
async handlePaymentEvent(
|
|
event: PaymentEventDto,
|
|
): Promise<MarkPaidResponseDto> {
|
|
if (
|
|
event.service !== PaymentServiceEnum.PASSENGER ||
|
|
event.referenceType !== PaymentReferenceType.BOOKING
|
|
) {
|
|
this.logger.warn(
|
|
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
|
);
|
|
return { processed: false, reason: "foreign-reference" };
|
|
}
|
|
|
|
if (event.eventType === "payment.failed") {
|
|
const intent = await this.prisma.paymentIntent.findUnique({
|
|
where: { bookingId: event.referenceId },
|
|
});
|
|
if (intent) {
|
|
await this.markPaymentFailed({
|
|
intentId: intent.id,
|
|
failureCode: event.failureCode,
|
|
failureMessage: event.failureMessage,
|
|
});
|
|
}
|
|
return { processed: true };
|
|
}
|
|
|
|
const booking = await this.prisma.booking.findUnique({
|
|
where: { id: event.referenceId },
|
|
});
|
|
if (!booking) {
|
|
// Ack (200) — a missing booking will not appear on redelivery; needs investigation.
|
|
this.logger.error(
|
|
`mark-paid: no booking for reference ${event.referenceId}`,
|
|
);
|
|
return { processed: false, reason: "booking-not-found" };
|
|
}
|
|
|
|
if (booking.totalMinor !== event.amountMinor) {
|
|
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
|
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
|
this.logger.error(
|
|
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
|
|
);
|
|
throw new BadRequestException(
|
|
"Event amount does not match booking total",
|
|
);
|
|
}
|
|
|
|
// Local intent row is a projection during the strangler migration: reuse it when the
|
|
// legacy initiate path created one, otherwise materialize it from the event.
|
|
let intent = await this.prisma.paymentIntent.findUnique({
|
|
where: { bookingId: event.referenceId },
|
|
});
|
|
if (!intent) {
|
|
intent = await this.prisma.paymentIntent.create({
|
|
data: {
|
|
bookingId: event.referenceId,
|
|
amountMinor: event.amountMinor,
|
|
currency: event.currency,
|
|
method: event.provider as unknown as PaymentMethodType,
|
|
status: PaymentIntentStatus.PROCESSING,
|
|
merchantOrderId: event.merchantOrderId,
|
|
providerTxnId: event.providerTxnId,
|
|
},
|
|
});
|
|
}
|
|
|
|
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
|
intentId: intent.id,
|
|
providerTxnId: event.providerTxnId,
|
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
|
});
|
|
return { processed: true, alreadyFinalized };
|
|
}
|
|
|
|
async markPaymentFailed(input: {
|
|
intentId: string;
|
|
failureCode?: string;
|
|
failureMessage?: string;
|
|
}): Promise<void> {
|
|
const intent = await this.prisma.paymentIntent.findUnique({
|
|
where: { id: input.intentId },
|
|
});
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
if (
|
|
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
|
intent.status === PaymentIntentStatus.CANCELLED
|
|
) {
|
|
return;
|
|
}
|
|
await this.prisma.paymentIntent.update({
|
|
where: { id: intent.id },
|
|
data: {
|
|
status: PaymentIntentStatus.FAILED,
|
|
failureCode: input.failureCode,
|
|
failureMessage: input.failureMessage,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async awardLoyaltyPoints(
|
|
passengerId: string,
|
|
amountMinor: number,
|
|
bookingId: string,
|
|
) {
|
|
const points = Math.floor(amountMinor / 100);
|
|
const account = await this.prisma.loyaltyAccount.findUnique({
|
|
where: { passengerId },
|
|
});
|
|
if (!account) return;
|
|
const newBalance = account.pointsBalance + points;
|
|
const tier =
|
|
newBalance >= 10000
|
|
? "PLATINUM"
|
|
: newBalance >= 5000
|
|
? "GOLD"
|
|
: newBalance >= 2000
|
|
? "SILVER"
|
|
: "BRONZE";
|
|
await this.prisma.loyaltyAccount.update({
|
|
where: { passengerId },
|
|
data: { pointsBalance: { increment: points }, tier: tier as any },
|
|
});
|
|
await this.prisma.loyaltyLedgerEntry.create({
|
|
data: {
|
|
accountId: account.id,
|
|
delta: points,
|
|
reason: "TRIP_COMPLETED",
|
|
bookingId,
|
|
balanceAfter: newBalance,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async createJourneySegments(
|
|
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
|
) {
|
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
where: { id: booking.scheduleId },
|
|
include: {
|
|
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
|
},
|
|
});
|
|
if (!schedule) return;
|
|
|
|
const stopTimes = schedule.stopTimes;
|
|
if (stopTimes.length < 2) return;
|
|
|
|
const originSequence = stopTimes.findIndex(
|
|
(st) => st.stationId === schedule.originStationId,
|
|
);
|
|
const destSequence = stopTimes.findIndex(
|
|
(st) => st.stationId === schedule.destinationStationId,
|
|
);
|
|
|
|
if (
|
|
originSequence < 0 ||
|
|
destSequence < 0 ||
|
|
originSequence >= destSequence
|
|
)
|
|
return;
|
|
|
|
const journey = await this.prisma.journey.create({
|
|
data: {
|
|
passengerId: booking.passengerId,
|
|
status: "CONFIRMED",
|
|
totalMinor: booking.totalMinor,
|
|
currency: booking.currency,
|
|
},
|
|
});
|
|
|
|
const journeySegments = [];
|
|
for (const bookingSeat of booking.seats) {
|
|
for (let i = originSequence; i < destSequence; i++) {
|
|
journeySegments.push({
|
|
journeyId: journey.id,
|
|
scheduleId: booking.scheduleId,
|
|
segmentOrder: i,
|
|
seatId: bookingSeat.seatId,
|
|
departureStationId: stopTimes[i].stationId,
|
|
arrivalStationId: stopTimes[i + 1].stationId,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (journeySegments.length > 0) {
|
|
await this.prisma.journeySegment.createMany({ data: journeySegments });
|
|
}
|
|
}
|
|
}
|