Skip to content

PayByBank Status Polling ​

Flow ID: SY-16 | Module(s): job, eshop, checkout, PayByBank | Complexity: High Last Updated: 2026-09-29 — Advisable-com/ecommercen#754: PaymentConfirmationService::confirmPayment() no longer writes is_paid = 1 (see the status-writes row in the comparison table); previous note, 2026-08-03 — Advisable-com/ecommercen#531: PAY_BY_BANK/EXPIRATION is now validated where the debris-cancellation cron consumes it (see SY-03's payByBankExpirationSeconds()), falling back to 86400s (24h) for an unset/empty/non-numeric value; this legacy polling job's own consumption of the same registry key (payByBankFactory(), below) is unrelated code and was not touched by that fix

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); passed unvalidated into Config's typed int $expirationSeconds constructor param here (payByBankFactory(), ecommercen/job/libraries/AdvPayByBankGetStatus.php:154-161) -- this job has no fallback of its own. The debris-cancellation cron's separate consumer of this same registry value (AdvCancelIncompleteOrders::payByBankExpirationSeconds(), see SY-03) does validate it and falls back to 86400s (24h) (Advisable-com/ecommercen#531)

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). Read raw via payByBankFactory() into Config's typed int constructor param -- no validation in this job. Since Advisable-com/ecommercen#531, the debris-cancellation cron's independent read of this same key (AdvCancelIncompleteOrders::payByBankExpirationSeconds(), SY-03) falls back to 86400s (24h) on an unset/empty/non-numeric value; that fallback does not extend to this polling job

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(). Since #754 confirmPayment() writes only status => PAID (plus optional tran_ticket / viva_payway / installments / meta_data) and no longer sets is_paid = 1 (product-owner ruling recorded in the comment at src/Domains/Checkout/PaymentConfirmationService.php:58-79), so a PollPayByBankStatus-confirmed order stays is_paid = 0, unlike orders confirmed by the legacy handlers that call set_is_paid(). The legacy AdvPayByBankGetStatus job also writes only status (AdvPayByBankGetStatus.php:83-86)
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.