Skip to content

Payment Debugging Guide

This guide provides a systematic approach to investigating payment failures in Upvendo. Payments flow through multiple services and providers, so debugging requires tracing through several layers.

Quick Reference: Key Files

FileWhat to Look For
app/Services/Payment/PaymentService.phpPayment intent creation, terminal interaction
app/Services/Payment/PaymentCaptureService.phpCapture logic, post-payment processing
app/Services/Payment/TransactionService.phpTransaction creation, state management
app/Services/Orchestrators/KioskOrchestrator.phpKiosk payment orchestration
app/Services/Orchestrators/OnlineOrderingOrchestrator.phpOnline payment orchestration
app/Services/Orchestrators/VivaWebhookOrchestrator.phpViva webhook processing
app/Services/Orchestrators/StripeWebhookOrchestrator.phpStripe webhook processing
app/Services/Common/VivaWalletService.phpViva Wallet API calls
app/Services/BackOffice/SquareIntegrationService.phpSquare API calls

Investigation Flowchart

Step 1: Identify the Transaction

Start by finding the transaction in MongoDB:

javascript
// By order number (displayed to customer)
db.transactions.findOne({ order_no: "ORDER-123" })

// By idempotency key (from logs)
db.transactions.findOne({ "payment_snapshot.idempotency_key": "idemp_..." })

// By time range and location
db.transactions.find({
  location_id: ObjectId("..."),
  created_at: { $gte: ISODate("2025-03-27T10:00:00Z"), $lte: ISODate("2025-03-27T11:00:00Z") }
}).sort({ created_at: -1 })

Step 2: Check Transaction Status

StatusWhat It MeansNext Step
pendingTransaction created, payment not initiatedGo to "Payment Not Initiated"
awaiting_paymentSent to terminal/checkout, waitingGo to "Payment Stuck Awaiting"
completePayment captured successfullyPayment worked, check post-payment
unpaidPayment attempted but not verifiedGo to "Payment Not Verified"
cancelledTransaction was cancelledCheck cancellation reason

Step 3: Check payment_snapshot

The payment_snapshot field contains provider-specific data:

javascript
// Viva Wallet fields
payment_snapshot: {
  idempotency_key: "idemp_...",
  sessionId: "idemp_...",
  transactionId: "...",      // Viva transaction ID
  orderCode: "...",           // Viva order code
  details: { ... },           // Full Viva transaction details
  sessionDetails: { ... },    // Terminal session info
  webhookData: { ... },       // Raw webhook payload
  capture: { ... },           // Capture response
  is_free: false,
  auto_success: false
}

// Square fields
payment_snapshot: {
  idempotency_key: "idemp_...",
  order_id: "...",
  webhookData: { status: "COMPLETED", ... }
}

Common Payment Failures

1. Payment Not Initiated (Stuck in "pending")

Symptoms: Transaction exists but terminal never prompted for payment.

Possible Causes:

  • API error when calling PaymentService::processPaymentIntent()
  • Terminal not reachable
  • Device not properly configured

Investigation:

  1. Check application logs around the transaction creation time
  2. Look for errors from VivaWalletService::initiateTerminalSale() or SquareIntegrationService::createTerminalCheckout()
  3. Verify the device has a valid terminal ID:
    • Viva: device.viva_wallet_terminal_id
    • Square: device.square_terminal_id

2. Terminal Not Prompting for Payment

Symptoms: API call succeeds but physical terminal shows nothing.

Possible Causes:

  • Terminal offline or disconnected
  • Terminal paired to wrong account
  • Terminal firmware needs update
  • Network connectivity issue between terminal and provider

Investigation:

  1. Check the terminal status in the provider's dashboard (Viva/Square)
  2. Verify terminal serial number matches device configuration
  3. Check if initiateTerminalSale() returned a successful response
  4. For Viva: check if session was created (look at payment_snapshot.sessionId)

3. 409 Conflict on Terminal Sale

Symptoms: HttpException with 409 status from Viva Wallet API.

Cause: The idempotency key was already used for this terminal.

How the Code Handles It:

processVivaWalletPayment()
  -> initiateTerminalSale() throws 409
  -> Catch HttpException(409)
  -> updateIdempotencyKey() generates new key
  -> Retry initiateTerminalSale() with new key

If it still fails after retry:

  • Check all_idempotency_keys on the transaction for key history
  • The terminal may have a stuck session from a previous payment
  • May need to manually abort via Viva dashboard

4. Payment Stuck in "awaiting_payment"

Symptoms: Customer paid on terminal, but transaction never moved to "complete".

Possible Causes:

A. Webhook not received:

  • Webhook URL misconfigured in provider dashboard
  • Webhook signature verification failing
  • Server returned error to webhook (provider stops retrying)

Investigation:

  • Check provider dashboard for webhook delivery status
  • Search logs for webhook-related errors
  • Verify webhook secret in .env matches provider

B. Webhook received but capture failed:

  • Search logs: "Payment capture failed" with the idempotency key
  • Check for write conflicts (concurrent capture attempts)
  • Verify tenant database resolution worked

