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 (POS dispatch)
app/Jobs/ProcessVivaWebhookJob.phpAsync Viva webhook processing
app/Listeners/StoreKitchenDisplayItems.phpWrites transaction_item docs on PaymentCaptured (synchronous)
app/Jobs/ReleaseD1StockReservationJob.phpReleases the cart's D1 stock reservation
app/Jobs/UpdateMerchantDailyStatsJob.phpIncrements merchant dashboard turnover
app/Jobs/UpdateD1StocksAfterPayment.phpSyncs stock levels to the D1 edge DB

Payment Providers

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

ProviderChannelsPayment Types
Viva WalletKiosk (terminal)Card terminal
SquareKiosk (terminal), Online OrderingTerminal checkout, online payment
StripeOnline Ordering, Kiosk (Stripe Terminal), SubscriptionsCard, Bancontact, SEPA Direct Debit

How the provider is chosen

The provider is resolved twice: once when the transaction is created (stamped onto the transaction's payment_provider field), and again at payment time, where an active Square integration overrides whatever was stamped.

TransactionService::resolvePaymentProvider() stamps payment_provider:

  • Any non-kiosk channel — online ordering included -> config('upvendo.zestidoo_payment_provider'), which defaults to stripe.
  • Kiosk -> stripe when the location's payment profile reports in_person_provider = stripe (Stripe Terminal); otherwise config('upvendo.kiosk_payment_provider'), which defaults to viva.

(Verified: app/Services/Payment/TransactionService.php lines 865-876, stamped at line 963; config/upvendo.php lines 4-5; app/RawModels/PaymentProfile.php lines 34 and 65.)

At payment time IntegrationTrait::isSquareIntegrated() is checked first and short-circuits to Square regardless of the stamped value; otherwise the stamped payment_provider selects the branch via Transaction::isStripe() / isViva(). (Verified: PaymentService::processPaymentIntent() lines 211-247 and verifyPayment() lines 1426-1448; app/RawModels/Transaction.php lines 620-628.)

Online ordering runs on Stripe, not Viva Wallet. The Viva hosted-checkout branch inside createZestidooPaymentOrder() is a legacy fallback that is only reachable when ZESTIDOO_PAYMENT_PROVIDER is overridden away from its stripe default. See Viva Wallet.


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)
   Inline: inventory update, KDS notification, gift cards, token expiry, receipts
   Deferred to FinalizePaidOrderJob: loyalty earn, offers, customer timeline
       |
       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()                  |
        |   - Amounts are already integer cents (Money) |
        |   - 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)
  |           +-> LoyaltyService::setTokenExpired() (expire loyalty token)
  |
  |     +-> Still inside the lock closure, after the DB work:
  |           +-> Publish payment status via Firebase (real-time kiosk update)
  |
  +-> [Past the transaction closure — see "Post-capture ordering" below]
        +-> Re-read the transaction by _id
        +-> event(PaymentCaptured)              (synchronous KDS item snapshot)
        +-> processOrderJob()                   (POS dispatch)
        +-> ReleaseD1StockReservationJob::dispatch()
        +-> UpdateMerchantDailyStatsJob::dispatch()  (skipped in test mode)
        +-> FinalizePaidOrderJob::dispatch()     (deferred side-effects, dispatched LAST)
  |
  +-> finally: if stock was updated:
        +-> UpdateD1StocksAfterPayment::dispatch() (sync D1 edge DB)

Post-payment side-effects are deferred to FinalizePaidOrderJob

Loyalty earn (including the MplusKassa points push), offer/reward redemption, the customer-timeline write, the kiosk customer-name update and the Stripe B2B auto-invoice used to run inline inside the capture lock and Mongo transaction, before the customer got a response. Their POS/Stripe network calls held the lock and session open and inflated capture latency.

They now run in a single queued FinalizePaidOrderJob, dispatched from both OnlineOrderingOrchestrator::verifyPayment() and PaymentCaptureService::capturePayment() after the payment and order are persisted (post-commit, post-lock). Dispatching inside the lock would push work onto the central jobs collection before COMMIT, letting a worker read a not-yet-visible (or retry-aborted) transaction.

