-
Notifications
You must be signed in to change notification settings - Fork 0
[Phase 1] PaymentAttempt & PaymentTransaction State Machine #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1bd8f79
2147301
dbe982b
70a85a1
5caeee8
a6d1ae5
c265449
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }); | ||
|
Comment on lines
+31
to
+52
|
||
|
|
||
| // 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 } | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The migration drops several indexes (lines 2-23) that may still be needed by the application. These appear to be unrelated to the payment state machine feature being added. Dropping production indexes can cause severe performance degradation.
Verify that these index drops are intentional and won't impact query performance. If they're not related to this feature, they should be removed from this migration.