Skip to content

Background Jobs

Upvendo uses Laravel's queue system to offload time-consuming operations from the HTTP request cycle. Jobs handle email sending, payment capture, integration syncing, image processing, and order processing.

Key Files

Jobs live in app/Jobs/102 files as of 2026-07-27, including the UberEats/ and Commission/ sub-directories. The tables below cover the notable ones and are not exhaustive; check the directory for the current set rather than treating any list here as closed.


Job Categories

Payment Jobs

JobPurposeDispatched By
CapturePaymentJobCapture payment after terminal confirmationKioskOrchestrator
ProcessOrderJobPost-payment order processing (KDS, notifications)PaymentCaptureService
FinalizePaidOrderJobDeferred post-payment side-effects: loyalty earn (+ Mplus points push), offer/reward redemption, customer timeline, kiosk customer-name update, Stripe B2B auto-invoicePaymentCaptureService, OnlineOrderingOrchestrator::verifyPayment()
UpdateD1StocksAfterPaymentSync stock changes to Cloudflare D1 edge databasePaymentCaptureService

ProcessOrderJob

The most important job in the payment flow. Dispatched after successful payment capture.

php
ProcessOrderJob::dispatch($transactionId, $locationId);

Dispatch timing (from PaymentCaptureService::processOrderJob()):

  • Kiosk orders: Dispatched immediately
  • Online ordering (dine-in): Dispatched immediately
  • Online ordering (delivery/pickup): May be delayed based on sent_at time
  • Local development: Executed synchronously via ->handle()

Delayed dispatch for scheduled orders:

php
$delayInSeconds = $processTime->diffInSeconds($now, true);
ProcessOrderJob::dispatch($transactionId, $locationId)->delay($delayInSeconds);

Email Jobs

JobPurposeTrigger
SendBackofficeOTPMailSend OTP code for 2FA loginAuthService::requestBackofficeOTP()
SendDeviceActivationCodeMailSend device activation codeDevice management
SendGridMailGeneric email via SendGridVarious
SendLocalOrderReceiptMailSend order receipt to customerAfter payment capture
SendLocalOrderReadyForPickupMailNotify customer order is readyKDS marks order ready
SendLocalOrderOutForDeliveryMailNotify customer order is out for deliveryOrder status change
SendLocalOrderDeliveredMailConfirm delivery to customerOrder status change
SendLocalOrderReadyDeliveryMailNotify order ready for deliveryKDS marks order ready
SendLocalOrderPickupCompletedMailConfirm pickup completeOrder status change
SendLocalOrderMissedDeliveryMailNotify missed deliveryDelivery status
SendLoyaltyProgramAlertMailLoyalty program notificationsLoyalty threshold reached
SendMerchantInvitationMailInvite user to merchant teamTeam management
SendPasswordResetMailPassword reset linkAuthService::forgetPassword()
SendVerificationCodeMailCustomer verification codeAuthCustomerService::sendOTP()

SMS Jobs

JobPurpose
SendSMSSend SMS via configured provider (customer OTP, notifications)

Integration Sync Jobs

JobPurposeTrigger
SyncSquareMenuJobSync menu catalog to SquareMenu publish
SyncSquareInventoryJobSync inventory to SquareInventory changes
SyncSquareLocationJobSync location data to SquareLocation update
SyncSquareIntegrationJobInitialize Square integrationOAuth completion
SyncLocationToDeliverooJobSync location data to DeliverooLocation update
ExportUpvendoMenuToShopifyJobExport menu to ShopifyManual trigger
ImportShopifyMenuJobImport menu from ShopifyManual trigger

Kassanet (POS Integration) Jobs

JobPurpose
ImportKassanetMenuJobImport full menu from Kassanet POS
ImportKassanetCategoriesJobImport categories from Kassanet
ImportKassanetDisplayGroupsJobImport display groups from Kassanet
ImportKassanetProductsJobImport products from Kassanet
GetKassanetBillJobRetrieve bill from Kassanet POS
PayKassanetBillJobSubmit payment to Kassanet POS

Uber Eats Jobs

Located in app/Jobs/UberEats/:

JobPurpose
ProcessUberEatsOrderNotificationJobProcess incoming Uber Eats order
ProcessUberEatsCancelNotificationJobHandle Uber Eats order cancellation
ProcessUberEatsScheduledNotificationJobHandle scheduled order updates

Image Processing Jobs

JobPurpose
CompressImageJobCompress uploaded images for storage optimization

QR Code Generation Jobs