Job semantics worth knowing before changing it:

  • $tries = 1. This preserves the original exactly-once, best-effort semantics of the non-idempotent local writes. Do not add retries without making each step idempotent.
  • Queue payments-medium, so it never starves live capture jobs on payments-high. It is dispatched last so the kitchen (processOrderJob) never waits on it.
  • The job re-binds tenancy in handle() from the carried tenant database and re-loads the transaction.
  • Each step runs under its own try/catch, so one failure (say MplusKassa being down) never skips the others.
  • Which steps run is decided at dispatch time by flags (runLoyalty, runTimeline, runOffers, updateKioskCustomerName), capturing the same gate the old inline getCustomerToken() checks used.

What deliberately stayed synchronous, and why — these are the easy things to "tidy up" wrongly:

Stays inlineReason
LoyaltyService::setTokenExpired()A single local Mongo write that never held the lock open. Burning the token inside the settlement transaction keeps it exactly-once and leaves no window in which an in-flight token could be replayed. The async loyalty earn does not need it un-expired — it resolves the customer via customer_id.
Gift-card creationPure local Mongo, no POS/Stripe I/O, so it never held the lock open. Under $tries = 1 a deferred version would make a paid-for card at-most-once — silently lost if the job dies.
event(PaymentCaptured) / KDS item snapshotThe kitchen must not wait on a queue.
publishPaymentStatus / publishOnlineOrderingCompleteLive Firebase payment-state pushes the customer's screen is waiting on.

Post-capture ordering (why it is load-bearing)

Everything after the capture commits runs inline past the transaction closure — not via defer() — and the order is deliberate. (Verified: app/Services/Payment/PaymentCaptureService.php lines 370-442.)

  1. Re-read the transaction by _id, never by idempotency key. The key rotates: a kiosk retry mints a fresh one and folds the old key into all_idempotency_keys, so a provider webhook for the first attempt arriving mid-retry would look up a stale key, get null, and fatal after the customer has already been charged. _id never rotates. (Lines 357-383.)
  2. event(PaymentCaptured) — the StoreKitchenDisplayItems listener runs synchronously here and writes the transaction_item documents that back Transaction::getItems(). Those documents are the only source the POS integrations (Kassanet — Hendrickx/Vanhoutte — and Lightspeed K-Series) read when building their order payloads.
  3. processOrderJob() must not be dispatched until step 2 has written those items. A worker that picks the job up first reads an empty getItems() and sends the POS an order carrying only the order-type PLU marker and no priced lines — the POS accepts it, the bill totals €0 against a non-zero transaction, and the order is parked unrecoverable. (Lines 385-410.)
  4. ReleaseD1StockReservationJob::dispatch() — fire-and-forget cart cleanup, only when a cart_session_id is present. Queued rather than run inline because a ~0.5-0.7s Cloudflare round-trip inside the capture lock widened the write-conflict window for nothing. (Lines 412-425.)
  5. UpdateMerchantDailyStatsJob::dispatch() — dashboard turnover, skipped when getIsTestMode(). MerchantDailyStatRepository increments with a blind read-modify-write and no transaction_id guard, so dispatching from an attempt that then re-ran would double-count the order's revenue. Out past the closure it can only fire on the attempt that actually committed. (Lines 427-442.)

