Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";

Comment on lines +1 to +24
Copy link

Copilot AI Dec 1, 2025

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.

Suggested change
-- 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";

Copilot uses AI. Check for mistakes.
-- 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");
66 changes: 66 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing foreign key relationship: The PaymentAttempt model references storeId (line 705) but doesn't define a foreign key relationship to the Store model. This creates referential integrity issues.

Add a foreign key relation: store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) and update the Store model to include paymentAttempts PaymentAttempt[]

Copilot uses AI. Check for mistakes.
orderId String
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing foreign key relationship: The PaymentAttempt model references orderId as a string field (line 706) but doesn't define a foreign key relationship to the Order model. This creates the same issues as in the SQLite schema (no referential integrity, no cascade behavior).

Add a foreign key relation: order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) and update the Order model to include paymentAttempts PaymentAttempt[]

Copilot uses AI. Check for mistakes.
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])
}
66 changes: 66 additions & 0 deletions prisma/schema.sqlite.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing foreign key relationship: The PaymentAttempt model references storeId (line 678) but doesn't define a foreign key relationship to the Store model. This creates referential integrity issues.

Add a foreign key relation: store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) and update the Store model to include paymentAttempts PaymentAttempt[]

Copilot uses AI. Check for mistakes.
orderId String
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing foreign key relationship: The PaymentAttempt model references orderId as a string field (line 679) but doesn't define a foreign key relationship to the Order model. This means:

  1. No referential integrity - payment attempts can reference non-existent orders
  2. No cascade behavior defined - orphaned payment attempts if orders are deleted
  3. Harder to query related data

Add a foreign key relation: order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) and update the Order model to include paymentAttempts PaymentAttempt[]

Copilot uses AI. Check for mistakes.
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])
}
111 changes: 111 additions & 0 deletions src/app/api/payments/attempt/route.ts
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
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing authorization check: The attempt creation endpoint doesn't verify the user has access to the specified storeId. An authenticated user could create payment attempts for any store.

Add a check to verify the user has access to the requested store before creating the payment attempt.

Copilot uses AI. Check for mistakes.

// 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 }
);
}
}
Loading