JobPurpose
GenerateQrCodesForSectionGenerate QR codes for a table section
GenerateQrCodesForSectionBatchBatch generate QR codes for section
GenerateQrCodesConsolidationConsolidate generated QR codes
GenerateQrCodesConsolidationBatchBatch consolidation of QR codes
GenerateQrCodesFinalConsolidationFinal consolidation step

Translation Jobs

JobPurpose
RetranslatePublishedLanguagesJobRe-translate all published language content
BulkTranslateJobBulk translation run

Notification / Push Jobs

Firebase writes were moved off the request thread — NotificationService::create() previously looped Firebase round-trips inline.

JobPurpose
SendFirebaseNotificationsJobFan out notifications to Firebase (FCM + RTDB)
SendFirebaseReadUpdateJobPush read-state updates to Firebase
SendFcmBroadcastJobBroadcast an FCM message
SendFieldPushJobPush to the field-ops rep app

Webhook Processing Jobs

Inbound provider webhooks are verified synchronously, then queued.

JobProvider
ProcessStripeWebhookJobStripe
ProcessVivaWebhookJobViva Wallet
ProcessSquareWebhookJobSquare
ProcessShopifyWebhookJobShopify
ProcessDeliverooWebhookJobDeliveroo
ProcessLightspeedKSeriesWebhookJobLightspeed K-Series
UberEats/ProcessUberEatsWebhookJobUber Eats

MplusKassa Sync Jobs

JobPurpose
SyncMplusKassaIntegrationJobInitialise the integration
SyncMplusKassaMenuJobMenu sync entry point
SyncMplusKassaProductsPageJobOne page of products
SyncMplusKassaProductsBatchJobOne batch of products
SyncMplusKassaInventoryJobInventory sync
SyncMplusKassaRelationsJobRelations sync
SyncMplusKassaImagesDispatchJobFan out image sync
SyncMplusKassaImagesBatchJobOne batch of images
SyncMplusKassaFinalizationJobFinalise the sync run

Odoo / CRM Jobs

JobPurpose
OdooSyncPartnerJobSync a merchant to Odoo
OdooErasePartnerJobGDPR erasure of a partner
OdooSyncResellerJobSync a reseller to Odoo
OdooEraseResellerJobGDPR erasure of a reseller
CreateOdooLeadJobCreate a CRM lead

Invoicing, Subscription & Payout Jobs

JobPurpose
ProcessInvoicePaidJobHandle an invoice paid event
ProcessInvoiceVoidedJobHandle an invoice voided event
ExpireSubscriptionJobExpire a subscription
ConvertGracePeriodJobConvert a grace period
MonthlySubscriptionReceivableJobMonthly receivable run
ExecutePayoutBatchJobExecute a payout batch
SettleOnlineOrderPaymentJobSettle an online-order payment
SettleLightspeedKSeriesPaymentJobSettle a Lightspeed order

Cloudflare D1 / Stock Jobs

JobPurpose
ReleaseD1StockReservationJobRelease an edge stock reservation
ReconcileLocationD1ConstantsJobReconcile location constants to D1
ReconcileLocationStocksJobReconcile location stock to D1

Other

JobPurpose
SendEmailVerificationMailBack-office email verification
SendFieldRepInviteMailInvite a field rep
UploadImageToCloudflareJobUpload an image to Cloudflare
ConfirmDeliverooScheduledOrderJobConfirm a scheduled Deliveroo order
GenerateStandManifestJobField-ops stand manifest
RetryPosForLocationJobRetry POS sync for a location
GeneratePdf*PDF generation family

Dispatch Patterns

Immediate Dispatch

Most jobs are dispatched immediately to the queue:

php
SendBackofficeOTPMail::dispatch($user, $otpCode);

Delayed Dispatch

Used for scheduled orders:

php
ProcessOrderJob::dispatch($transactionId, $locationId)->delay($delayInSeconds);

Synchronous Execution (Local Dev)

In local environment, some jobs execute synchronously for easier debugging:

php
if (app()->isLocal()) {
    (new ProcessOrderJob($transactionId, $locationId))->handle();
    return;
}

After-Response Dispatch

There is no defer() in the codebase. The after-response mechanism is Laravel's ->afterResponse(), applied conditionally on the dispatched job so it still runs inline under tests and Artisan commands:

php
$useAfterResponse = ! app()->runningUnitTests() && ! app()->runningInConsole();

$job = ProcessOrderJob::dispatch($transactionId, $locationId);

if ($useAfterResponse) {
    $job->afterResponse();
}

(PaymentCaptureService.php:103, :119-121, :128-130.)


Queue Configuration

The queue driver and connection are configured via environment variables:

VariablePurpose
QUEUE_CONNECTIONQueue driver (redis, database, sqs, sync)
DB_QUEUEDefault queue name for the database driver (config/queue.php:41)

