Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Nov 29, 2025

[Phase 1] PaymentAttempt & PaymentTransaction State Machine

Introduces atomic, auditable payment flows with idempotent state transitions to prevent double-charging and enable reconciliation.

Screenshot

Payments Dashboard

Data Model

  • PaymentAttempt: Tracks payment lifecycle with status enum (INITIATED → AUTHORIZING → AUTHORIZED → CAPTURED | FAILED | CANCELED)
  • PaymentTransaction: Records AUTH, CAPTURE, REFUND, VOID transactions linked to attempts
  • Unique constraints on providerReference and idempotencyKey
  • Indexed on [storeId, orderId], status, createdAt

State Machine

INITIATED → AUTHORIZING → AUTHORIZED → CAPTURED
INITIATED/AUTHORIZED → CANCELED (void)
Any → FAILED (terminal)
CAPTURED + REFUND transactions (status unchanged)

API Endpoints

  • POST /api/payments/attempt — Idempotent creation via Idempotency-Key header
  • POST /api/payments/capture — Returns 409 on double-capture
  • POST /api/payments/refund — Validates amount ≤ remaining refundable
  • GET/POST /api/payments/reconciliation — Flags attempts stuck >15min in AUTHORIZING
  • GET /api/payments — List all payments for store (orderId optional filter)

Dashboard UI

  • Payments Page (/dashboard/payments) — View and manage payment attempts
  • Reconciliation Status Card — Monitor for stuck payments, run reconciliation job
  • Payments Table — Filter by status, search, view details, capture/refund actions
  • Sidebar Navigation — Payments link with credit card icon

Service Layer

  • PaymentService singleton with transition validation
  • createAttempt() returns { attempt, isExisting } for idempotency
  • failAuthorization() increments attemptCount, stores error, schedules retry
  • All state changes logged via AuditLogService with action PAYMENT_STATE_CHANGE
  • All queries scoped by storeId

Fixes #63

Original prompt

This section details on the original issue you should resolve

