-
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?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- 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 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>
Implemented the complete payment state machine with API, UI, and DB in commit Database: API Endpoints:
Dashboard UI (
|
- 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>
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.
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
PaymentAttemptandPaymentTransactiondatabase models with a state machine (INITIATED → AUTHORIZING → AUTHORIZED → CAPTURED → FAILED/CANCELED) - Implements
PaymentServicesingleton 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 |
| 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); |
Copilot
AI
Dec 1, 2025
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.
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.
| // Try to get from body for POST | ||
| try { | ||
| const body = await request.json(); | ||
| if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number') { |
Copilot
AI
Dec 1, 2025
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 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)
| if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number') { | |
| if (body.timeoutMinutes && typeof body.timeoutMinutes === 'number' && body.timeoutMinutes > 0) { |
| model PaymentAttempt { | ||
| id String @id @default(cuid()) | ||
| storeId String | ||
| orderId String |
Copilot
AI
Dec 1, 2025
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.
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[]
| // PaymentAttempt model - tracks individual payment attempts with state machine | ||
| model PaymentAttempt { | ||
| id String @id @default(cuid()) | ||
| storeId String |
Copilot
AI
Dec 1, 2025
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.
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[]
| // PaymentAttempt model - tracks individual payment attempts with state machine | ||
| model PaymentAttempt { | ||
| id String @id @default(cuid()) | ||
| storeId String |
Copilot
AI
Dec 1, 2025
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.
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[]
| 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, |
Copilot
AI
Dec 1, 2025
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.
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).
| storeId: searchParams.get('storeId'), | ||
| orderId: searchParams.get('orderId') || undefined, | ||
| }); | ||
|
|
Copilot
AI
Dec 1, 2025
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.
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.
| // 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 }); | |
| } |
| 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, | ||
| }); |
Copilot
AI
Dec 1, 2025
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.
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.
| if (attempt.status === PaymentAttemptStatus.CAPTURED) { | ||
| const error = new Error('Payment already captured'); | ||
| (error as Error & { code: string }).code = 'ALREADY_CAPTURED'; | ||
| throw error; | ||
| } |
Copilot
AI
Dec 1, 2025
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.
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.
| useEffect(() => { | ||
| if (storeId) { | ||
| fetchPayments(); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [storeId, statusFilter]); |
Copilot
AI
Dec 1, 2025
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.
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.

[Phase 1] PaymentAttempt & PaymentTransaction State Machine
Introduces atomic, auditable payment flows with idempotent state transitions to prevent double-charging and enable reconciliation.
Screenshot
Data Model
providerReferenceandidempotencyKey[storeId, orderId],status,createdAtState Machine
API Endpoints
POST /api/payments/attempt— Idempotent creation viaIdempotency-KeyheaderPOST /api/payments/capture— Returns 409 on double-capturePOST /api/payments/refund— Validates amount ≤ remaining refundableGET/POST /api/payments/reconciliation— Flags attempts stuck >15min in AUTHORIZINGGET /api/payments— List all payments for store (orderId optional filter)Dashboard UI
/dashboard/payments) — View and manage payment attemptsService Layer
PaymentServicesingleton with transition validationcreateAttempt()returns{ attempt, isExisting }for idempotencyfailAuthorization()incrementsattemptCount, stores error, schedules retryAuditLogServicewith actionPAYMENT_STATE_CHANGEstoreIdFixes #63
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.