Skip to content

PayByBank Status Polling

Flow ID: SY-16 | Module(s): job, eshop, checkout, PayByBank | Complexity: High Last Updated: 2026-05-29

Business Context

PayByBank is a Greek open-banking payment provider that allows customers to pay for orders via direct bank transfer. Unlike card payments, PayByBank transactions are asynchronous -- the customer initiates payment and the bank processes it independently. The platform receives webhook callbacks for status changes, but this polling job acts as a safety net to catch any missed callbacks.

What the job does:

  • Queries all orders with payway='paybybank' and status='PENDING'
  • Batch-fetches their status from the PayByBank API by order serial
  • Maps PayByBank statuses to platform order statuses
  • Cancels orders that were never captured by PayByBank (not found in API response)
  • Rolls back coupon usage on cancellation
  • Logs all API interactions to the pbb_logging table

Business value: Ensures payment state consistency between PayByBank and the platform, catching edge cases where webhook delivery fails.


Legacy Job (AdvPayByBankGetStatus)

Still active. This is the currently deployed polling job on all live merchants. PollPayByBankStatus (see below) is the intended long-term replacement; operators switch by commenting out this entry and uncommenting the modern one in application/config/jobs.php.

Architecture

AdvPayByBankGetStatus (JobCommand)
  |
  +--> getOrders()
  |      order_model::getRecords() where payway='paybybank', status='PENDING'
  |
  +--> payByBankFactory()
  |      PayByBank(Config(API_KEY, API_URL, EXPIRATION))
  |
  +--> payByBank::getOrdersBySerial($serials)
  |      GET /order/merchant/{apiKey}/{serial1,serial2,...}
  |
  +--> logPayByBank($response, 'getlist')
  |
  +--> processOrders($payByBankOrders, $orderSerials)
  |      For each PayByBank order:
  |        CANCELLED/CANCELLED_BY_MERCHANT -> status='CANCELED' + rollback coupon
  |        PAID/COMPLETED               -> status='PAID'
  |        READY_TO_CANCEL              -> no action (wait for next cycle)
  |
  +--> cancelNotCapturedOrders($missingSerials)
         Orders in our DB but NOT in PayByBank response -> CANCELED + rollback coupon

Job Classes

ClassFilePurpose
AdvPayByBankGetStatusecommercen/job/libraries/AdvPayByBankGetStatus.phpBase polling job
PayByBankGetStatusapplication/modules/job/libraries/PayByBankGetStatus.phpClient-overridable subclass

PayByBank SDK Classes

ClassFilePurpose
PayByBanksrc/PayByBank/PayByBank.phpAPI client (Guzzle HTTP)
Configsrc/PayByBank/Config.phpAPI configuration (key, URL, expiration)
PayByBankStatussrc/PayByBank/PayByBankStatus.phpStatus constants

Supporting Classes

ClassFileRole
Adv_order_modelecommercen/eshop/models/Adv_order_model.phpOrder queries, updates, PBB logging
Coupons_modelapplication/modules/coupons/models/Coupons_model.phpCoupon rollback on cancel

Code Flow

1. Fetch Pending PayByBank Orders

php
order_model->getRecords([
    'select' => 'id, order_serial, coupon_id, shipping_mobile, shipping_phone, pricing_mobile, pricing_phone',
    'conditions' => ['payway' => 'paybybank', 'status' => 'PENDING']
])

If no PENDING PayByBank orders exist, the job exits early.

2. Build PayByBank Client

The payByBankFactory() method creates a PayByBank instance configured from registry:

Registry GroupRegistry KeyPurpose
PAY_BY_BANKAPI_KEYMerchant API key
PAY_BY_BANKAPI_URLAPI base URL
PAY_BY_BANKEXPIRATIONPayment code lifetime (seconds)

3. Batch Status Check

Calls payByBank->getOrdersBySerial() with all pending order serials. This makes a single GET request to:

GET {API_URL}/order/merchant/{API_KEY}/{serial1,serial2,...}

The response is logged to pbb_logging with called_for='getlist'.

4. Process API Response

For each order in the PayByBank response, maps the bank status to a platform action:

PayByBank StatusPlatform ActionNotes
CANCELLEDSet status CANCELEDCustomer canceled or payment expired
CANCELLED_BY_MERCHANTSet status CANCELEDMerchant canceled from PBB admin panel
PAIDSet status PAIDPayment completed
COMPLETEDSet status PAIDPayment fully settled
READY_TO_CANCELNo actionTransitional state; may resolve on next cycle

On cancellation, if the order has a coupon_id, the coupon is marked as unused via coupons_model->markCouponUnused().

5. Cancel Uncaptured Orders

Orders present in the platform database but NOT in the PayByBank API response are assumed to have never been captured (the customer never completed the bank authorization). These are automatically canceled with coupon rollback.


Data Model

Primary Table: shop_order

ColumnTypeRole
idintPK
order_serialvarcharMerchant order ID sent to PayByBank
statusvarcharOrder status (PENDING, PAID, CANCELED, etc.)
paywayvarcharPayment method identifier (paybybank)
coupon_idint/nullFK to coupon used on the order
pbb_payment_codevarcharPayByBank payment code

Logging Table: pbb_logging

ColumnTypeRole
created_atdatetimeWhen the API call was made
called_forvarcharAPI operation (getlist, cancel, order)
order_serialvarcharRelated order serial (optional)
successbooleanWhether the API call succeeded
response_texttextFull API response (JSON) or error message

PayByBank Status Constants

ConstantValueDescription
PENDINGPENDINGAwaiting customer action
PAIDPAIDPayment received
COMPLETEDCOMPLETEDFully settled
READY_TO_CANCELREADY_TO_CANCELPending cancellation
CANCELLEDCANCELLEDCanceled by customer/timeout
CANCELLED_BY_MERCHANTCANCELLED_BY_MERCHANTCanceled by merchant
SETTLEDSETTLEDFunds transferred
REFUND_PENDINGREFUND_PENDINGRefund initiated
REFUND_PAIDREFUND_PAIDRefund processed
REFUND_COMPLETEDREFUND_COMPLETEDRefund settled
REFUND_SETTLEDREFUND_SETTLEDRefund fully completed

Configuration

Job Scheduling (application/config/jobs.php)

php
// ['command' => 'PayByBankGetStatus', 'schedule' => '0 */5 * * *', 'graceTime' => 300, 'retryTimes' => 3],

Default schedule: every 5 hours. Commented out by default (only enabled for merchants using PayByBank).

Job Options

The job defines no options (getOptions() returns an empty array).

Registry Settings

GroupKeyPurpose
PAY_BY_BANKAPI_KEYMerchant API key for PayByBank
PAY_BY_BANKAPI_URLPayByBank API base URL
PAY_BY_BANKEXPIRATIONPayment code expiration in seconds (converted to hours for API calls)

Environment-Specific Behavior

In development environments (ENVIRONMENT=development), the Guzzle HTTP client uses a local CA certificate bundle (application/config/cacert.pem) for SSL verification. In production, it uses the system's default CA store.


Client Extension Points

  1. Override the job class: Create PayByBankGetStatus in application/modules/job/libraries/ extending AdvPayByBankGetStatus. Override processOrders() to add custom status handling or getOrders() to change the query criteria.

  2. Override order update logic: Extend Order_model to customize update_order() behavior when PayByBank transitions occur (e.g., trigger additional notifications).

  3. Override coupon rollback: Extend Coupons_model to customize markCouponUnused() for client-specific coupon rules.

  4. Adjust polling frequency: Modify the cron schedule in jobs.php to poll more or less frequently based on payment volume.


Business Rules

RuleDescription
Safety-net designThis job is a fallback for missed webhooks; primary status updates come via PayByBank callbacks
Batch API callAll pending orders are checked in a single API request to minimize API usage
READY_TO_CANCEL waitOrders in READY_TO_CANCEL state are not acted upon; they may resolve by the next polling cycle
Missing = canceledOrders not found in the PayByBank API response are assumed uncaptured and auto-canceled
Coupon rollbackWhen an order is canceled, any associated coupon is restored to unused state
Full API loggingEvery API interaction is logged to pbb_logging for audit and debugging
Error resilienceAPI errors (network failures, non-200 responses, null responses) are caught and logged without crashing the job

Modern REST-Path Job (PollPayByBankStatus)