<issue_title>[Phase 1] PaymentAttempt & PaymentTransaction State Machine</issue_title>
<issue_description>## Priority: P0 (Critical)
Phase: 1 - E-Commerce Core
Epic Link: Checkout / Payments (Stripe Integration #27 placeholder)
Estimate: 4 days
Type: Story

Context

Introduce PaymentAttempt and PaymentTransaction models to ensure atomic, auditable payment flows with retriable, idempotent state transitions. Prevent double-charging, enable reconciliation, and support future split payouts (Marketplace epic #37).

Problem

Current architecture lacks discrete tracking of payment lifecycle states (INITIATED → AUTHORIZED → CAPTURED → REFUNDED / FAILED) and does not persist provider references or failure metadata, increasing financial and integrity risk.

Scope

  • Model definitions (SQLite dev): PaymentAttempt, PaymentTransaction (later extend for Marketplace split payments)
  • State machine with allowed transitions & validation rules
  • Persistent providerReference (Stripe payment intent ID, etc.)
  • Error & retry metadata (lastErrorCode, attemptCount, nextRetryAt)
  • Idempotent create/capture endpoints
  • Reconciliation job (daily) to detect orphaned states

Acceptance Criteria

  • Prisma models created & migrated (incl. indexes: storeId+orderId, providerReference unique)
  • API endpoint: POST /api/payments/attempt (idempotent key header) creates INITIATED attempt
  • API endpoint: POST /api/payments/capture transitions AUTHORIZED → CAPTURED with validation
  • Double capture prevented (conflict 409 returned)
  • Failed attempt logs failure reason & increments attemptCount
  • Daily reconciliation job flags attempts stuck > 15m in AUTHORIZING state
  • Refund flow: POST /api/payments/refund creates REFUND transaction linked to original capture
  • Audit log entries for each state transition (action: payment_state_change)
  • All queries scoped by storeId

Data Model (Initial)

model PaymentAttempt {
  id              String   @id @default(cuid())
  storeId         String
  orderId         String
  provider        String   // stripe, bkash, cod
  providerReference String? @unique
  status          PaymentAttemptStatus @default(INITIATED)
  amount          Int      // minor units
  currency        String
  idempotencyKey  String?  @unique
  attemptCount    Int      @default(1)
  lastErrorCode   String?
  lastErrorMessage String?
  nextRetryAt     DateTime?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  transactions    PaymentTransaction[]

  @@index([storeId, orderId])
  @@index([status])
}

enum PaymentAttemptStatus {
  INITIATED
  AUTHORIZING
  AUTHORIZED
  CAPTURED
  FAILED
  CANCELED
}

model PaymentTransaction {
  id           String   @id @default(cuid())
  attemptId    String
  storeId      String
  type         PaymentTransactionType
  amount       Int
  currency     String
  providerReference String?
  createdAt    DateTime @default(now())

  attempt      PaymentAttempt @relation(fields: [attemptId], references: [id])

  @@index([storeId, attemptId])
}

enum PaymentTransactionType {
  AUTH
  CAPTURE
  REFUND
  VOID
}

State Machine Rules

  • INITIATED → AUTHORIZING → AUTHORIZED → CAPTURED
  • AUTHORIZED → VOID allowed
  • CAPTURED → REFUND (multi refund allowed up to captured amount)
  • Any → FAILED (terminal)

Dependencies

Metrics

  • Mean auth latency < 3s
  • Double-charge incidents = 0
  • Reconciliation discrepancies < 0.5% of attempts

Testing Checklist

  • Idempotent attempt creation
  • Failed authorization triggers retry scheduling
  • Capture only after AUTHORIZED
  • Refund limited to remaining refundable amount
  • Audit log written for transitions

Risk

High financial & integrity risk if absent (score: 18). Addresses double-charging & orphaned payment states.

References

  • docs/GITHUB_ISSUES_COMPARISON_ANALYSIS.md (risk matrix section)
  • Stripe Payment Intent lifecycle
    </issue_description>

<agent_instructions>review all the #codebase and implement very carefully</agent_instructions>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel
Copy link

vercel bot commented Nov 29, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
stormcomui Error Error Dec 1, 2025 6:17pm

- Add Prisma models for PaymentAttempt and PaymentTransaction
- Add PaymentAttemptStatus and PaymentTransactionType enums
- Create database migration with proper indexes
- Implement PaymentService with state machine validation
  - Idempotent attempt creation with idempotency key
  - State transitions: INITIATED→AUTHORIZING→AUTHORIZED→CAPTURED
  - Double-capture prevention (409 conflict)
  - Refund flow with amount validation
  - Void authorization support
  - Reconciliation for stuck payments (>15m in AUTHORIZING)
- Add API endpoints:
  - POST /api/payments/attempt (idempotent)
  - POST /api/payments/capture
  - POST /api/payments/refund
  - GET/POST /api/payments/reconciliation
  - GET /api/payments (list by order)
- Integrate with audit log service for state transitions

Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
- Fix void error message to correctly list allowed states
- Use reliable idempotency detection via pre-check database query
- Add currency normalization in schema transform (consistent handling)

Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
AUTHORIZING is not a valid state for void (cancel) operations per the
state machine rules. Only INITIATED and AUTHORIZED can transition to
CANCELED.

Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
- Use generic error message for idempotency key conflict (security)
- Return isExisting flag from createAttempt to avoid redundant query
- Add CreatePaymentAttemptResult type for cleaner API

Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
Copilot AI changed the title [WIP] Add PaymentAttempt and PaymentTransaction state models [Phase 1] PaymentAttempt & PaymentTransaction State Machine Nov 29, 2025
Copilot AI requested a review from AshrafAbir November 29, 2025 03:20
@AshrafAbir
Copy link
Contributor

@copilot implemet alll the task with api ui and db

- Add /dashboard/payments page with server-side auth
- Create PaymentsPageClient component with store selector
- Create PaymentsTable component with:
  - Status filtering (INITIATED, AUTHORIZING, AUTHORIZED, etc.)
  - Search by ID, order, provider
  - Payment details dialog with capture/refund actions
  - Transaction history display
- Create ReconciliationCard component showing:
  - Stuck payments monitoring status
  - Ability to run reconciliation job
  - Visual feedback for healthy/unhealthy state
- Add Payments link to sidebar navigation with credit card icon

Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Dec 1, 2025

@copilot implemet alll the task with api ui and db

Implemented the complete payment state machine with API, UI, and DB in commit a6d1ae5:

Database: PaymentAttempt and PaymentTransaction models with Prisma enums, indexes, and unique constraints.

API Endpoints:

  • POST /api/payments/attempt - Idempotent creation
  • POST /api/payments/capture - With 409 double-capture prevention
  • POST /api/payments/refund - Amount validation
  • GET/POST /api/payments/reconciliation - Stuck payment detection

Dashboard UI (/dashboard/payments):

  • Payment attempts table with status filtering and search
  • Reconciliation status card with monitoring
  • Details dialog with capture/refund actions
  • Sidebar navigation link

Payments Dashboard

- Remove duplicate Payments navigation entry in sidebar
- Fix HTML entities in reconciliation card text
- Add bulk API endpoint to fetch all store payments (fixes N+1 query)
- Update PaymentsTable to use single API call instead of per-order requests

Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements Phase 1 of a payment state machine for the StormCom e-commerce platform, introducing atomic and auditable payment flows with idempotent state transitions to prevent double-charging and enable reconciliation.

Key Changes:

  • Introduces PaymentAttempt and PaymentTransaction database models with a state machine (INITIATED → AUTHORIZING → AUTHORIZED → CAPTURED → FAILED/CANCELED)
  • Implements PaymentService singleton with transition validation, idempotent payment creation, and reconciliation capabilities
  • Adds 5 API endpoints for payment operations: attempt creation, capture, refund, reconciliation (GET/POST), and listing
  • Creates a payments dashboard with table, reconciliation status card, and store selector integration

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
src/lib/services/payment.service.ts Core payment service implementing state machine logic, idempotency, and audit logging
src/components/payments-table.tsx Client component for displaying and managing payment attempts with filtering
src/components/payments-reconciliation-card.tsx Dashboard card showing stuck payment attempts requiring reconciliation
src/components/payments-page-client.tsx Main client wrapper orchestrating store selection and payment components
src/components/app-sidebar.tsx Adds "Payments" navigation link with credit card icon
src/app/dashboard/payments/page.tsx Protected server page for payments dashboard
src/app/api/payments/route.ts GET endpoint for listing payment attempts by store/order
src/app/api/payments/attempt/route.ts POST endpoint for idempotent payment attempt creation
src/app/api/payments/capture/route.ts POST endpoint for capturing authorized payments with double-capture prevention
src/app/api/payments/refund/route.ts POST endpoint for refunding captured payments with validation
src/app/api/payments/reconciliation/route.ts GET/POST endpoints for detecting and logging stuck payment attempts
prisma/schema.sqlite.prisma Adds PaymentAttempt and PaymentTransaction models with enums and indexes
prisma/schema.prisma PostgreSQL schema with same payment models
prisma/migrations/20251129025911_add_payment_state_machine/migration.sql Database migration creating payment tables

Comment on lines +27 to +37
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);
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 capture endpoint doesn't verify the user has access to the specified storeId. An authenticated user could capture payments for any store.

Add a check to verify the user has access to the requested store before processing the capture.

Copilot uses AI. Check for mistakes.
// Try to get from body for POST
try {
const body = await request.json();
if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number') {
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 timeoutMinutes parameter parsing allows negative or zero values when parsed from the query string but checks > 0 (line 36). However, the body parsing (lines 42-46) doesn't perform this validation, allowing negative values to be passed through.

Add validation for the body-parsed timeoutMinutes to ensure it's positive: if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number' && body.timeoutMinutes > 0)

Suggested change
if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number') {
if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number' && body.timeoutMinutes > 0) {

Copilot uses AI. Check for mistakes.
model PaymentAttempt {
id String @id @default(cuid())
storeId String
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.
// 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.
// 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.
Comment on lines +59 to +89
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,
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.

Inconsistent behavior: The API limits results to 100 most recent attempts (line 59) but returns total: attempts.length (line 89), which will never exceed 100. This makes the total field misleading - it represents the count of returned attempts, not the total count available.

Either remove the limit and implement proper pagination, or add a separate query to get the true total count and return both total (all records) and returned (limited count).

Copilot uses AI. Check for mistakes.
storeId: searchParams.get('storeId'),
orderId: searchParams.get('orderId') || undefined,
});

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 API verifies the user is authenticated but doesn't verify they have access to the specified storeId. An authenticated user could query payment attempts for any store by changing the storeId parameter.