Queue Names

Queues are a fixed, named taxonomy declared as constants on App\Constants (app/Constants.php:858-887). Jobs pin themselves in their constructor via $this->onQueue(...).

ConstantQueue name
Constants::QUEUE_PAYMENTS_HIGHpayments-high
Constants::QUEUE_PAYMENTS_MEDIUMpayments-medium
Constants::QUEUE_EMAILemail
Constants::QUEUE_MENU_SYNCmenu-sync
Constants::QUEUE_MPLUSKASSA_SYNCmpluskassa-sync
Constants::QUEUE_KASSANET_SYNCkassanet-sync
Constants::QUEUE_ODOO_SYNCodoo-sync
php
// ProcessOrderJob.php:41
$this->onQueue(Constants::QUEUE_PAYMENTS_HIGH);

FinalizePaidOrderJob sits on payments-medium on purpose, so deferred side-effects never starve live capture jobs on payments-high, and it is dispatched last at its call sites so the kitchen never waits on it.

FinalizePaidOrderJob runs once, by design

It carries $tries = 1. Its steps are non-idempotent local writes, and single-attempt preserves the exactly-once, best-effort semantics they had when they ran inline. Do not add retries without making each step idempotent first.

It re-binds tenancy in handle() from the carried tenant database, re-loads the transaction, and runs each step under its own try/catch so one failure (MplusKassa being down, say) never skips the others. Which steps run is decided at dispatch time by flags (runLoyalty, runTimeline, runOffers, updateKioskCustomerName).

It must be dispatched after the payment/order is persisted — post-commit and past the lock closure. Dispatching inside the lock writes to the central jobs collection before COMMIT, letting a worker read a not-yet-visible (or retry-aborted) transaction. See Payment Flow for what deliberately stayed synchronous and why.


Job Structure Pattern

All jobs follow Laravel's standard structure:

php
class ExampleJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private string $someId,
        private string $tenantDatabase
    ) {}

    public function handle(): void
    {
        // Set tenant database if multi-tenant
        config()->set('database.connections.tenant.database', $this->tenantDatabase);

        // Execute job logic
        // ...
    }
}

Multi-Tenant Considerations

Jobs that access tenant data must set the tenant database at the start of handle():

php
config()->set('database.connections.tenant.database', $this->tenantDatabase);

The tenant database name is typically passed as a constructor parameter when dispatching:

php
UpdateD1StocksAfterPayment::dispatch(
    $transaction,
    config('database.connections.tenant.database')
);

Error Handling and Retries

Default Behavior

Laravel's queue system provides automatic retry behavior. Failed jobs are stored in the failed_jobs table.

Manual Retry

bash
php artisan queue:retry {job_id}
php artisan queue:retry all

Monitoring Failed Jobs

bash
php artisan queue:failed

Async Processing Monitor (back office)

There is a shipped back-office monitor at route async-monitoring (nav icon tabler-clock-bolt) with three tabs: Summary, Recent runs and Live queue.

Live queue reads GET /back-office/async-monitoring/queue?limit=100 and shows waiting / processing / delayed / failed counts plus two tables:

TableColumns
JobsJob, Queue, State, In queue (waiting_seconds), Attempts, Runs at, Queued
Failed jobsJob, Queue, Error, Failed at

waiting_seconds is populated only for waiting jobs — processing and delayed rows render an em dash. An Auto-refresh toggle polls every 5s, and only while Live queue is the active tab.

Three endpoints — GET /async-monitoring, /summary, /queue — and the whole prefix sits behind the global-admin middleware, deliberately: the monitor exposes cross-tenant job internals (tenant DBs, exception traces).

Gating note. This page introduced a front-end gate primitive: an optional role on nav items and route meta, ANDed with the existing CASL/permission gate. hasRole() reads userData.role / userData.roles and fails closed when userData is absent. Both the nav entry and the route carry role: 'global-administrator', because view global-settings is also held by global-support and global-developer — which CASL cannot tell apart, since all three carry manage:all.


Debugging Jobs

Common Issues

  1. Job not executing: Check queue worker is running (php artisan queue:work)
  2. Tenant database error: Verify tenant database name is passed to job constructor
  3. Serialization error: Ensure job constructor parameters are serializable (use IDs, not models)
  4. Job timing: For delayed jobs, check sent_at calculation in PaymentCaptureService::processOrderJob()

Logging

Jobs typically log their execution. Search for job class name in logs:

grep "ProcessOrderJob" storage/logs/laravel.log

Running Jobs Synchronously

For debugging, set QUEUE_CONNECTION=sync in .env to execute all jobs synchronously.