Skip to content

Payment Flow

This document covers the complete payment lifecycle in Upvendo, from creating a payment intent through terminal interaction to final capture and post-payment processing.

Key Files

FilePurpose
app/Services/Payment/PaymentService.phpPayment intent creation, terminal interaction, capture
app/Services/Payment/PaymentCaptureService.phpPost-payment capture orchestration
app/Services/Payment/TransactionService.phpTransaction CRUD and state management
app/Services/Payment/WebhookService.phpWebhook utilities (Stripe event construction, subscription sync)
app/Services/Orchestrators/KioskOrchestrator.phpKiosk payment flow coordinator
app/Services/Orchestrators/OnlineOrderingOrchestrator.phpOnline ordering payment coordinator
app/Services/Orchestrators/StripeWebhookOrchestrator.phpStripe webhook processing
app/Services/Orchestrators/VivaWebhookOrchestrator.phpViva Wallet webhook processing
app/Services/Common/VivaWalletService.phpViva Wallet API client
app/Services/BackOffice/SquareIntegrationService.phpSquare API client
app/Jobs/CapturePaymentJob.phpAsync payment capture job
app/Jobs/ProcessOrderJob.phpPost-capture order processing

Payment Providers

Upvendo supports multiple payment providers, determined by the merchant's configuration:

ProviderChannelsPayment Types
Viva WalletKiosk (terminal), Online Ordering (hosted checkout)Card terminal, online card
SquareKiosk (terminal), Online OrderingTerminal checkout, online payment
StripeSubscriptions, future online paymentsCard, SEPA Direct Debit

The active provider is determined by checking IntegrationTrait::isSquareIntegrated(). If Square is integrated for the location, Square is used; otherwise Viva Wallet is the default.


Payment Lifecycle Overview

1. CREATE TRANSACTION
   TransactionService creates the order/transaction record
   Status: "pending"
       |
       v
2. CREATE PAYMENT INTENT
   PaymentService::processPaymentIntent() or createPaymentOrder()
   Initiates payment on terminal or creates online payment order
   Status: "awaiting_payment"
       |
       v
3. CUSTOMER PAYS
   Physical terminal or online checkout
   (external to Upvendo)
       |
       v
4. PAYMENT CONFIRMATION
   Via webhook (Viva/Stripe/Square) or polling (session status check)
       |
       v
5. CAPTURE PAYMENT
   PaymentCaptureService::capturePayment()
   Verifies and records payment, updates status
   Status: "complete" or "unpaid"
       |
       v
6. POST-PAYMENT PROCESSING (if complete)
   Inventory update, KDS notification, loyalty, offers, receipts
       |
       v
7. ORDER PROCESSING
   ProcessOrderJob dispatched (may be delayed for scheduled orders)

Kiosk Payment Flow (In Detail)

Step 1: Store Payment

Route: POST /api/kiosk/payment/create-intentController: KioskController::storePayment()Orchestrator: KioskOrchestrator::storePayment()

KioskOrchestrator::storePayment($request)
  |
  +-> TransactionService::createTransaction($request)
  |     Creates transaction document in MongoDB with:
  |     - order_no (auto-generated)
  |     - items snapshot
  |     - pricing calculations
  |     - customer info (if loyalty token present)
  |     - idempotency_key
  |     - status: pending
  |
  +-> PaymentService::processPaymentIntent($transaction, $newTransaction)
        |
        +-> Check provider: isSquareIntegrated()?
        |
        +-- [Viva Wallet Path] -------------------------+
        |   processVivaWalletPayment()                  |
        |   - Convert amount to cents: ceil(total * 100)|
        |   - Check existing session status             |
        |   - initiateTerminalSale() via VivaWalletService
        |   - Handle 409 Conflict (regenerate idempotency key)
        |   - Save sessionId to payment_snapshot        |
        |   - Return updated transaction                |
        +-----------------------------------------------+
        |
        +-- [Square Path] -----------------------------+
        |   processSquarePayment()                     |
        |   - Get Square Terminal device ID            |
        |   - Check existing checkout status           |
        |   - createTerminalCheckout() via SquareService
        |   - Return updated transaction               |
        +----------------------------------------------+

