diff --git a/prisma/migrations/20251129025911_add_payment_state_machine/migration.sql b/prisma/migrations/20251129025911_add_payment_state_machine/migration.sql new file mode 100644 index 00000000..f79f9f00 --- /dev/null +++ b/prisma/migrations/20251129025911_add_payment_state_machine/migration.sql @@ -0,0 +1,112 @@ +-- DropIndex +DROP INDEX "Category_storeId_slug_idx"; + +-- DropIndex +DROP INDEX "Category_slug_idx"; + +-- DropIndex +DROP INDEX "Customer_email_storeId_idx"; + +-- DropIndex +DROP INDEX "Customer_storeId_email_idx"; + +-- DropIndex +DROP INDEX "Order_customerId_createdAt_idx"; + +-- DropIndex +DROP INDEX "Order_paymentStatus_idx"; + +-- DropIndex +DROP INDEX "Order_orderNumber_idx"; + +-- DropIndex +DROP INDEX "ProductAttribute_name_idx"; + +-- CreateTable +CREATE TABLE "PaymentAttempt" ( + "id" TEXT NOT NULL PRIMARY KEY, + "storeId" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerReference" TEXT, + "status" TEXT NOT NULL DEFAULT 'INITIATED', + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "idempotencyKey" TEXT, + "attemptCount" INTEGER NOT NULL DEFAULT 1, + "lastErrorCode" TEXT, + "lastErrorMessage" TEXT, + "nextRetryAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "PaymentTransaction" ( + "id" TEXT NOT NULL PRIMARY KEY, + "attemptId" TEXT NOT NULL, + "storeId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "providerReference" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PaymentTransaction_attemptId_fkey" FOREIGN KEY ("attemptId") REFERENCES "PaymentAttempt" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_InventoryLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "storeId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "variantId" TEXT, + "orderId" TEXT, + "previousQty" INTEGER NOT NULL, + "newQty" INTEGER NOT NULL, + "changeQty" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "note" TEXT, + "userId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "InventoryLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "InventoryLog_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "InventoryLog_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "ProductVariant" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "InventoryLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_InventoryLog" ("changeQty", "createdAt", "id", "newQty", "note", "previousQty", "productId", "reason", "storeId", "userId") SELECT "changeQty", "createdAt", "id", "newQty", "note", "previousQty", "productId", "reason", "storeId", "userId" FROM "InventoryLog"; +DROP TABLE "InventoryLog"; +ALTER TABLE "new_InventoryLog" RENAME TO "InventoryLog"; +CREATE INDEX "InventoryLog_storeId_productId_createdAt_idx" ON "InventoryLog"("storeId", "productId", "createdAt"); +CREATE INDEX "InventoryLog_productId_createdAt_idx" ON "InventoryLog"("productId", "createdAt"); +CREATE INDEX "InventoryLog_variantId_createdAt_idx" ON "InventoryLog"("variantId", "createdAt"); +CREATE INDEX "InventoryLog_orderId_idx" ON "InventoryLog"("orderId"); +CREATE INDEX "InventoryLog_userId_createdAt_idx" ON "InventoryLog"("userId", "createdAt"); +CREATE INDEX "InventoryLog_reason_idx" ON "InventoryLog"("reason"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentAttempt_providerReference_key" ON "PaymentAttempt"("providerReference"); + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentAttempt_idempotencyKey_key" ON "PaymentAttempt"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "PaymentAttempt_storeId_orderId_idx" ON "PaymentAttempt"("storeId", "orderId"); + +-- CreateIndex +CREATE INDEX "PaymentAttempt_status_idx" ON "PaymentAttempt"("status"); + +-- CreateIndex +CREATE INDEX "PaymentAttempt_createdAt_idx" ON "PaymentAttempt"("createdAt"); + +-- CreateIndex +CREATE INDEX "PaymentTransaction_storeId_attemptId_idx" ON "PaymentTransaction"("storeId", "attemptId"); + +-- CreateIndex +CREATE INDEX "PaymentTransaction_attemptId_type_idx" ON "PaymentTransaction"("attemptId", "type"); + +-- CreateIndex +CREATE INDEX "Order_storeId_customerId_createdAt_idx" ON "Order"("storeId", "customerId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 068f4a52..f6fbe7f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -675,4 +675,70 @@ model AuditLog { @@index([userId, createdAt]) @@index([entityType, entityId, createdAt]) @@map("audit_logs") +} + +// ============================================================================ +// PAYMENT STATE MACHINE MODELS +// ============================================================================ + +// Payment attempt status enum - lifecycle states for payment flow +enum PaymentAttemptStatus { + INITIATED // Payment attempt created, awaiting provider authorization + AUTHORIZING // Authorization in progress with provider + AUTHORIZED // Payment authorized, ready for capture + CAPTURED // Payment captured successfully + FAILED // Terminal failure state + CANCELED // Payment attempt was canceled (before capture) +} + +// Payment transaction type enum - types of financial transactions +enum PaymentTransactionType { + AUTH // Authorization transaction + CAPTURE // Capture of authorized funds + REFUND // Refund of captured funds + VOID // Void of authorization (before capture) +} + +// PaymentAttempt model - tracks individual payment attempts with state machine +model PaymentAttempt { + id String @id @default(cuid()) + storeId String + orderId String + provider String // stripe, bkash, cod + providerReference String? @unique // Stripe payment intent ID, etc. + status PaymentAttemptStatus @default(INITIATED) + amount Int // Amount in minor units (cents/paisa) + currency String // ISO 4217 currency code (e.g., USD, BDT) + idempotencyKey String? @unique // Client-provided idempotency key + attemptCount Int @default(1) + lastErrorCode String? // Provider error code + lastErrorMessage String? // Human-readable error message + nextRetryAt DateTime? // Scheduled retry time for failed attempts + + transactions PaymentTransaction[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId, orderId]) + @@index([status]) + @@index([createdAt]) +} + +// PaymentTransaction model - records individual financial transactions within an attempt +model PaymentTransaction { + id String @id @default(cuid()) + attemptId String + storeId String + type PaymentTransactionType + amount Int // Amount in minor units + currency String // ISO 4217 currency code + providerReference String? // Provider transaction reference + + attempt PaymentAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@index([storeId, attemptId]) + @@index([attemptId, type]) } \ No newline at end of file diff --git a/prisma/schema.sqlite.prisma b/prisma/schema.sqlite.prisma index e9b7e06e..f7673551 100644 --- a/prisma/schema.sqlite.prisma +++ b/prisma/schema.sqlite.prisma @@ -648,4 +648,70 @@ model AuditLog { @@index([userId, createdAt]) @@index([entityType, entityId, createdAt]) @@map("audit_logs") +} + +// ============================================================================ +// PAYMENT STATE MACHINE MODELS +// ============================================================================ + +// Payment attempt status enum - lifecycle states for payment flow +enum PaymentAttemptStatus { + INITIATED // Payment attempt created, awaiting provider authorization + AUTHORIZING // Authorization in progress with provider + AUTHORIZED // Payment authorized, ready for capture + CAPTURED // Payment captured successfully + FAILED // Terminal failure state + CANCELED // Payment attempt was canceled (before capture) +} + +// Payment transaction type enum - types of financial transactions +enum PaymentTransactionType { + AUTH // Authorization transaction + CAPTURE // Capture of authorized funds + REFUND // Refund of captured funds + VOID // Void of authorization (before capture) +} + +// PaymentAttempt model - tracks individual payment attempts with state machine +model PaymentAttempt { + id String @id @default(cuid()) + storeId String + orderId String + provider String // stripe, bkash, cod + providerReference String? @unique // Stripe payment intent ID, etc. + status PaymentAttemptStatus @default(INITIATED) + amount Int // Amount in minor units (cents/paisa) + currency String // ISO 4217 currency code (e.g., USD, BDT) + idempotencyKey String? @unique // Client-provided idempotency key + attemptCount Int @default(1) + lastErrorCode String? // Provider error code + lastErrorMessage String? // Human-readable error message + nextRetryAt DateTime? // Scheduled retry time for failed attempts + + transactions PaymentTransaction[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId, orderId]) + @@index([status]) + @@index([createdAt]) +} + +// PaymentTransaction model - records individual financial transactions within an attempt +model PaymentTransaction { + id String @id @default(cuid()) + attemptId String + storeId String + type PaymentTransactionType + amount Int // Amount in minor units + currency String // ISO 4217 currency code + providerReference String? // Provider transaction reference + + attempt PaymentAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@index([storeId, attemptId]) + @@index([attemptId, type]) } \ No newline at end of file diff --git a/src/app/api/payments/attempt/route.ts b/src/app/api/payments/attempt/route.ts new file mode 100644 index 00000000..ff4d9d35 --- /dev/null +++ b/src/app/api/payments/attempt/route.ts @@ -0,0 +1,111 @@ +/** + * POST /api/payments/attempt + * + * Create a new payment attempt (idempotent) + * + * Headers: + * - Idempotency-Key: Optional client-provided key for idempotent requests + * + * Body: + * - storeId: string (required) + * - orderId: string (required) + * - provider: 'stripe' | 'bkash' | 'cod' (required) + * - amount: number (required, minor units e.g., cents) + * - currency: string (required, ISO 4217 code) + * - providerReference: string (optional) + * + * Returns: + * - 200: Existing attempt if idempotency key matches + * - 201: Newly created attempt + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { PaymentService, createPaymentAttemptSchema } from '@/lib/services/payment.service'; +import { z } from 'zod'; + +export async function POST(request: NextRequest) { + try { + // Auth check + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse body + const body = await request.json(); + + // Extract idempotency key from header or body + const idempotencyKey = + request.headers.get('Idempotency-Key') || + request.headers.get('X-Idempotency-Key') || + body.idempotencyKey; + + // Validate input + const input = createPaymentAttemptSchema.parse({ + ...body, + idempotencyKey, + }); + + // Create or retrieve attempt + const paymentService = PaymentService.getInstance(); + + const { attempt, isExisting } = await paymentService.createAttempt(input, { + userId: (session.user as typeof session.user & { id?: string }).id, + ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }); + + return NextResponse.json( + { + attempt: { + id: attempt.id, + storeId: attempt.storeId, + orderId: attempt.orderId, + provider: attempt.provider, + providerReference: attempt.providerReference, + status: attempt.status, + amount: attempt.amount, + currency: attempt.currency, + idempotencyKey: attempt.idempotencyKey, + attemptCount: attempt.attemptCount, + createdAt: attempt.createdAt, + updatedAt: attempt.updatedAt, + }, + isIdempotentReturn: isExisting, + }, + { status: isExisting ? 200 : 201 } + ); + } catch (error) { + console.error('POST /api/payments/attempt error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error) { + if (error.message.includes('Idempotency key')) { + return NextResponse.json( + { error: error.message }, + { status: 409 } + ); + } + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to create payment attempt' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/payments/capture/route.ts b/src/app/api/payments/capture/route.ts new file mode 100644 index 00000000..110d1fa5 --- /dev/null +++ b/src/app/api/payments/capture/route.ts @@ -0,0 +1,117 @@ +/** + * POST /api/payments/capture + * + * Capture an authorized payment (AUTHORIZED → CAPTURED) + * + * Body: + * - attemptId: string (required) + * - storeId: string (required) + * - amount: number (optional, defaults to authorized amount) + * - providerReference: string (optional) + * + * Returns: + * - 200: Payment captured successfully + * - 400: Validation error or invalid transition + * - 401: Unauthorized + * - 404: Payment attempt not found + * - 409: Payment already captured (double-capture prevention) + * - 500: Server error + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { PaymentService, capturePaymentSchema } from '@/lib/services/payment.service'; +import { z } from 'zod'; + +export async function POST(request: NextRequest) { + try { + // Auth check + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse and validate body + const body = await request.json(); + const input = capturePaymentSchema.parse(body); + + // Capture payment + const paymentService = PaymentService.getInstance(); + const attempt = await paymentService.capture(input, { + userId: (session.user as typeof session.user & { id?: string }).id, + ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }); + + return NextResponse.json({ + attempt: { + id: attempt.id, + storeId: attempt.storeId, + orderId: attempt.orderId, + provider: attempt.provider, + providerReference: attempt.providerReference, + status: attempt.status, + amount: attempt.amount, + currency: attempt.currency, + createdAt: attempt.createdAt, + updatedAt: attempt.updatedAt, + transactions: attempt.transactions.map((t) => ({ + id: t.id, + type: t.type, + amount: t.amount, + currency: t.currency, + providerReference: t.providerReference, + createdAt: t.createdAt, + })), + }, + message: 'Payment captured successfully', + }); + } catch (error) { + console.error('POST /api/payments/capture error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error) { + // Double-capture prevention - return 409 Conflict + if ((error as Error & { code?: string }).code === 'ALREADY_CAPTURED' || + error.message.includes('already captured')) { + return NextResponse.json( + { error: 'Payment already captured', code: 'ALREADY_CAPTURED' }, + { status: 409 } + ); + } + + // Not found + if (error.message.includes('not found')) { + return NextResponse.json( + { error: error.message }, + { status: 404 } + ); + } + + // Invalid transition + if (error.message.includes('Invalid transition')) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to capture payment' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/payments/reconciliation/route.ts b/src/app/api/payments/reconciliation/route.ts new file mode 100644 index 00000000..a0e972ee --- /dev/null +++ b/src/app/api/payments/reconciliation/route.ts @@ -0,0 +1,141 @@ +/** + * POST /api/payments/reconciliation + * + * Run reconciliation job to find payment attempts stuck in AUTHORIZING state + * This endpoint should be called by a scheduled job (e.g., daily CRON) + * + * Query Parameters: + * - timeoutMinutes: number (optional, default 15) + * + * Returns: + * - 200: Reconciliation result with stuck attempts + * - 401: Unauthorized + * - 500: Server error + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { PaymentService } from '@/lib/services/payment.service'; + +export async function POST(request: NextRequest) { + try { + // Auth check - this endpoint should be restricted to admins + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse timeout from query or body + const { searchParams } = new URL(request.url); + let timeoutMinutes = 15; // Default + + const timeoutParam = searchParams.get('timeoutMinutes'); + if (timeoutParam) { + const parsed = parseInt(timeoutParam, 10); + if (!isNaN(parsed) && parsed > 0) { + timeoutMinutes = parsed; + } + } + + // Try to get from body for POST + try { + const body = await request.json(); + if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number') { + timeoutMinutes = body.timeoutMinutes; + } + } catch { + // Body parsing failed, use default or query param + } + + // Run reconciliation + const paymentService = PaymentService.getInstance(); + const result = await paymentService.runReconciliation(timeoutMinutes); + + return NextResponse.json({ + success: true, + reconciliation: { + checkedAt: result.checkedAt.toISOString(), + timeoutMinutes, + totalStuck: result.totalStuck, + stuckAttempts: result.stuckAttempts.map((attempt) => ({ + id: attempt.id, + storeId: attempt.storeId, + orderId: attempt.orderId, + status: attempt.status, + createdAt: attempt.createdAt.toISOString(), + stuckMinutes: attempt.stuckMinutes, + })), + }, + message: result.totalStuck > 0 + ? `Found ${result.totalStuck} payment(s) stuck in AUTHORIZING state` + : 'No stuck payments found', + }); + } catch (error) { + console.error('POST /api/payments/reconciliation error:', error); + + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to run reconciliation' }, + { status: 500 } + ); + } +} + +/** + * GET /api/payments/reconciliation + * + * Check for stuck payments without triggering full reconciliation + * Useful for monitoring dashboards + */ +export async function GET(request: NextRequest) { + try { + // Auth check + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const timeoutParam = searchParams.get('timeoutMinutes'); + const timeoutMinutes = timeoutParam ? parseInt(timeoutParam, 10) : 15; + + // Find stuck attempts (without logging to audit) + const paymentService = PaymentService.getInstance(); + const result = await paymentService.findStuckAttempts(timeoutMinutes); + + return NextResponse.json({ + checkedAt: result.checkedAt.toISOString(), + timeoutMinutes, + totalStuck: result.totalStuck, + stuckAttempts: result.stuckAttempts.map((attempt) => ({ + id: attempt.id, + storeId: attempt.storeId, + orderId: attempt.orderId, + status: attempt.status, + createdAt: attempt.createdAt.toISOString(), + stuckMinutes: attempt.stuckMinutes, + })), + }); + } catch (error) { + console.error('GET /api/payments/reconciliation error:', error); + + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to check reconciliation status' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/payments/refund/route.ts b/src/app/api/payments/refund/route.ts new file mode 100644 index 00000000..5e15990a --- /dev/null +++ b/src/app/api/payments/refund/route.ts @@ -0,0 +1,132 @@ +/** + * POST /api/payments/refund + * + * Refund a captured payment + * + * Body: + * - attemptId: string (required) + * - storeId: string (required) + * - amount: number (required, minor units) + * - reason: string (optional) + * - providerReference: string (optional) + * + * Returns: + * - 200: Refund processed successfully + * - 400: Validation error (e.g., amount exceeds refundable amount) + * - 401: Unauthorized + * - 404: Payment attempt not found + * - 500: Server error + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { PaymentService, refundPaymentSchema } from '@/lib/services/payment.service'; +import { z } from 'zod'; + +export async function POST(request: NextRequest) { + try { + // Auth check + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse and validate body + const body = await request.json(); + const input = refundPaymentSchema.parse(body); + + // Get refundable amount first for validation + const paymentService = PaymentService.getInstance(); + const refundableAmount = await paymentService.getRefundableAmount( + input.attemptId, + input.storeId + ); + + if (refundableAmount === 0) { + return NextResponse.json( + { error: 'No refundable amount available. Payment may not be captured or already fully refunded.' }, + { status: 400 } + ); + } + + // Process refund + const attempt = await paymentService.refund(input, { + userId: (session.user as typeof session.user & { id?: string }).id, + ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }); + + // Calculate remaining refundable amount + const newRefundableAmount = await paymentService.getRefundableAmount( + input.attemptId, + input.storeId + ); + + return NextResponse.json({ + attempt: { + id: attempt.id, + storeId: attempt.storeId, + orderId: attempt.orderId, + provider: attempt.provider, + status: attempt.status, + amount: attempt.amount, + currency: attempt.currency, + createdAt: attempt.createdAt, + updatedAt: attempt.updatedAt, + transactions: attempt.transactions.map((t) => ({ + id: t.id, + type: t.type, + amount: t.amount, + currency: t.currency, + providerReference: t.providerReference, + createdAt: t.createdAt, + })), + }, + refund: { + amount: input.amount, + reason: input.reason, + remainingRefundable: newRefundableAmount, + }, + message: 'Refund processed successfully', + }); + } catch (error) { + console.error('POST /api/payments/refund error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error) { + // Not found + if (error.message.includes('not found')) { + return NextResponse.json( + { error: error.message }, + { status: 404 } + ); + } + + // Refund amount validation + if (error.message.includes('exceeds refundable amount') || + error.message.includes('not been captured')) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to process refund' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/payments/route.ts b/src/app/api/payments/route.ts new file mode 100644 index 00000000..035ee574 --- /dev/null +++ b/src/app/api/payments/route.ts @@ -0,0 +1,113 @@ +/** + * GET /api/payments + * + * Get payment attempts for a store (optionally filtered by orderId) + * + * Query Parameters: + * - storeId: string (required) + * - orderId: string (optional) - filter by specific order + * + * Returns: + * - 200: List of payment attempts with transactions + * - 400: Missing required parameters + * - 401: Unauthorized + * - 500: Server error + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { PaymentService } from '@/lib/services/payment.service'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +const querySchema = z.object({ + storeId: z.string().cuid(), + orderId: z.string().cuid().optional(), +}); + +export async function GET(request: NextRequest) { + try { + // Auth check + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse and validate query parameters + const { searchParams } = new URL(request.url); + const params = querySchema.parse({ + storeId: searchParams.get('storeId'), + orderId: searchParams.get('orderId') || undefined, + }); + + let attempts; + + if (params.orderId) { + // Get payment attempts for specific order + const paymentService = PaymentService.getInstance(); + attempts = await paymentService.getAttemptsByOrderId( + params.orderId, + params.storeId + ); + } else { + // Get all payment attempts for the store + attempts = await prisma.paymentAttempt.findMany({ + where: { storeId: params.storeId }, + include: { transactions: true }, + orderBy: { createdAt: 'desc' }, + take: 100, // Limit to 100 most recent + }); + } + + return NextResponse.json({ + attempts: attempts.map((attempt) => ({ + id: attempt.id, + storeId: attempt.storeId, + orderId: attempt.orderId, + provider: attempt.provider, + providerReference: attempt.providerReference, + status: attempt.status, + amount: attempt.amount, + currency: attempt.currency, + idempotencyKey: attempt.idempotencyKey, + attemptCount: attempt.attemptCount, + lastErrorCode: attempt.lastErrorCode, + lastErrorMessage: attempt.lastErrorMessage, + nextRetryAt: attempt.nextRetryAt, + createdAt: attempt.createdAt, + updatedAt: attempt.updatedAt, + transactions: attempt.transactions.map((t) => ({ + id: t.id, + type: t.type, + amount: t.amount, + currency: t.currency, + providerReference: t.providerReference, + createdAt: t.createdAt, + })), + })), + total: attempts.length, + }); + } catch (error) { + console.error('GET /api/payments error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to fetch payment attempts' }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/payments/page.tsx b/src/app/dashboard/payments/page.tsx new file mode 100644 index 00000000..825185eb --- /dev/null +++ b/src/app/dashboard/payments/page.tsx @@ -0,0 +1,60 @@ +// src/app/dashboard/payments/page.tsx +// Payments listing page + +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { PaymentsPageClient } from '@/components/payments-page-client'; +import { AppSidebar } from "@/components/app-sidebar"; +import { SiteHeader } from "@/components/site-header"; +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar"; + +export const metadata = { + title: 'Payments | Dashboard', + description: 'Manage payment attempts and transactions', +}; + +export default async function PaymentsPage() { + const session = await getServerSession(authOptions); + + if (!session?.user) { + redirect('/login'); + } + + return ( + + + + +
+
+
+
+
+
+

Payments

+

+ Track payment attempts, transactions, and reconciliation status. +

+
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 899be707..c231ec9b 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { IconCamera, IconChartBar, + IconCreditCard, IconDashboard, IconDatabase, IconFileAi, @@ -97,6 +98,11 @@ const data = { }, ], }, + { + title: "Payments", + url: "/dashboard/payments", + icon: IconCreditCard, + }, { title: "Customers", url: "/dashboard/customers", diff --git a/src/components/payments-page-client.tsx b/src/components/payments-page-client.tsx new file mode 100644 index 00000000..013733bf --- /dev/null +++ b/src/components/payments-page-client.tsx @@ -0,0 +1,35 @@ +"use client"; + +// src/components/payments-page-client.tsx +// Client wrapper for payments page + +import { useState } from 'react'; +import { StoreSelector } from '@/components/store-selector'; +import { PaymentsTable } from '@/components/payments-table'; +import { ReconciliationCard } from '@/components/payments-reconciliation-card'; + +export function PaymentsPageClient() { + const [storeId, setStoreId] = useState(''); + + return ( +
+
+ + +
+ + {storeId ? ( +
+ + +
+ ) : ( +
+

+ Select a store to view payment attempts +

+
+ )} +
+ ); +} diff --git a/src/components/payments-reconciliation-card.tsx b/src/components/payments-reconciliation-card.tsx new file mode 100644 index 00000000..0f703ff3 --- /dev/null +++ b/src/components/payments-reconciliation-card.tsx @@ -0,0 +1,213 @@ +"use client"; + +// src/components/payments-reconciliation-card.tsx +// Reconciliation status card for payments dashboard + +import { useEffect, useState } from 'react'; +import { format } from 'date-fns'; +import { + AlertTriangle, + CheckCircle, + RefreshCw, + Clock, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; + +interface StuckAttempt { + id: string; + storeId: string; + orderId: string; + status: string; + createdAt: string; + stuckMinutes: number; +} + +interface ReconciliationResult { + checkedAt: string; + timeoutMinutes: number; + totalStuck: number; + stuckAttempts: StuckAttempt[]; +} + +interface ReconciliationCardProps { + storeId: string; +} + +export function ReconciliationCard({ storeId }: ReconciliationCardProps) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [running, setRunning] = useState(false); + + // Check reconciliation status + const checkReconciliation = async () => { + try { + setLoading(true); + const response = await fetch('/api/payments/reconciliation'); + if (!response.ok) { + throw new Error('Failed to check reconciliation status'); + } + const data: ReconciliationResult = await response.json(); + + // Filter to only show stuck attempts for this store + const filteredData = { + ...data, + stuckAttempts: data.stuckAttempts.filter(a => a.storeId === storeId), + totalStuck: data.stuckAttempts.filter(a => a.storeId === storeId).length, + }; + + setResult(filteredData); + } catch (error) { + console.error('Error checking reconciliation:', error); + } finally { + setLoading(false); + } + }; + + // Run reconciliation job + const runReconciliation = async () => { + try { + setRunning(true); + const response = await fetch('/api/payments/reconciliation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ timeoutMinutes: 15 }), + }); + + if (!response.ok) { + throw new Error('Failed to run reconciliation'); + } + + const data = await response.json(); + toast.success(data.message || 'Reconciliation completed'); + + // Refresh status + checkReconciliation(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to run reconciliation'); + } finally { + setRunning(false); + } + }; + + // Check on mount + useEffect(() => { + if (storeId) { + checkReconciliation(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storeId]); + + const hasIssues = result && result.totalStuck > 0; + + return ( + + +
+
+ {hasIssues ? ( + + ) : ( + + )} + Reconciliation Status +
+
+ + +
+
+ + Monitors for payment attempts stuck in AUTHORIZING state for {'>'}15 minutes + +
+ + {loading && !result ? ( +
+ +
+ ) : result ? ( +
+ {/* Summary */} +
+
+ Last checked: + {format(new Date(result.checkedAt), 'MMM dd, HH:mm:ss')} +
+
+ Timeout: + {result.timeoutMinutes} minutes +
+
+ + {/* Status */} + {hasIssues ? ( +
+
+ + + {result.totalStuck} stuck payment{result.totalStuck !== 1 ? 's' : ''} detected + +
+
+ {result.stuckAttempts.map((attempt) => ( +
+
+ + {attempt.id.slice(0, 12)}... +
+ + Stuck for {attempt.stuckMinutes} min + +
+ ))} +
+
+ ) : ( +
+
+ + + All payment attempts are processing normally + +
+
+ )} +
+ ) : ( +

+ Click 'Check' to verify reconciliation status +

+ )} +
+
+ ); +} diff --git a/src/components/payments-table.tsx b/src/components/payments-table.tsx new file mode 100644 index 00000000..01bde0ec --- /dev/null +++ b/src/components/payments-table.tsx @@ -0,0 +1,545 @@ +"use client"; + +// src/components/payments-table.tsx +// Payments table with filtering and pagination + +import { useEffect, useState } from 'react'; +import { format } from 'date-fns'; +import { + CreditCard, + Search, + Filter, + Eye, + RefreshCw, + Download, + AlertCircle, + CheckCircle, + XCircle, + Clock, + Ban, + Loader2, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; + +// PaymentAttempt type based on API response +interface PaymentTransaction { + id: string; + type: 'AUTH' | 'CAPTURE' | 'REFUND' | 'VOID'; + amount: number; + currency: string; + providerReference: string | null; + createdAt: string; +} + +interface PaymentAttempt { + id: string; + storeId: string; + orderId: string; + provider: string; + providerReference: string | null; + status: 'INITIATED' | 'AUTHORIZING' | 'AUTHORIZED' | 'CAPTURED' | 'FAILED' | 'CANCELED'; + amount: number; + currency: string; + idempotencyKey: string | null; + attemptCount: number; + lastErrorCode: string | null; + lastErrorMessage: string | null; + nextRetryAt: string | null; + createdAt: string; + updatedAt: string; + transactions: PaymentTransaction[]; +} + +interface PaymentsResponse { + attempts: PaymentAttempt[]; + total: number; +} + +interface PaymentsTableProps { + storeId: string; +} + +// Status badge configurations +const statusConfig: Record = { + INITIATED: { + icon: , + className: 'bg-slate-500/10 text-slate-500 hover:bg-slate-500/20', + label: 'Initiated', + }, + AUTHORIZING: { + icon: , + className: 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20', + label: 'Authorizing', + }, + AUTHORIZED: { + icon: , + className: 'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20', + label: 'Authorized', + }, + CAPTURED: { + icon: , + className: 'bg-green-500/10 text-green-500 hover:bg-green-500/20', + label: 'Captured', + }, + FAILED: { + icon: , + className: 'bg-red-500/10 text-red-500 hover:bg-red-500/20', + label: 'Failed', + }, + CANCELED: { + icon: , + className: 'bg-orange-500/10 text-orange-500 hover:bg-orange-500/20', + label: 'Canceled', + }, +}; + +// Transaction type badge colors +const transactionTypeColors: Record = { + AUTH: 'bg-blue-500/10 text-blue-500', + CAPTURE: 'bg-green-500/10 text-green-500', + REFUND: 'bg-orange-500/10 text-orange-500', + VOID: 'bg-gray-500/10 text-gray-500', +}; + +export function PaymentsTable({ storeId }: PaymentsTableProps) { + const [attempts, setAttempts] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [selectedAttempt, setSelectedAttempt] = useState(null); + + // Format currency + const formatCurrency = (amount: number, currency: string) => { + // Convert from minor units (cents/paisa) + const value = amount / 100; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD', + }).format(value); + }; + + // Fetch all payment attempts for the store in a single request + const fetchPayments = async () => { + try { + setLoading(true); + + // Fetch all payment attempts for the store + const response = await fetch(`/api/payments?storeId=${storeId}`); + if (!response.ok) { + throw new Error('Failed to fetch payment attempts'); + } + const data: PaymentsResponse = await response.json(); + let allAttempts = data.attempts; + + // Filter based on status + let filteredAttempts = allAttempts; + if (statusFilter !== 'all') { + filteredAttempts = allAttempts.filter(a => a.status === statusFilter); + } + + // Filter based on search + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filteredAttempts = filteredAttempts.filter(a => + a.id.toLowerCase().includes(query) || + a.orderId.toLowerCase().includes(query) || + a.provider.toLowerCase().includes(query) || + a.providerReference?.toLowerCase().includes(query) || + a.idempotencyKey?.toLowerCase().includes(query) + ); + } + + // Sort by createdAt descending + filteredAttempts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + setAttempts(filteredAttempts); + setTotal(filteredAttempts.length); + } catch (error) { + console.error('Error fetching payments:', error); + toast.error('Failed to load payment attempts'); + } finally { + setLoading(false); + } + }; + + // Fetch on mount and when filters change + useEffect(() => { + if (storeId) { + fetchPayments(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storeId, statusFilter]); + + // Handle search + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + fetchPayments(); + }; + + // Handle capture + const handleCapture = async (attempt: PaymentAttempt) => { + try { + const response = await fetch('/api/payments/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + attemptId: attempt.id, + storeId: attempt.storeId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to capture payment'); + } + + toast.success('Payment captured successfully'); + fetchPayments(); + setSelectedAttempt(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to capture payment'); + } + }; + + // Handle refund + const handleRefund = async (attempt: PaymentAttempt, amount: number) => { + try { + const response = await fetch('/api/payments/refund', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + attemptId: attempt.id, + storeId: attempt.storeId, + amount, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to refund payment'); + } + + toast.success('Refund processed successfully'); + fetchPayments(); + setSelectedAttempt(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to process refund'); + } + }; + + return ( +
+ {/* Header Actions */} +
+ + +
+ + {/* Filters */} + + + Filter Payments + + +
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + {/* Status Filter */} + +
+
+
+ + {/* Payments Table */} + + +
+
+ Payment Attempts + + {total} total payment attempts + +
+
+
+ + {loading ? ( +
+ +
+ ) : attempts.length === 0 ? ( +
+ +

No payment attempts found

+

+ {searchQuery || statusFilter !== 'all' + ? 'Try adjusting your filters' + : 'Payment attempts will appear here when customers make purchases'} +

+
+ ) : ( +
+ + + + ID + Provider + Status + Amount + Transactions + Date + Actions + + + + {attempts.map((attempt) => { + const config = statusConfig[attempt.status]; + return ( + + + {attempt.id.slice(0, 8)}... + + + + {attempt.provider} + + + + + {config?.icon} + {config?.label || attempt.status} + + + + {formatCurrency(attempt.amount, attempt.currency)} + + +
+ {attempt.transactions.map((tx) => ( + + {tx.type} + + ))} + {attempt.transactions.length === 0 && ( + None + )} +
+
+ + {format(new Date(attempt.createdAt), 'MMM dd, yyyy HH:mm')} + + + + +
+ ); + })} +
+
+
+ )} +
+
+ + {/* Payment Details Dialog */} + setSelectedAttempt(null)}> + + + Payment Attempt Details + + View and manage payment attempt information + + + + {selectedAttempt && ( +
+ {/* Status & Amount */} +
+
+

Status

+ + {statusConfig[selectedAttempt.status]?.icon} + {statusConfig[selectedAttempt.status]?.label || selectedAttempt.status} + +
+
+

Amount

+

+ {formatCurrency(selectedAttempt.amount, selectedAttempt.currency)} +

+
+
+ + {/* Details Grid */} +
+
+

Attempt ID

+

{selectedAttempt.id}

+
+
+

Order ID

+

{selectedAttempt.orderId}

+
+
+

Provider

+

{selectedAttempt.provider}

+
+
+

Provider Reference

+

{selectedAttempt.providerReference || 'N/A'}

+
+
+

Attempt Count

+

{selectedAttempt.attemptCount}

+
+
+

Idempotency Key

+

{selectedAttempt.idempotencyKey || 'N/A'}

+
+ {selectedAttempt.lastErrorCode && ( + <> +
+

Last Error

+
+ + {selectedAttempt.lastErrorCode}: {selectedAttempt.lastErrorMessage} +
+
+ + )} +
+ + {/* Transactions */} + {selectedAttempt.transactions.length > 0 && ( +
+

Transactions

+
+ {selectedAttempt.transactions.map((tx) => ( +
+
+ + {tx.type} + + + {tx.providerReference || tx.id} + +
+
+

+ {formatCurrency(tx.amount, tx.currency)} +

+

+ {format(new Date(tx.createdAt), 'MMM dd, HH:mm')} +

+
+
+ ))} +
+
+ )} + + {/* Actions */} +
+ {selectedAttempt.status === 'AUTHORIZED' && ( + + )} + {selectedAttempt.status === 'CAPTURED' && ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/src/lib/services/payment.service.ts b/src/lib/services/payment.service.ts new file mode 100644 index 00000000..1e8139eb --- /dev/null +++ b/src/lib/services/payment.service.ts @@ -0,0 +1,885 @@ +/** + * PaymentService - State machine for payment attempts and transactions + * + * Provides atomic, auditable payment flows with: + * - Idempotent payment attempt creation + * - State machine transitions with validation + * - Double-capture prevention (409 conflict) + * - Refund tracking with amount validation + * - Reconciliation for stuck payments + * + * State Machine Rules: + * - INITIATED → AUTHORIZING → AUTHORIZED → CAPTURED + * - AUTHORIZED → VOID (cancel before capture) + * - CAPTURED → REFUND (multi-refund allowed up to captured amount) + * - Any → FAILED (terminal state) + * + * @module lib/services/payment.service + */ + +import { prisma } from '@/lib/prisma'; +import { + PaymentAttemptStatus, + PaymentTransactionType, + Prisma, +} from '@prisma/client'; +import { z } from 'zod'; +import { AuditLogService } from './audit-log.service'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** Maximum time (in minutes) an attempt can stay in AUTHORIZING before flagged */ +const AUTHORIZING_TIMEOUT_MINUTES = 15; + +/** Supported payment providers */ +export const PAYMENT_PROVIDERS = ['stripe', 'bkash', 'cod'] as const; +export type PaymentProvider = (typeof PAYMENT_PROVIDERS)[number]; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +/** + * Schema for creating a new payment attempt + */ +export const createPaymentAttemptSchema = z.object({ + storeId: z.string().cuid(), + orderId: z.string().cuid(), + provider: z.enum(['stripe', 'bkash', 'cod']), + amount: z.number().int().positive('Amount must be positive (minor units)'), + currency: z.string().length(3, 'Currency must be ISO 4217 code (3 chars)').transform(c => c.toUpperCase()), + idempotencyKey: z.string().min(1).max(128).optional(), + providerReference: z.string().optional(), +}); + +/** + * Schema for starting authorization + */ +export const startAuthorizationSchema = z.object({ + attemptId: z.string().cuid(), + storeId: z.string().cuid(), + providerReference: z.string().optional(), +}); + +/** + * Schema for completing authorization + */ +export const completeAuthorizationSchema = z.object({ + attemptId: z.string().cuid(), + storeId: z.string().cuid(), + providerReference: z.string().optional(), +}); + +/** + * Schema for authorization failure + */ +export const failAuthorizationSchema = z.object({ + attemptId: z.string().cuid(), + storeId: z.string().cuid(), + errorCode: z.string().optional(), + errorMessage: z.string().optional(), + scheduleRetry: z.boolean().default(false), + retryDelayMinutes: z.number().int().positive().optional(), +}); + +/** + * Schema for capturing a payment + */ +export const capturePaymentSchema = z.object({ + attemptId: z.string().cuid(), + storeId: z.string().cuid(), + amount: z.number().int().positive().optional(), // Optional: capture less than authorized + providerReference: z.string().optional(), +}); + +/** + * Schema for refunding a payment + */ +export const refundPaymentSchema = z.object({ + attemptId: z.string().cuid(), + storeId: z.string().cuid(), + amount: z.number().int().positive('Refund amount must be positive'), + reason: z.string().max(500).optional(), + providerReference: z.string().optional(), +}); + +/** + * Schema for voiding an authorization + */ +export const voidPaymentSchema = z.object({ + attemptId: z.string().cuid(), + storeId: z.string().cuid(), + reason: z.string().max(500).optional(), + providerReference: z.string().optional(), +}); + +// ============================================================================ +// TYPES +// ============================================================================ + +export type CreatePaymentAttemptInput = z.infer; +export type StartAuthorizationInput = z.infer; +export type CompleteAuthorizationInput = z.infer; +export type FailAuthorizationInput = z.infer; +export type CapturePaymentInput = z.infer; +export type RefundPaymentInput = z.infer; +export type VoidPaymentInput = z.infer; + +/** + * Valid state transitions for payment attempts + */ +const VALID_TRANSITIONS: Record = { + INITIATED: [PaymentAttemptStatus.AUTHORIZING, PaymentAttemptStatus.FAILED, PaymentAttemptStatus.CANCELED], + AUTHORIZING: [PaymentAttemptStatus.AUTHORIZED, PaymentAttemptStatus.FAILED], + AUTHORIZED: [PaymentAttemptStatus.CAPTURED, PaymentAttemptStatus.CANCELED, PaymentAttemptStatus.FAILED], + CAPTURED: [PaymentAttemptStatus.FAILED], // Refunds don't change attempt status + FAILED: [], // Terminal state + CANCELED: [], // Terminal state +}; + +/** + * PaymentAttempt with transactions included + */ +export type PaymentAttemptWithTransactions = Prisma.PaymentAttemptGetPayload<{ + include: { transactions: true }; +}>; + +/** + * Result of creating a payment attempt + */ +export interface CreatePaymentAttemptResult { + attempt: PaymentAttemptWithTransactions; + isExisting: boolean; +} + +/** + * Result of reconciliation check + */ +export interface ReconciliationResult { + stuckAttempts: { + id: string; + storeId: string; + orderId: string; + status: PaymentAttemptStatus; + createdAt: Date; + stuckMinutes: number; + }[]; + totalStuck: number; + checkedAt: Date; +} + +// ============================================================================ +// SERVICE CLASS +// ============================================================================ + +export class PaymentService { + private static instance: PaymentService; + private auditLogService: AuditLogService; + + private constructor() { + this.auditLogService = AuditLogService.getInstance(); + } + + /** + * Get singleton instance + */ + public static getInstance(): PaymentService { + if (!PaymentService.instance) { + PaymentService.instance = new PaymentService(); + } + return PaymentService.instance; + } + + // ========================================================================== + // STATE MACHINE VALIDATION + // ========================================================================== + + /** + * Check if a state transition is valid + */ + private isValidTransition( + from: PaymentAttemptStatus, + to: PaymentAttemptStatus + ): boolean { + return VALID_TRANSITIONS[from]?.includes(to) ?? false; + } + + /** + * Log a state transition to audit log + */ + private async logStateTransition( + attemptId: string, + storeId: string, + fromStatus: PaymentAttemptStatus, + toStatus: PaymentAttemptStatus, + metadata?: { + userId?: string; + ipAddress?: string; + userAgent?: string; + additionalInfo?: Record; + } + ): Promise { + await this.auditLogService.create( + 'PAYMENT_STATE_CHANGE', + 'PaymentAttempt', + attemptId, + { + storeId, + userId: metadata?.userId, + changes: { + status: { + old: fromStatus, + new: toStatus, + }, + ...(metadata?.additionalInfo && { info: { old: null, new: metadata.additionalInfo } }), + }, + metadata: { + ipAddress: metadata?.ipAddress, + userAgent: metadata?.userAgent, + }, + } + ); + } + + // ========================================================================== + // PAYMENT ATTEMPT OPERATIONS + // ========================================================================== + + /** + * Create a new payment attempt (idempotent) + * + * If an idempotency key is provided and an attempt with that key exists, + * returns the existing attempt instead of creating a new one. + * + * @returns Object containing the attempt and whether it was an existing attempt + */ + async createAttempt( + input: CreatePaymentAttemptInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = createPaymentAttemptSchema.parse(input); + + // Check for existing attempt with same idempotency key + if (validated.idempotencyKey) { + const existing = await prisma.paymentAttempt.findUnique({ + where: { idempotencyKey: validated.idempotencyKey }, + include: { transactions: true }, + }); + + if (existing) { + // Verify store ID matches for security + if (existing.storeId !== validated.storeId) { + // Return generic error to prevent information leakage + throw new Error('Idempotency key conflict'); + } + return { attempt: existing, isExisting: true }; + } + } + + // Create new attempt + const attempt = await prisma.paymentAttempt.create({ + data: { + storeId: validated.storeId, + orderId: validated.orderId, + provider: validated.provider, + providerReference: validated.providerReference, + amount: validated.amount, + currency: validated.currency, // Already normalized to uppercase by schema + idempotencyKey: validated.idempotencyKey, + status: PaymentAttemptStatus.INITIATED, + attemptCount: 1, + }, + include: { transactions: true }, + }); + + // Log creation + await this.auditLogService.create('CREATE', 'PaymentAttempt', attempt.id, { + storeId: attempt.storeId, + userId: metadata?.userId, + changes: { + status: { old: null, new: attempt.status }, + amount: { old: null, new: attempt.amount }, + currency: { old: null, new: attempt.currency }, + provider: { old: null, new: attempt.provider }, + }, + metadata: { + ipAddress: metadata?.ipAddress, + userAgent: metadata?.userAgent, + }, + }); + + return { attempt, isExisting: false }; + } + + /** + * Start authorization process (INITIATED → AUTHORIZING) + */ + async startAuthorization( + input: StartAuthorizationInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = startAuthorizationSchema.parse(input); + + const attempt = await prisma.paymentAttempt.findFirst({ + where: { + id: validated.attemptId, + storeId: validated.storeId, + }, + include: { transactions: true }, + }); + + if (!attempt) { + throw new Error('Payment attempt not found'); + } + + if (!this.isValidTransition(attempt.status, PaymentAttemptStatus.AUTHORIZING)) { + throw new Error( + `Invalid transition from ${attempt.status} to AUTHORIZING` + ); + } + + const updated = await prisma.paymentAttempt.update({ + where: { id: attempt.id }, + data: { + status: PaymentAttemptStatus.AUTHORIZING, + providerReference: validated.providerReference ?? attempt.providerReference, + }, + include: { transactions: true }, + }); + + await this.logStateTransition( + attempt.id, + attempt.storeId, + attempt.status, + PaymentAttemptStatus.AUTHORIZING, + metadata + ); + + return updated; + } + + /** + * Complete authorization (AUTHORIZING → AUTHORIZED) + * Creates an AUTH transaction record + */ + async completeAuthorization( + input: CompleteAuthorizationInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = completeAuthorizationSchema.parse(input); + + const attempt = await prisma.paymentAttempt.findFirst({ + where: { + id: validated.attemptId, + storeId: validated.storeId, + }, + include: { transactions: true }, + }); + + if (!attempt) { + throw new Error('Payment attempt not found'); + } + + if (!this.isValidTransition(attempt.status, PaymentAttemptStatus.AUTHORIZED)) { + throw new Error( + `Invalid transition from ${attempt.status} to AUTHORIZED` + ); + } + + // Use transaction to update attempt and create AUTH transaction atomically + const updated = await prisma.$transaction(async (tx) => { + // Update attempt status + await tx.paymentAttempt.update({ + where: { id: attempt.id }, + data: { + status: PaymentAttemptStatus.AUTHORIZED, + providerReference: validated.providerReference ?? attempt.providerReference, + lastErrorCode: null, + lastErrorMessage: null, + }, + }); + + // Create AUTH transaction + await tx.paymentTransaction.create({ + data: { + attemptId: attempt.id, + storeId: attempt.storeId, + type: PaymentTransactionType.AUTH, + amount: attempt.amount, + currency: attempt.currency, + providerReference: validated.providerReference, + }, + }); + + return tx.paymentAttempt.findUnique({ + where: { id: attempt.id }, + include: { transactions: true }, + }); + }); + + if (!updated) { + throw new Error('Failed to update payment attempt'); + } + + await this.logStateTransition( + attempt.id, + attempt.storeId, + attempt.status, + PaymentAttemptStatus.AUTHORIZED, + metadata + ); + + return updated; + } + + /** + * Handle authorization failure (AUTHORIZING → FAILED) + * Optionally schedules a retry + */ + async failAuthorization( + input: FailAuthorizationInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = failAuthorizationSchema.parse(input); + + const attempt = await prisma.paymentAttempt.findFirst({ + where: { + id: validated.attemptId, + storeId: validated.storeId, + }, + include: { transactions: true }, + }); + + if (!attempt) { + throw new Error('Payment attempt not found'); + } + + if (!this.isValidTransition(attempt.status, PaymentAttemptStatus.FAILED)) { + throw new Error( + `Invalid transition from ${attempt.status} to FAILED` + ); + } + + // Calculate next retry time if requested + let nextRetryAt: Date | null = null; + if (validated.scheduleRetry && validated.retryDelayMinutes) { + nextRetryAt = new Date(Date.now() + validated.retryDelayMinutes * 60 * 1000); + } + + const updated = await prisma.paymentAttempt.update({ + where: { id: attempt.id }, + data: { + status: PaymentAttemptStatus.FAILED, + lastErrorCode: validated.errorCode, + lastErrorMessage: validated.errorMessage, + attemptCount: attempt.attemptCount + 1, + nextRetryAt, + }, + include: { transactions: true }, + }); + + await this.logStateTransition( + attempt.id, + attempt.storeId, + attempt.status, + PaymentAttemptStatus.FAILED, + { + ...metadata, + additionalInfo: { + errorCode: validated.errorCode, + errorMessage: validated.errorMessage, + attemptCount: updated.attemptCount, + scheduledRetry: nextRetryAt?.toISOString(), + }, + } + ); + + return updated; + } + + /** + * Capture authorized payment (AUTHORIZED → CAPTURED) + * Creates a CAPTURE transaction record + * Returns 409 Conflict if already captured (double-capture prevention) + */ + async capture( + input: CapturePaymentInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = capturePaymentSchema.parse(input); + + const attempt = await prisma.paymentAttempt.findFirst({ + where: { + id: validated.attemptId, + storeId: validated.storeId, + }, + include: { transactions: true }, + }); + + if (!attempt) { + throw new Error('Payment attempt not found'); + } + + // Check for double capture - throw specific error for 409 response + if (attempt.status === PaymentAttemptStatus.CAPTURED) { + const error = new Error('Payment already captured'); + (error as Error & { code: string }).code = 'ALREADY_CAPTURED'; + throw error; + } + + if (!this.isValidTransition(attempt.status, PaymentAttemptStatus.CAPTURED)) { + throw new Error( + `Invalid transition from ${attempt.status} to CAPTURED. Payment must be AUTHORIZED first.` + ); + } + + // Determine capture amount (use authorized amount if not specified) + const captureAmount = validated.amount ?? attempt.amount; + if (captureAmount > attempt.amount) { + throw new Error( + `Capture amount (${captureAmount}) cannot exceed authorized amount (${attempt.amount})` + ); + } + + // Use transaction to update attempt and create CAPTURE transaction atomically + const updated = await prisma.$transaction(async (tx) => { + // Update attempt status + await tx.paymentAttempt.update({ + where: { id: attempt.id }, + data: { + status: PaymentAttemptStatus.CAPTURED, + }, + }); + + // Create CAPTURE transaction + await tx.paymentTransaction.create({ + data: { + attemptId: attempt.id, + storeId: attempt.storeId, + type: PaymentTransactionType.CAPTURE, + amount: captureAmount, + currency: attempt.currency, + providerReference: validated.providerReference, + }, + }); + + return tx.paymentAttempt.findUnique({ + where: { id: attempt.id }, + include: { transactions: true }, + }); + }); + + if (!updated) { + throw new Error('Failed to update payment attempt'); + } + + await this.logStateTransition( + attempt.id, + attempt.storeId, + attempt.status, + PaymentAttemptStatus.CAPTURED, + { + ...metadata, + additionalInfo: { + captureAmount, + authorizedAmount: attempt.amount, + }, + } + ); + + return updated; + } + + /** + * Refund captured payment + * Creates a REFUND transaction record + * Validates refund amount doesn't exceed remaining refundable amount + */ + async refund( + input: RefundPaymentInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = refundPaymentSchema.parse(input); + + const attempt = await prisma.paymentAttempt.findFirst({ + where: { + id: validated.attemptId, + storeId: validated.storeId, + }, + include: { transactions: true }, + }); + + if (!attempt) { + throw new Error('Payment attempt not found'); + } + + // Must be captured to refund + if (attempt.status !== PaymentAttemptStatus.CAPTURED) { + throw new Error('Cannot refund a payment that has not been captured'); + } + + // Calculate captured and refunded amounts + const capturedAmount = attempt.transactions + .filter((t) => t.type === PaymentTransactionType.CAPTURE) + .reduce((sum, t) => sum + t.amount, 0); + + const refundedAmount = attempt.transactions + .filter((t) => t.type === PaymentTransactionType.REFUND) + .reduce((sum, t) => sum + t.amount, 0); + + const refundableAmount = capturedAmount - refundedAmount; + + if (validated.amount > refundableAmount) { + throw new Error( + `Refund amount (${validated.amount}) exceeds refundable amount (${refundableAmount}). ` + + `Captured: ${capturedAmount}, Already refunded: ${refundedAmount}` + ); + } + + // Create REFUND transaction (attempt status stays CAPTURED) + const refundTransaction = await prisma.paymentTransaction.create({ + data: { + attemptId: attempt.id, + storeId: attempt.storeId, + type: PaymentTransactionType.REFUND, + amount: validated.amount, + currency: attempt.currency, + providerReference: validated.providerReference, + }, + }); + + // Log refund + await this.auditLogService.create('CREATE', 'PaymentTransaction', refundTransaction.id, { + storeId: attempt.storeId, + userId: metadata?.userId, + changes: { + type: { old: null, new: PaymentTransactionType.REFUND }, + amount: { old: null, new: validated.amount }, + reason: { old: null, new: validated.reason ?? 'Refund requested' }, + }, + metadata: { + ipAddress: metadata?.ipAddress, + userAgent: metadata?.userAgent, + }, + }); + + // Return updated attempt with all transactions + const updated = await prisma.paymentAttempt.findUnique({ + where: { id: attempt.id }, + include: { transactions: true }, + }); + + if (!updated) { + throw new Error('Failed to retrieve updated payment attempt'); + } + + return updated; + } + + /** + * Void an authorized payment (AUTHORIZED → CANCELED) + * Creates a VOID transaction record + */ + async void( + input: VoidPaymentInput, + metadata?: { userId?: string; ipAddress?: string; userAgent?: string } + ): Promise { + const validated = voidPaymentSchema.parse(input); + + const attempt = await prisma.paymentAttempt.findFirst({ + where: { + id: validated.attemptId, + storeId: validated.storeId, + }, + include: { transactions: true }, + }); + + if (!attempt) { + throw new Error('Payment attempt not found'); + } + + if (!this.isValidTransition(attempt.status, PaymentAttemptStatus.CANCELED)) { + throw new Error( + `Invalid transition from ${attempt.status} to CANCELED. ` + + 'Void is only allowed for INITIATED or AUTHORIZED payments.' + ); + } + + // Use transaction to update attempt and create VOID transaction atomically + const updated = await prisma.$transaction(async (tx) => { + // Update attempt status + await tx.paymentAttempt.update({ + where: { id: attempt.id }, + data: { + status: PaymentAttemptStatus.CANCELED, + }, + }); + + // Create VOID transaction + await tx.paymentTransaction.create({ + data: { + attemptId: attempt.id, + storeId: attempt.storeId, + type: PaymentTransactionType.VOID, + amount: attempt.amount, + currency: attempt.currency, + providerReference: validated.providerReference, + }, + }); + + return tx.paymentAttempt.findUnique({ + where: { id: attempt.id }, + include: { transactions: true }, + }); + }); + + if (!updated) { + throw new Error('Failed to update payment attempt'); + } + + await this.logStateTransition( + attempt.id, + attempt.storeId, + attempt.status, + PaymentAttemptStatus.CANCELED, + { + ...metadata, + additionalInfo: { + reason: validated.reason, + }, + } + ); + + return updated; + } + + // ========================================================================== + // QUERY OPERATIONS + // ========================================================================== + + /** + * Get payment attempt by ID (store-scoped) + */ + async getAttemptById( + attemptId: string, + storeId: string + ): Promise { + return prisma.paymentAttempt.findFirst({ + where: { + id: attemptId, + storeId, + }, + include: { transactions: true }, + }); + } + + /** + * Get payment attempts for an order (store-scoped) + */ + async getAttemptsByOrderId( + orderId: string, + storeId: string + ): Promise { + return prisma.paymentAttempt.findMany({ + where: { + orderId, + storeId, + }, + include: { transactions: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + /** + * Calculate refundable amount for an attempt + */ + async getRefundableAmount(attemptId: string, storeId: string): Promise { + const attempt = await this.getAttemptById(attemptId, storeId); + + if (!attempt || attempt.status !== PaymentAttemptStatus.CAPTURED) { + return 0; + } + + const capturedAmount = attempt.transactions + .filter((t) => t.type === PaymentTransactionType.CAPTURE) + .reduce((sum, t) => sum + t.amount, 0); + + const refundedAmount = attempt.transactions + .filter((t) => t.type === PaymentTransactionType.REFUND) + .reduce((sum, t) => sum + t.amount, 0); + + return capturedAmount - refundedAmount; + } + + // ========================================================================== + // RECONCILIATION + // ========================================================================== + + /** + * Find payment attempts stuck in AUTHORIZING state + * for longer than the timeout threshold (default 15 minutes) + */ + async findStuckAttempts( + timeoutMinutes: number = AUTHORIZING_TIMEOUT_MINUTES + ): Promise { + const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000); + + const stuckAttempts = await prisma.paymentAttempt.findMany({ + where: { + status: PaymentAttemptStatus.AUTHORIZING, + createdAt: { lt: cutoffTime }, + }, + select: { + id: true, + storeId: true, + orderId: true, + status: true, + createdAt: true, + }, + orderBy: { createdAt: 'asc' }, + }); + + const now = Date.now(); + + return { + stuckAttempts: stuckAttempts.map((attempt) => ({ + ...attempt, + stuckMinutes: Math.floor((now - attempt.createdAt.getTime()) / 60000), + })), + totalStuck: stuckAttempts.length, + checkedAt: new Date(), + }; + } + + /** + * Run reconciliation job - flags stuck attempts and logs them + * Returns count of flagged attempts for monitoring + */ + async runReconciliation( + timeoutMinutes: number = AUTHORIZING_TIMEOUT_MINUTES + ): Promise { + const result = await this.findStuckAttempts(timeoutMinutes); + + // Log reconciliation run to audit + if (result.totalStuck > 0) { + await this.auditLogService.create( + 'RECONCILIATION', + 'PaymentAttempt', + 'system', + { + changes: { + stuckCount: { old: null, new: result.totalStuck }, + stuckAttemptIds: { + old: null, + new: result.stuckAttempts.map((a) => a.id), + }, + }, + } + ); + } + + return result; + } +}