Appearance
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
| File | Purpose |
|---|---|
app/Services/Payment/PaymentService.php | Payment intent creation, terminal interaction, capture |
app/Services/Payment/PaymentCaptureService.php | Post-payment capture orchestration |
app/Services/Payment/TransactionService.php | Transaction CRUD and state management |
app/Services/Payment/WebhookService.php | Webhook utilities (Stripe event construction, subscription sync) |
app/Services/Orchestrators/KioskOrchestrator.php | Kiosk payment flow coordinator |
app/Services/Orchestrators/OnlineOrderingOrchestrator.php | Online ordering payment coordinator |
app/Services/Orchestrators/StripeWebhookOrchestrator.php | Stripe webhook processing |
app/Services/Orchestrators/VivaWebhookOrchestrator.php | Viva Wallet webhook processing |
app/Services/Common/VivaWalletService.php | Viva Wallet API client |
app/Services/BackOffice/SquareIntegrationService.php | Square API client |
app/Jobs/CapturePaymentJob.php | Async payment capture job |
app/Jobs/ProcessOrderJob.php | Post-capture order processing |
Payment Providers
Upvendo supports multiple payment providers, determined by the merchant's configuration:
| Provider | Channels | Payment Types |
|---|---|---|
| Viva Wallet | Kiosk (terminal), Online Ordering (hosted checkout) | Card terminal, online card |
| Square | Kiosk (terminal), Online Ordering | Terminal checkout, online payment |
| Stripe | Subscriptions, future online payments | Card, 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 keyall_idempotency_keys[]-- History of all keys (for retry scenarios)invalid_idempotency_keys[]-- Keys that were aborted/cancelled
Conflict handling (409 from Viva Wallet):
- Generate new idempotency key
- Save new key to transaction
- Retry terminal sale with new key
Payment Status States
| Status | Meaning |
|---|---|
pending | Transaction created, no payment initiated |
awaiting_payment | Payment intent sent to terminal/checkout |
complete | Payment verified and captured successfully |
unpaid | Payment attempted but not verified (may still succeed) |
cancelled | Transaction 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/failureAuto-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_amountfield 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
- Find the transaction: Search by
order_nooridempotency_keyin MongoDB - Check payment_snapshot: Contains all provider-specific data
- Check status field: See where in the lifecycle the payment stopped
- Check logs: Search for the idempotency key in application logs
Common Failure Points
| Symptom | Likely Cause | Fix |
|---|---|---|
| Terminal not prompted | 409 conflict on Viva API | Check all_idempotency_keys, may need key rotation |
| Payment stuck in "awaiting_payment" | Webhook not received | Check webhook configuration, manually trigger capture |
| Duplicate capture | Lock not acquired | Check for stuck cache locks |
| "Transaction not found" in webhook | Wrong tenant database | Verify location ID in webhook data |
| Amount mismatch | Float rounding | Check ceil() conversion from float to cents |