Add a check to verify the user has access to the requested store, similar to how other endpoints likely verify store membership through the session or database.

Suggested change
// Authorization check: ensure user has access to the requested store
// Assumes: Store has organizationId, Membership links userId+organizationId
const store = await prisma.store.findUnique({
where: { id: params.storeId },
select: { organizationId: true },
});
if (!store) {
return NextResponse.json({ error: 'Store not found' }, { status: 404 });
}
const membership = await prisma.membership.findUnique({
where: {
userId_organizationId: {
userId: session.user.id,
organizationId: store.organizationId,
},
},
});
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +52
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,
});
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.
Comment on lines +526 to +530
if (attempt.status === PaymentAttemptStatus.CAPTURED) {
const error = new Error('Payment already captured');
(error as Error & { code: string }).code = 'ALREADY_CAPTURED';
throw error;
}
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.

Potential race condition: The double-capture check (line 526) happens outside the database transaction that starts at line 547. If two capture requests arrive simultaneously, both could pass the check before either updates the status, leading to double-capture.

Move the status check inside the $transaction block or use a database-level constraint to prevent concurrent captures.

Copilot uses AI. Check for mistakes.
Comment on lines +194 to +199
useEffect(() => {
if (storeId) {
fetchPayments();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeId, statusFilter]);
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 dependency in useEffect: The fetchPayments function is called in the useEffect but depends on searchQuery (line 169). However, searchQuery is not included in the dependency array (line 199), which means the search won't automatically trigger when the user types. Users must submit the form explicitly.

If automatic search on typing is desired, add searchQuery to the dependency array. Otherwise, the current implementation (explicit form submission) is acceptable but may not meet user expectations for a search field.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

[Phase 1] PaymentAttempt & PaymentTransaction State Machine

2 participants