diff --git a/apps/bot/services/payment-service/src/payment-consumer.ts b/apps/bot/services/payment-service/src/payment-consumer.ts index 26f5824..91e721e 100644 --- a/apps/bot/services/payment-service/src/payment-consumer.ts +++ b/apps/bot/services/payment-service/src/payment-consumer.ts @@ -7,6 +7,7 @@ import type { BookingServiceClient } from './booking-service-client.js' import type { BookingConfirmation } from './booking-service-client.js' import type { PaymentServiceLogger } from './logger.js' import { ValidationError } from './errors.js' +import { markSagaBookingCompleted, markSagaBookingFailed } from './saga-transitions.js' import { parsePaymentCompletedEvent } from './validation.js' // Зависимости consumer-а. @@ -100,14 +101,7 @@ async function handlePaymentCompleted(event: PaymentCompletedEvent, deps: Paymen ) // Если booking создан, закрываем hold и saga. await deps.prisma.$transaction(async (tx) => { - await tx.slotHold.updateMany({ - data: { status: 'paid' }, - where: { invoiceId: event.invoiceId, status: 'held' }, - }) - await tx.paymentSaga.updateMany({ - data: { bookingId: booking.id, status: 'completed' }, - where: { invoiceId: event.invoiceId }, - }) + await markSagaBookingCompleted(tx, event.invoiceId, booking.id) }) await writeAudit(deps.prisma, deps.logger, { action: 'payment.booking_created', @@ -125,13 +119,7 @@ async function handlePaymentCompleted(event: PaymentCompletedEvent, deps: Paymen // Ошибка booking-service переводит saga в failed для ручного recovery. const failureReason = error instanceof Error ? error.message : 'booking create failed' await deps.prisma.$transaction(async (tx) => { - await tx.paymentSaga.updateMany({ - data: { - failureReason, - status: 'failed', - }, - where: { invoiceId: event.invoiceId }, - }) + await markSagaBookingFailed(tx, event.invoiceId, failureReason) }) await writeAudit(deps.prisma, deps.logger, { action: 'payment.booking_failed', diff --git a/apps/bot/services/payment-service/src/payment-router.ts b/apps/bot/services/payment-service/src/payment-router.ts index 87081c4..65b1309 100644 --- a/apps/bot/services/payment-service/src/payment-router.ts +++ b/apps/bot/services/payment-service/src/payment-router.ts @@ -10,6 +10,7 @@ import { AuthenticationError, ConflictError, DownstreamServiceError, NotFoundErr import { sendJson } from './http-response.js' import type { PaymentServiceLogger } from './logger.js' import type { BookingServiceClient } from './booking-service-client.js' +import { markSagaAwaitingBooking, startSagaCompensation } from './saga-transitions.js' import { parseCreateInvoiceInput, parsePreCheckoutQuery, parseSuccessfulPaymentMessage, readIdFromPath } from './validation.js' // Telegram Stars ограничивает сумму одного инвойса значением 9 999 999 минимальных единиц. @@ -460,10 +461,7 @@ export class PaymentRouter { // Обновляем invoice и saga в базе. audit({ ts: new Date().toISOString(), service: 'payment', action: 'payment.completed', userId: Number(invoice.telegramUserId), invoiceId: payload }) await this.deps.prisma.$transaction(async (tx) => { - await tx.paymentSaga.updateMany({ - data: { paidAmount: paid, status: 'awaiting_booking' }, - where: { invoiceId: payload }, - }) + await markSagaAwaitingBooking(tx, payload, paid) await tx.pendingInvoice.update({ data: { completedAt: new Date(), paidAmountMinorUnits: paid, status: 'completed' }, where: { id: payload }, @@ -579,18 +577,7 @@ export class PaymentRouter { // В транзакции переводим saga и связанные записи в recovery-состояние. await this.deps.prisma.$transaction(async (tx) => { - await tx.paymentSaga.update({ - data: { status: 'compensating' }, - where: { invoiceId }, - }) - await tx.slotHold.updateMany({ - data: { status: 'cancelled' }, - where: { invoiceId, status: 'held' }, - }) - await tx.pendingInvoice.updateMany({ - data: { status: 'failed' }, - where: { id: invoiceId }, - }) + await startSagaCompensation(tx, invoiceId) }) await this.writeAudit({ action: 'payment.compensation_started', diff --git a/apps/bot/services/payment-service/src/saga-transitions.ts b/apps/bot/services/payment-service/src/saga-transitions.ts new file mode 100644 index 0000000..eccf2a2 --- /dev/null +++ b/apps/bot/services/payment-service/src/saga-transitions.ts @@ -0,0 +1,82 @@ +import type { Prisma } from '@prisma/client' + +type PaymentSagaTransaction = Prisma.TransactionClient +type ReleasedHoldStatus = 'cancelled' | 'paid' + +/** + * Releases a held slot as part of an invoice saga transition. + */ +export async function releaseHeldSlot( + tx: PaymentSagaTransaction, + invoiceId: string, + status: ReleasedHoldStatus, +): Promise { + await tx.slotHold.updateMany({ + data: { status }, + where: { invoiceId, status: 'held' }, + }) +} + +/** + * Marks the saga as fully completed after booking-service confirms a booking. + */ +export async function markSagaBookingCompleted( + tx: PaymentSagaTransaction, + invoiceId: string, + bookingId: string, +): Promise { + await releaseHeldSlot(tx, invoiceId, 'paid') + await tx.paymentSaga.updateMany({ + data: { bookingId, status: 'completed' }, + where: { invoiceId }, + }) +} + +/** + * Moves a paid saga into manual recovery after booking creation fails. + */ +export async function markSagaBookingFailed( + tx: PaymentSagaTransaction, + invoiceId: string, + failureReason: string, +): Promise { + await tx.paymentSaga.updateMany({ + data: { + failureReason, + status: 'failed', + }, + where: { invoiceId }, + }) +} + +/** + * Records a completed payment before booking-service creates the booking. + */ +export async function markSagaAwaitingBooking( + tx: PaymentSagaTransaction, + invoiceId: string, + paidAmount: number, +): Promise { + await tx.paymentSaga.updateMany({ + data: { paidAmount, status: 'awaiting_booking' }, + where: { invoiceId }, + }) +} + +/** + * Starts manual compensation and releases the held slot. + */ +export async function startSagaCompensation( + tx: PaymentSagaTransaction, + invoiceId: string, +): Promise { + await tx.paymentSaga.update({ + data: { status: 'compensating' }, + where: { invoiceId }, + }) + await releaseHeldSlot(tx, invoiceId, 'cancelled') + await tx.pendingInvoice.updateMany({ + data: { status: 'failed' }, + where: { id: invoiceId }, + }) +}