Payment Intent
Payment intents represent a payment request to the payment gateway (Razorpay). They are created during checkout and serve as the bridge between the checkout session and actual payment processing.
Payment Intent Overview
What is a Payment Intent?
A payment intent is a record of a payment request that:
- Represents a pending payment transaction
- Links checkout sessions to payment gateway orders
- Ensures idempotent payment processing
- Tracks payment status through webhooks
Payment Intent Lifecycle
Payment Intent Creation
Creation Flow
Idempotent Creation
Payment intents are created idempotently to prevent duplicates:
async createPaymentIntent(
checkoutSessionId: string,
amount: number,
currency: string = "INR",
): Promise<PaymentIntent> {
// Assert checkout state is LOCKED
await this.checkoutStore.assertState(
checkoutSessionId,
CheckoutState.LOCKED,
);
// Create or get payment intent atomically
return await this.checkoutStore.createOrGetPaymentIntent(
checkoutSessionId,
async () => {
// Only called if payment intent doesn't exist
const razorpayOrder = await this.razorpay.orders.create({
amount: amount, // Amount in paise
currency: currency,
receipt: checkoutSessionId,
notes: {
checkoutSessionId,
order_number: `pending-${Date.now()}`,
},
});
return {
paymentProvider: "razorpay",
paymentIntentId: razorpayOrder.id,
status: PaymentIntentStatus.CREATED,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
},
);
}
Payment Intent Data Structure
Payment Intent Entity
interface PaymentIntent {
paymentProvider: "razorpay";
paymentIntentId: string; // Razorpay order ID
status: PaymentIntentStatus;
createdAt: string;
updatedAt: string;
}
Payment Intent Status
enum PaymentIntentStatus {
CREATED = "created", // Payment intent created
AUTHORIZED = "authorized", // Payment authorized
CAPTURED = "captured", // Payment captured
FAILED = "failed", // Payment failed
}
Storage in Redis
Key Patterns
// Payment intent by checkout session
payment:intent:{checkoutSessionId} → PaymentIntent
// Reverse lookup: checkout session by payment intent
payment:intent:by-id:{paymentIntentId} → checkoutSessionId
TTL Strategy
- Payment Intent TTL: 30 minutes (matches checkout session)
- Reverse Lookup TTL: Same as payment intent
- Automatic Cleanup: Expired intents cleaned up automatically
Amount Calculation
Total Amount Components
const amountInPaise = Math.round(
(subtotalAfterDiscount + totalGstAmount + shippingCost) * 100
) + paymentFee;
Components:
- Subtotal: Sum of item prices after discounts
- GST: Total GST amount (CGST + SGST or IGST)
- Shipping Cost: Shipping charges
- Payment Fee: Payment gateway fee (in paise)
Amount Verification
// Verify amount includes fee before creating payment intent
const expectedAmount =
Math.round(
(subtotalAfterDiscount + totalGstAmount + shippingCost) * 100
) + paymentFee;
if (amountInPaise !== expectedAmount) {
throw new ConflictException(
"Payment intent amount calculation error - fee must be included"
);
}
Payment Intent States
State Transitions
CREATED → AUTHORIZED → CAPTURED
↓ ↓
FAILED FAILED
State Meanings
- CREATED: Payment intent created, waiting for customer payment
- AUTHORIZED: Payment authorized by customer, awaiting capture
- CAPTURED: Payment captured successfully, order can be created
- FAILED: Payment failed or was declined
Atomic Creation with Lua Script
Lua Script Purpose
Prevents race conditions during concurrent payment intent creation:
-- Atomic payment intent creation
-- KEYS[1] = payment:intent:{checkoutSessionId}
-- KEYS[2] = payment:intent:by-id:{paymentIntentId}
-- ARGV[1] = paymentIntentJson
-- ARGV[2] = checkoutSessionId
-- ARGV[3] = ttlSeconds
local intentKey = KEYS[1]
local reverseLookupKey = KEYS[2]
local paymentIntentJson = ARGV[1]
local checkoutSessionId = ARGV[2]
local ttlSeconds = tonumber(ARGV[3])
-- Check if payment intent already exists
local existing = redis.call('GET', intentKey)
if existing then
return {'ok', 'EXISTS', existing}
end
-- Create atomically using SET NX
local created = redis.call('SET', intentKey, paymentIntentJson, 'EX', ttlSeconds, 'NX')
if created == 'OK' then
redis.call('SET', reverseLookupKey, checkoutSessionId, 'EX', ttlSeconds)
return {'ok', 'CREATED', paymentIntentJson}
else
local concurrent = redis.call('GET', intentKey)
if concurrent then
return {'ok', 'EXISTS', concurrent}
else
return {'err', 'RACE_CONDITION'}
end
end
Payment Intent Retrieval
Get by Checkout Session
async getPaymentIntent(
checkoutSessionId: string,
): Promise<PaymentIntent | null> {
const key = `payment:intent:${checkoutSessionId}`;
const data = await this.client.get(key);
return data ? JSON.parse(data) : null;
}
Get by Payment Intent ID
async getCheckoutSessionByPaymentIntent(
paymentIntentId: string,
): Promise<string | null> {
const reverseKey = `payment:intent:by-id:${paymentIntentId}`;
return await this.client.get(reverseKey);
}
Integration with Razorpay
Razorpay Order Creation
const razorpayOrder = await this.razorpay.orders.create({
amount: amountInPaise, // Amount in paise (smallest currency unit)
currency: "INR",
receipt: checkoutSessionId, // Unique receipt ID
notes: {
checkoutSessionId,
order_number: `pending-${Date.now()}`,
payment_fee: paymentFee.toString(),
payment_method: paymentMethod || "unknown",
},
});
Razorpay Order Response
{
id: "order_abc123", // Payment intent ID
entity: "order",
amount: 100000, // Amount in paise
amount_paid: 0,
amount_due: 100000,
currency: "INR",
receipt: "checkout-session-123",
status: "created",
attempts: 0,
created_at: 1234567890
}
Payment Intent Updates
Status Updates via Webhooks
Payment intent status is updated when webhooks are received:
// Payment captured webhook
case "payment.captured":
await this.handlePaymentCaptured(webhookEvent);
// Updates payment intent status to CAPTURED
// Creates order from checkout session
break;
Manual Status Updates
async updatePaymentIntentStatus(
checkoutSessionId: string,
status: PaymentIntentStatus,
): Promise<void> {
const intent = await this.getPaymentIntent(checkoutSessionId);
if (intent) {
intent.status = status;
intent.updatedAt = new Date().toISOString();
await this.setPaymentIntent(checkoutSessionId, intent);
}
}
Error Handling
Creation Failures
try {
const paymentIntent = await this.createPaymentIntent(...);
} catch (error) {
// Handle creation failure
// Release inventory reservations
// Unlock cart
// Return error to client
}
Razorpay Errors
// Common Razorpay errors
- "Invalid amount" → Amount validation failed
- "Invalid currency" → Currency not supported
- "Order creation failed" → Razorpay API error
Payment Intent Cleanup
Expiration
Payment intents expire after 30 minutes:
- Automatic: Redis TTL handles expiration
- Manual: Can be cleaned up via admin tools
- Failed Intents: Cleaned up after failure handling
Cleanup Process
// Cleanup expired payment intents
async cleanupExpiredIntents(): Promise<void> {
// Find expired intents
// Clean up reverse lookups
// Log cleanup actions
}
API Endpoints
Create Payment Intent
POST /orders
Content-Type: application/json
X-Session-Id: {session-id}
{
"email": "customer@example.com",
"name": "Customer Name",
"shippingAddressId": "address-123",
"billingAddressId": "address-123",
"shippingCost": 50.0
}
Response:
{
"paymentIntent": {
"paymentProvider": "razorpay",
"paymentIntentId": "order_abc123",
"status": "created"
},
"orderId": null,
"redirectUrl": "https://razorpay.com/checkout/..."
}
Get Payment Intent
GET /checkout/sessions/{sessionId}/payment-intent
Security Considerations
Idempotency
- Payment intents are idempotent per checkout session
- Prevents duplicate payment requests
- Ensures exactly one payment intent per checkout
Signature Verification
- Webhook signatures verified before processing
- Payment intent updates only via verified webhooks
- Prevents unauthorized status changes
Best Practices
- Idempotent Creation: Always check for existing payment intent
- Amount Verification: Verify amount includes all fees
- Error Handling: Handle Razorpay errors gracefully
- Status Tracking: Track payment intent status accurately
- Cleanup: Clean up expired/failed payment intents
Edge Cases
Concurrent Creation
- Lua script ensures atomic creation
- Only one payment intent created per checkout session
- Concurrent requests return existing intent
Payment Intent Expiration
- Expired intents cannot be used for payment
- Customer must restart checkout
- Old payment intents cleaned up automatically
Razorpay Order Already Exists
- Razorpay may return existing order
- System handles gracefully
- Payment intent linked to existing order