-
Notifications
You must be signed in to change notification settings - Fork 0
Add Stripe payment integration with webhook handling and refund processing #118
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.
|
…mponents Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com>
Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com>
Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com>
Co-authored-by: rafiqul4 <124497017+rafiqul4@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 comprehensive Stripe payment integration for the StormCom multi-tenant e-commerce platform, enabling secure checkout sessions, webhook-driven payment confirmation, and refund processing with multi-currency support and Stripe Connect for marketplace functionality.
Key Changes:
- Database Schema: New
PaymentAttemptandRefundmodels for tracking payment lifecycle with Stripe integration points; added Stripe Connect fields toStoremodel andpaidAttimestamp toOrder - Payment Service: Complete payment processing service with checkout session creation, refund management with idempotency, and multi-currency support (USD, BDT, EUR, GBP, etc.)
- Webhook Handler: Secure webhook endpoint with signature verification processing payment lifecycle events (checkout completion, payment success/failure, refunds)
- API Routes: Authenticated checkout session creation endpoint with organization membership validation
- Frontend Component: React checkout button with loading states and error handling for Stripe redirect flow
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
src/lib/services/payment.service.ts |
Core payment service implementing checkout session creation, refund processing with Stripe API integration and Stripe Connect support |
src/lib/services/order-processing.service.ts |
Simplified refund logic to delegate to payment service, replacing 112 lines of inline Stripe code with service call |
src/components/checkout-button.tsx |
Client-side checkout button component handling session creation API calls and Stripe redirect flow |
src/app/api/webhooks/stripe/route.ts |
Webhook handler with signature verification processing checkout completion, payment success/failure, and refund events |
src/app/api/payments/create-session/route.ts |
Protected API endpoint for creating Stripe checkout sessions with organization membership authorization |
prisma/schema.prisma |
Added PaymentAttempt and Refund models with status enums; extended Store with Stripe Connect fields and Order with payment timestamp |
prisma/migrations/20251211112500_add_stripe_payment_integration/migration.sql |
PostgreSQL migration creating PaymentAttempt and Refund tables with indexes on orderId, storeId, externalId, and status |
docs/STRIPE_PAYMENT_INTEGRATION.md |
Complete implementation guide covering setup, configuration, testing, and usage examples |
docs/IMPLEMENTATION_SUMMARY.md |
Detailed summary of implementation decisions, architecture, testing strategy, and success criteria |
.env.example |
Added Stripe configuration variables (secret key, publishable key, webhook secret) with documentation |
| await prisma.order.update({ | ||
| where: { id: orderId }, | ||
| data: { | ||
| status: "PAID", | ||
| paymentStatus: "PAID", | ||
| paidAt: new Date(), | ||
| stripePaymentIntentId: paymentIntentId, | ||
| }, | ||
| }); |
Copilot
AI
Dec 11, 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 order status is updated to PAID in the webhook handler, but there's no check if it's already been paid. If the webhook is replayed or delivered multiple times (which Stripe may do), this could cause duplicate processing or inconsistent state. Add a check to ensure the order is in an appropriate state (e.g., PENDING) before transitioning to PAID.
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.
Fixed in commit 3811e36. Added order status check before updating to PAID. If already PAID, the webhook handler logs and returns early to prevent duplicate processing.
src/lib/services/payment.service.ts
Outdated
| if (order.store.stripeSecretKey && order.store.stripeAccountId) { | ||
| // Use store-specific Stripe instance for Stripe Connect | ||
| const storeStripe = new Stripe(order.store.stripeSecretKey, { | ||
| apiVersion: "2025-11-17.clover", | ||
| typescript: true, | ||
| }); | ||
| session = await storeStripe.checkout.sessions.create(sessionOptions); |
Copilot
AI
Dec 11, 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.
When using store-specific Stripe credentials, the store's stripeAccountId is not included in the session creation options. For Stripe Connect, you typically need to either use the store's Stripe instance OR add stripeAccount to requestOptions when using the platform instance. Currently, a new Stripe instance is created with store credentials but the session is created without the account context, which may not work as intended for connected accounts.
| if (order.store.stripeSecretKey && order.store.stripeAccountId) { | |
| // Use store-specific Stripe instance for Stripe Connect | |
| const storeStripe = new Stripe(order.store.stripeSecretKey, { | |
| apiVersion: "2025-11-17.clover", | |
| typescript: true, | |
| }); | |
| session = await storeStripe.checkout.sessions.create(sessionOptions); | |
| if (order.store.stripeAccountId) { | |
| // Use platform Stripe instance with stripeAccount for Stripe Connect | |
| session = await stripe.checkout.sessions.create(sessionOptions, { | |
| stripeAccount: order.store.stripeAccountId, | |
| }); |
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.
Fixed in commit 3811e36. Changed to use platform Stripe instance with stripeAccount option in requestOptions instead of creating store-specific instances, following Stripe Connect best practices.
| async function handleChargeRefunded(charge: Stripe.Charge) { | ||
| const paymentIntentId = charge.payment_intent as string; | ||
|
|
||
| console.log(`[Stripe Webhook] Processing charge.refunded for payment_intent ${paymentIntentId}`); | ||
|
|
||
| try { | ||
| // Find refund record by payment intent | ||
| const refund = await prisma.refund.findFirst({ | ||
| where: { | ||
| paymentAttempt: { | ||
| externalId: paymentIntentId, | ||
| }, | ||
| status: "PENDING", | ||
| }, | ||
| orderBy: { | ||
| createdAt: "desc", | ||
| }, | ||
| }); | ||
|
|
||
| if (refund) { | ||
| await prisma.refund.update({ | ||
| where: { id: refund.id }, | ||
| data: { | ||
| status: "COMPLETED", | ||
| processedAt: new Date(), | ||
| }, | ||
| }); | ||
|
|
||
| console.log(`[Stripe Webhook] Refund completed: ${refund.id}`); | ||
| } else { | ||
| console.log(`[Stripe Webhook] No pending refund found for payment_intent ${paymentIntentId}`); | ||
| } |
Copilot
AI
Dec 11, 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 handleChargeRefunded function doesn't extract the orderId or storeId to add to the audit log. The refund lookup finds a pending refund by payment intent, but doesn't use the order information for logging. This makes it difficult to track which order was refunded in audit logs. Consider fetching the order details and creating an audit log entry similar to handleCheckoutCompleted.
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.
Fixed in commit 3811e36. Added order details to refund lookup and created audit log entry for REFUND_COMPLETED action with storeId, orderId, and refund details.
| }, | ||
| }, |
Copilot
AI
Dec 11, 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 order.items query doesn't include the variant relationship needed for inventory restoration. Line 172 includes product but not variant, yet the inventory restoration logic needs to check for variants to restore the correct inventory (see the old implementation that was removed which checked item.variantId). Add variant to the include statement on line 172.
| }, | |
| }, | |
| variant: true, | |
| }, |
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.
Fixed in commit 3811e36. Added variant with inventoryQty to the items include statement. This data is now used for proper variant inventory restoration logic.
| return { success: true, refund }; | ||
| } catch (error) { | ||
| console.error('Refund processing error:', error); | ||
| throw error; |
Copilot
AI
Dec 11, 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 error handling catches all errors and re-throws them (line 411), but the calling code in the API route also catches errors and returns generic error responses. This means the specific error from processRefund (like "Cannot refund X. Available balance: Y") will be exposed to the client. While helpful for debugging, consider whether detailed balance information should be exposed in production, as it could reveal business logic to malicious actors.
| throw error; | |
| throw new Error('Refund processing failed'); |
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.
Fixed in commit 3811e36. Changed error handling to throw generic "Refund processing failed" message instead of exposing detailed balance information to clients.
| await prisma.auditLog.create({ | ||
| data: { | ||
| action: "PAYMENT_COMPLETED", | ||
| entityType: "Order", | ||
| entityId: orderId, | ||
| changes: JSON.stringify({ | ||
| paymentIntentId, | ||
| amount: session.amount_total, | ||
| currency: session.currency, | ||
| }), | ||
| }, | ||
| }); |
Copilot
AI
Dec 11, 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 audit log creation is missing critical fields. The AuditLog model typically requires userId and storeId for proper multi-tenant tracking and audit trails. Currently, only action, entityType, entityId, and changes are provided. Without userId, you cannot track who initiated the payment, and without storeId, multi-tenant filtering becomes difficult.
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.
Fixed in commit 3811e36. Added storeId to audit log entries by fetching order details first. Now properly tracks which store the payment belongs to for multi-tenant audit trails.
src/lib/services/payment.service.ts
Outdated
| // Create refund record in database | ||
| const refundRecord = await prisma.refund.create({ | ||
| data: { | ||
| orderId, | ||
| storeId: order.storeId, | ||
| paymentAttemptId: paymentAttempt.id, | ||
| amount, | ||
| status: refund.status === "succeeded" ? "COMPLETED" : "PENDING", | ||
| externalId: refund.id, | ||
| reason, | ||
| processedAt: refund.status === "succeeded" ? new Date() : null, | ||
| }, | ||
| }); | ||
|
|
||
| // Update order status and restore inventory if fully refunded | ||
| if (amount === refundableBalance) { | ||
| await prisma.$transaction(async (tx) => { | ||
| // Update order status to REFUNDED | ||
| await tx.order.update({ | ||
| where: { id: orderId }, | ||
| data: { | ||
| status: "REFUNDED", | ||
| refundedAmount: order.totalAmount, | ||
| }, | ||
| }); | ||
|
|
||
| // Restore inventory for each order item | ||
| for (const item of order.items) { | ||
| if (item.product) { | ||
| await tx.product.update({ | ||
| where: { id: item.product.id }, | ||
| data: { inventoryQty: { increment: item.quantity } }, | ||
| }); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
Copilot
AI
Dec 11, 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 transaction for full refunds updates order status and restores inventory, but this is not atomic with the Stripe refund API call that happens before it (lines 238-250). If the Stripe refund succeeds but the database transaction fails, you'll have issued a refund without updating your records. Consider restructuring to create the refund record first with PENDING status, then update to COMPLETED in the webhook handler when charge.refunded is received.
| // Create refund record in database | |
| const refundRecord = await prisma.refund.create({ | |
| data: { | |
| orderId, | |
| storeId: order.storeId, | |
| paymentAttemptId: paymentAttempt.id, | |
| amount, | |
| status: refund.status === "succeeded" ? "COMPLETED" : "PENDING", | |
| externalId: refund.id, | |
| reason, | |
| processedAt: refund.status === "succeeded" ? new Date() : null, | |
| }, | |
| }); | |
| // Update order status and restore inventory if fully refunded | |
| if (amount === refundableBalance) { | |
| await prisma.$transaction(async (tx) => { | |
| // Update order status to REFUNDED | |
| await tx.order.update({ | |
| where: { id: orderId }, | |
| data: { | |
| status: "REFUNDED", | |
| refundedAmount: order.totalAmount, | |
| }, | |
| }); | |
| // Restore inventory for each order item | |
| for (const item of order.items) { | |
| if (item.product) { | |
| await tx.product.update({ | |
| where: { id: item.product.id }, | |
| data: { inventoryQty: { increment: item.quantity } }, | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| // Create refund record in database with PENDING status before calling Stripe | |
| const refundRecord = await prisma.refund.create({ | |
| data: { | |
| orderId, | |
| storeId: order.storeId, | |
| paymentAttemptId: paymentAttempt.id, | |
| amount, | |
| status: "PENDING", | |
| externalId: null, | |
| reason, | |
| processedAt: null, | |
| }, | |
| }); | |
| // Issue refund via Stripe API | |
| const refund = await stripe.refunds.create( | |
| { | |
| payment_intent: paymentAttempt.externalId, | |
| amount: Math.round(amount * 100), // Stripe expects amount in cents | |
| reason: reason as any, | |
| metadata: { | |
| refundRecordId: refundRecord.id, | |
| orderId: orderId, | |
| }, | |
| }, | |
| { | |
| idempotencyKey: params.idempotencyKey, | |
| } | |
| ); | |
| // Optionally, update the refund record with the Stripe refund ID (externalId) | |
| await prisma.refund.update({ | |
| where: { id: refundRecord.id }, | |
| data: { | |
| externalId: refund.id, | |
| }, | |
| }); | |
| // NOTE: Do NOT update order status or inventory here. | |
| // This should be handled in the Stripe webhook handler when the refund is confirmed. |
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.
Fixed in commit 3811e36. Refactored to create refund record with PENDING status before Stripe API call. Record is updated with externalId and status after Stripe response. Inventory restoration only happens if refund succeeds immediately, otherwise handled by webhook.
src/lib/services/payment.service.ts
Outdated
| where: { id: orderId }, | ||
| data: { | ||
| status: "REFUNDED", | ||
| refundedAmount: order.totalAmount, |
Copilot
AI
Dec 11, 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 refundedAmount field is set to order.totalAmount for full refunds, but this doesn't account for scenarios where multiple partial refunds could eventually total the full amount. The field should be incremented rather than set to a fixed value. Consider using: refundedAmount: { increment: amount } to properly track cumulative refunds.
| refundedAmount: order.totalAmount, | |
| refundedAmount: { increment: amount }, |
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.
Fixed in commit 3811e36. Changed refundedAmount from set to increment: { increment: amount } to properly track cumulative refunds across multiple partial refunds.
src/lib/services/payment.service.ts
Outdated
| const order = await prisma.order.findUnique({ | ||
| where: { id: orderId }, |
Copilot
AI
Dec 11, 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 storeId in the where clause for the order lookup. While multi-tenancy filtering is not strictly necessary here since we're fetching by unique ID, it's a security best practice to always filter by storeId to prevent potential data leakage. The repository guidelines emphasize always filtering by both userId AND organizationId (or storeId) for multi-tenant queries.
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.
Fixed in commit 3811e36. Changed to findFirst with storeId filter following repository multi-tenancy security best practices to prevent potential data leakage.
| // Redirect to Stripe Checkout | ||
| window.location.href = sessionUrl; | ||
| } catch (error) { | ||
| console.error("[CheckoutButton] Error:", error); | ||
|
|
||
| const errorMessage = error instanceof Error | ||
| ? error.message | ||
| : "Failed to start checkout"; | ||
|
|
||
| toast.error(errorMessage); | ||
| setLoading(false); | ||
| } |
Copilot
AI
Dec 11, 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 CheckoutButton component doesn't reset the loading state if the redirect fails or is blocked. After setting window.location.href (line 54), if the redirect doesn't happen (e.g., popup blocker, navigation error), the button will remain in the loading state indefinitely. While rare, consider adding a timeout fallback or error handling for failed redirects.
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.
Fixed in commit 3811e36. Added 5-second timeout fallback that checks if document still has focus, then resets loading state and shows error toast if redirect failed (e.g., popup blocker).
|
@copilot apply changes based on the comments in this thread |
…handling, refund atomicity, and inventory restoration Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com>
Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com>
All 18 code review comments have been addressed in commit 3811e36. Key fixes include: Security & Validation:
Payment Processing:
Refund Processing:
Audit & Monitoring:
Frontend:
Detailed documentation available in |
|
@copilot fix vercel deploiyment issue |
…e local Prisma CLI Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com>
Fixed in commit 793166b. The Vercel deployment issues were caused by:
Build now succeeds with placeholder values from |
Stripe Payment Integration - Phase 1 ✅ COMPLETE
Implementation Checklist
Database Schema Updates ✅
Core Payment Service ✅
Webhook Infrastructure ✅
Payment API Routes ✅
Frontend Components ✅
Environment Configuration ✅
Documentation ✅
Code Quality & Validation ✅
Code Review Fixes ✅ (18 Comments Addressed)
Deployment Fixes ✅
Vercel Deployment Fix (Commit [commit_hash])
Issue
Vercel builds were failing due to:
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETwere validated at module initialization, causing build failures when environment variables weren't setnpx prisma generatewhich pulled Prisma v7, incompatible with project's v6.19.0Solution
1. Runtime-Only Validation
Changed Stripe environment variable validation from module-level (throws at import) to runtime (throws when routes/services are used):
Before (breaks build):
After (build-safe):
2. Local Prisma CLI
Changed build script from
npx prisma generatetonode node_modules/.bin/prisma generateto use project's local Prisma v6.19.0 instead of npx's v7.1.0.Build Status
✅ Build now succeeds with placeholder environment variables from
.env.example✅ Runtime validation ensures Stripe features fail gracefully with 503 errors when not configured
✅ Backward compatible with existing deployments that have proper Stripe credentials
Code Review Changes Summary (Commit 3811e36)
Critical Fixes
Security Enhancements
Data Integrity
API Improvements
Frontend
Future Work Documented
Documentation Files
What's Ready for Testing
All code is implemented, code review comments addressed, and Vercel deployment issues fixed. Ready for manual testing:
npx prisma migrate deployAll code review comments addressed + Vercel deployment fixed! 🚀
Original prompt
This section details on the original issue you should resolve
<issue_title>[Phase 1] Stripe Payment Integration</issue_title>
<issue_description>## Priority: P0
Phase: 1
Epic: #26 Payment Integration
Estimate: 2 days
Type: Story
Context
Stripe Payment Integration provides the payment processing infrastructure for StormCom, enabling secure credit/debit card payments, handling asynchronous webhooks for payment confirmation, and supporting refund processing. This implementation must handle idempotency, webhook signature verification, and seamless integration with the Order Processing API.
According to the research documentation, payment processing requires atomic transaction handling to prevent order creation without payment confirmation, proper webhook security to prevent spoofing attacks, and comprehensive error handling for declined payments.
Acceptance Criteria
checkout.session.completed,payment_intent.succeeded,charge.refundedevents with signature verification, idempotency key validationTechnical Implementation
1. Stripe Checkout Session Creation