Order Lifecycle
Understanding the order lifecycle helps you build robust payment integrations. This guide covers all order states, state transitions, and best practices for handling each stage.
Order States
DVPay orders progress through the following states:
State Diagram
stateDiagram-v2
[*] --> pending: Create Order
pending --> processing: Customer Scans QR
pending --> cancelled: Cancel Order
pending --> expired: 30min Timeout
processing --> paid: Payment Success
processing --> failed: Payment Failed
failed --> processing: Retry Payment
failed --> cancelled: Cancel Order
paid --> completed: Fulfill Order
paid --> refunded: Full Refund
paid --> partially_refunded: Partial Refund
partially_refunded --> refunded: Refund Remaining
completed --> [*]
cancelled --> [*]
refunded --> [*]
expired --> [*]
State Transitions
Creating an Order (→ pending)
When you create an order, it starts in pending state:
order, err := createOrder(CreateOrderRequest{
StoreID: "store-123",
Amount: 100.00,
Currency: "USD",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Order created: %s\n", order.OrderID)
fmt.Printf("Status: %s\n", order.Status) // "pending"
fmt.Printf("QR Code: %s\n", order.QRCode)
fmt.Printf("Expires at: %s\n", order.ExpiresAt)
const order = await createOrder({
store_id: 'store-123',
amount: 100.00,
currency: 'USD'
});
console.log(`Order created: ${order.order_id}`);
console.log(`Status: ${order.status}`); // "pending"
console.log(`QR Code: ${order.qr_code}`);
console.log(`Expires at: ${order.expires_at}`);
Next possible states:
processing- Customer scans QR codecancelled- Merchant cancels orderexpired- Payment window expires
Payment Processing (pending → processing)
When customer scans the QR code:
{
"event": "payment.processing",
"order_id": "ord_abc123",
"status": "processing",
"timestamp": 1696435200
}
Actions to take:
- Update UI to show "Processing payment..."
- Lock inventory if applicable
- Do not fulfill order yet
Next possible states:
paid- Payment succeedsfailed- Payment fails
Payment Success (processing → paid)
Payment completed successfully:
{
"event": "payment.success",
"order_id": "ord_abc123",
"status": "paid",
"amount": 100.00,
"currency": "USD",
"payment_method": "khqr",
"timestamp": 1696435205
}
func handlePaymentSuccess(webhook PaymentWebhook) {
// Update order status
updateOrderStatus(webhook.OrderID, "paid")
// Process fulfillment
fulfillOrder(webhook.OrderID)
// Send confirmation
sendConfirmationEmail(webhook.OrderID)
// Log transaction
logTransaction(webhook.OrderID, webhook.Amount, webhook.Currency)
}
async function handlePaymentSuccess(webhook) {
// Update order status
await updateOrderStatus(webhook.order_id, 'paid');
// Process fulfillment
await fulfillOrder(webhook.order_id);
// Send confirmation
await sendConfirmationEmail(webhook.order_id);
// Log transaction
await logTransaction(webhook.order_id, webhook.amount, webhook.currency);
}
Actions to take:
- Update order status in database
- Begin order fulfillment
- Send customer confirmation
- Update inventory
Next possible states:
completed- Order fulfilledrefunded- Full refund issuedpartially_refunded- Partial refund issued
Payment Failure (processing → failed)
Payment attempt failed:
{
"event": "payment.failed",
"order_id": "ord_abc123",
"status": "failed",
"failure_reason": "insufficient_funds",
"timestamp": 1696435205
}
func handlePaymentFailure(webhook PaymentWebhook) {
// Update order status
updateOrderStatus(webhook.OrderID, "failed")
// Release locked inventory
releaseInventory(webhook.OrderID)
// Notify customer
notifyPaymentFailed(webhook.OrderID, webhook.FailureReason)
// Log failure
logFailure(webhook.OrderID, webhook.FailureReason)
}
async function handlePaymentFailure(webhook) {
// Update order status
await updateOrderStatus(webhook.order_id, 'failed');
// Release locked inventory
await releaseInventory(webhook.order_id);
// Notify customer
await notifyPaymentFailed(webhook.order_id, webhook.failure_reason);
// Log failure
await logFailure(webhook.order_id, webhook.failure_reason);
}
Common failure reasons:
insufficient_funds- Customer wallet balance too lowpayment_declined- Payment rejected by providertimeout- Customer didn't complete payment in time
Next possible states:
processing- Customer retries paymentcancelled- Order cancelled
Order Completion (paid → completed)
Mark order as fulfilled:
POST /api/v1/payment-gateway/order/complete
{
"order_id": "ord_abc123"
}
Actions to take:
- Confirm delivery/service provided
- Archive order records
- Update analytics
Final state - No further transitions possible
Cancellation (pending/failed → cancelled)
Cancel an unpaid order:
curl -X POST https://merchant.dv.vai247.pro/api/v1/payment-gateway/order/cancel \
-H "X-App-Id: your-app-id" \
-H "X-Api-Key: your-api-key" \
-H "X-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{
"order_id": "ord_abc123",
"reason": "Customer requested cancellation"
}'
func cancelOrder(orderID, reason string) error {
// Check current status
order, err := getOrderDetails(orderID)
if err != nil {
return err
}
// Only cancel pending or failed orders
if order.Status != "pending" && order.Status != "failed" {
return errors.New("order cannot be cancelled")
}
payload := CancelRequest{
OrderID: orderID,
Reason: reason,
}
return processCancel(payload)
}
async function cancelOrder(orderId, reason) {
// Check current status
const order = await getOrderDetails(orderId);
// Only cancel pending or failed orders
if (!['pending', 'failed'].includes(order.status)) {
throw new Error('Order cannot be cancelled');
}
return await processCancel({
order_id: orderId,
reason: reason
});
}
Cancellable states:
pending- Before paymentfailed- After failed payment
Cannot cancel:
paid- Use refund insteadcompleted- Already finalizedcancelled- Already cancelled
Refunds (paid → refunded/partially_refunded)
Issue full or partial refunds:
// Full refund
func refundOrder(orderID string) error {
order, _ := getOrderDetails(orderID)
return processRefund(RefundRequest{
OrderID: orderID,
Amount: order.Amount, // Full amount
Currency: order.Currency,
Reason: "Customer requested refund",
})
}
// Partial refund
func partialRefund(orderID string, refundAmount float64) error {
order, _ := getOrderDetails(orderID)
if refundAmount >= order.Amount {
return errors.New("partial refund must be less than order amount")
}
return processRefund(RefundRequest{
OrderID: orderID,
Amount: refundAmount,
Currency: order.Currency,
Reason: "Partial refund requested",
})
}
// Full refund
async function refundOrder(orderId) {
const order = await getOrderDetails(orderId);
return await processRefund({
order_id: orderId,
amount: order.amount, // Full amount
currency: order.currency,
reason: 'Customer requested refund'
});
}
// Partial refund
async function partialRefund(orderId, refundAmount) {
const order = await getOrderDetails(orderId);
if (refundAmount >= order.amount) {
throw new Error('Partial refund must be less than order amount');
}
return await processRefund({
order_id: orderId,
amount: refundAmount,
currency: order.currency,
reason: 'Partial refund requested'
});
}
Refund rules:
- Only
paidorders can be refunded - Refunds processed in original currency
- Partial refunds can be issued multiple times
- Total refunds cannot exceed original amount
Expiration (pending → expired)
Orders expire after 30 minutes (default):
{
"event": "payment.expired",
"order_id": "ord_abc123",
"status": "expired",
"created_at": 1696435200,
"expired_at": 1696437000,
"timestamp": 1696437000
}
Actions to take:
- Release locked inventory
- Update order status
- Notify customer (optional)
Final state - No further transitions possible
Checking Order Status
Query Order Status
curl -X GET https://merchant.dv.vai247.pro/api/v1/payment-gateway/order/ord_abc123 \
-H "X-App-Id: your-app-id" \
-H "X-Api-Key: your-api-key" \
-H "X-Timestamp: $(date +%s)"
func getOrderStatus(orderID string) (string, error) {
order, err := getOrderDetails(orderID)
if err != nil {
return "", err
}
return order.Status, nil
}
// Usage
status, _ := getOrderStatus("ord_abc123")
fmt.Printf("Order status: %s\n", status)
async function getOrderStatus(orderId) {
const order = await getOrderDetails(orderId);
return order.status;
}
// Usage
const status = await getOrderStatus('ord_abc123');
console.log(`Order status: ${status}`);
Poll for Status Updates
For systems without webhooks:
async function pollOrderStatus(orderId, maxAttempts = 60) {
for (let i = 0; i < maxAttempts; i++) {
const status = await getOrderStatus(orderId);
if (['paid', 'failed', 'cancelled', 'expired'].includes(status)) {
return status; // Final state reached
}
// Wait 5 seconds before next check
await new Promise(resolve => setTimeout(resolve, 5000));
}
throw new Error('Order status check timeout');
}
// Usage
const finalStatus = await pollOrderStatus('ord_abc123');
State-Specific Actions
Actions by State
| State | Can Cancel | Can Refund | Can Complete | Should Fulfill |
|---|---|---|---|---|
pending | ✅ | ❌ | ❌ | ❌ |
processing | ❌ | ❌ | ❌ | ❌ |
paid | ❌ | ✅ | ✅ | ✅ |
completed | ❌ | ❌ | ❌ | N/A |
failed | ✅ | ❌ | ❌ | ❌ |
cancelled | ❌ | ❌ | ❌ | ❌ |
refunded | ❌ | ❌ | ❌ | ❌ |
partially_refunded | ❌ | ✅ | ✅ | ✅ |
expired | ❌ | ❌ | ❌ | ❌ |
State Validation
Always validate state before performing actions:
function canCancel(orderStatus) {
return ['pending', 'failed'].includes(orderStatus);
}
function canRefund(orderStatus) {
return ['paid', 'partially_refunded'].includes(orderStatus);
}
function canComplete(orderStatus) {
return ['paid', 'partially_refunded'].includes(orderStatus);
}
// Usage
const order = await getOrderDetails(orderId);
if (canRefund(order.status)) {
await refundOrder(orderId);
} else {
throw new Error(`Cannot refund order in ${order.status} state`);
}
Best Practices
1. Handle Webhooks Idempotently
Process each webhook event only once:
const processedEvents = new Set();
function processWebhook(webhook) {
const eventKey = `${webhook.order_id}_${webhook.event}_${webhook.timestamp}`;
if (processedEvents.has(eventKey)) {
console.log('Event already processed');
return;
}
processedEvents.add(eventKey);
// Process the event
switch (webhook.event) {
case 'payment.success':
handlePaymentSuccess(webhook);
break;
// ... other events
}
}
2. Log State Transitions
Track all state changes for debugging:
async function updateOrderStatus(orderId, newStatus, reason = null) {
const order = await getOrderDetails(orderId);
const oldStatus = order.status;
await db.orders.update(orderId, { status: newStatus });
await db.statusHistory.insert({
order_id: orderId,
old_status: oldStatus,
new_status: newStatus,
reason: reason,
timestamp: Date.now()
});
console.log(`Order ${orderId}: ${oldStatus} → ${newStatus}`);
}
3. Set Appropriate Timeouts
Configure order expiration times:
const ORDER_TIMEOUT_MINUTES = 30; // Default
const order = await createOrder({
store_id: storeId,
amount: amount,
currency: currency,
expires_in: ORDER_TIMEOUT_MINUTES * 60 // seconds
});
4. Handle Concurrent Updates
Prevent race conditions with optimistic locking:
async function updateOrderWithLock(orderId, updates) {
const order = await db.orders.findOne({ id: orderId });
// Check version hasn't changed
const result = await db.orders.updateOne(
{ id: orderId, version: order.version },
{ ...updates, version: order.version + 1 }
);
if (result.modifiedCount === 0) {
throw new Error('Order was modified by another process');
}
}