Step 2: Check Payment Status

Route: GET /api/kiosk/payment/session-statusController: KioskController::checkSessionStatus()

The kiosk polls this endpoint to check if the customer has completed payment on the terminal.

Step 3: Capture Payment (via webhook or polling)

Two paths to capture:

Path A: Webhook-triggered (preferred)

Webhook received (Viva or Square)
  -> WebhookOrchestrator resolves tenant
  -> PaymentCaptureService::capturePayment($idempotencyKey, $webhookData)

Path B: Polling-triggered

Kiosk calls GET /payment/details/{idempotencyKey}
  -> KioskOrchestrator::retrieveDetailsAfterPayment()
  -> CapturePaymentJob::dispatch() (async)
  -> PaymentCaptureService::capturePayment($idempotencyKey)

Step 4: Payment Capture Details

PaymentCaptureService::capturePayment() is the core capture method:

capturePayment($idempotencyKey, $webhookData = null)
  |
  +-> Acquire cache lock: "payment_verification_{key}" (20s TTL)
  |
  +-> executeWithTransactionRetry() (up to 5 retries)
  |     |
  |     +-> TransactionService::setTransactionAwaitingCapture()
  |     |     Find transaction by idempotency key, set status
  |     |
  |     +-> PaymentService::capturePayment($transaction, $webhookData)
  |     |     |
  |     |     +-> Free order? -> status = "complete", mark is_free
  |     |     +-> Auto-success? -> status = "complete", mark auto_success
  |     |     +-> Square? -> Save webhook data, status from webhook
  |     |     +-> Viva (no webhook)? -> Poll session status via API
  |     |     +-> Viva (webhook)? -> Save webhook data, status from StatusId
  |     |     |
  |     |     +-> Generate receipt_no if status = "complete"
  |     |     +-> Set order_status = "queued"
  |     |
  |     +-> If status changed to "complete":
  |           +-> OrderCapacityService::setTransactionSentAtByOrderCapacity()
  |           +-> InventoryService::manageLocationStocks() (decrement stock)
  |           +-> KitchenDisplayService::storeTransactionItems() (create KDS items)
  |           +-> LoyaltyService::handleCustomerLoyalty() (award points)
  |           +-> CustomerService::addTimelines() (customer history)
  |           +-> OfferService::redeemOffers() (mark offers used)
  |           +-> LoyaltyService::setTokenExpired() (expire loyalty token)
  |
  +-> defer() (after DB transaction commits):
        +-> Publish payment status via Firebase (real-time kiosk update)
        +-> Release stock reservation via Cloudflare D1
        +-> ProcessOrderJob::dispatch() (order processing)
  |
  +-> If stock was updated:
        +-> UpdateD1StocksAfterPayment::dispatch() (sync D1 edge DB)

Online Ordering Payment Flow

Step 1: Create Payment Order

Route: POST /api/online-ordering/{slug}/{locationId}/paymentOrchestrator: OnlineOrderingOrchestrator

OnlineOrderingOrchestrator::storePayment()
  |
  +-> TransactionService::createTransaction()
  +-> PaymentService::createPaymentOrder($transaction)
        |
        +-- [Square] -> SquareIntegrationService::createPaymentOrder()
        |   Returns: { success, amount, square_location_id }
        |
        +-- [Viva Wallet] -> VivaWalletService::createPaymentOrder()
            Creates hosted payment order with:
            - merchantId, amount, customerEmail
            - currencyCode, tipAmount, sourceCode
            - orderNo, locationId, countryCode, primaryColor
            Returns: { orderCode, redirectUrl }

