Appearance
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'andstatus='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_loggingtable
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 inapplication/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 couponJob Classes
| Class | File | Purpose |
|---|---|---|
AdvPayByBankGetStatus | ecommercen/job/libraries/AdvPayByBankGetStatus.php | Base polling job |
PayByBankGetStatus | application/modules/job/libraries/PayByBankGetStatus.php | Client-overridable subclass |
PayByBank SDK Classes
| Class | File | Purpose |
|---|---|---|
PayByBank | src/PayByBank/PayByBank.php | API client (Guzzle HTTP) |
Config | src/PayByBank/Config.php | API configuration (key, URL, expiration) |
PayByBankStatus | src/PayByBank/PayByBankStatus.php | Status constants |
Supporting Classes
| Class | File | Role |
|---|---|---|
Adv_order_model | ecommercen/eshop/models/Adv_order_model.php | Order queries, updates, PBB logging |
Coupons_model | application/modules/coupons/models/Coupons_model.php | Coupon 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 Group | Registry Key | Purpose |
|---|---|---|
PAY_BY_BANK | API_KEY | Merchant API key |
PAY_BY_BANK | API_URL | API base URL |
PAY_BY_BANK | EXPIRATION | Payment 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 Status | Platform Action | Notes |
|---|---|---|
CANCELLED | Set status CANCELED | Customer canceled or payment expired |
CANCELLED_BY_MERCHANT | Set status CANCELED | Merchant canceled from PBB admin panel |
PAID | Set status PAID | Payment completed |
COMPLETED | Set status PAID | Payment fully settled |
READY_TO_CANCEL | No action | Transitional 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
| Column | Type | Role |
|---|---|---|
id | int | PK |
order_serial | varchar | Merchant order ID sent to PayByBank |
status | varchar | Order status (PENDING, PAID, CANCELED, etc.) |
payway | varchar | Payment method identifier (paybybank) |
coupon_id | int/null | FK to coupon used on the order |
pbb_payment_code | varchar | PayByBank payment code |
Logging Table: pbb_logging
| Column | Type | Role |
|---|---|---|
created_at | datetime | When the API call was made |
called_for | varchar | API operation (getlist, cancel, order) |
order_serial | varchar | Related order serial (optional) |
success | boolean | Whether the API call succeeded |
response_text | text | Full API response (JSON) or error message |
PayByBank Status Constants
| Constant | Value | Description |
|---|---|---|
PENDING | PENDING | Awaiting customer action |
PAID | PAID | Payment received |
COMPLETED | COMPLETED | Fully settled |
READY_TO_CANCEL | READY_TO_CANCEL | Pending cancellation |
CANCELLED | CANCELLED | Canceled by customer/timeout |
CANCELLED_BY_MERCHANT | CANCELLED_BY_MERCHANT | Canceled by merchant |
SETTLED | SETTLED | Funds transferred |
REFUND_PENDING | REFUND_PENDING | Refund initiated |
REFUND_PAID | REFUND_PAID | Refund processed |
REFUND_COMPLETED | REFUND_COMPLETED | Refund settled |
REFUND_SETTLED | REFUND_SETTLED | Refund 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
| Group | Key | Purpose |
|---|---|---|
PAY_BY_BANK | API_KEY | Merchant API key for PayByBank |
PAY_BY_BANK | API_URL | PayByBank API base URL |
PAY_BY_BANK | EXPIRATION | Payment 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
Override the job class: Create
PayByBankGetStatusinapplication/modules/job/libraries/extendingAdvPayByBankGetStatus. OverrideprocessOrders()to add custom status handling orgetOrders()to change the query criteria.Override order update logic: Extend
Order_modelto customizeupdate_order()behavior when PayByBank transitions occur (e.g., trigger additional notifications).Override coupon rollback: Extend
Coupons_modelto customizemarkCouponUnused()for client-specific coupon rules.Adjust polling frequency: Modify the cron schedule in
jobs.phpto poll more or less frequently based on payment volume.
Business Rules
| Rule | Description |
|---|---|
| Safety-net design | This job is a fallback for missed webhooks; primary status updates come via PayByBank callbacks |
| Batch API call | All pending orders are checked in a single API request to minimize API usage |
| READY_TO_CANCEL wait | Orders in READY_TO_CANCEL state are not acted upon; they may resolve by the next polling cycle |
| Missing = canceled | Orders not found in the PayByBank API response are assumed uncaptured and auto-canceled |
| Coupon rollback | When an order is canceled, any associated coupon is restored to unused state |
| Full API logging | Every API interaction is logged to pbb_logging for audit and debugging |
| Error resilience | API 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'])forPAID/COMPLETEDcancelPayment($orderId, 'job:paybybank:<status>')forCANCELLED,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 eventKey classes
| Class | File | Role |
|---|---|---|
PollPayByBankStatus | src/Domains/Checkout/Jobs/PollPayByBankStatus.php | Modern polling job |
PaymentConfirmationService | src/Domains/Checkout/PaymentConfirmationService.php | Routes state transitions + dispatches events |
OrderRepository | src/Domains/Order/Order/Repository/Repository.php | Domain-layer order query |
OrderPaid | src/Domains/Order/Event/OrderPaid.php | Event dispatched on payment confirmation |
OrderCanceled | src/Domains/Order/Event/OrderCanceled.php | Event 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
| Aspect | AdvPayByBankGetStatus (legacy) | PollPayByBankStatus (modern) |
|---|---|---|
| File | ecommercen/job/libraries/AdvPayByBankGetStatus.php | src/Domains/Checkout/Jobs/PollPayByBankStatus.php |
| Dependencies | CI registry, order_model, coupons_model | DI container: OrderRepository, PaymentConfirmationService |
| Config source | $ci->registry->value('PAY_BY_BANK', ...) | getPayByBankSettings() helper |
| Status writes | order_model->update_order() direct DB write | PaymentConfirmationService::confirmPayment() / cancelPayment() |
| Event dispatch | None | OrderPaid / OrderCanceled via OrderEventDispatcher |
| Coupon rollback | Inline via coupons_model->markCouponUnused() | Handled by OrderCanceled listener chain |
| API error logging | Writes to pbb_logging via order_model->insertPBBLog() | No logging (returns early on error) |
| Idempotency | None (re-writes status on every run if order stays PENDING) | PaymentConfirmationService guards already-PAID/CANCELED orders |
| Client extension | Subclass 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
| File | Type | Coverage |
|---|---|---|
tests/Integration/Domains/Checkout/Jobs/PollPayByBankStatusContainerTest.php | Integration | Asserts 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
Legacy job suppresses all post-payment listeners (
ecommercen/job/libraries/AdvPayByBankGetStatus.php:83). Directorder_model->update_order()calls bypassOrderPaid/OrderCanceledevent 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 modernPollPayByBankStatusjob resolves this by routing throughPaymentConfirmationService.Legacy job has an early-exit logic inversion (
ecommercen/job/libraries/AdvPayByBankGetStatus.php:22-24). The guard readsif ($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 withif (empty($orders)) { return; }.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) topbb_logging. This gap should be addressed before the modern job is put into production.
Related Flows
- SY-01 Cron Job Framework -- job scheduling and execution
- CF-08 Payment Processing -- initial PayByBank order creation
- CF-09 Payment Webhooks -- primary webhook-based status updates from PayByBank