Skip to content

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:

TypeProviderPurpose
Card paymentJCCAsynchronous notification after card authorization/deposit
BNPL / DeferredKlarna PaymentsAuthorization token delivery (payment not yet captured)
MarketplaceSkroutz SmartCartMarketplace order creation and state updates
Multi-methodViva WalletTransaction payment confirmation or failure, including gift cards
Card payment (in-controller)Nexi XPay GreeceAsynchronous 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:

MethodPathProviderAuth Mechanism
POST/rest/webhooks/stripeStripeSignature verification via STRIPE.WEBHOOK_SECRET registry key
POST/rest/webhooks/vivawalletViva WalletVerification handshake on empty body; processes transactionPaymentCreated / transactionFailed
POST/rest/webhooks/paypalPayPalServer-side re-fetch via PayPalRestApi::getOrderDetails(); confirms only when PayPal reports COMPLETED/APPROVED
POST/rest/webhooks/piraeusPiraeusHMAC-SHA256 via Piraeus::validateNotificationResponse()
POST/rest/webhooks/paybybankPayByBankNone (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_response checks 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 PatternRoute TargetHTTP MethodProvider
/jccNotificationwebhooks/jcc/handleNotificationPOSTJCC
/klarnaAuthorizationCallback/{secretToken}webhooks/KlarnaPayments/authorizationCallback/$1POSTKlarna
/handleSkroutzWebhookwebhooks/smartCart/handlePOSTSkroutz SmartCart
/webrun/skroutzWebhookRequestwebhooks/smartCart/handlePOSTSkroutz SmartCart (legacy alias)
/vivaWallet/{event}webhooks/viva/handleWebhook/$1POST (or GET for verification)Viva Wallet
/checkout/xPayHookcheckout/xPayHookPOSTNexi XPay (orders)
/{lang}/checkout/xPayHookcheckout/xPayHookPOSTNexi XPay (orders, localized)
/gift-card/xPayHookgift_cards/gift_card_page/xPayHook (via catch-all gift-card/(.+) at application/config/routes.php:720,722)POSTNexi 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-overridable

The 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 on Adv_checkout (xPayHook, line 3502) for regular orders and AdvGiftCardPage (xPayHook, line 1218) for gift cards. Verification is delegated to the modern Advisable\PaymentGateways\NexiXPay\XPay service class — the only legacy webhook to use a src/-domain helper.


JCC Webhook (AdvJcc)

File: ecommercen/webhooks/AdvJcc.phpController: application/controllers/webhooks/Jcc.phpTrait: OrderForErpHookFireTrait

Step-by-Step Flow

  1. Receive POST at /jccNotification

    • Reads all POST parameters via $this->input->post()
    • If params are empty, logs error and exits
  2. Look up order

    • Queries jcc_order_ids table by mdOrder parameter to find the shop_order_id
    • Loads the full order from shop_order via order_model->getRecordsByJccOrderId()
    • If no order found, logs error with mdOrder and orderNumber, exits
  3. Validate HMAC-SHA256 checksum

    • Retrieves JCC.CALLBACK_TOKEN from the registry
    • Removes the checksum field 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 checksum value
  4. Calculate status

    • Only processes when operation is approved or deposited
    • If status param is '1' => order status = PAID; any other value => order status = CANCELED (ecommercen/webhooks/AdvJcc.php:65: ($params['status'] === '1') ? 'PAID' : 'CANCELED')
    • For other operation values, returns null (no action)
  5. Apply status update

    • If status is PAID: fires afterOrderSuccessHooks(orderId) which calls the ERP hook
    • Updates shop_order with status and meta_data = 'controller:webhooks/jcc'
    • Returns HTTP 200

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

  1. Receive POST at /klarnaAuthorizationCallback/{secretToken}

    • Validates secretToken is not empty (returns 422 if missing)
    • Reads raw JSON body via $this->input->raw_input_stream
    • Returns 422 if body is empty
  2. Validate payload

    • Checks that authorization_token and session_id are both present
    • Logs individual errors for each missing field
    • Returns 422 if validation fails
  3. Update authorization token

    • Queries shop_order_klarna_payments table by session_id
    • If no matching record found, logs error and returns 422
    • Updates the authorization_token field on the matching record
    • Returns HTTP 200

Important: No Status Change

This webhook does not change order status. Klarna uses a decoupled flow:

  1. Customer authorizes payment on Klarna's side
  2. Klarna sends the authorization token to this webhook
  3. Later, during the get_response confirmation stage (klarnaPaymentsConfirmation), the shop uses the stored authorization token to capture the payment
  4. The checkout controller's getAuthorizationToken() method looks up the token from shop_order_klarna_payments if 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

  1. Receive POST at /handleSkroutzWebhook

    • Checks SMARTCART.IS_ENABLED registry value; returns 500 if disabled
    • Reads raw JSON input stream
  2. Route by event type

    new_order event:

    • Validates that order.state is open; returns 422 if not
    • Calls skroutz_orders_model->handleOrderCreation()
    • Checks for duplicate order by code
    • Inserts into skroutz_orders table (code, state, invoice, comments, courier details, customer data, pickup/collection point info, express flag)
    • If invoice is true, inserts into skroutz_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_updated event:

    • Calls skroutz_orders_model->handleOrderUpdate()
    • Updates the existing skroutz_orders record with new state, pickup window, location, courier voucher, tracking codes
    • Returns 200 on success, 500 on failure
    • Then checks for express order auto-accept
  3. Express order auto-accept (post-update)

    • If order.express is true and order.state is accepted
    • 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 PAID with provider skroutz_smart_cart
      • Links the Skroutz order to the shop order (shop_order_id update)
    • Fires afterOrderSuccessHooks() (ERP notification)

Viva Wallet Webhook (AdvViva)

File: ecommercen/webhooks/AdvViva.phpController: application/controllers/webhooks/Viva.php

Step-by-Step Flow

  1. 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
  2. Look up order (dual-path)

    • First searches shop_order by payway = 'vivawallet' and tran_ticket = EventData.OrderCode
    • If not found, flags as gift card and searches gift_card_orders by tran_ticket
    • If neither found, logs error and exits
  3. Gift card path (if order found in gift_card_orders)

    EventAction
    transactionPaymentCreatedUpdates installments on the gift card order, then calls acceptGiftCard()
    transactionFailedCalls cancelGiftCard()
    Other eventsExits 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, sets completed_at, links coupon_id, resets email_sent and sms_sent flags
    • Wrapped in a DB transaction

    cancelGiftCard() details:

    • If status is Pending: bulk-cancels (sets gift_card_status = Canceled, canceled_at timestamp)
    • If status is Completed: deletes the associated coupon, nulls coupon_id, sets canceled status (wrapped in a DB transaction)

    Returns HTTP 200 and exits.

  4. 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'
    EventStatus UpdateAdditional Fields
    transactionPaymentCreatedPAIDviva_payway (mapped from TransactionTypeId), installments
    transactionFailedCANCELEDNone
    Other eventsNo action (exits)N/A
    • Calls order_model->update_order() to persist the status change
    • Returns HTTP 200

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:

TransactionTypeIdInternal Method
0, 1, 4-8, 13, 18-19, 22, 31, 69vivawallet_credit_card
9, 11vivawallet_viva_wallet
15vivawallet_dias
16, 17vivawallet_cash
23, 24vivawallet_ideal
25, 26vivawallet_p24
48, 49vivawallet_paypal
52, 53, 80, 81vivawallet_klarna
60, 61vivawallet_iris
62, 63vivawallet_pay_by_bank
68vivawallet_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:

  1. Reads raw JSON via $this->input->input_stream() — HTTP 400 on decode failure; audit-logs CALLBACK to xpay_logging via logXPayTransaction().
  2. XPay::parsePaymentNotification($notification) validates operation, order, operationResult, orderId fields; HTTP 400 on InvalidArgumentException.
  3. XPay::verifyNotificationSecurityToken($notification, $orderId) (default flow='order') — HTTP 401 on mismatch (see securityToken section below).
  4. Order lookup via order_model->getOrder(['order_serial' => $orderId]) — HTTP 404 on miss.
  5. REFUNDED notifications: logged, exit HTTP 200 with no state change.
  6. State machine:
shop_order.statusXPay statusAction
PENDINGPAIDprocessXPayPaymentResult() → mark PAID
PENDINGCANCELEDprocessXPayPaymentResult() → cancel
PAIDCANCELEDprocessXPayPaymentResult()reversal
CANCELEDPAIDprocessXPayPaymentResult()resurrect
otheranylog, 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_statusXPay statusAction
PendingPAIDacceptGiftCard() + acceptPostActions() (mint coupon, Completed)
PendingCANCELEDcancelGiftCard() + cancelPostActions()
CompletedCANCELEDcancelGiftCard() — reversal (revokes coupon)
otheranynoop (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:

  1. XPay::createHostedPaymentOrder() logs the Nexi HPP creation RESPONSE row to xpay_logging, which includes securityToken in response_data (src/PaymentGateways/NexiXPay/XPay.php:144).
  2. On every notification, Nexi echoes the same token in notification.securityToken.
  3. XPay::verifyNotificationSecurityToken() (:281) reads the stored token from xpay_logging filtered by (order_serial, flow, transaction_type='RESPONSE', status='SUCCESS') ordered by created_at DESC (:308-334), then compares with hash_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

  1. Receive POST at /rest/webhooks/stripe

    • Entry at Webhook.php:126; reads raw request body and verifies the Stripe signature via Stripe\Webhook::constructEvent() (HMAC-SHA256) using the STRIPE.WEBHOOK_SECRET registry key (Webhook.php:139)
    • Returns HTTP 400 on invalid signature; secret loaded at :139
  2. Route by event type (:159-185)

    Stripe EventActionCitation
    checkout.session.completedPaymentConfirmationService::confirmPayment($orderId):159-174
    checkout.session.expiredPaymentConfirmationService::cancelPayment($orderId, 'webhook:stripe:expired'):175-185

    Unmatched / late-commit (#33): stripe() reads the return value of confirmPayment()/cancelPayment(). A checkout.session.completed or checkout.session.expired event returns HTTP 404 {"received":true,"matched":false} when client_reference_id <= 0 or 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

  1. Receive request at /rest/webhooks/vivawallet

    • Entry at :198; verification handshake: Accepts an empty body or {} as the verification trigger (:203). Unlike the legacy AdvViva handler which uses Factories::vivaWallet(), the REST handler constructs VivaWallet directly via the registry helper, bypassing the factory.
    • handleVivaWalletVerification() at :266-293
  2. Route by event type

    • Checks EventTypeId (numeric) first at :245-251, with EventType string name as fallback
    EventTypeIdEventType (fallback)ActionCitation
    1796transactionPaymentCreatedPaymentConfirmationService::confirmPayment() with metaData = 'webhook:vivawallet':253-258
    1797transactionFailedPaymentConfirmationService::cancelPayment() with metaData = 'webhook:vivawallet:failed':259-261
  3. Order lookup: findOrderByTranTicket() thin-delegates to $this->orderRepository->findByTranTicketAndPayway() (Webhook.php:633-636). Note: on a shop_order miss the handler falls back to gift_card_orders by transaction ticket via handleVivaWalletGiftCard() (#32), mirroring the legacy AdvViva dual lookup — transactionPaymentCreated runs acceptGiftCard() (after setting installments), transactionFailed runs cancelGiftCard(). Gift-card accept/cancel delegates to the legacy gift_card_orders_model (the controller extends Base_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

  1. Receive POST at /rest/webhooks/paypal (Webhook.php:359-434)

  2. Server-side payment verification (Webhook.php:449-486)

    • verifyPayPalPaymentCompleted(?string $payPalOrderId): ?bool at :449-486
    • Re-fetches the PayPal order via PayPalRestApi::getOrderDetails()
    • Returns true only when PayPal reports the order as COMPLETED or APPROVED
    • Forged or inconclusive responses → HTTP 202 with no order change, logged on webhooks channel (:398/:411)
  3. Route by event type (:388-431)

    PayPal EventAction
    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')
  4. Cancel branch (:426-431)

  5. 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.

  6. Order ID extraction via extractPayPalOrderId() (Webhook.php:644-677) — three-tier lookup:

    1. purchase_units[0].reference_id (direct field)
    2. supplementary_data.related_ids.order_id (DB lookup via OrderRepository::findByTranTicketAndPayway())
    3. resource.custom_id (fallback)

Piraeus REST Webhook (Webhook::piraeus())

File: src/Rest/Webhooks/Controllers/Webhook.phpAdded: commit fbf8c8943 (#271)

Step-by-Step Flow

  1. Receive POST at /rest/webhooks/piraeus; route webhook_routes.php:17

    • Body is form-encoded $_POST (not JSON); requires MerchantReference
  2. Order lookup

    • findOrderBySerialAndPayway($merchantReference, 'piraeus') (:509)
    • Unmatched → HTTP 404 (:514) so Piraeus retries (all five REST webhook handlers now return 404 on unmatched — #33)
  3. Load config

    • PiraeusConfig::fromArray(getPiraeusBankSettings()) (:521-523)
    • Returns HTTP 500 if unconfigured (:525-528)
  4. HMAC validation

    • Piraeus::validateNotificationResponse($postData, $tranTicket) using stored tran_ticket from the order lookup; mismatch → HTTP 400 (:532)
  5. Status dispatch

    • Piraeus::isSuccessfulNotification()confirmPayment() with transactionId=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

  1. Receive POST at /rest/webhooks/paybybank; route webhook_routes.php:18

    • JSON body; extracts merchantOrderId and omtTransactionBank.merchantOrderStatus
    • Returns HTTP 400 on missing fields
  2. Order lookup

    • findOrderBySerialAndPayway($merchantOrderId, 'paybybank') (:592)
    • Unmatched → HTTP 404 (:597) so the provider retries
  3. No signature verification

    • Mirrors legacy Adv_checkout::payByBankResponse() (unsigned); hardened only by the payway-filter on order lookup — see Known Issues
  4. Status dispatch

    merchantOrderStatusAction
    PAIDconfirmPayment() with transactionId=bankPaymentCode, metaData='webhook:paybybank'
    CANCELLEDcancelPayment() with metaData='webhook:paybybank:cancelled'
    READY_TO_CANCELcancelPayment() with metaData='webhook:paybybank:ready_to_cancel'
    otherHTTP 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)

StepDetailCitation
Idempotency checkReturns true immediately if status is already PAID or PENDING_ACCEPTED:46
GuardReturns false if status is not PENDING:54
Status updateSets status = PAID, is_paid = 1:59-62
Optional fieldsSets transactionId, vivaPayway, installments, metaData from $options if provided:64-78
StockDoes 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 dispatchDispatches OrderPaid via OrderEventDispatcher:95-109

cancelPayment(int $orderId, string $metaData = ''): bool (:120-172)

  • Sets order status to CANCELED with the provided $metaData value
  • Restores stock via StockService::restoreStockForOrder() (PaymentConfirmationService.php:156) — stock was reserved at placement for every payway (#282), so cancelling a still-PENDING order must release it, matching legacy set_status('CANCELED', stockMode='+') and the AdvCancelIncompleteOrders cron. The pre-#282 comment "No stock to restore — online payments never had stock reduced" has been removed.
  • Idempotency: returns true immediately if status is already CANCELED (:129); the early-return prevents a double-restore.
  • Dispatches OrderCanceled via OrderEventDispatcher (:162-168)

Event Bus Side Effects (REST path only)

On the REST webhook path, confirmPayment() and cancelPayment() both dispatch events via OrderEventDispatcher:

  • OrderPaid triggers 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.
  • OrderCanceled triggers 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/...)

FileResponsibility
src/Domains/Checkout/PaymentConfirmationService.phpIdempotent 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.phpStock 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.phpfindBySerialAndPayway(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.phpNexi 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.

FileResponsibility
src/Rest/Webhooks/Controllers/Webhook.phpREST webhook handlers for Stripe, Viva Wallet, PayPal, Piraeus, and PayByBank
src/Rest/Webhooks/container.phpDI 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/)
FileResponsibility
ecommercen/webhooks/AdvJcc.phpJCC card payment notification handler; HMAC-SHA256 checksum validation
ecommercen/webhooks/AdvKlarnaPayments.phpKlarna authorization token delivery; stores token in shop_order_klarna_payments
ecommercen/webhooks/AdvSmartCart.phpSkroutz SmartCart order creation, update, and express auto-accept
ecommercen/webhooks/AdvViva.phpViva 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.phpThin subclass; client-overridable entry point
application/controllers/webhooks/KlarnaPayments.phpThin subclass; client-overridable entry point
application/controllers/webhooks/SmartCart.phpThin subclass; client-overridable entry point
application/controllers/webhooks/Viva.phpThin 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 with post(), 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 config

The 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

TableHandlerOperationPurpose
shop_orderJCC, VivaUPDATEStatus change (PAID/CANCELED), meta_data, viva_payway, installments
shop_orderSmartCart (auto-accept)INSERT + UPDATECreates new shop order, sets status to PAID
jcc_order_idsJCCREADMaps JCC mdOrder to shop_order_id
shop_order_klarna_paymentsKlarnaREAD + UPDATEStores/updates authorization_token by session_id
skroutz_ordersSmartCartINSERT + UPDATEStores marketplace order data
skroutz_line_itemsSmartCartINSERTStores order line items
skroutz_invoice_detailsSmartCartINSERTStores invoice data for business orders
gift_card_ordersViva (gift card)READ + UPDATEStatus changes, installment updates
couponsViva (gift card accept)INSERTCreates gift card coupon
couponsViva (gift card cancel)DELETERemoves coupon if gift card was completed
product_codesSmartCart (auto-accept)UPDATEStock decrement via create_order_admin
shop_customerSmartCart (auto-accept)INSERTCreates guest customer for marketplace order
shop_order_basketSmartCart (auto-accept)INSERTOrder line items for the shop order
xpay_loggingXPay (both flows)INSERT + READAudit trail and securityToken lookup source
gift_card_ordersXPay (gift cards)UPDATEStatus (Completed/Canceled), coupon_id

Order Status Values

StatusMeaningLayer
PENDINGOrder created, awaiting payment confirmationAll (legacy + modern REST idempotency guard)
PAIDPayment confirmed successfullyAll
PENDING_ACCEPTEDIdempotent success state; treated as already-confirmed by confirmPayment() (src/Domains/Checkout/PaymentConfirmationService.php:46)Modern REST (PaymentConfirmationService)
CANCELEDPayment failed or was rejectedAll

Gift Card Status Values (Spatie Enum)

StatusValueMeaning
Pending1Awaiting payment
Completed10Payment confirmed, coupon created
Canceled11Payment failed or coupon deleted

meta_data Field Tracing

The shop_order.meta_data field tracks which code path last modified the order:

ValueSource
controller:webhooks/jccJCC webhook
controller:WebhooksHandler:vivaViva Wallet legacy webhook
model:set_status:CANCELED:update_orderupdate_order() when canceling (JCC/Viva legacy)
model:set_is_paidset_is_paid() in synchronous flow
webhook:stripeREST Stripe webhook on confirm (Webhook.php:159-167)
webhook:stripe:expiredREST Stripe webhook on cancel (Webhook.php:175-185)
webhook:vivawalletREST Viva Wallet webhook on confirm (Webhook.php:253-258)
webhook:vivawallet:failedREST 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:piraeusREST Piraeus webhook on confirm (Webhook.php:541-544)
webhook:piraeus:failedREST Piraeus webhook on cancel (Webhook.php:546)
webhook:paybybankREST PayByBank webhook on PAID
webhook:paybybank:cancelledREST PayByBank webhook on CANCELLED status
webhook:paybybank:ready_to_cancelREST PayByBank webhook on READY_TO_CANCEL status

Configuration

Registry Keys

GroupKeyUsed ByPurpose
JCCCALLBACK_TOKENAdvJccHMAC-SHA256 secret for checksum validation
SMARTCARTIS_ENABLEDAdvSmartCartFeature flag to enable/disable SmartCart webhooks
STRIPEWEBHOOK_SECRETStripe REST webhookHMAC-SHA256 secret for Stripe signature verification (Webhook.php:139)
XPAYAPI_KEYXPay gateway serviceX-API-KEY header for Nexi XPay Greece API calls
XPAYIS_PRODUCTIONXPay gateway serviceToggles between sandbox and production base URL
Piraeus keys(via getPiraeusBankSettings())Piraeus REST webhookLoaded via PiraeusConfig::fromArray() (Webhook.php:521-523)

Application Config

Config PathUsed ByPurpose
internalApi.useApiERP hookMaster switch for internal API hooks
internalApi.apiOrderWebHooks.erpReadyERP hookBase URL for ERP notification endpoint
internalApi.apiTokenERP hookBearer token for internal API auth
internalApi.clientConnectTimeoutERP hookGuzzle connection timeout (seconds)

Viva Wallet Config (via Factories::vivaWallet())

Registry/ConfigPurpose
Viva merchantIdBasic Auth username for webhook verification
Viva apiKeyBasic Auth password for webhook verification
Viva isProductionDetermines 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

HandlerOverridable MethodsPurpose
AdvJccafterOrderSuccessHooks(), validateChecksum(), calculateStatus()Custom ERP hooks, alternative validation, custom status logic
AdvKlarnaPaymentscheckPayload(), updateShopOrderKlarnaPayment()Additional validation, custom storage logic
AdvSmartCartafterOrderSuccessHooks(), checkAndAutoAcceptExpressOrder()Custom ERP hooks, custom auto-accept logic
AdvVivahandleWebhook() (entire method)Full custom handling
Adv_checkout (xPayHook)processXPayPaymentResult(), logXPayTransaction() — both protectedCustom side effects on PAID/CANCELED, custom audit logging
AdvGiftCardPage (xPayHook)acceptGiftCard(), cancelGiftCard(), acceptPostActions(), cancelPostActions() — all protectedCustomize coupon issuance/revocation, post-acceptance side effects

Adding New Webhook Handlers

To add a new payment webhook in a client repo:

  1. Create the handler class in ecommercen/webhooks/Adv{Provider}.php (main repo) or directly in application/controllers/webhooks/{Provider}.php (client repo)
  2. Add a route in application/config/routes.php
  3. If the handler needs to fire the ERP hook, use OrderForErpHookFireTrait

Business Rules

RuleDescriptionHandler(s)
Legacy PENDING guardLegacy Viva/JCC handlers only process orders in PENDING status and exit if the order is in any other stateViva (legacy), JCC
Modern PENDING guardPaymentConfirmationService::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 checkAll handlers verify the order exists before processingAll
HMAC validationJCC validates callback authenticity via HMAC-SHA256 checksumJCC
Piraeus HMAC validationREST Piraeus handler validates via Piraeus::validateNotificationResponse() using stored tran_ticketPiraeus REST
Feature flagSmartCart requires SMARTCART.IS_ENABLED registry flagSmartCart
ERP notificationERP hook fires on successful payment (only if internalApi.useApi is enabled)JCC, SmartCart (direct); REST via OrderPaid event bus
No emails from legacy webhooksLegacy webhook handlers do not send confirmation emails -- the synchronous flow handles thisJCC, Viva (XPay is an exception — see Known Issues)
REST webhooks dispatch full event busREST confirmPayment() dispatches OrderPaid (email, ERP, analytics, etc.); cancelPayment() dispatches OrderCanceled (loyalty, coupons)REST Stripe, Viva, PayPal, Piraeus, PayByBank
Klarna is decoupledOnly stores authorization token; payment capture happens at confirmation stageKlarna
Express auto-acceptSkroutz express orders are automatically converted to shop orders when state becomes acceptedSmartCart
Gift card dual lookupViva webhook checks both shop_order and gift_card_orders tablesViva
Gift card coupon lifecycleAccept creates a coupon (5-year validity, 1 use); cancel deletes the coupon if already createdViva
Webhook-first for JCCJCC synchronous flow defers to webhook result if order is no longer PENDINGJCC
Viva pre-auth handlingSynchronous Viva flow detects when webhook already confirmed payment and renders success without reprocessingViva
Stock management (legacy cancel)Legacy cancel via update_order triggers set_status('CANCELED') which calls returnOrderStock() to restore stockJCC, 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 tracingEach handler writes its identity into shop_order.meta_data for audit trailJCC, 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 filterREFUNDED notifications dropped silently by both handlersXPay
XPay sends emails/SMS from webhookUnlike JCC/Viva, the XPay webhook fires adv_mailer->order_complete(), sendSmsSuccess(), and informLowStock() on PAIDXPay (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 EffectLegacy WebhookSynchronous (get_response)
Update order statusYesYes
Fire ERP hookJCC + SmartCart onlyYes (all gateways)
Send confirmation emailNo (except XPay orders webhook — see Known Issues)Yes
Send confirmation SMSNo (except XPay orders webhook — see Known Issues)Yes
Low stock notificationNo (except XPay orders webhook — see Known Issues)Yes
Analytics reporting (Matomo, Meta, Manago)NoYes
Destroy cart sessionNoYes
Guest customer logoutNoYes
Render thank-you pageNoYes
Set is_paid = 1REST webhooks only (PaymentConfirmationService.php:59-62)Yes
Reserve stock at placementAll payways — PlaceOrderService.php:307 (#282)All payways — legacy create_order / POS
Restore stock on cancelREST webhooks — PaymentConfirmationService.php:156set_status('CANCELED', stockMode='+')

Known Issues & Security Gaps

  1. PayPal REST webhook has no signature verification. RESOLVED (commit 4d15de008). The paypal() handler now performs a server-side re-fetch via PayPalRestApi::getOrderDetails() at Webhook.php:449-486 and 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: verifyWebhookSignature API is not used because no Webhook ID is stored in the registry; verification is done server-side instead.

  2. Viva Wallet REST webhook placeholder code. RESOLVED — dead $order = $this->orderService->get(0) assignment removed in src/Rest/Webhooks/Controllers/Webhook.php::vivawallet().

  3. REST webhooks require APP_REST_API_ENABLED. Fixed in 80b18a4e1 (#31). The five webhook routes were extracted from rest_routes.php into application/config/webhook_routes.php and routes.php now @includes this file unconditionally at line 786, outside the APP_REST_API_ENABLED flag block. Stripe and PayPal REST webhooks are now reachable regardless of the flag. Regression locked by tests/Integration/Routing/WebhookRoutesAreAlwaysAvailableTest (4 tests / 28 assertions) which also guards the site-mode namespace exemption.

  4. OrderService injected but functionally unused in REST Webhook controller. RESOLVED — OrderService dropped from the Webhook constructor alongside the dead get(0) assignment.

  5. Viva REST webhook has no gift card support. Fixed in 7a858a28a (#32). On a shop_order miss, vivawallet() now falls back to gift_card_orders by transaction ticket via handleVivaWalletGiftCard(), restoring parity with the legacy AdvViva::handleWebhook() dual lookup (accept on transactionPaymentCreated, cancel on transactionFailed). The gift-card branch delegates accept/cancel to the legacy gift_card_orders_model; only when both lookups miss does the handler return {"received":true,"matched":false}. Covered by 5 new WebhookVivaWalletTest cases.

  6. Raw CI DB queries in modern REST controller. Fixed in a65d06908 (#44). The three private helpers (findOrderBySerialAndPayway, findOrderByTranTicket, and the supplementary_data branch of extractPayPalOrderId) previously called get_instance()->db directly. They now thin-delegate to two new methods on Advisable\Domains\Order\Order\Repository\Repository: findBySerialAndPayway() (Repository.php:27-33) and findByTranTicketAndPayway() (Repository.php:43-49). Both use the Filter Specification pattern; the payway argument on each call acts as a cross-gateway security boundary.

  7. Inconsistent unmatched-order behavior across REST webhooks. Fixed in 997e7af1d (#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 the confirmPayment()/cancelPayment() return value to catch the late-commit race (it has no legacy fallback). Covered by updated unmatched-order tests across WebhookVivaWalletTest, WebhookPayPalTest, WebhookRepositoryLookupTest, plus a new Stripe late-commit-race test.

  8. XPay webhook does not stamp shop_order.meta_data: Other webhooks (JCC, Viva legacy) write meta_data identifying the code path. xPayHook() and processXPayPaymentResult() omit this. The xpay_logging table provides a parallel audit trail but meta_data inconsistency makes cross-provider forensics harder. (ecommercen/checkout/controllers/Adv_checkout.php:3502-3582)

  9. XPay PAID→CANCELED reversal stock behavior: cancelOrder() is called with '+' (positive) stock direction arg, restoring stock. Verify whether set_status('PAID') previously decremented stock; if not, this reversal may double-credit. (ecommercen/checkout/controllers/Adv_checkout.php:3556-3581)

  10. XPay CANCELED→PAID resurrection re-sends customer emails/SMS: The PAID branch of processXPayPaymentResult() always fires adv_mailer->order_complete() + sendSmsSuccess(), with no guard for prior sends. (ecommercen/checkout/controllers/Adv_checkout.php:3722)

  11. 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)

  12. xPaySuccess() writes tran_ticket from customer-controlled querystring: reads ?paymentid= before API verification completes; spoof damages the tran_ticket audit field only (downstream getOrderStatus is by order_serial), but may affect admin views.

  13. PayByBank REST webhook has no authentication. The paybybank() handler accepts POST payloads without any HMAC, token, or signature verification, mirroring the legacy Adv_checkout::payByBankResponse() unsigned pattern. Hardened only by a payway-filter on the order lookup (findOrderBySerialAndPayway($merchantOrderId, 'paybybank')). A caller with knowledge of a valid merchantOrderId could fraudulently confirm or cancel an order. (Webhook.php:592-597)

  14. Potential duplicate event dispatch on webhook + synchronous confirmation for the same order. PaymentConfirmationService::confirmPayment() and cancelPayment() now dispatch OrderPaid/OrderCanceled events via OrderEventDispatcher. If the synchronous get_response flow 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 FileWhat It Covers
tests/Unit/Domains/Checkout/PaymentConfirmationServiceTest.phpconfirmPayment() 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.php45 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.php12 tests on xpayWebhookAction state machine
tests/Unit/Rest/Webhooks/Controllers/WebhookPayPalTest.php10 tests on the PayPal REST webhook handler
tests/Unit/Rest/Webhooks/Controllers/WebhookRepositoryLookupTest.php7 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.php12 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.php17 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.php8 tests: real-HMAC validation, success/fail notification paths, unmatched-order 404
tests/Unit/Rest/Webhooks/Controllers/WebhookPayByBankTest.php19 tests: status mapping (all PayByBankStatus constants), payway-scoped lookup, intermediate-status no-ops (8 DataProvider cases)
tests/Legacy/Webhooks/AdvVivaTest.php + AdvSmartCartTest.php38 combined tests: handleWebhook/handle dispatch, payload validation, status transitions via reflection + mocked models

No unit tests exist for the legacy webhook controllers (AdvJcc, AdvKlarnaPayments, AdvSmartCart, AdvViva) or the REST Webhook controller for Stripe, Viva, Piraeus, and PayByBank. RESOLVED (commit 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.