Skip to content

Gift Cards

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

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 24h)
  • 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

MethodPathAuthDescription
GET/rest/order/gift-card-orderBackendList gift card orders
POST/rest/order/gift-card-orderBackendCreate gift card order
DELETE/rest/order/gift-card-order/{id}BackendDelete

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:1118-1166) 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 1168-1194)

  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) — triggers acceptGiftCard() + acceptPostActions()
  5. Otherwise: xPayCancel($orderSerial) (logged at info level)

xPayCancel() (lines 1196-1207) 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 1218-1290)

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 (idempotency guard — 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)
piraeusFailPendingcancel
piraeusFailCompleted/Canceledrender_error (never revoke a paid coupon)

The guard matters because Piraeus posts the result to the success URL via the browser — a refresh re-POSTs the (still-valid) payload, so a non-guarded handler would issue a second coupon. 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()

  1. Fetch order: getGiftCard($orderId) -- throws if not found
  2. Create coupon: Inserts coupon with name=GIFTCARD_{orderId}, discount_price=amount, max_count_usage=1, coupon_type=GiftCard
  3. Coupon rules: total_cart_from=amount (minimum cart for redemption), date_start=now, date_end=now+5years
  4. Generate code: coupons_model->generateCoupons($couponId, 1) -- creates unique redeemable code
  5. Update order: Set gift_card_status=10 (Completed), completed_at=now, coupon_id, email_sent=false, sms_sent=false
  6. Transaction: All wrapped in trans_begin()/trans_complete() -- rolls back on failure

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 P1D = 24 hours)
  2. Regular orders: Batch-cancel all pending orders older than cutoff (except payway not in ('iris','paypaladvanced','xpay'))
  3. Iris orders: Check each order status via Iris API -- cancel if CANCELED, accept if paid
  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:91-114).

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

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)

Every PIRAEUSBANK_GIFTCARDS key is read in isolation — the gift-card POS configuration is fully independent of the checkout PIRAEUSBANK POS (mirrors getPiraeusBankSettings()). | PIRAEUSBANK_GIFTCARDS | INSTALLMENTS | \|-delimited installment counts for the gift-card POS (empty = none) | | PIRAEUSBANK_GIFTCARDS | MINIMUM_CART_FOR_INSTALLMENTS | Minimum gift-card amount required to offer installments |


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
  • 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

Known Issues & Security Gaps

No known issues at the time of writing.