Skip to content

Gift Cards ​

Flow ID: CF-23 | Module(s): gift_cards, coupons, Order domain | Complexity: Medium | Last Updated: 2026-09-14

Business Overview ​

Gift cards are purchased through a dedicated checkout flow (separate from cart), processed via payment gateways, and redeemed as single-use coupons. On payment success, a coupon code is generated and delivered via email/SMS.

Lifecycle: Purchase -> Pay -> Generate coupon -> Deliver code -> Redeem at checkout

What customers experience:

  • Visit /gift-card page and select a gift card amount (preset or free-form)
  • Enter recipient details (email and/or phone) and personal message
  • Choose payment method and complete payment
  • System generates a unique coupon code and sends it to the recipient
  • Recipient enters the code at checkout to redeem the full amount

Key business behaviors:

  • Coupon: type=GiftCard, max_usage=1, validity=5 years, min_cart=gift_amount
  • Auto-cancel pending orders after giftCardDateTimeIntervalToDrop (default PT180M = 3 hours; falls back to this same default if the config value is missing, empty, or an invalid DateInterval spec -- Advisable-com/ecommercen#531)
  • No partial redemption (full amount or nothing -- enforced by total_cart_from rule)
  • Separate checkout flow: not part of the regular cart/order pipeline
  • Supports multiple payment gateways (PayPal, Eurobank, Piraeus, Viva Wallet, NBG, Iris, Alpha, PayPal Advanced, XPay)
  • SMS delivery optional (gated by GIFT_CARDS.SMS registry setting)
  • Feature gated by GIFT_CARDS.ENABLED registry setting

API Reference ​

REST Endpoints ​

MethodPathAuthRolesDescription
GET/rest/order/gift-card-orderBackendADMIN, ORDERSList gift card orders
POST/rest/order/gift-card-orderBackendADMIN, ORDERSCreate gift card order
GET/rest/order/gift-card-order/itemBackendADMIN, ORDERSFetch single item
GET/rest/order/gift-card-order/{id}BackendADMIN, ORDERSShow gift card order
POST/rest/order/gift-card-order/{id}BackendADMIN, ORDERSUpdate gift card order
DELETE/rest/order/gift-card-order/{id}BackendADMIN, ORDERSDelete

Routes registered at application/config/rest_routes.php:778-791. Policy at application/config/rest_policies.php:770 defines roles [AUTH_ROLE_ADMIN, AUTH_ROLE_ORDERS].

Filters: customerId, giftCardStatus, couponId, payway, emailSent, smsSent

Legacy Storefront ​

URLMethodDescription
/gift-cardindex()Gift card purchase page (Vue-powered)
/gift-card/checkoutcheckout()POST -- create pending order + get payment form data
/gift-card/paypalSuccess/{orderId}paypalSuccess()PayPal return URL (success)
/gift-card/paypalCancel/{orderId}paypalCancel()PayPal return URL (cancel)
/gift-card/xPaySuccess/{orderSerial}xPaySuccess()XPay return URL — verifies via $xpay->getOrderStatus(), calls successView() on PAID
/gift-card/xPayCancel/{orderSerial}xPayCancel()XPay cancel return URL — calls errorView() with checkout.xpay.fail.title language key
/gift-card/xPayHookxPayHook()XPay server-to-server webhook — verifies securityToken with flow='gift_card', state machine
/gift-card/piraeusSuccesspiraeusSuccess()Piraeus Success URL (pre-registered bank-side) — validates the HashKey, then successView()
/gift-card/piraeusFailpiraeusFail()Piraeus Failure + Backlink URL (pre-registered bank-side) — cancels a pending order, renders giftCard.piraeus.fail.title

XPay is the only gift-card gateway with a server-to-server webhook; all other gateways finalize on the return URL. Piraeus return URLs are pre-registered with the bank against a dedicated POS (see below), unlike other gateways that send return URLs per transaction.


Code Flow ​

Step 1: Gift Card Page ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php::index()

  1. Feature gate: _remap() returns 404 if giftCardsEnabled() is false
  2. Settings: GiftCardSettingsReader loads config from Registry: ENABLED, FREE_AMOUNT, MIN_AMOUNT, MAX_AMOUNT, GIFT_CARDS (preset amounts), SMS, ORDER_PREFIX
  3. Render: Vue-powered page (giftCardPage layout) with useVue=true

Step 2: Checkout (Create Pending Order) ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php::checkout()

  1. Method check: POST only via ensureMethodIs('post')
  2. Parse input: jsonDecodeInputStream() -- JSON body from Vue form
  3. Validate: Form validation rules for amount, email, phone, payway, recipient details
  4. Customer: getOrCreateCustomerId($postData) -- creates guest customer if needed
  5. Create order: createPendingGiftCard() -- inserts with gift_card_status=1 (Pending)
  6. Order serial: orderSerial($orderId, $payway) -- generates unique reference with optional prefix
  7. Payment form data: getPayWayFormData() -- routes to gateway-specific handler
  8. Response: JSON {message: 'success', orderId, payWayFormData: {type, url, data}}

Step 3: Payment Gateway Integration ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php

Each gateway returns {type, url, data} where type is redirect or iframe:

GatewayMethodType
PayPal ExpresspaypalFormData()redirect
PayPal AdvancedpaypalAdvancedFormData()redirect
EurobankeurobankFormData()redirect (form POST)
PiraeuspiraeusFormData()redirect (ticket-based, dedicated POS — see below)
AlphaalphaFormData()redirect
NBG (Ethniki)ethnikiFormData()redirect
NBG EEethnikiEEFormData()redirect
Viva WalletvivaWalletFormData()redirect
IrisirisFormData()redirect
XPayxpayFormData()redirect (HPP)

XPay (AdvGiftCardPage.php:1402-1450) uses Nexi XPay's Hosted Payment Page. It calls $xpay->createHostedPaymentOrder() with flow='gift_card', stashes the gateway-side orderId in gift_card_orders.tran_ticket, and returns {type: redirect, url: $hostedPage, data: []}. payWayInstallments['xpay'] is empty — XPay does not offer installments. Customer payload includes id, description (via checkout.xpay.order_description language key), email, and phone split via PhoneHelper.

XPay Return-URL Verification ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php::xPaySuccess() (lines 1452-1478)

  1. Lookup: serialToId($orderSerial, 'xpay') → fetch gift-card row
  2. Verify upstream: $xpay->getOrderStatus($orderSerial) — calls Nexi API; on failure falls through to xPayCancel()
  3. Map status: $xpay->mapOperationResultToStatus($operationResult)
  4. On PAID: successView($orderId) (AdvGiftCardPage.php:1154-1171) — calls acceptGiftCard($orderId); acceptPostActions($orderId) fires only when that call returns true (the claim was won). renderSuccess($orderId) runs either way, so a refreshed or re-POSTed return still shows the success page for an already-Completed order
  5. Otherwise: xPayCancel($orderSerial) (logged at info level)

xPayCancel() (AdvGiftCardPage.php:1480-1491) fetches the order and calls errorView() with checkout.xpay.fail.title. Same pattern as Iris and PayPal Advanced: verify via API on return, no trust placed on querystring data.

XPay Server-to-Server Webhook ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php::xPayHook() (lines 1502-1583)

XPay is the only gift-card gateway with a server-to-server webhook. The handler is separate from checkout/xPayHook because the lookup table differs (gift_card_orders vs orders).

  1. Parse json_decode($input_stream) — HTTP 400 on invalid JSON
  2. $xpay->parsePaymentNotification() — HTTP 400 on exception
  3. $xpay->verifyNotificationSecurityToken($notification, $orderSerial, 'gift_card') — HTTP 401 on mismatch. The flow='gift_card' namespace isolates gift-card tokens from regular-checkout tokens.
  4. serialToId() + gift_card_orders_model->get() — HTTP 404 if missing
  5. REFUNDED notifications: ignored (logged, no state change)
  6. State machine via xpayWebhookAction(GiftCardStatus, string): string:
Current statusXPay statusAction
PendingPAIDaccept (issue coupon, mark Completed)
PendingCANCELEDcancel
CompletedCANCELEDcancel (revoke coupon — reversal)
CompletedPAIDnoop (outcome selection — accept vs cancel vs a logged no-op; the model's Pending-only claim, not this mapping, now prevents duplicate coupons)
Canceledanynoop (terminal)
anyPENDING/UNKNOWN/emptynoop

xpayWebhookAction() is a public static pure helper unit-tested in tests/Legacy/GiftCards/AdvGiftCardPageTest.php with 10 data-provider rows + 2 exhaustive invariant guards.

Piraeus Ticketing + Return-URL Verification ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php::piraeusFormData()

Piraeus is unique among the gateways: the success/failure/backlink URLs are pre-registered with the bank against a specific POS, not sent per transaction. Because the gift-card return URLs (/gift-card/piraeusSuccess, /gift-card/piraeusFail) differ from the regular-checkout ones, the bank issues a dedicated POS for gift cards, read via getPiraeusGiftCardBankSettings() — which mirrors getPiraeusBankSettings() field-for-field but reads every setting from the PIRAEUSBANK_GIFTCARDS registry group, fully independent of the checkout PIRAEUSBANK group (nothing is inherited). The gift-card POS is provisioned independently, so it may need a different RequestType than checkout — e.g. 02 (Sale, an immediate purchase) where the checkout POS runs 00 (Preauthorization); sending the wrong type returns "Invalid transaction type". All 13 settings (credentials, transaction type, currency, post action, BNPL, parameters, installments) are entered in admin for the gift-card POS. Installments are config-driven — an empty PIRAEUSBANK_GIFTCARDS.INSTALLMENTS simply yields none. The flow otherwise mirrors the classic checkout (Adv_checkout::_piraeus) and the official Redirection Manual (§4 Ticketing, §5 Response/HashKey):

  1. Issue ticket: PireausServiceIssue->IssueNewTicket() (SOAP) with the gift-card POS credentials. On ResultCode == 0, the returned TranTicket is persisted to gift_card_orders.tran_ticket — it is the HMAC secret needed to validate the callback.
  2. Redirect form: returns {type: redirect, url: postAction, data: {acquirerId, merchantId, posId, user, languageCode, merchantReference, paramBackLink, postAction}}. No TranTicket is sent in the form (per the manual); the transaction is identified by MerchantReference (the order serial). Installments are validated by resolvePiraeusInstallments() against the gift-card POS allowlist (PIRAEUSBANK_GIFTCARDS.INSTALLMENTS) + minimum, mirroring the classic checkout; payWayInstallments['piraeus'] is populated from piraeusGiftCardInstallmentsDropDown() (empty config → no dropdown).
  3. HashKey verification (piraeusSuccess()): the bank POSTs the result to the Success URL. piraeusHashKeyMatches() recomputes the HMAC-SHA256 over TranTicket;PosId;AcquirerId;MerchantReference;ApprovalCode;Parameters;ResponseCode;SupportReferenceID;AuthStatus;PackageNo;StatusFlag (keyed on the gift-card TranTicket, UPPERCASE hex) and compares it with the response HashKey.

Success/fail state machine via piraeusSuccessAction(GiftCardStatus, bool $hmacValid): string and piraeusFailAction(GiftCardStatus): string:

HandlerCurrent statusConditionAction
piraeusSuccessPendingvalid HashKeyaccept (issue coupon, mark Completed)
piraeusSuccessPendinginvalid HashKeyerror (cancel the pending order)
piraeusSuccessCompletedanyrender_success (idempotent re-POST/refresh — no mutation)
piraeusSuccessCanceledanyrender_error (terminal — no mutation)
piraeusFailPending—cancel
piraeusFailCompleted/Canceled—render_error (never revoke a paid coupon)

The model itself now enforces the Pending-only claim (see Step 4: Payment Acceptance), so piraeusSuccessAction is no longer what stops a duplicate coupon on a refreshed or re-POSTed callback (AdvGiftCardPage.php:418-422). It stays because it selects the page the customer is shown: a re-POSTed callback for an already-Completed order must render the success page, not an error, mirroring the render_success branch above. piraeusSuccessAction, piraeusFailAction, piraeusHashKeyMatches and resolvePiraeusInstallments are public static pure helpers unit-tested in tests/Legacy/GiftCards/AdvGiftCardPageTest.php.

Step 4: Payment Acceptance (Coupon Generation) ​

File: ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php::acceptGiftCard() (:49-51), which delegates to the shared protected acceptGiftCardFrom($orderId, array $allowedFrom): bool (:70-133)

  1. Fetch order: getGiftCard($orderId) -- throws if not found (:72)
  2. Begin transaction: trans_begin() (:74) -- moved above coupon generation so the claim and the coupon issue share one transaction
  3. Claim the order — the FIRST statement, before any coupon is issued: a single conditional UPDATE ... WHERE id = ? AND gift_card_status IN (...) (:92-101) sets gift_card_status=10 (Completed), completed_at=now, canceled_at=null, email_sent=false, sms_sent=false. acceptGiftCard() restricts the allow-list to [Pending]; the admin-only acceptGiftCardManually() (:62-68) widens it to [Pending, Canceled] -- see AD-23 Gift Cards Admin. canceled_at is explicitly cleared, not merely left behind: applyStatusFilter() (AdvGiftCardOrdersModel.php:294) resolves Completed as completed_at IS NOT NULL AND canceled_at IS NULL, so a Canceled -> Completed accept that kept canceled_at would still list as Canceled in the admin grid
  4. Claim check: if $this->db->affected_rows() === 0 (:103-106), the order was not in an allowed source state -- trans_rollback() and return false without touching coupons. This is the concurrency guard: two overlapping retries for the same order (a gateway retry, a refreshed return URL) serialise on the row's InnoDB lock, and only the winner proceeds. The method returns bool, not void
  5. Create coupon: Inserts coupon with name=GIFTCARD_{orderId}, discount_price=amount, max_count_usage=1, coupon_type=GiftCard (:109-115; insertCoupon() call at :123)
  6. Coupon rules: total_cart_from=amount (minimum cart for redemption), date_start=now, date_end=now+5years (:117-121)
  7. Generate code: coupons_model->generateCoupons($couponId, 1) -- creates unique redeemable code (:124)
  8. Link coupon: A separate update($orderId, ['coupon_id' => $couponId]) call (:125) writes coupon_id onto the order after the coupon exists -- not part of the claim UPDATE in step 3
  9. Complete transaction: trans_complete() (:127); a failed commit throws TransactionException (:128-130)
  10. Return: true, but only once the claim was won and the coupon issued (:132)

New source-state policy across ~15 call sites: every automated caller -- the legacy Viva webhook (ecommercen/webhooks/AdvViva.php:96-103), the ten bank return URLs via successView(), xPayHook(), and all three AdvCancelPendingGiftCards branches -- calls acceptGiftCard() and may only accept a Pending order. Only the admin grid calls acceptGiftCardManually() and may accept a Canceled order.

Client extension point: AdvGiftCardPage::acceptGiftCard() (:1125-1128), the controller-level protected wrapper, widened its signature from void to bool. A fork overriding it must return bool and must not run acceptPostActions() when it returns false -- see successView() below and Client Extension Points.

Step 5: Email Delivery Job ​

File: ecommercen/gift_cards/jobs/AdvSendGiftCardsToEmails.php

  1. Feature gate: Check enabledGiftCards setting
  2. Query: Completed orders where email_sent=false and send_to_email is set
  3. Send to recipient: adv_mailer->sendGiftCardToEmail($order) -- delivers coupon code
  4. Inform purchaser: adv_mailer->sendGiftCardToInformCustomer($order) -- confirms delivery
  5. Mark sent: markGiftCardMailSent($orderId) -- sets email_sent=true and marks coupon as sent

Step 6: SMS Delivery Job ​

File: ecommercen/gift_cards/jobs/AdvSendGiftCardsToPhones.php

  1. Feature gate: Check enabledGiftCards AND enabledSms settings
  2. Query: Completed orders where sms_sent=false and send_to_phone is set
  3. Send: GiftCardSendSms->sendSms($order) per order
  4. Mark sent: Updates sms_sent=true

Step 7: Auto-Cancel Pending Orders ​

File: ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php

  1. Cutoff date: now - giftCardDateTimeIntervalToDrop (config, default PT180M = 3 hours). Since Advisable-com/ecommercen#531, the config item is read via dateTimeIntervalToDrop(), which validates it before constructing the DateInterval -- a missing, empty, non-string, or malformed value falls back to DEFAULT_DATE_TIME_INTERVAL_TO_DROP = 'PT180M' and logs at error level, instead of new \DateInterval(...) throwing and aborting the whole job (the live trigger is a client fork's application/config/app.php predating this key).
  2. Regular orders: Batch-cancel all pending orders older than cutoff (except payway not in ('iris','paypaladvanced','xpay'))
  3. Iris orders: cancelPendingIrisOrders() (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php:105-171) reconciles each pending Iris order against the bank independently, so one order's outcome can never abort the batch. The decision is routed through irisReconcileAction(string $orderStatus): string (:196-207), a pure static decision table: an explicit 'PAID' is the only status that accepts (issues the coupon); an empty/unresolved status is a noop that leaves the order Pending for the next run; anything else cancels. Accept is an allowlist, not a default, because issuing the coupon is a money action that must not fire on an ambiguous or unresolved bank status -- not because of any idempotency concern; acceptGiftCard() is idempotent per order now (see Step 4). An order whose iris_orders row is missing (getIrisRecordsByGiftCardOrderId() returned null) is skipped and logged at error level rather than dereferenced (Advisable-com/ecommercen#582)
  4. PayPal Advanced: Check each order via PayPal REST API -- cancel if not COMPLETED, accept if paid
  5. XPay orders: cancelPendingXpayOrders() — for each pending XPay row, calls $xpay->getOrderStatus($order_serial) and $xpay->mapOperationResultToStatus(). Accepts if PAID, cancels otherwise (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php:253-278).

Step 8: Order Cancellation ​

File: ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php::cancelGiftCard()

  • Pending orders: Simply update gift_card_status=11 (Canceled), canceled_at=now
  • Completed orders: Delete the generated coupon via coupons_model->deleteRecord(), then cancel the order (wrapped in transaction)

Domain Layer ​

ComponentPath
Servicesrc/Domains/Order/GiftCardOrder/Service.php
WriteServicesrc/Domains/Order/GiftCardOrder/WriteService.php
Entitysrc/Domains/Order/GiftCardOrder/Repository/Entity.php
Legacy Controllerecommercen/gift_cards/controllers/AdvGiftCardPage.php
Admin Controllerecommercen/gift_cards/controllers/AdvGiftCardAdminListing.php
Settings Controllerecommercen/gift_cards/controllers/AdvGiftCardSettings.php
Legacy Modelecommercen/gift_cards/models/AdvGiftCardOrdersModel.php
Status Enumecommercen/gift_cards/libraries/GiftCardStatus.php (Spatie Enum)
Settings Readerecommercen/gift_cards/libraries/AdvGiftCardSettingsReader.php
Email Jobecommercen/gift_cards/jobs/AdvSendGiftCardsToEmails.php
SMS Jobecommercen/gift_cards/jobs/AdvSendGiftCardsToPhones.php
Cancel Jobecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php
Resend Email Jobecommercen/gift_cards/jobs/AdvResendGiftCardEmail.php
Resend SMS Jobecommercen/gift_cards/jobs/AdvResendGiftCardSMS.php
SMS Senderecommercen/gift_cards/jobs/GiftCardSendSms.php

Status values (GiftCardStatus Spatie Enum): Pending=1, Completed=10, Canceled=11.


Data Model ​

gift_card_orders ​

ColumnTypeDescription
idint (PK, AI)Gift card order ID
customer_idint (FK)Purchasing customer
amountdecimal(11,2)Gift card face value
currency_idint (FK)Currency reference
currency_ratedecimal(11,4)Exchange rate at time of purchase
paywayvarchar(255)Payment method identifier
gift_card_statustinyint(1)Status: 1=Pending, 10=Completed, 11=Canceled
coupon_idint (FK, nullable)Generated coupon reference (set on completion)
send_to_emailvarchar(255, nullable)Recipient email
send_to_phonevarchar(255, nullable)Recipient phone (for SMS)
send_to_namevarchar(255, nullable)Recipient first name
send_to_surnamevarchar(255, nullable)Recipient last name
messagetext (nullable)Personal message from purchaser
email_senttinyint(1)Whether email was delivered
sms_senttinyint(1)Whether SMS was delivered
created_atdatetimeOrder creation timestamp
completed_atdatetime (nullable)When payment was confirmed
canceled_atdatetime (nullable)When order was canceled
order_serialvarchar(255, nullable)Unique payment reference (prefix + ID + payway)
tran_ticketvarchar(32, nullable)Payment gateway transaction ticket

Indexes: customer_id, coupon_id, gift_card_status, currency_id, created_at, payway, status+email_sent, status+sms_sent

Schema source: database/initial/initial.sql:589-619.


Configuration ​

Registry Settings ​

GroupKeyDescription
GIFT_CARDSENABLEDFeature toggle (boolean)
GIFT_CARDSMIN_AMOUNTMinimum gift card amount
GIFT_CARDSMAX_AMOUNTMaximum gift card amount
GIFT_CARDSFREE_AMOUNTWhether custom amounts are allowed
GIFT_CARDSGIFT_CARDSArray of preset gift card amounts
GIFT_CARDSSMSWhether SMS delivery is enabled
GIFT_CARDSORDER_PREFIXPrefix for order serial numbers
PIRAEUSBANK_GIFTCARDSACQUIRER_IDGift-card POS acquirer id (dedicated Piraeus credentials)
PIRAEUSBANK_GIFTCARDSMERCHANT_IDGift-card POS merchant id
PIRAEUSBANK_GIFTCARDSPOS_IDGift-card POS id (binds the pre-registered return URLs)
PIRAEUSBANK_GIFTCARDSUSERNAMEGift-card POS username
PIRAEUSBANK_GIFTCARDSPASSWORDGift-card POS password (MD5-hashed at request time)
PIRAEUSBANK_GIFTCARDSREQUEST_TYPEGift-card POS transaction type (02 Sale recommended, 00 Preauthorization)
PIRAEUSBANK_GIFTCARDSEXPIRE_PRE_AUTHPre-auth expiry (days); 0 for Sale
PIRAEUSBANK_GIFTCARDSCURRENCY_CODETransaction currency (e.g. 978 EUR)
PIRAEUSBANK_GIFTCARDSBNPLPer-POS Buy-Now-Pay-Later flag (e.g. 0)
PIRAEUSBANK_GIFTCARDSPARAMETERSResponse passthrough
PIRAEUSBANK_GIFTCARDSPOST_ACTIONBank redirection URL (the gift-card POS's own; use the test endpoint when testing)
PIRAEUSBANK_GIFTCARDSINSTALLMENTS|-delimited installment counts for the gift-card POS (empty = none)
PIRAEUSBANK_GIFTCARDSMINIMUM_CART_FOR_INSTALLMENTSMinimum gift-card amount required to offer installments

Every PIRAEUSBANK_GIFTCARDS key is read in isolation — the gift-card POS configuration is fully independent of the checkout PIRAEUSBANK POS (mirrors getPiraeusBankSettings()).


Client Extension Points ​

  • acceptPostActions() / cancelPostActions() hooks: Custom logic on accept/cancel
  • indexExtras() hook: Customize gift card purchase page
  • Payment gateways: Override getPayWayFormData() to add custom gateways
  • formatPiraeusCardholderName() override (ecommercen/gift_cards/controllers/AdvGiftCardPage.php:250): Subclass to sanitize cardholder surname/name before Piraeus IssueNewTicket. Called from piraeusFormData() (line 298); base returns "$surname $name" unchanged.
  • Job queue: AdvSendGiftCardsToEmails, AdvSendGiftCardsToPhones, AdvCancelPendingGiftCards
  • AdvCancelPendingGiftCards::irisClient() override (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php:221-224): protected seam returning Iris\Iris, used to substitute a stubbed gateway client in tests. A fork overriding it must keep the return type covariant.
  • AdvCancelPendingGiftCards::irisReconcileAction() override (:196-207): public static pure decision table (noop/cancel/accept) for a reconciled Iris order's status. A fork that widens the accept allowlist beyond an explicit PAID reintroduces the payout risk fixed in Advisable-com/ecommercen#582.
  • AdvGiftCardPage::acceptGiftCard() override (ecommercen/gift_cards/controllers/AdvGiftCardPage.php:1125-1128): protected seam widened void -> bool (see Step 4). A fork overriding it must return bool and must not run acceptPostActions() on false.
  • Resend jobs: AdvResendGiftCardEmail, AdvResendGiftCardSMS for manual retry
  • Registry settings: All gift card behavior configurable via GIFT_CARDS registry group
  • Views: {client_views}/gift_cards/ -- purchase page, success/error views, email templates

Business Rules ​

  1. Coupon is single-use, non-refundable. Each gift card generates exactly one coupon with max_usage=1, ensuring one redemption per card.
  2. No partial redemption. The coupon total_cart_from rule equals the gift-card amount, so the full value must be redeemed or nothing.
  3. Coupon validity spans 5 years. Generated on acceptance, valid date_start=now, date_end=now+5 years (AdvGiftCardOrdersModel.php:117-121).
  4. Auto-cancel pending orders after giftCardDateTimeIntervalToDrop. Default PT180M (3 hours, fallback validated by dateTimeIntervalToDrop() in AdvCancelPendingGiftCards.php). Orders not paid within the cutoff are canceled (AdvCancelPendingGiftCards.php:36-49).
  5. Multiple payment gateway support. Gateways include PayPal, Eurobank, Piraeus, Alpha, NBG, Viva Wallet, Iris, XPay, PayPal Advanced. Each has distinct return-URL or webhook handling.
  6. XPay is the only webhook-capable gateway. Other gateways rely on return-URL verification; XPay has a dedicated server-to-server webhook at xPayHook() with state-machine acceptance.
  7. Piraeus uses a dedicated gift-card POS. The return URLs (/gift-card/piraeusSuccess, /gift-card/piraeusFail) are pre-registered with the bank against a separate POS from the checkout one, fully configured in the PIRAEUSBANK_GIFTCARDS registry group.
  8. SMS delivery is optional, gated by GIFT_CARDS.SMS. Default off; when enabled, AdvSendGiftCardsToPhones job dispatches the code to send_to_phone.

Tests ​

Test FileLinesCoverage
tests/Legacy/GiftCards/AdvGiftCardPageTest.php38010 data-provider rows + 2 invariant guards for xpayWebhookAction; also covers piraeusSuccessAction / piraeusFailAction / piraeusHashKeyMatches / resolvePiraeusInstallments
tests/Legacy/GiftCards/AdvGiftCardOrdersModelTest.php559New (#583). 10 #[Test] methods covering acceptGiftCardFrom()'s shared claim: duplicate accept on a Completed order via both acceptGiftCard() and acceptGiftCardManually(), accept on a Canceled order via both entry points, the canceled_at clear on a Canceled -> Completed accept, claim-before-coupon-issue ordering, and NotFoundException propagation from getGiftCard()

Known Issues & Security Gaps ​

  • AdvGiftCardOrdersModel::acceptGiftCard() idempotency — RESOLVED (#583, commits f89660a84 + a9e5d9788). acceptGiftCard($orderId): bool is now Pending-only (AdvGiftCardOrdersModel.php:49-51) and delegates to a shared acceptGiftCardFrom($orderId, array $allowedFrom) (:70-133) that claims the order with a single conditional UPDATE ... WHERE id = ? AND gift_card_status IN (...) BEFORE any coupon is issued (see Step 4: Payment Acceptance). The check and the write are one statement, so two concurrent retries for the same order serialise on the row's InnoDB lock and the loser sees affected_rows() === 0, rolls back, and returns false without touching coupons. The fix did not add a blanket guard to the shared method — the "blanket guard breaks the admin override" concern was resolved by splitting the entry point instead: acceptGiftCardManually($orderId): bool (:62-68) widens the allow-list to include Canceled, for the admin grid only. See AD-23 Gift Cards Admin for the admin-side 409 response and the manual-accept override.
  • An Iris order the bank never resolves stays Pending indefinitely — by design. A bounded retry count / hard cutoff was explicitly considered and rejected at triage: cancelling on a transport blip (no reply, non-200, unparseable body — indistinguishable here from "still in flight") would destroy a gift card the bank was about to confirm as PAID. The 15-minute re-query (cancelPendingIrisOrders()) is the backstop instead of a cutoff.
  • Stale docblock still asserts the pre-#583 premise. ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php:177-181 reads: "Accept is an allowlist ON PURPOSE. acceptGiftCard() is not idempotent: every call inserts a fresh coupon for the full face value, valid five years, flips the order to Completed and queues the customer email." This is false since #583 (AdvGiftCardOrdersModel.php:92-106): acceptGiftCard() is now idempotent via the claim-before-issue UPDATE (see Step 4: Payment Acceptance). The allowlist rationale itself survives -- a money action must not fire on an ambiguous bank status -- only the stated premise is wrong. The same stale claim is repeated in tests/Unit/Jobs/AdvCancelPendingGiftCardsTest.php:486-487. This is a code-comment defect, not a behavior bug; the PHP has not been edited to correct it.