Appearance
Payment Webhooks & Callbacks
Flow ID: CF-09 | Module(s): webhooks, checkout, eshop, gift_cards | Complexity: High Last Updated: 2026-05-29
Business Context
Payment webhooks are asynchronous, server-to-server notifications sent by external payment providers to confirm (or reject) transactions. They serve as the authoritative payment confirmation for gateways that operate asynchronously, and as a fallback/supplement for gateways that also have a synchronous browser-redirect flow (get_response).
The platform has two layers of webhook handlers:
Legacy CI Layer — four ecommercen/webhooks/ handlers plus XPay in-controller handlers for provider-specific flows:
| Type | Provider | Purpose |
|---|---|---|
| Card payment | JCC | Asynchronous notification after card authorization/deposit |
| BNPL / Deferred | Klarna Payments | Authorization token delivery (payment not yet captured) |
| Marketplace | Skroutz SmartCart | Marketplace order creation and state updates |
| Multi-method | Viva Wallet | Transaction payment confirmation or failure, including gift cards |
| Card payment (in-controller) | Nexi XPay Greece | Asynchronous notification after card authorization; co-hosted on Adv_checkout (orders) and AdvGiftCardPage (gift cards) — not in ecommercen/webhooks/ |
Modern REST Layer (added in 4.99.6) — src/Rest/Webhooks/Controllers/Webhook.php (701 lines) provides REST-routed webhook endpoints for five gateways using the shared PaymentConfirmationService:
| Method | Path | Provider | Auth Mechanism |
|---|---|---|---|
| POST | /rest/webhooks/stripe | Stripe | Signature verification via STRIPE.WEBHOOK_SECRET registry key |
| POST | /rest/webhooks/vivawallet | Viva Wallet | Verification handshake on empty body; processes transactionPaymentCreated / transactionFailed |
| POST | /rest/webhooks/paypal | PayPal | Server-side re-fetch via PayPalRestApi::getOrderDetails(); confirms only when PayPal reports COMPLETED/APPROVED |
| POST | /rest/webhooks/piraeus | Piraeus | HMAC-SHA256 via Piraeus::validateNotificationResponse() |
| POST | /rest/webhooks/paybybank | PayByBank | None (mirrors legacy unsigned pattern; hardened only by payway-filter on order lookup) |
These REST webhooks are not behind JWT auth — each handler verifies requests using the gateway's own mechanism. They do not require APP_REST_API_ENABLED=true — webhook routes are registered unconditionally via application/config/webhook_routes.php (loaded in routes.php:786). DI registration: src/Rest/Webhooks/container.php. Routes: application/config/webhook_routes.php (lines 14-18).
The constructor is __construct(PaymentConfirmationService $confirmationService, OrderRepository $orderRepository, ?PayPalRestApi $payPalApi = null) (src/Rest/Webhooks/Controllers/Webhook.php:41-47). container.php wires a makePayPalApi factory (nullable); OrderRepository is wired as a constructor dependency (wired at container.php:33).
Key architectural difference from the synchronous flow (CF-08):
- Webhooks are triggered by the payment server pushing to the shop. They are retryable by the provider and do not depend on the customer's browser session.
get_response(synchronous) is triggered by the customer's browser redirect back to the shop. It is a single-shot flow that also renders the thank-you page or error page for the customer.- For JCC and Viva Wallet, both flows can fire for the same order. The synchronous
get_responsechecks whether the order status has already been updated by the webhook and acts accordingly (displaying the result without re-processing).
API Reference
Webhook URL Endpoints
| Endpoint URL Pattern | Route Target | HTTP Method | Provider |
|---|---|---|---|
/jccNotification | webhooks/jcc/handleNotification | POST | JCC |
/klarnaAuthorizationCallback/{secretToken} | webhooks/KlarnaPayments/authorizationCallback/$1 | POST | Klarna |
/handleSkroutzWebhook | webhooks/smartCart/handle | POST | Skroutz SmartCart |
/webrun/skroutzWebhookRequest | webhooks/smartCart/handle | POST | Skroutz SmartCart (legacy alias) |
/vivaWallet/{event} | webhooks/viva/handleWebhook/$1 | POST (or GET for verification) | Viva Wallet |
/checkout/xPayHook | checkout/xPayHook | POST | Nexi XPay (orders) |
/{lang}/checkout/xPayHook | checkout/xPayHook | POST | Nexi XPay (orders, localized) |
/gift-card/xPayHook | gift_cards/gift_card_page/xPayHook (via catch-all gift-card/(.+) at application/config/routes.php:720,722) | POST | Nexi XPay (gift cards) |
Legacy handler routes are defined in application/config/routes.php (lines 538, 551-554). XPay regular-order routes are at routes.php:622-623; the gift-card route uses the catch-all at :720,722.
Viva Wallet Verification Endpoint
Viva Wallet uses a verification handshake before activating webhooks. When Viva sends a GET request to the webhook URL with an empty body, the handler responds with a verification key generated via the Viva API (VivaWallet::generateWebhookVerificationKey()). This calls Viva's /api/messages/config/token endpoint with Basic Auth (merchantId:apiKey).
Code Flow
Controller Inheritance
Each webhook follows the same two-tier class structure:
Base_c (application/core/)
└─ Adv{Handler} (ecommercen/webhooks/) ← business logic
└─ {Handler} (application/controllers/webhooks/) ← empty subclass, client-overridableThe application/controllers/webhooks/ files are thin subclasses (typically empty) that allow client repos to override behavior by extending the same class name in their own application/controllers/webhooks/ directory.
XPay structural exception: The XPay handler does not live in
ecommercen/webhooks/. It is a public method onAdv_checkout(xPayHook, line 3502) for regular orders andAdvGiftCardPage(xPayHook, line 1218) for gift cards. Verification is delegated to the modernAdvisable\PaymentGateways\NexiXPay\XPayservice class — the only legacy webhook to use asrc/-domain helper.
JCC Webhook (AdvJcc)
File: ecommercen/webhooks/AdvJcc.phpController: application/controllers/webhooks/Jcc.phpTrait: OrderForErpHookFireTrait
Step-by-Step Flow
Receive POST at
/jccNotification- Reads all POST parameters via
$this->input->post() - If params are empty, logs error and exits
- Reads all POST parameters via
Look up order
- Queries
jcc_order_idstable bymdOrderparameter to find theshop_order_id - Loads the full order from
shop_orderviaorder_model->getRecordsByJccOrderId() - If no order found, logs error with
mdOrderandorderNumber, exits
- Queries
Validate HMAC-SHA256 checksum
- Retrieves
JCC.CALLBACK_TOKENfrom the registry - Removes the
checksumfield from params - Sorts remaining params alphabetically by key (
ksort) - Builds a parameter string in format:
key1;value1;key2;value2;... - Computes
HMAC-SHA256(paramString, callbackToken)and uppercases it - Compares against the received
checksumvalue
- Retrieves
Calculate status
- Only processes when
operationisapprovedordeposited - If
statusparam is'1'=> order status =PAID; any other value => order status =CANCELED(ecommercen/webhooks/AdvJcc.php:65:($params['status'] === '1') ? 'PAID' : 'CANCELED') - For other
operationvalues, returnsnull(no action)
- Only processes when
Apply status update
- If status is
PAID: firesafterOrderSuccessHooks(orderId)which calls the ERP hook - Updates
shop_orderwithstatusandmeta_data = 'controller:webhooks/jcc' - Returns HTTP 200
- If status is
Side Effects on PAID
internalApiOrderForErpHook($orderId)sends a GET request to the configured internal API ERP endpoint (internalApi.apiOrderWebHooks.erpReady/{orderId}) to notify the ERP system
Relationship with Synchronous Flow
The synchronous jccResponse() in Adv_checkout checks the order status first. If the order is no longer PENDING (because the webhook already processed it), it displays the appropriate success/failure message without re-processing. Only if the order is still PENDING does the synchronous flow query JCC's API for the order status.
Klarna Payments Webhook (AdvKlarnaPayments)
File: ecommercen/webhooks/AdvKlarnaPayments.phpController: application/controllers/webhooks/KlarnaPayments.php
Step-by-Step Flow
Receive POST at
/klarnaAuthorizationCallback/{secretToken}- Validates
secretTokenis not empty (returns 422 if missing) - Reads raw JSON body via
$this->input->raw_input_stream - Returns 422 if body is empty
- Validates
Validate payload
- Checks that
authorization_tokenandsession_idare both present - Logs individual errors for each missing field
- Returns 422 if validation fails
- Checks that
Update authorization token
- Queries
shop_order_klarna_paymentstable bysession_id - If no matching record found, logs error and returns 422
- Updates the
authorization_tokenfield on the matching record - Returns HTTP 200
- Queries
Important: No Status Change
This webhook does not change order status. Klarna uses a decoupled flow:
- Customer authorizes payment on Klarna's side
- Klarna sends the authorization token to this webhook
- Later, during the
get_responseconfirmation stage (klarnaPaymentsConfirmation), the shop uses the stored authorization token to capture the payment - The checkout controller's
getAuthorizationToken()method looks up the token fromshop_order_klarna_paymentsif it was not passed directly in the checkout data
Skroutz SmartCart Webhook (AdvSmartCart)
File: ecommercen/webhooks/AdvSmartCart.phpController: application/controllers/webhooks/SmartCart.phpTrait: OrderForErpHookFireTraitModel: ecommercen/eshop/models/Adv_skroutz_orders_model.php
Step-by-Step Flow
Receive POST at
/handleSkroutzWebhook- Checks
SMARTCART.IS_ENABLEDregistry value; returns 500 if disabled - Reads raw JSON input stream
- Checks
Route by event type
new_orderevent:- Validates that
order.stateisopen; returns 422 if not - Calls
skroutz_orders_model->handleOrderCreation() - Checks for duplicate order by
code - Inserts into
skroutz_orderstable (code, state, invoice, comments, courier details, customer data, pickup/collection point info, express flag) - If
invoiceis true, inserts intoskroutz_invoice_details(company, VAT, DOY, address, VAT exclusion) - Inserts each line item into
skroutz_line_items(product details, quantity, pricing, size, EAN) - Returns 200 on success, 500 on failure
order_updatedevent:- Calls
skroutz_orders_model->handleOrderUpdate() - Updates the existing
skroutz_ordersrecord with new state, pickup window, location, courier voucher, tracking codes - Returns 200 on success, 500 on failure
- Then checks for express order auto-accept
- Validates that
Express order auto-accept (post-update)
- If
order.expressistrueandorder.stateisaccepted - Queries for a matching Skroutz order with
shop_order_id IS NULL(not yet converted to a shop order) - Calls
autoAcceptExpressOrder(code)which:- Creates a guest customer with a generated invalid email
- Builds a fake cart from Skroutz line items with live product data
- Creates a full shop order via
order_model->create_order_admin() - Sets the shop order status to
PAIDwith providerskroutz_smart_cart - Links the Skroutz order to the shop order (
shop_order_idupdate)
- Fires
afterOrderSuccessHooks()(ERP notification)
- If
Viva Wallet Webhook (AdvViva)
File: ecommercen/webhooks/AdvViva.phpController: application/controllers/webhooks/Viva.php
Step-by-Step Flow
Receive request at
/vivaWallet/{event}- Reads raw JSON body
- Verification handshake: If body is empty, returns the webhook verification key from
Factories::vivaWallet()->generateWebhookVerificationKey()and exits
Look up order (dual-path)
- First searches
shop_orderbypayway = 'vivawallet'andtran_ticket = EventData.OrderCode - If not found, flags as gift card and searches
gift_card_ordersbytran_ticket - If neither found, logs error and exits
- First searches
Gift card path (if order found in
gift_card_orders)Event Action transactionPaymentCreatedUpdates installments on the gift card order, then calls acceptGiftCard()transactionFailedCalls cancelGiftCard()Other events Exits silently acceptGiftCard()details:- Creates a coupon with type
GiftCard, discount equal to the gift card amount, max 1 usage, 5-year validity - Generates the coupon code
- Updates gift card order: status ->
Completed, setscompleted_at, linkscoupon_id, resetsemail_sentandsms_sentflags - Wrapped in a DB transaction
cancelGiftCard()details:- If status is
Pending: bulk-cancels (setsgift_card_status = Canceled,canceled_attimestamp) - If status is
Completed: deletes the associated coupon, nullscoupon_id, sets canceled status (wrapped in a DB transaction)
Returns HTTP 200 and exits.
- Creates a coupon with type
Regular order path
- Idempotency guard: If order status is not
PENDING, logs a warning and exits (prevents double-processing) - Sets
meta_data = 'controller:WebhooksHandler:viva'
Event Status Update Additional Fields transactionPaymentCreatedPAIDviva_payway(mapped fromTransactionTypeId),installmentstransactionFailedCANCELEDNone Other events No action (exits) N/A - Calls
order_model->update_order()to persist the status change - Returns HTTP 200
- Idempotency guard: If order status is not
Viva Payment Method Mapping
The getVivaPaymentMethodFromTransaction() helper (in ecommercen/helpers/eshop_helper.php) maps Viva's TransactionTypeId integers to internal payment method strings. Key mappings:
| TransactionTypeId | Internal Method |
|---|---|
| 0, 1, 4-8, 13, 18-19, 22, 31, 69 | vivawallet_credit_card |
| 9, 11 | vivawallet_viva_wallet |
| 15 | vivawallet_dias |
| 16, 17 | vivawallet_cash |
| 23, 24 | vivawallet_ideal |
| 25, 26 | vivawallet_p24 |
| 48, 49 | vivawallet_paypal |
| 52, 53, 80, 81 | vivawallet_klarna |
| 60, 61 | vivawallet_iris |
| 62, 63 | vivawallet_pay_by_bank |
| 68 | vivawallet_pay_on_delivery |
(Plus additional methods: BLIK, PayU, Giropay, SOFORT, EPS, WeChat Pay, BitPay, Trustly, MB Way, Multibanco, Payconiq, Bancomat Pay, TBI Bank, Swish, Bluecode.)
ecommercen/helpers/eshop_helper.php:304
Relationship with Synchronous Flow
The synchronous vivaWalletResponse() in Adv_checkout:
- If order is still
PENDING, queries Viva's API to validate payment - If order is already
PAID(webhook arrived first), skips validation and renders the success page - The synchronous flow handles emails, SMS, stock updates, analytics, and session cleanup -- none of which the webhook handles
Important: The Viva Wallet webhook does not fire the ERP hook, send emails, or trigger any post-order side effects. This is by design -- the synchronous get_response flow handles those. If the synchronous flow arrives after the webhook, it detects the PAID status and still runs the full success pipeline.
XPay Webhook — Orders (Adv_checkout::xPayHook())
File: ecommercen/checkout/controllers/Adv_checkout.php:3502-3582Gateway service: src/PaymentGateways/NexiXPay/XPay.php
Step-by-step flow:
- Reads raw JSON via
$this->input->input_stream()— HTTP 400 on decode failure; audit-logsCALLBACKtoxpay_loggingvialogXPayTransaction(). XPay::parsePaymentNotification($notification)validatesoperation,order,operationResult,orderIdfields; HTTP 400 onInvalidArgumentException.XPay::verifyNotificationSecurityToken($notification, $orderId)(defaultflow='order') — HTTP 401 on mismatch (see securityToken section below).- Order lookup via
order_model->getOrder(['order_serial' => $orderId])— HTTP 404 on miss. - REFUNDED notifications: logged, exit HTTP 200 with no state change.
- State machine:
shop_order.status | XPay status | Action |
|---|---|---|
| PENDING | PAID | processXPayPaymentResult() → mark PAID |
| PENDING | CANCELED | processXPayPaymentResult() → cancel |
| PAID | CANCELED | processXPayPaymentResult() → reversal |
| CANCELED | PAID | processXPayPaymentResult() → resurrect |
| other | any | log, exit HTTP 200, no change |
PAID side effects (via processXPayPaymentResult(), Adv_checkout.php:3722): set_status('PAID') + set_is_paid() + afterOrderSuccessHooks() (ERP) + adv_mailer->order_complete() + sendSmsSuccess() + informLowStock(). CANCELED side effects: cancelOrder('xpay_callback') + afterOrderCancelHooks().
Note: unlike JCC/Viva, the XPay webhook fires customer-facing emails and SMS directly. See Known Issues.
XPay Webhook — Gift Cards (AdvGiftCardPage::xPayHook())
File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php:1218-1290
Separate from checkout/xPayHook — lookup targets gift_card_orders, not orders. Uses flow='gift_card' to scope the securityToken lookup (cross-flow collision guard).
State machine via xpayWebhookAction(GiftCardStatus $current, string $xpayStatus): string:
Current gift_card_status | XPay status | Action |
|---|---|---|
| Pending | PAID | acceptGiftCard() + acceptPostActions() (mint coupon, Completed) |
| Pending | CANCELED | cancelGiftCard() + cancelPostActions() |
| Completed | CANCELED | cancelGiftCard() — reversal (revokes coupon) |
| other | any | noop (idempotency guard; acceptGiftCard() is not idempotent) |
xpayWebhookAction() is public static, pure, unit-tested at tests/Legacy/GiftCards/AdvGiftCardPageTest.php:28-115.
XPay securityToken Verification
XPay Greece's only documented webhook-authentication mechanism is the securityToken echo-back (no HMAC, per developer.nexigroup.com/xpaygreece/en-EU/api/notification-api-v1/).
Token lifecycle:
XPay::createHostedPaymentOrder()logs the Nexi HPP creation RESPONSE row toxpay_logging, which includessecurityTokeninresponse_data(src/PaymentGateways/NexiXPay/XPay.php:144).- On every notification, Nexi echoes the same token in
notification.securityToken. XPay::verifyNotificationSecurityToken()(:281) reads the stored token fromxpay_loggingfiltered by(order_serial, flow, transaction_type='RESPONSE', status='SUCCESS')ordered bycreated_at DESC(:308-334), then compares withhash_equals().
Fail-closed on all error paths: missing token field, non-string value, empty string, no stored row, malformed JSON, DB exception — all return false (11 regression tests at XPayTest.php:380-509).
Cross-flow collision guard: the flow filter ('order' or 'gift_card') ensures a gift-card webhook cannot resolve a regular order's stored token even when GIFT_CARDS.ORDER_PREFIX is empty. Regression test at XPayTest.php:510-531.
Covering index: idx_token_lookup (order_serial, flow, transaction_type, status, created_at) on xpay_logging (database/migrations/20251117205429_create_xpay_logging_table.php:28-31).
Stripe REST Webhook (Webhook::stripe())
File: src/Rest/Webhooks/Controllers/Webhook.php
Step-by-Step Flow
Receive POST at
/rest/webhooks/stripe- Entry at
Webhook.php:126; reads raw request body and verifies the Stripe signature viaStripe\Webhook::constructEvent()(HMAC-SHA256) using theSTRIPE.WEBHOOK_SECRETregistry key (Webhook.php:139) - Returns HTTP 400 on invalid signature; secret loaded at
:139
- Entry at
Route by event type (
:159-185)Stripe Event Action Citation checkout.session.completedPaymentConfirmationService::confirmPayment($orderId):159-174checkout.session.expiredPaymentConfirmationService::cancelPayment($orderId, 'webhook:stripe:expired'):175-185Unmatched / late-commit (#33):
stripe()reads the return value ofconfirmPayment()/cancelPayment(). Acheckout.session.completedorcheckout.session.expiredevent returns HTTP 404{"received":true,"matched":false}whenclient_reference_id <= 0or when the confirm/cancel call returns false (order not found or not yet committed) — Stripe has no legacy fallback, so 404 forces a retry rather than silently dropping a real confirmation. Unknown event types still return HTTP 200. (Webhook.php:161-184)
Viva REST Webhook (Webhook::vivawallet())
File: src/Rest/Webhooks/Controllers/Webhook.php
Step-by-Step Flow
Receive request at
/rest/webhooks/vivawallet- Entry at
:198; verification handshake: Accepts an empty body or{}as the verification trigger (:203). Unlike the legacyAdvVivahandler which usesFactories::vivaWallet(), the REST handler constructsVivaWalletdirectly via the registry helper, bypassing the factory. handleVivaWalletVerification()at:266-293
- Entry at
Route by event type
- Checks
EventTypeId(numeric) first at:245-251, withEventTypestring name as fallback
EventTypeId EventType (fallback) Action Citation 1796transactionPaymentCreatedPaymentConfirmationService::confirmPayment()withmetaData = 'webhook:vivawallet':253-2581797transactionFailedPaymentConfirmationService::cancelPayment()withmetaData = 'webhook:vivawallet:failed':259-261- Checks
Order lookup:
findOrderByTranTicket()thin-delegates to$this->orderRepository->findByTranTicketAndPayway()(Webhook.php:633-636). Note: on ashop_ordermiss the handler falls back togift_card_ordersby transaction ticket viahandleVivaWalletGiftCard()(#32), mirroring the legacyAdvVivadual lookup —transactionPaymentCreatedrunsacceptGiftCard()(after setting installments),transactionFailedrunscancelGiftCard(). Gift-card accept/cancel delegates to the legacygift_card_orders_model(the controller extendsBase_c), since that coupon-generation logic is not ported to the modern layer. Only when BOTH lookups miss does the handler return HTTP 404{"received":true,"matched":false}(#33) so Viva retries — a legitimate webhook may arrive before the order row commits.
PayPal REST Webhook (Webhook::paypal())
File: src/Rest/Webhooks/Controllers/Webhook.php
Step-by-Step Flow
Receive POST at
/rest/webhooks/paypal(Webhook.php:359-434)Server-side payment verification (
Webhook.php:449-486)verifyPayPalPaymentCompleted(?string $payPalOrderId): ?boolat:449-486- Re-fetches the PayPal order via
PayPalRestApi::getOrderDetails() - Returns
trueonly when PayPal reports the order as COMPLETED or APPROVED - Forged or inconclusive responses → HTTP 202 with no order change, logged on
webhookschannel (:398/:411)
Route by event type (
:388-431)PayPal Event Action CHECKOUT.ORDER.APPROVEDPaymentConfirmationService::confirmPayment($orderId)PAYMENT.CAPTURE.COMPLETEDPaymentConfirmationService::confirmPayment($orderId)PAYMENT.CAPTURE.DENIEDPaymentConfirmationService::cancelPayment($orderId, 'webhook:paypal:PAYMENT.CAPTURE.DENIED')PAYMENT.CAPTURE.REVERSEDPaymentConfirmationService::cancelPayment($orderId, 'webhook:paypal:PAYMENT.CAPTURE.REVERSED')Cancel branch (
:426-431)Unmatched order: Returns HTTP 404
{"received":true,"matched":false}if no order can be resolved from the payload (Webhook.php:381-385, #33), so PayPal retries.Order ID extraction via
extractPayPalOrderId()(Webhook.php:644-677) — three-tier lookup:purchase_units[0].reference_id(direct field)supplementary_data.related_ids.order_id(DB lookup viaOrderRepository::findByTranTicketAndPayway())resource.custom_id(fallback)
Piraeus REST Webhook (Webhook::piraeus())
File: src/Rest/Webhooks/Controllers/Webhook.phpAdded: commit fbf8c8943 (#271)
Step-by-Step Flow
Receive POST at
/rest/webhooks/piraeus; routewebhook_routes.php:17- Body is form-encoded
$_POST(not JSON); requiresMerchantReference
- Body is form-encoded
Order lookup
findOrderBySerialAndPayway($merchantReference, 'piraeus')(:509)- Unmatched → HTTP 404 (
:514) so Piraeus retries (all five REST webhook handlers now return 404 on unmatched — #33)
Load config
PiraeusConfig::fromArray(getPiraeusBankSettings())(:521-523)- Returns HTTP 500 if unconfigured (
:525-528)
HMAC validation
Piraeus::validateNotificationResponse($postData, $tranTicket)using storedtran_ticketfrom the order lookup; mismatch → HTTP 400 (:532)
Status dispatch
Piraeus::isSuccessfulNotification()→confirmPayment()withtransactionId=ApprovalCode,metaData='webhook:piraeus'(:541-544)- Otherwise →
cancelPayment(... 'webhook:piraeus:failed')(:546)
PayByBank REST Webhook (Webhook::paybybank())
File: src/Rest/Webhooks/Controllers/Webhook.phpAdded: commit 7e73da217 (#266)
Step-by-Step Flow
Receive POST at
/rest/webhooks/paybybank; routewebhook_routes.php:18- JSON body; extracts
merchantOrderIdandomtTransactionBank.merchantOrderStatus - Returns HTTP 400 on missing fields
- JSON body; extracts
Order lookup
findOrderBySerialAndPayway($merchantOrderId, 'paybybank')(:592)- Unmatched → HTTP 404 (
:597) so the provider retries
No signature verification
- Mirrors legacy
Adv_checkout::payByBankResponse()(unsigned); hardened only by the payway-filter on order lookup — see Known Issues
- Mirrors legacy
Status dispatch
merchantOrderStatusAction PAIDconfirmPayment()withtransactionId=bankPaymentCode,metaData='webhook:paybybank'CANCELLEDcancelPayment()withmetaData='webhook:paybybank:cancelled'READY_TO_CANCELcancelPayment()withmetaData='webhook:paybybank:ready_to_cancel'other HTTP 200 no-op
Private Helpers in Webhook.php
findOrderByTranTicket() (:633-636)
Thin-delegates to $this->orderRepository->findByTranTicketAndPayway($tranTicket, 'vivawallet'). The 'vivawallet' literal is the only payway this helper is called for. Used by vivawallet().
findOrderBySerialAndPayway() (:625-628)
Thin-delegates to $this->orderRepository->findBySerialAndPayway($serial, $payway). Returns the full Order entity; tran_ticket is accessed as $order->tran_ticket by the Piraeus HMAC step. Used by piraeus() and paybybank().
extractPayPalOrderId() (:644-677)
Three-tier PayPal order ID lookup (direct field → DB lookup → fallback). DB branch at :656-665 calls $this->orderRepository->findByTranTicketAndPayway() and reads (int) $entity->id. extractPayPalGatewayOrderId() at :688-700 is a pure payload parser (no DB).
PaymentConfirmationService Contract
File: src/Domains/Checkout/PaymentConfirmationService.php
The shared service used by all five REST webhook handlers for idempotent order status updates.
confirmPayment(int $orderId, array $options = []): bool (:35-111)
| Step | Detail | Citation |
|---|---|---|
| Idempotency check | Returns true immediately if status is already PAID or PENDING_ACCEPTED | :46 |
| Guard | Returns false if status is not PENDING | :54 |
| Status update | Sets status = PAID, is_paid = 1 | :59-62 |
| Optional fields | Sets transactionId, vivaPayway, installments, metaData from $options if provided | :64-78 |
| Stock | Does NOT reduce stock — stock was already reserved at order placement by PlaceOrderService::placeOrder() for every payway (#282). An explanatory comment at :82-87 documents this. | :82-87 |
| Event dispatch | Dispatches OrderPaid via OrderEventDispatcher | :95-109 |
cancelPayment(int $orderId, string $metaData = ''): bool (:120-172)
- Sets order status to
CANCELEDwith the provided$metaDatavalue - Restores stock via
StockService::restoreStockForOrder()(PaymentConfirmationService.php:156) — stock was reserved at placement for every payway (#282), so cancelling a still-PENDINGorder must release it, matching legacyset_status('CANCELED', stockMode='+')and theAdvCancelIncompleteOrderscron. The pre-#282 comment "No stock to restore — online payments never had stock reduced" has been removed. - Idempotency: returns
trueimmediately if status is alreadyCANCELED(:129); the early-return prevents a double-restore. - Dispatches
OrderCanceledviaOrderEventDispatcher(:162-168)
Event Bus Side Effects (REST path only)
On the REST webhook path, confirmPayment() and cancelPayment() both dispatch events via OrderEventDispatcher:
OrderPaidtriggers the full listener chain: confirmation email, ERP, low-stock notification, Matomo, Meta CAPI, Manago, Project Agora — see CF-06 Order Preview for the full listener list.OrderCanceledtriggers loyalty-point and coupon-usage restoration.
This means the "Webhook vs. Synchronous Side Effects Comparison" table below applies to the legacy REST path before event-bus integration. The modern REST path (Stripe, Viva REST, PayPal, Piraeus, PayByBank) now dispatches equivalent side effects via the bus on confirmation/cancellation.
confirm-payment Client Endpoint
POST /rest/checkout/confirm-payment/{orderId} is a JWT-authenticated client-initiated confirmation endpoint added in the same commit as the REST webhooks. It uses the same PaymentConfirmationService, making it the synchronous client-facing counterpart to the webhook-triggered confirmation flow. See CF-06 Order Preview for details.
Domain Layer
Modern Domain (src/Domains/...)
| File | Responsibility |
|---|---|
src/Domains/Checkout/PaymentConfirmationService.php | Idempotent order status updates (confirmPayment, cancelPayment); confirmPayment does NOT reduce stock (reserved at placement); cancelPayment restores stock via StockService::restoreStockForOrder() (#282); OrderPaid/OrderCanceled event dispatch via OrderEventDispatcher |
src/Domains/Checkout/StockService.php | Stock reservation at order placement (reduceStockForOrder, called by PlaceOrderService) and restoration on cancellation (restoreStockForOrder, called by cancelPayment and the AdvCancelIncompleteOrders cron) |
src/Domains/Order/Order/Repository/Repository.php | findBySerialAndPayway(string $serial, string $payway): ?Entity (Repository.php:27-33) — webhook lookup by order_serial + payway; payway scoping is the cross-gateway security boundary. findByTranTicketAndPayway(string $tranTicket, string $payway): ?Entity (Repository.php:43-49) — webhook lookup by tran_ticket + payway; same cross-gateway scoping rationale. Both use the Filter Specification pattern. |
src/PaymentGateways/NexiXPay/XPay.php | Nexi XPay Greece gateway service: HPP creation, order status query, notification parsing, securityToken verification (shared by both the orders and gift-card webhook paths) |
REST Layer (src/Rest/...)
As of 4.99.6, a parallel set of webhook endpoints exists. These use the PaymentConfirmationService for idempotent order status updates and stock management, unlike the legacy handlers which use direct model calls. Piraeus and PayByBank were added later (commits fbf8c8943 and 7e73da217). The legacy CI handlers remain authoritative for JCC, Klarna, and Skroutz SmartCart, which have no REST equivalent.
The REST webhook controller extends Base_c (not HandlesRestfulActions) and uses ApiEndpointTrait for JSON responses. It has no auth guard — each method verifies the request using the gateway's own signature/verification mechanism.
| File | Responsibility |
|---|---|
src/Rest/Webhooks/Controllers/Webhook.php | REST webhook handlers for Stripe, Viva Wallet, PayPal, Piraeus, and PayByBank |
src/Rest/Webhooks/container.php | DI registration for the REST Webhooks module |
Routes registered in application/config/webhook_routes.php (lines 14-18), loaded unconditionally from routes.php:786.
Legacy Layer (ecommercen/...)
Class Diagram
application/controllers/webhooks/
Jcc.php ─extends─> AdvJcc (ecommercen/webhooks/)
KlarnaPayments.php ─extends─> AdvKlarnaPayments (ecommercen/webhooks/)
SmartCart.php ─extends─> AdvSmartCart (ecommercen/webhooks/)
Viva.php ─extends─> AdvViva (ecommercen/webhooks/)| File | Responsibility |
|---|---|
ecommercen/webhooks/AdvJcc.php | JCC card payment notification handler; HMAC-SHA256 checksum validation |
ecommercen/webhooks/AdvKlarnaPayments.php | Klarna authorization token delivery; stores token in shop_order_klarna_payments |
ecommercen/webhooks/AdvSmartCart.php | Skroutz SmartCart order creation, update, and express auto-accept |
ecommercen/webhooks/AdvViva.php | Viva Wallet payment confirmation/failure; dual lookup for gift card orders |
ecommercen/checkout/controllers/Adv_checkout.php (xPayHook, processXPayPaymentResult, logXPayTransaction) | XPay webhook for regular orders; co-hosted on checkout controller |
ecommercen/gift_cards/controllers/AdvGiftCardPage.php (xPayHook, xpayWebhookAction) | XPay webhook for gift cards; co-hosted on gift-card controller |
application/controllers/webhooks/Jcc.php | Thin subclass; client-overridable entry point |
application/controllers/webhooks/KlarnaPayments.php | Thin subclass; client-overridable entry point |
application/controllers/webhooks/SmartCart.php | Thin subclass; client-overridable entry point |
application/controllers/webhooks/Viva.php | Thin subclass; client-overridable entry point |
All upstream handler classes extend Base_c (which extends Adv_base_controller). This gives them access to:
$this->registry(DB-backed configuration)$this->input(CI input class withpost(),raw_input_stream,inputStream())$this->load->model()(model loading)$this->output->set_header()(HTTP response headers)
ERP Hook Chain
OrderForErpHookFireTrait::internalApiOrderForErpHook($orderId)
└─ InternalApiOrderForErpHook::fireHook($orderId) [application/libraries/internal/]
└─ AdvInternalApiOrderForErpHook [ecommercen/libraries/internal/]
├─ FireHookTrait::fireHook()
│ ├─ IsApiEnabledTrait::isApiEnabled() ← checks config('internalApi.useApi')
│ ├─ getUrl($orderId) ← builds URL from config('internalApi.apiOrderWebHooks.erpReady')
│ └─ call($url, $orderId) ← Guzzle GET with Bearer token + timeout
└─ HookGetClientOptionsTrait
├─ getClient() ← Guzzle client with Authorization, Accept, Content-Type headers
└─ defaultRequestOptions() ← connect_timeout and timeout from configThe ERP hook is only fired by JCC and SmartCart webhooks via this trait. Viva Wallet and Klarna webhooks do not fire it. XPay also fires the ERP hook (via afterOrderSuccessHooks() in Adv_checkout::processXPayPaymentResult()). REST webhook handlers fire the ERP hook indirectly via the OrderPaid event bus listener.
Data Model
Database Tables Affected
| Table | Handler | Operation | Purpose |
|---|---|---|---|
shop_order | JCC, Viva | UPDATE | Status change (PAID/CANCELED), meta_data, viva_payway, installments |
shop_order | SmartCart (auto-accept) | INSERT + UPDATE | Creates new shop order, sets status to PAID |
jcc_order_ids | JCC | READ | Maps JCC mdOrder to shop_order_id |
shop_order_klarna_payments | Klarna | READ + UPDATE | Stores/updates authorization_token by session_id |
skroutz_orders | SmartCart | INSERT + UPDATE | Stores marketplace order data |
skroutz_line_items | SmartCart | INSERT | Stores order line items |
skroutz_invoice_details | SmartCart | INSERT | Stores invoice data for business orders |
gift_card_orders | Viva (gift card) | READ + UPDATE | Status changes, installment updates |
coupons | Viva (gift card accept) | INSERT | Creates gift card coupon |
coupons | Viva (gift card cancel) | DELETE | Removes coupon if gift card was completed |
product_codes | SmartCart (auto-accept) | UPDATE | Stock decrement via create_order_admin |
shop_customer | SmartCart (auto-accept) | INSERT | Creates guest customer for marketplace order |
shop_order_basket | SmartCart (auto-accept) | INSERT | Order line items for the shop order |
xpay_logging | XPay (both flows) | INSERT + READ | Audit trail and securityToken lookup source |
gift_card_orders | XPay (gift cards) | UPDATE | Status (Completed/Canceled), coupon_id |
Order Status Values
| Status | Meaning | Layer |
|---|---|---|
PENDING | Order created, awaiting payment confirmation | All (legacy + modern REST idempotency guard) |
PAID | Payment confirmed successfully | All |
PENDING_ACCEPTED | Idempotent success state; treated as already-confirmed by confirmPayment() (src/Domains/Checkout/PaymentConfirmationService.php:46) | Modern REST (PaymentConfirmationService) |
CANCELED | Payment failed or was rejected | All |
Gift Card Status Values (Spatie Enum)
| Status | Value | Meaning |
|---|---|---|
Pending | 1 | Awaiting payment |
Completed | 10 | Payment confirmed, coupon created |
Canceled | 11 | Payment failed or coupon deleted |
meta_data Field Tracing
The shop_order.meta_data field tracks which code path last modified the order:
| Value | Source |
|---|---|
controller:webhooks/jcc | JCC webhook |
controller:WebhooksHandler:viva | Viva Wallet legacy webhook |
model:set_status:CANCELED:update_order | update_order() when canceling (JCC/Viva legacy) |
model:set_is_paid | set_is_paid() in synchronous flow |
webhook:stripe | REST Stripe webhook on confirm (Webhook.php:159-167) |
webhook:stripe:expired | REST Stripe webhook on cancel (Webhook.php:175-185) |
webhook:vivawallet | REST Viva Wallet webhook on confirm (Webhook.php:253-258) |
webhook:vivawallet:failed | REST Viva Wallet webhook on cancel (Webhook.php:259-261) |
webhook:paypal:{eventType} | REST PayPal webhook, where {eventType} is the full PayPal event name (Webhook.php:388-431) |
webhook:piraeus | REST Piraeus webhook on confirm (Webhook.php:541-544) |
webhook:piraeus:failed | REST Piraeus webhook on cancel (Webhook.php:546) |
webhook:paybybank | REST PayByBank webhook on PAID |
webhook:paybybank:cancelled | REST PayByBank webhook on CANCELLED status |
webhook:paybybank:ready_to_cancel | REST PayByBank webhook on READY_TO_CANCEL status |
Configuration
Registry Keys
| Group | Key | Used By | Purpose |
|---|---|---|---|
JCC | CALLBACK_TOKEN | AdvJcc | HMAC-SHA256 secret for checksum validation |
SMARTCART | IS_ENABLED | AdvSmartCart | Feature flag to enable/disable SmartCart webhooks |
STRIPE | WEBHOOK_SECRET | Stripe REST webhook | HMAC-SHA256 secret for Stripe signature verification (Webhook.php:139) |
XPAY | API_KEY | XPay gateway service | X-API-KEY header for Nexi XPay Greece API calls |
XPAY | IS_PRODUCTION | XPay gateway service | Toggles between sandbox and production base URL |
| Piraeus keys | (via getPiraeusBankSettings()) | Piraeus REST webhook | Loaded via PiraeusConfig::fromArray() (Webhook.php:521-523) |
Application Config
| Config Path | Used By | Purpose |
|---|---|---|
internalApi.useApi | ERP hook | Master switch for internal API hooks |
internalApi.apiOrderWebHooks.erpReady | ERP hook | Base URL for ERP notification endpoint |
internalApi.apiToken | ERP hook | Bearer token for internal API auth |
internalApi.clientConnectTimeout | ERP hook | Guzzle connection timeout (seconds) |
Viva Wallet Config (via Factories::vivaWallet())
| Registry/Config | Purpose |
|---|---|
Viva merchantId | Basic Auth username for webhook verification |
Viva apiKey | Basic Auth password for webhook verification |
Viva isProduction | Determines verification URL (production vs demo) |
Client Extension Points
Overriding Webhook Handlers
Client repos can override any webhook handler by creating a class with the same name in application/controllers/webhooks/:
php
// application/controllers/webhooks/Jcc.php (client repo)
class Jcc extends AdvJcc
{
protected function afterOrderSuccessHooks(int $orderId): void
{
parent::afterOrderSuccessHooks($orderId);
// Client-specific logic (e.g., custom ERP integration, notifications)
}
}Key Override Points per Handler
| Handler | Overridable Methods | Purpose |
|---|---|---|
| AdvJcc | afterOrderSuccessHooks(), validateChecksum(), calculateStatus() | Custom ERP hooks, alternative validation, custom status logic |
| AdvKlarnaPayments | checkPayload(), updateShopOrderKlarnaPayment() | Additional validation, custom storage logic |
| AdvSmartCart | afterOrderSuccessHooks(), checkAndAutoAcceptExpressOrder() | Custom ERP hooks, custom auto-accept logic |
| AdvViva | handleWebhook() (entire method) | Full custom handling |
| Adv_checkout (xPayHook) | processXPayPaymentResult(), logXPayTransaction() — both protected | Custom side effects on PAID/CANCELED, custom audit logging |
| AdvGiftCardPage (xPayHook) | acceptGiftCard(), cancelGiftCard(), acceptPostActions(), cancelPostActions() — all protected | Customize coupon issuance/revocation, post-acceptance side effects |
Adding New Webhook Handlers
To add a new payment webhook in a client repo:
- Create the handler class in
ecommercen/webhooks/Adv{Provider}.php(main repo) or directly inapplication/controllers/webhooks/{Provider}.php(client repo) - Add a route in
application/config/routes.php - If the handler needs to fire the ERP hook, use
OrderForErpHookFireTrait
Business Rules
| Rule | Description | Handler(s) |
|---|---|---|
| Legacy PENDING guard | Legacy Viva/JCC handlers only process orders in PENDING status and exit if the order is in any other state | Viva (legacy), JCC |
Modern PENDING guard | PaymentConfirmationService::confirmPayment() only processes orders in PENDING status; returns true idempotently if status is already PAID or PENDING_ACCEPTED (PaymentConfirmationService.php:46,54) | REST Stripe, Viva, PayPal, Piraeus, PayByBank |
| Order existence check | All handlers verify the order exists before processing | All |
| HMAC validation | JCC validates callback authenticity via HMAC-SHA256 checksum | JCC |
| Piraeus HMAC validation | REST Piraeus handler validates via Piraeus::validateNotificationResponse() using stored tran_ticket | Piraeus REST |
| Feature flag | SmartCart requires SMARTCART.IS_ENABLED registry flag | SmartCart |
| ERP notification | ERP hook fires on successful payment (only if internalApi.useApi is enabled) | JCC, SmartCart (direct); REST via OrderPaid event bus |
| No emails from legacy webhooks | Legacy webhook handlers do not send confirmation emails -- the synchronous flow handles this | JCC, Viva (XPay is an exception — see Known Issues) |
| REST webhooks dispatch full event bus | REST confirmPayment() dispatches OrderPaid (email, ERP, analytics, etc.); cancelPayment() dispatches OrderCanceled (loyalty, coupons) | REST Stripe, Viva, PayPal, Piraeus, PayByBank |
| Klarna is decoupled | Only stores authorization token; payment capture happens at confirmation stage | Klarna |
| Express auto-accept | Skroutz express orders are automatically converted to shop orders when state becomes accepted | SmartCart |
| Gift card dual lookup | Viva webhook checks both shop_order and gift_card_orders tables | Viva |
| Gift card coupon lifecycle | Accept creates a coupon (5-year validity, 1 use); cancel deletes the coupon if already created | Viva |
| Webhook-first for JCC | JCC synchronous flow defers to webhook result if order is no longer PENDING | JCC |
| Viva pre-auth handling | Synchronous Viva flow detects when webhook already confirmed payment and renders success without reprocessing | Viva |
| Stock management (legacy cancel) | Legacy cancel via update_order triggers set_status('CANCELED') which calls returnOrderStock() to restore stock | JCC, Viva (legacy) |
| Stock management (modern) | Stock is reserved at order placement for every payway by PlaceOrderService::placeOrder() (:307, #282 — anti-overselling parity with legacy). PaymentConfirmationService::confirmPayment() does not reduce stock (:82-87). cancelPayment() restores stock via StockService::restoreStockForOrder() (:156). Abandoned PENDING orders are restored by the AdvCancelIncompleteOrders cron. | REST Stripe, Viva, PayPal, Piraeus, PayByBank |
| Meta data tracing | Each handler writes its identity into shop_order.meta_data for audit trail | JCC, Viva; all REST handlers |
| XPay state machine (orders) | PENDING+PAID, PENDING+CANCELED, PAID+CANCELED (reversal), CANCELED+PAID (resurrect) — other combinations are noop (Adv_checkout.php:3556-3581) | XPay (orders) |
| XPay state machine (gift cards) | Pending+PAID→accept; Pending/Completed+CANCELED→cancel; all others noop to prevent duplicate coupon minting (AdvGiftCardPage.php:1309-1324) | XPay (gift cards) |
| XPay REFUND filter | REFUNDED notifications dropped silently by both handlers | XPay |
| XPay sends emails/SMS from webhook | Unlike JCC/Viva, the XPay webhook fires adv_mailer->order_complete(), sendSmsSuccess(), and informLowStock() on PAID | XPay (orders) |
| 404 on unmatched (all REST webhooks) | All five REST webhook handlers (Stripe, Viva, PayPal, Piraeus, PayByBank) return HTTP 404 on an unmatched/unconfirmable order so providers retry (#33) | all REST webhooks |
Webhook vs. Synchronous Side Effects Comparison
The table below applies to the legacy layer (JCC, Viva legacy, Klarna, SmartCart, XPay). For the REST layer (Stripe, Viva REST, PayPal, Piraeus, PayByBank), confirmPayment() now dispatches OrderPaid via OrderEventDispatcher, triggering the same listener chain as the synchronous flow — see Event Bus Side Effects above.
| Side Effect | Legacy Webhook | Synchronous (get_response) |
|---|---|---|
| Update order status | Yes | Yes |
| Fire ERP hook | JCC + SmartCart only | Yes (all gateways) |
| Send confirmation email | No (except XPay orders webhook — see Known Issues) | Yes |
| Send confirmation SMS | No (except XPay orders webhook — see Known Issues) | Yes |
| Low stock notification | No (except XPay orders webhook — see Known Issues) | Yes |
| Analytics reporting (Matomo, Meta, Manago) | No | Yes |
| Destroy cart session | No | Yes |
| Guest customer logout | No | Yes |
| Render thank-you page | No | Yes |
Set is_paid = 1 | REST webhooks only (PaymentConfirmationService.php:59-62) | Yes |
| Reserve stock at placement | All payways — PlaceOrderService.php:307 (#282) | All payways — legacy create_order / POS |
| Restore stock on cancel | REST webhooks — PaymentConfirmationService.php:156 | set_status('CANCELED', stockMode='+') |
Known Issues & Security Gaps
PayPal REST webhook has no signature verification.RESOLVED (commit4d15de008). Thepaypal()handler now performs a server-side re-fetch viaPayPalRestApi::getOrderDetails()atWebhook.php:449-486and confirms only when PayPal reports the order as COMPLETED or APPROVED. Forged or inconclusive responses return HTTP 202 with no order change. Residual design choice:verifyWebhookSignatureAPI is not used because no Webhook ID is stored in the registry; verification is done server-side instead.Viva Wallet REST webhook placeholder code.RESOLVED — dead$order = $this->orderService->get(0)assignment removed insrc/Rest/Webhooks/Controllers/Webhook.php::vivawallet().REST webhooks require APP_REST_API_ENABLED.Fixed in80b18a4e1(#31). The five webhook routes were extracted fromrest_routes.phpintoapplication/config/webhook_routes.phpandroutes.phpnow@includes this file unconditionally at line 786, outside theAPP_REST_API_ENABLEDflag block. Stripe and PayPal REST webhooks are now reachable regardless of the flag. Regression locked bytests/Integration/Routing/WebhookRoutesAreAlwaysAvailableTest(4 tests / 28 assertions) which also guards the site-mode namespace exemption.OrderService injected but functionally unused in REST Webhook controller.RESOLVED —OrderServicedropped from theWebhookconstructor alongside the deadget(0)assignment.Viva REST webhook has no gift card support.Fixed in7a858a28a(#32). On ashop_ordermiss,vivawallet()now falls back togift_card_ordersby transaction ticket viahandleVivaWalletGiftCard(), restoring parity with the legacyAdvViva::handleWebhook()dual lookup (accept ontransactionPaymentCreated, cancel ontransactionFailed). The gift-card branch delegates accept/cancel to the legacygift_card_orders_model; only when both lookups miss does the handler return{"received":true,"matched":false}. Covered by 5 newWebhookVivaWalletTestcases.Raw CI DB queries in modern REST controller.Fixed ina65d06908(#44). The three private helpers (findOrderBySerialAndPayway,findOrderByTranTicket, and the supplementary_data branch ofextractPayPalOrderId) previously calledget_instance()->dbdirectly. They now thin-delegate to two new methods onAdvisable\Domains\Order\Order\Repository\Repository:findBySerialAndPayway()(Repository.php:27-33) andfindByTranTicketAndPayway()(Repository.php:43-49). Both use the Filter Specification pattern; the payway argument on each call acts as a cross-gateway security boundary.Inconsistent unmatched-order behavior across REST webhooks.Fixed in997e7af1d(#33). All five REST webhook handlers now return HTTP 404 on an unmatched/unconfirmable order, so providers retry rather than treating 200 as success and dropping the webhook.stripe()additionally gates on theconfirmPayment()/cancelPayment()return value to catch the late-commit race (it has no legacy fallback). Covered by updated unmatched-order tests acrossWebhookVivaWalletTest,WebhookPayPalTest,WebhookRepositoryLookupTest, plus a new Stripe late-commit-race test.XPay webhook does not stamp
shop_order.meta_data: Other webhooks (JCC, Viva legacy) writemeta_dataidentifying the code path.xPayHook()andprocessXPayPaymentResult()omit this. Thexpay_loggingtable provides a parallel audit trail butmeta_datainconsistency makes cross-provider forensics harder. (ecommercen/checkout/controllers/Adv_checkout.php:3502-3582)XPay PAID→CANCELED reversal stock behavior:
cancelOrder()is called with'+'(positive) stock direction arg, restoring stock. Verify whetherset_status('PAID')previously decremented stock; if not, this reversal may double-credit. (ecommercen/checkout/controllers/Adv_checkout.php:3556-3581)XPay CANCELED→PAID resurrection re-sends customer emails/SMS: The PAID branch of
processXPayPaymentResult()always firesadv_mailer->order_complete()+sendSmsSuccess(), with no guard for prior sends. (ecommercen/checkout/controllers/Adv_checkout.php:3722)XPay webhook fires emails/SMS — diverges from JCC/Viva contract: Both the webhook and
xPaySuccess()synchronous path may run these side effects. If both fire, customer may receive duplicate confirmation email/SMS. (ecommercen/checkout/controllers/Adv_checkout.php:3502-3582)xPaySuccess()writestran_ticketfrom customer-controlled querystring: reads?paymentid=before API verification completes; spoof damages thetran_ticketaudit field only (downstreamgetOrderStatusis byorder_serial), but may affect admin views.PayByBank REST webhook has no authentication. The
paybybank()handler accepts POST payloads without any HMAC, token, or signature verification, mirroring the legacyAdv_checkout::payByBankResponse()unsigned pattern. Hardened only by a payway-filter on the order lookup (findOrderBySerialAndPayway($merchantOrderId, 'paybybank')). A caller with knowledge of a validmerchantOrderIdcould fraudulently confirm or cancel an order. (Webhook.php:592-597)Potential duplicate event dispatch on webhook + synchronous confirmation for the same order.
PaymentConfirmationService::confirmPayment()andcancelPayment()now dispatchOrderPaid/OrderCanceledevents viaOrderEventDispatcher. If the synchronousget_responseflow for the same gateway also runs post-success listeners for the same order after the webhook has already confirmed it, the event bus could dispatch confirmation email, ERP, analytics, and loyalty side effects twice. Listener-side idempotency (particularly for email/ERP dispatch) should be verified against this scenario. (PaymentConfirmationService.php:95-109,:162-168)
Tests
| Test File | What It Covers |
|---|---|
tests/Unit/Domains/Checkout/PaymentConfirmationServiceTest.php | confirmPayment() status update (does NOT reduce stock — reserved at placement, #282); idempotency for PAID and PENDING_ACCEPTED; rejection of non-PENDING orders; cancelPayment() status update and stock restoration via restoreStockForOrder() (#282); optional field mapping (transactionId, vivaPayway, installments) |
tests/Unit/PaymentGateways/NexiXPay/XPayTest.php | 45 tests on gateway service: parsePaymentNotification, mapOperationResultToStatus, verifyNotificationSecurityToken (11 cases incl. cross-flow guard), HTTP-mocked HPP creation and order status, API-key redaction. Does NOT cover the controller-level xPayHook() — only the gateway helper. |
tests/Legacy/GiftCards/AdvGiftCardPageTest.php | 12 tests on xpayWebhookAction state machine |
tests/Unit/Rest/Webhooks/Controllers/WebhookPayPalTest.php | 10 tests on the PayPal REST webhook handler |
tests/Unit/Rest/Webhooks/Controllers/WebhookRepositoryLookupTest.php | 7 tests: payway-scoped order lookups by serial and tran_ticket; 404 on unmatched for Piraeus, PayByBank, and PayPal DB-lookup branches (#33) |
tests/Unit/Rest/Webhooks/Controllers/WebhookStripeTest.php | 12 tests: real-HMAC signature verification, checkout.session.completed/expired confirm/cancel, invalid-signature 400, unconfigured-secret 500, unknown event, orderId <= 0 → 404, late-commit race (confirmPayment returns false) → 404 |
tests/Unit/Rest/Webhooks/Controllers/WebhookVivaWalletTest.php | 17 tests: event-type dispatch, order lookup, confirm/cancel transitions, malformed-payload early-outs, gift-card fallback (5 cases for #32), both-lookups-miss → 404 (#33) |
tests/Unit/Rest/Webhooks/Controllers/WebhookPiraeusTest.php | 8 tests: real-HMAC validation, success/fail notification paths, unmatched-order 404 |
tests/Unit/Rest/Webhooks/Controllers/WebhookPayByBankTest.php | 19 tests: status mapping (all PayByBankStatus constants), payway-scoped lookup, intermediate-status no-ops (8 DataProvider cases) |
tests/Legacy/Webhooks/AdvVivaTest.php + AdvSmartCartTest.php | 38 combined tests: handleWebhook/handle dispatch, payload validation, status transitions via reflection + mocked models |
No unit tests exist for the legacy webhook controllers ( RESOLVED (commit AdvJcc, AdvKlarnaPayments, AdvSmartCart, AdvViva) or the REST Webhook controller for Stripe, Viva, Piraeus, and PayByBank.4fd81ed1e, Advisable-com/ecommercen#34). All six previously-untested controllers now have unit test coverage (see table above). The controller-level XPay webhook flow (Adv_checkout::xPayHook()) also has no controller test; only the underlying XPay gateway service and the gift-card state machine are directly unit-tested.
Related Flows
- CF-06 Order Preview & Checkout --
confirm-paymentclient endpoint uses the samePaymentConfirmationServiceas REST webhooks; fullOrderPaidlistener chain - CF-07 Order Confirmation -- order creation that triggers payment
- CF-08 Payment Processing -- synchronous
get_responseflow that complements webhooks - CF-23 Gift Cards -- Viva Wallet gift card purchase and coupon creation
- AD-03 Order Management -- order status lifecycle and admin actions
- AD-11 Marketplace Orders -- Skroutz SmartCart order management in admin
- IN-08 ERP Integrations -- ERP hook fired by JCC and SmartCart webhooks
- IN-20 Order Webhooks -- general order webhook integrations