Added in commit 2c0688081 on branch feature/rest-checkout-event-bus; DI container registration added in commit 36859de26 on branch fix/paybybank-job-di-registration (closes issue #163).

FQCN: Advisable\Domains\Checkout\Jobs\PollPayByBankStatusFile: src/Domains/Checkout/Jobs/PollPayByBankStatus.php

What it does

PollPayByBankStatus is a direct port of the legacy AdvPayByBankGetStatus job at the business-logic level: it queries PENDING PayByBank orders, batch-fetches their status from the PayByBank API, and maps gateway statuses to platform actions using the same status constants and the same "missing = canceled" semantic.

The single structural difference is in how state transitions are applied. The legacy job calls order_model->update_order() directly — a raw database write that does not fire any post-status-change hooks. The modern job routes every transition through PaymentConfirmationService:

  • confirmPayment($orderId, ['metaData' => 'job:paybybank:reconcile']) for PAID/COMPLETED
  • cancelPayment($orderId, 'job:paybybank:<status>') for CANCELLED, CANCELLED_BY_MERCHANT, and missing orders

PaymentConfirmationService (src/Domains/Checkout/PaymentConfirmationService.php) dispatches OrderPaid or OrderCanceled events after writing the status, ensuring the full post-payment listener chain fires (confirmation email, ERP webhook, low-stock alert, coupon-usage decrement). The legacy job does not trigger any of these listeners.

Architecture

PollPayByBankStatus (JobCommand, DI-managed)
  |
  +--> createPayByBank()
  |      getPayByBankSettings() -> PayByBank(PayByBankConfig)
  |      Returns null if gateway not configured -> early exit
  |
  +--> OrderRepository::match(
  |        Filter('payway','paybybank'), Filter('status','PENDING'))
  |
  +--> payByBank::getOrdersBySerial($serials)
  |      GET /order/merchant/{API_KEY}/{serial1,serial2,...}
  |      Returns null/error_code -> early exit (no logging in modern path)
  |
  +--> reconcile($orderId, $gatewayStatus)    [per captured order]
  |      PAID / COMPLETED               -> confirmPayment() -> OrderPaid event
  |      CANCELLED / CANCELLED_BY_MERCHANT -> cancelPayment() -> OrderCanceled event
  |      READY_TO_CANCEL                -> no-op (deferred to next run)
  |
  +--> cancelPayment($orderId, 'job:paybybank:missing')  [per uncaptured order]
         OrderCanceled event

Key classes

ClassFileRole
PollPayByBankStatussrc/Domains/Checkout/Jobs/PollPayByBankStatus.phpModern polling job
PaymentConfirmationServicesrc/Domains/Checkout/PaymentConfirmationService.phpRoutes state transitions + dispatches events
OrderRepositorysrc/Domains/Order/Order/Repository/Repository.phpDomain-layer order query
OrderPaidsrc/Domains/Order/Event/OrderPaid.phpEvent dispatched on payment confirmation
OrderCanceledsrc/Domains/Order/Event/OrderCanceled.phpEvent dispatched on cancellation

Registration in jobs.php

The job is registered in the commandOptions map so the job framework can resolve its options:

php
// application/config/jobs.php line 199
\Advisable\Domains\Checkout\Jobs\PollPayByBankStatus::class => \Advisable\Domains\Checkout\Jobs\PollPayByBankStatus::getOptions(),

The schedule entry lives in the core queue's commands array alongside the legacy entry, but remains commented out:

php
// application/config/jobs.php lines 41–46
// ['command' => 'PayByBankGetStatus', 'schedule' => '0 */5 * * *', 'graceTime' => 300, 'retryTimes' => 3],
// Modern port of the legacy `PayByBankGetStatus` (#163). Routes status flips through
// PaymentConfirmationService so OrderPaid/OrderCanceled listeners fire (confirmation
// email, ERP webhook, coupon-usage decrement, etc.). Operators flip the leading `//`
// to switch from the legacy job to the modern one when ready.
// ['command' => \Advisable\Domains\Checkout\Jobs\PollPayByBankStatus::class, 'schedule' => '0 */5 * * *', 'graceTime' => 300, 'retryTimes' => 3],

The job is also registered as a DI service in src/Domains/Checkout/container.php:

php
// src/Domains/Checkout/container.php
// ─── Jobs ───────────────────────────────────────────────────────────
$services->set(Jobs\PollPayByBankStatus::class);

This registration is required. The cron dispatcher (AdvJob) resolves jobs via $container->has($name) ? $container->get($name) : new $name(). Without the $services->set(...) entry, any operator who enables the schedule entry would get a fatal error because the fallback new $name() path cannot satisfy the required constructor arguments (OrderRepository, PaymentConfirmationService). Commit 36859de26 added this registration; it is verified by tests/Integration/Domains/Checkout/Jobs/PollPayByBankStatusContainerTest.php.

Operator toggle pattern: Both the legacy PayByBankGetStatus and the modern PollPayByBankStatus schedule entries are commented out by default (the job is only enabled for merchants that use PayByBank). When a merchant is ready to migrate to the modern path, the operator uncomments the PollPayByBankStatus line and leaves the PayByBankGetStatus line commented. Running both simultaneously is not intended — the legacy job would win on any overlap because it does not publish events, while the modern job would attempt to re-fire events on already-written status changes (idempotency guards in PaymentConfirmationService prevent double-dispatch, but running both wastes API quota).

Differences vs legacy job

AspectAdvPayByBankGetStatus (legacy)PollPayByBankStatus (modern)
Fileecommercen/job/libraries/AdvPayByBankGetStatus.phpsrc/Domains/Checkout/Jobs/PollPayByBankStatus.php
DependenciesCI registry, order_model, coupons_modelDI container: OrderRepository, PaymentConfirmationService
Config source$ci->registry->value('PAY_BY_BANK', ...)getPayByBankSettings() helper
Status writesorder_model->update_order() direct DB writePaymentConfirmationService::confirmPayment() / cancelPayment()
Event dispatchNoneOrderPaid / OrderCanceled via OrderEventDispatcher
Coupon rollbackInline via coupons_model->markCouponUnused()Handled by OrderCanceled listener chain
API error loggingWrites to pbb_logging via order_model->insertPBBLog()No logging (returns early on error)
IdempotencyNone (re-writes status on every run if order stays PENDING)PaymentConfirmationService guards already-PAID/CANCELED orders
Client extensionSubclass PayByBankGetStatus in application/modules/job/libraries/Override via DI alias in custom/Domains/container.php

GetOrdersTransferStatus not ported

The port of GetOrdersTransferStatus is now complete. See AD-33 Multi-Carrier Tracking for details. Implemented in src/Domains/Transporter/Jobs/GetOrdersTransferStatus.php; registered by FQCN in src/Domains/Transporter/container.php; operator-toggled via application/config/jobs.php using the same //-flip convention as this job. Closed Advisable-com/ecommercen#274.


Tests

FileTypeCoverage
tests/Integration/Domains/Checkout/Jobs/PollPayByBankStatusContainerTest.phpIntegrationAsserts PollPayByBankStatus resolves from the DI container and receives its constructor dependencies (OrderRepository, PaymentConfirmationService). Guards against future container re-breaks.

Coverage gaps: No unit or integration test exercises the full polling loop, the PayByBank API mock, status mapping, or order cancellation side-effects. The legacy AdvPayByBankGetStatus has no test coverage at all.


Known Issues & Security Gaps

  1. Legacy job suppresses all post-payment listeners (ecommercen/job/libraries/AdvPayByBankGetStatus.php:83). Direct order_model->update_order() calls bypass OrderPaid/OrderCanceled event dispatch — confirmation emails, ERP webhooks, and low-stock alerts do not fire for status transitions that originate in the legacy polling job rather than the webhook. The modern PollPayByBankStatus job resolves this by routing through PaymentConfirmationService.

  2. Legacy job has an early-exit logic inversion (ecommercen/job/libraries/AdvPayByBankGetStatus.php:22-24). The guard reads if ($orders) { return; } — it exits when orders ARE found, not when they are absent. This means the job is currently a no-op whenever there are pending PayByBank orders. The modern job corrects this with if (empty($orders)) { return; }.

  3. Modern job does not write to pbb_logging (src/Domains/Checkout/Jobs/PollPayByBankStatus.php:85-87). On API error, the modern job returns silently without an audit entry. The legacy job logs every interaction (including errors) to pbb_logging. This gap should be addressed before the modern job is put into production.