Step 2: Customer Pays

  • Viva Wallet: Customer redirected to Viva hosted checkout page
  • Square: Payment processed via Square web SDK

Step 3: Verify Payment

Route: POST /api/online-ordering/{slug}/{locationId}/verify-paymentOrchestrator: OnlineOrderingOrchestrator::verifyPayment()

Or triggered by webhook:

VivaWebhookOrchestrator -> handlePaymentCreated()
  -> OnlineOrderingOrchestrator::verifyPayment($idempotencyKey)
PaymentService::verifyPayment($transaction)
  |
  +-> Free order? -> afterPaymentVerified(), return success
  +-> Square? -> Return success (already verified via webhook)
  +-> Viva Wallet:
        +-> Retrieve transaction by orderCode via Viva API
        +-> Save transactionId and details to payment_snapshot
        +-> If status is "unpaid" and preauth:
        |     -> captureTransaction() via Viva API
        +-> afterPaymentVerified():
              -> Update status to "complete"
              -> Generate receipt_no
              -> Save capture response
              -> Bind order to session (if table QR ordering)

Idempotency Key System

Every transaction has an idempotency key that prevents duplicate payments:

Generation: idemp_{deviceId}_{timestampMs}

Key tracking on transaction:

  • payment_snapshot.idempotency_key -- Current active key
  • all_idempotency_keys[] -- History of all keys (for retry scenarios)
  • invalid_idempotency_keys[] -- Keys that were aborted/cancelled

Conflict handling (409 from Viva Wallet):

  1. Generate new idempotency key
  2. Save new key to transaction
  3. Retry terminal sale with new key

Payment Status States

StatusMeaning
pendingTransaction created, no payment initiated
awaiting_paymentPayment intent sent to terminal/checkout
completePayment verified and captured successfully
unpaidPayment attempted but not verified (may still succeed)
cancelledTransaction cancelled before payment

Cancel Payment Flow

Route: POST /api/kiosk/payment/cancel-actionService: PaymentService::cancelTransaction()

cancelTransaction($transaction)
  |
  +-- [Square] -> cancelSquareTerminalCheckout()
  |     Cancel via SquareIntegrationService
  |
  +-- [Viva Wallet] -> Iterate all idempotency keys
        For each non-invalid key:
          -> VivaWalletService::abortTerminalSession()
          -> Mark key as invalid
          -> Log success/failure

Auto-Success Mode

For development/testing, GeneralHelper::autoSuccessPayment() returns true when:

  • Environment variable AUTO_SUCCESS_PAYMENT=true

This bypasses all terminal interaction and immediately marks payments as complete.


Amount Handling

  • All internal amounts are stored as floats (e.g., 12.50)
  • Viva Wallet API expects cents as integers: (int) ceil($amount * 100)
  • Square handles amount conversion internally
  • Tip amounts are handled separately: tip_amount field on transaction

Performance Monitoring

The PaymentCaptureService includes performance monitoring:

  • Sentry tracing spans for payment capture operations
  • Slow payment detection: logs warning if capture takes > 2 seconds
  • Execution time tracked in milliseconds
  • Tracing is conditionally enabled based on environment and Sentry config

Debugging Payment Issues

Quick Diagnosis

  1. Find the transaction: Search by order_no or idempotency_key in MongoDB
  2. Check payment_snapshot: Contains all provider-specific data
  3. Check status field: See where in the lifecycle the payment stopped
  4. Check logs: Search for the idempotency key in application logs

Common Failure Points

SymptomLikely CauseFix
Terminal not prompted409 conflict on Viva APICheck all_idempotency_keys, may need key rotation
Payment stuck in "awaiting_payment"Webhook not receivedCheck webhook configuration, manually trigger capture
Duplicate captureLock not acquiredCheck for stuck cache locks
"Transaction not found" in webhookWrong tenant databaseVerify location ID in webhook data
Amount mismatchFloat roundingCheck ceil() conversion from float to cents