C. Webhook received but transaction not found:

  • Search logs: "Transaction not found for"
  • Check if location ID in webhook data matches the transaction's location
  • Verify tenant database was correctly resolved

5. Payment Shows "unpaid" After Capture

Symptoms: capturePayment() ran but status is "unpaid" instead of "complete".

Cause: The payment provider reported a non-success status.

For Viva Wallet:

php
$newStatus = match ($transactionDetails['statusId'] ?? null) {
    'F' => 'complete',       // F = Finalized
    default => 'unpaid'      // Any other status
};
  • Check payment_snapshot.details.statusId -- should be "F" for success
  • If not "F", check the Viva Wallet dashboard for the actual transaction status

For Square:

php
$newStatus = match ($webhookData['status'] ?? null) {
    'COMPLETED' => 'complete',
    default => 'unpaid'
};
  • Check payment_snapshot.webhookData.status -- should be "COMPLETED"

6. Duplicate Payment Capture

Symptoms: Post-payment actions (inventory, loyalty, KDS) run twice.

Cause: Two capture attempts race past the lock.

Prevention Mechanisms:

  1. Cache lock: payment_verification_{idempotencyKey} (20s TTL)
  2. Database lock in Viva webhook: payment_processing_{channel}_{key} (30s TTL)
  3. Viva duplicate detection: SHA-256 webhook hash comparison

Investigation:

  • Check if capturePayment() was called from both webhook and polling
  • Look for concurrent log entries with the same idempotency key
  • Check if the cache lock expired too quickly (>20s processing time)

7. Online Ordering Payment Not Verified

Symptoms: Customer completed payment on checkout page, but order never appears.

Investigation for Viva Wallet:

  1. Check payment_snapshot.orderCode exists on the transaction
  2. Search for POST /verify-payment in access logs
  3. Check if Viva webhook (TRANSACTION_PAYMENT_CREATED) was received
  4. Verify the transaction exists via Viva API: retrieveTransactionByOrderCode()

Investigation for Square:

  1. Check Square dashboard for the payment status
  2. Verify Square webhook was received
  3. Check payment_snapshot.webhookData

8. Preauthorization Capture Failure

Symptoms: Online ordering payment preauthorized but capture fails.

Code Path: PaymentService::verifyPayment() -> VivaWalletService::captureTransaction()

Check:

  • payment_snapshot.capture field for the capture response
  • If capture.Success === false, the capture was rejected
  • Log message: "Error capturing transaction: {idempotencyKey}"
  • Common cause: preauth expired (too much time between auth and capture)

Diagnostic Commands

Check Transaction State

javascript
// Full transaction with payment details
db.transactions.findOne(
  { order_no: "ORDER-123" },
  { status: 1, payment_snapshot: 1, all_idempotency_keys: 1, invalid_idempotency_keys: 1, order_status: 1, receipt_no: 1 }
)

Check for Stuck Locks

javascript
// Find cache locks related to payment
db.cache.find({ key: /payment_verification/ })
db.cache.find({ key: /payment_processing/ })

// Clear stuck lock manually (use with caution)
db.cache.deleteOne({ key: "payment_verification_idemp_xxx" })

Check Webhook Processing

javascript
// Viva webhook recent processing records
db.cache.find({ key: /viva_webhook_recent/ }).sort({ _id: -1 }).limit(10)

// Failed SEPA invoice cache
db.cache.find({ key: /failed_sepa_invoice/ })

Provider-Specific Debugging

Viva Wallet

FieldWhere to FindPurpose
sessionIdpayment_snapshot.sessionIdTerminal session identifier
orderCodepayment_snapshot.orderCodeViva order reference
transactionIdpayment_snapshot.transactionIdViva transaction reference
statusIdpayment_snapshot.details.statusId"F" = success, others = failure

Viva Status Codes:

  • F = Finalized (success)
  • A = Active (pending)
  • E = Error
  • C = Cancelled

Square

FieldWhere to FindPurpose
terminal_checkout_idexternal_data.square.terminal_checkout_idSquare checkout reference
order_idpayment_snapshot.order_idSquare order reference
statuspayment_snapshot.webhookData.status"COMPLETED" = success

Square Terminal Statuses:

  • PENDING = Waiting for customer
  • IN_PROGRESS = Customer interacting
  • COMPLETED = Payment successful
  • CANCELLED = Cancelled
  • TIMED_OUT = No response from terminal

Stripe (Subscriptions)

Stripe is used for subscription billing, not direct order payments. For subscription payment issues:

  1. Check Stripe dashboard for the subscription status
  2. Look at invoice.payment_failed webhook handler
  3. Check SEPA recovery logic in StripeWebhookOrchestrator

Escalation Checklist

Before escalating a payment issue, gather:

  1. Transaction _id and order_no
  2. Transaction status and payment_snapshot contents
  3. Device ID and location ID
  4. Provider (Viva Wallet or Square)
  5. Provider-side transaction/order reference
  6. Relevant log entries (search by idempotency key)
  7. Timestamp of the issue
  8. Whether this is a one-off or recurring pattern