The whole block lives outside the closure for one reason: enqueuing any of it inside put the job on the central jobs collection before COMMIT, where a worker could pick it up ahead of the listener. afterResponse() masked that on the HTTP path only — every queue-context caller (ProcessVivaWebhookJob, CapturePaymentJob, SquareWebhookService) skips afterResponse() and raced it. (Introduced by fix(payment): dispatch POS, stats and D1 work after the settlement commits (#1336).)

UpdateD1StocksAfterPayment is separate again: it is dispatched from the method's finally block whenever inventory actually changed, and re-reads by _id for the same rotated-key reason. (Verified: lines 530-549.)


Online Ordering Payment Flow

Step 1: Create Payment Order

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

Online ordering is Stripe-driven. The transaction is stamped payment_provider = stripe at creation (see "How the provider is chosen" above), and Transaction::isStripe() gates the intent-creation branches.

OnlineOrderingOrchestrator::storePayment()
  |
  +-> TransactionService::createTransaction()
  |
  +-- Free order, or location in payment_test_mode?
  |     -> synthetic payment_snapshot { id: uuid, is_free: true }
  |     -> dispatch verifyPayment() afterResponse(), return
  |
  +-- isStripe() AND payment_method = bancontact:
  |     -> services.stripe.bancontact_payment_intent on (default)
  |     |    -> createStripeBancontactPaymentIntent()
  |     |       (requires a customer full name; 422 otherwise)
  |     -> off
  |          -> createStripeBancontactCheckoutSession()
  |
  +-- otherwise -> PaymentService::createZestidooPaymentOrder()
        |
        +-- [Square integrated] -> SquareIntegrationService::createPaymentOrder()
        |   Returns: { success, amount, square_location_id }
        |
        +-- [MplusKassa integrated] -> Stripe PaymentIntent (card only)
        |
        +-- [isStripe()] -> Stripe PaymentIntent (card only)
        |   Returns: { id, client_secret, amount, created, status }
        |
        +-- [legacy fallback] -> VivaWalletService::createPaymentOrder()
            Hosted Viva checkout. Only reachable when
            ZESTIDOO_PAYMENT_PROVIDER is overridden away from 'stripe'.
            Returns: { orderCode, redirectUrl }

(Verified: app/Services/Orchestrators/OnlineOrderingOrchestrator.php lines 555-656; app/Services/Payment/PaymentService.php lines 1624-1770.)

Step 2: Customer Pays

  • Stripe: The storefront confirms the PaymentIntent with the returned client_secret; Bancontact may instead redirect to a Stripe Checkout Session.
  • Square: Payment processed via Square web SDK.
  • Viva Wallet (legacy fallback only): Customer redirected to the Viva hosted checkout page.

Step 3: Verify Payment

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

Or triggered by webhook — Stripe's payment_intent.succeeded in the normal case, or Viva's TRANSACTION_PAYMENT_CREATED on the legacy path:

StripeWebhookOrchestrator -> handleSuccessfulPayment()
  -> OnlineOrderingOrchestrator::verifyPayment($idempotencyKey)
PaymentService::verifyPayment($transaction)
  |
  +-> Free order or test mode? -> afterPaymentVerified(), return success
  +-> Square integrated? -> verifySquarePayment()
  +-> isStripe()? -> verifyStripePayment()
  |     +-> Already "Complete" (webhook won the race)? -> return success, no API call
  |     +-> Retrieve the Checkout Session, or fall back to the PaymentIntent directly
  |     +-> afterPaymentVerified()
  |
  +-> otherwise -> verifyVivaWalletPayment()   (legacy)
        +-> 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)

(Verified: PaymentService::verifyPayment() lines 1426-1469, verifyStripePayment() lines 1185+, verifyVivaWalletPayment() lines 1324+.)


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

Stored values are the exact Title Case strings of the OrderStatuses enum (app/Enums/OrderStatuses.php). Querying a lowercase value like unpaid matches nothing.

Stored valueMeaning
PendingTransaction created, no payment initiated (third-party orders)
Awaiting PaymentPayment intent sent to terminal/checkout
Awaiting CaptureAuthorised, awaiting capture
Awaiting InvoiceAwaiting invoice settlement (Invoice Collection)
CompletePayment verified and captured successfully
UnpaidPayment attempted but not verified (may still succeed)
CancelledTransaction cancelled before payment

The same enum also carries the order statuses Queued, In Progress and Ready.


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

  • Every money field on a transaction is an App\ValueObjects\Money holding integer cents, not a float. MigratePricesToCents moved the stored data off floats; RawModels/Transaction.php types subtotal, total, vat, discount, fees, tip_amount and the rest as Money.
  • Inbound values are normalised with PriceConverter::normalize(); a provider API that wants a decimal gets Money->decimal(). There is no ceil($amount * 100) anywhere in PaymentService.
  • Square handles amount conversion internally
  • Tip amounts are handled separately: the tip_amount Money field on the 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 mismatchConversion at a provider boundaryCheck PriceConverter::normalize() on the way in and Money->decimal() on the way out