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 (POS dispatch) |
app/Jobs/ProcessVivaWebhookJob.php | Async Viva webhook processing |
app/Listeners/StoreKitchenDisplayItems.php | Writes transaction_item docs on PaymentCaptured (synchronous) |
app/Jobs/ReleaseD1StockReservationJob.php | Releases the cart's D1 stock reservation |
app/Jobs/UpdateMerchantDailyStatsJob.php | Increments merchant dashboard turnover |
app/Jobs/UpdateD1StocksAfterPayment.php | Syncs stock levels to the D1 edge DB |
Payment Providers
Upvendo supports multiple payment providers, determined by the merchant's configuration:
| Provider | Channels | Payment Types |
|---|---|---|
| Viva Wallet | Kiosk (terminal) | Card terminal |
| Square | Kiosk (terminal), Online Ordering | Terminal checkout, online payment |
| Stripe | Online Ordering, Kiosk (Stripe Terminal), Subscriptions | Card, 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 tostripe. - Kiosk ->
stripewhen the location's payment profile reportsin_person_provider = stripe(Stripe Terminal); otherwiseconfig('upvendo.kiosk_payment_provider'), which defaults toviva.
(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 whenZESTIDOO_PAYMENT_PROVIDERis overridden away from itsstripedefault. 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 onpayments-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 inlinegetCustomerToken()checks used.
What deliberately stayed synchronous, and why — these are the easy things to "tidy up" wrongly:
| Stays inline | Reason |
|---|---|
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 creation | Pure 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 snapshot | The kitchen must not wait on a queue. |
publishPaymentStatus / publishOnlineOrderingComplete | Live 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.)
- 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 intoall_idempotency_keys, so a provider webhook for the first attempt arriving mid-retry would look up a stale key, getnull, and fatal after the customer has already been charged._idnever rotates. (Lines 357-383.) event(PaymentCaptured)— theStoreKitchenDisplayItemslistener runs synchronously here and writes thetransaction_itemdocuments that backTransaction::getItems(). Those documents are the only source the POS integrations (Kassanet — Hendrickx/Vanhoutte — and Lightspeed K-Series) read when building their order payloads.processOrderJob()must not be dispatched until step 2 has written those items. A worker that picks the job up first reads an emptygetItems()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.)ReleaseD1StockReservationJob::dispatch()— fire-and-forget cart cleanup, only when acart_session_idis 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.)UpdateMerchantDailyStatsJob::dispatch()— dashboard turnover, skipped whengetIsTestMode().MerchantDailyStatRepositoryincrements with a blind read-modify-write and notransaction_idguard, 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 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
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 value | Meaning |
|---|---|
Pending | Transaction created, no payment initiated (third-party orders) |
Awaiting Payment | Payment intent sent to terminal/checkout |
Awaiting Capture | Authorised, awaiting capture |
Awaiting Invoice | Awaiting invoice settlement (Invoice Collection) |
Complete | Payment verified and captured successfully |
Unpaid | Payment attempted but not verified (may still succeed) |
Cancelled | Transaction 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/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
- Every money field on a transaction is an
App\ValueObjects\Moneyholding integer cents, not a float.MigratePricesToCentsmoved the stored data off floats;RawModels/Transaction.phptypessubtotal,total,vat,discount,fees,tip_amountand the rest asMoney. - Inbound values are normalised with
PriceConverter::normalize(); a provider API that wants a decimal getsMoney->decimal(). There is noceil($amount * 100)anywhere inPaymentService. - Square handles amount conversion internally
- Tip amounts are handled separately: the
tip_amountMoneyfield 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
- 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 | Conversion at a provider boundary | Check PriceConverter::normalize() on the way in and Money->decimal() on the way out |