Appearance
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-cardpage 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(defaultPT180M= 3 hours; falls back to this same default if the config value is missing, empty, or an invalidDateIntervalspec -- Advisable-com/ecommercen#531) - No partial redemption (full amount or nothing -- enforced by
total_cart_fromrule) - 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.SMSregistry setting) - Feature gated by
GIFT_CARDS.ENABLEDregistry setting
API Reference
REST Endpoints
| Method | Path | Auth | Roles | Description |
|---|---|---|---|---|
| GET | /rest/order/gift-card-order | Backend | ADMIN, ORDERS | List gift card orders |
| POST | /rest/order/gift-card-order | Backend | ADMIN, ORDERS | Create gift card order |
| GET | /rest/order/gift-card-order/item | Backend | ADMIN, ORDERS | Fetch single item |
| GET | /rest/order/gift-card-order/{id} | Backend | ADMIN, ORDERS | Show gift card order |
| POST | /rest/order/gift-card-order/{id} | Backend | ADMIN, ORDERS | Update gift card order |
| DELETE | /rest/order/gift-card-order/{id} | Backend | ADMIN, ORDERS | Delete |
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
| URL | Method | Description |
|---|---|---|
/gift-card | index() | Gift card purchase page (Vue-powered) |
/gift-card/checkout | checkout() | 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/xPayHook | xPayHook() | XPay server-to-server webhook — verifies securityToken with flow='gift_card', state machine |
/gift-card/piraeusSuccess | piraeusSuccess() | Piraeus Success URL (pre-registered bank-side) — validates the HashKey, then successView() |
/gift-card/piraeusFail | piraeusFail() | 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()
- Feature gate:
_remap()returns 404 ifgiftCardsEnabled()is false - Settings:
GiftCardSettingsReaderloads config from Registry:ENABLED,FREE_AMOUNT,MIN_AMOUNT,MAX_AMOUNT,GIFT_CARDS(preset amounts),SMS,ORDER_PREFIX - Render: Vue-powered page (
giftCardPagelayout) withuseVue=true
Step 2: Checkout (Create Pending Order)
File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php::checkout()
- Method check: POST only via
ensureMethodIs('post') - Parse input:
jsonDecodeInputStream()-- JSON body from Vue form - Validate: Form validation rules for amount, email, phone, payway, recipient details
- Customer:
getOrCreateCustomerId($postData)-- creates guest customer if needed - Create order:
createPendingGiftCard()-- inserts withgift_card_status=1(Pending) - Order serial:
orderSerial($orderId, $payway)-- generates unique reference with optional prefix - Payment form data:
getPayWayFormData()-- routes to gateway-specific handler - 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:
| Gateway | Method | Type |
|---|---|---|
| PayPal Express | paypalFormData() | redirect |
| PayPal Advanced | paypalAdvancedFormData() | redirect |
| Eurobank | eurobankFormData() | redirect (form POST) |
| Piraeus | piraeusFormData() | redirect (ticket-based, dedicated POS — see below) |
| Alpha | alphaFormData() | redirect |
| NBG (Ethniki) | ethnikiFormData() | redirect |
| NBG EE | ethnikiEEFormData() | redirect |
| Viva Wallet | vivaWalletFormData() | redirect |
| Iris | irisFormData() | redirect |
| XPay | xpayFormData() | 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)
- Lookup:
serialToId($orderSerial, 'xpay')→ fetch gift-card row - Verify upstream:
$xpay->getOrderStatus($orderSerial)— calls Nexi API; on failure falls through toxPayCancel() - Map status:
$xpay->mapOperationResultToStatus($operationResult) - On PAID:
successView($orderId)(AdvGiftCardPage.php:1154-1171) — callsacceptGiftCard($orderId);acceptPostActions($orderId)fires only when that call returnstrue(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 - 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).
- Parse
json_decode($input_stream)— HTTP 400 on invalid JSON $xpay->parsePaymentNotification()— HTTP 400 on exception$xpay->verifyNotificationSecurityToken($notification, $orderSerial, 'gift_card')— HTTP 401 on mismatch. Theflow='gift_card'namespace isolates gift-card tokens from regular-checkout tokens.serialToId() + gift_card_orders_model->get()— HTTP 404 if missing- REFUNDED notifications: ignored (logged, no state change)
- State machine via
xpayWebhookAction(GiftCardStatus, string): string:
| Current status | XPay status | Action |
|---|---|---|
| Pending | PAID | accept (issue coupon, mark Completed) |
| Pending | CANCELED | cancel |
| Completed | CANCELED | cancel (revoke coupon — reversal) |
| Completed | PAID | noop (outcome selection — accept vs cancel vs a logged no-op; the model's Pending-only claim, not this mapping, now prevents duplicate coupons) |
| Canceled | any | noop (terminal) |
| any | PENDING/UNKNOWN/empty | noop |
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):
- Issue ticket:
PireausServiceIssue->IssueNewTicket()(SOAP) with the gift-card POS credentials. OnResultCode == 0, the returnedTranTicketis persisted togift_card_orders.tran_ticket— it is the HMAC secret needed to validate the callback. - Redirect form: returns
{type: redirect, url: postAction, data: {acquirerId, merchantId, posId, user, languageCode, merchantReference, paramBackLink, postAction}}. NoTranTicketis sent in the form (per the manual); the transaction is identified byMerchantReference(the order serial). Installments are validated byresolvePiraeusInstallments()against the gift-card POS allowlist (PIRAEUSBANK_GIFTCARDS.INSTALLMENTS) + minimum, mirroring the classic checkout;payWayInstallments['piraeus']is populated frompiraeusGiftCardInstallmentsDropDown()(empty config → no dropdown). - HashKey verification (
piraeusSuccess()): the bank POSTs the result to the Success URL.piraeusHashKeyMatches()recomputes the HMAC-SHA256 overTranTicket;PosId;AcquirerId;MerchantReference;ApprovalCode;Parameters;ResponseCode;SupportReferenceID;AuthStatus;PackageNo;StatusFlag(keyed on the gift-cardTranTicket, UPPERCASE hex) and compares it with the responseHashKey.
Success/fail state machine via piraeusSuccessAction(GiftCardStatus, bool $hmacValid): string and piraeusFailAction(GiftCardStatus): string:
| Handler | Current status | Condition | Action |
|---|---|---|---|
piraeusSuccess | Pending | valid HashKey | accept (issue coupon, mark Completed) |
piraeusSuccess | Pending | invalid HashKey | error (cancel the pending order) |
piraeusSuccess | Completed | any | render_success (idempotent re-POST/refresh — no mutation) |
piraeusSuccess | Canceled | any | render_error (terminal — no mutation) |
piraeusFail | Pending | — | cancel |
piraeusFail | Completed/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)
- Fetch order:
getGiftCard($orderId)-- throws if not found (:72) - Begin transaction:
trans_begin()(:74) -- moved above coupon generation so the claim and the coupon issue share one transaction - Claim the order — the FIRST statement, before any coupon is issued: a single conditional
UPDATE ... WHERE id = ? AND gift_card_status IN (...)(:92-101) setsgift_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-onlyacceptGiftCardManually()(:62-68) widens it to[Pending, Canceled]-- see AD-23 Gift Cards Admin.canceled_atis explicitly cleared, not merely left behind:applyStatusFilter()(AdvGiftCardOrdersModel.php:294) resolves Completed ascompleted_at IS NOT NULL AND canceled_at IS NULL, so a Canceled -> Completed accept that keptcanceled_atwould still list as Canceled in the admin grid - Claim check: if
$this->db->affected_rows() === 0(:103-106), the order was not in an allowed source state --trans_rollback()and returnfalsewithout 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 returnsbool, notvoid - Create coupon: Inserts coupon with
name=GIFTCARD_{orderId},discount_price=amount,max_count_usage=1,coupon_type=GiftCard(:109-115;insertCoupon()call at:123) - Coupon rules:
total_cart_from=amount(minimum cart for redemption),date_start=now,date_end=now+5years(:117-121) - Generate code:
coupons_model->generateCoupons($couponId, 1)-- creates unique redeemable code (:124) - Link coupon: A separate
update($orderId, ['coupon_id' => $couponId])call (:125) writescoupon_idonto the order after the coupon exists -- not part of the claim UPDATE in step 3 - Complete transaction:
trans_complete()(:127); a failed commit throwsTransactionException(:128-130) - 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
- Feature gate: Check
enabledGiftCardssetting - Query: Completed orders where
email_sent=falseandsend_to_emailis set - Send to recipient:
adv_mailer->sendGiftCardToEmail($order)-- delivers coupon code - Inform purchaser:
adv_mailer->sendGiftCardToInformCustomer($order)-- confirms delivery - Mark sent:
markGiftCardMailSent($orderId)-- setsemail_sent=trueand marks coupon as sent
Step 6: SMS Delivery Job
File: ecommercen/gift_cards/jobs/AdvSendGiftCardsToPhones.php
- Feature gate: Check
enabledGiftCardsANDenabledSmssettings - Query: Completed orders where
sms_sent=falseandsend_to_phoneis set - Send:
GiftCardSendSms->sendSms($order)per order - Mark sent: Updates
sms_sent=true
Step 7: Auto-Cancel Pending Orders
File: ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php
- Cutoff date:
now - giftCardDateTimeIntervalToDrop(config, defaultPT180M= 3 hours). Since Advisable-com/ecommercen#531, the config item is read viadateTimeIntervalToDrop(), which validates it before constructing theDateInterval-- a missing, empty, non-string, or malformed value falls back toDEFAULT_DATE_TIME_INTERVAL_TO_DROP = 'PT180M'and logs at error level, instead ofnew \DateInterval(...)throwing and aborting the whole job (the live trigger is a client fork'sapplication/config/app.phppredating this key). - Regular orders: Batch-cancel all pending orders older than cutoff (except
payway not in ('iris','paypaladvanced','xpay')) - 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 throughirisReconcileAction(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 anoopthat leaves the orderPendingfor 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 whoseiris_ordersrow is missing (getIrisRecordsByGiftCardOrderId()returnednull) is skipped and logged aterrorlevel rather than dereferenced (Advisable-com/ecommercen#582) - PayPal Advanced: Check each order via PayPal REST API -- cancel if not
COMPLETED, accept if paid - 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
| Component | Path |
|---|---|
| Service | src/Domains/Order/GiftCardOrder/Service.php |
| WriteService | src/Domains/Order/GiftCardOrder/WriteService.php |
| Entity | src/Domains/Order/GiftCardOrder/Repository/Entity.php |
| Legacy Controller | ecommercen/gift_cards/controllers/AdvGiftCardPage.php |
| Admin Controller | ecommercen/gift_cards/controllers/AdvGiftCardAdminListing.php |
| Settings Controller | ecommercen/gift_cards/controllers/AdvGiftCardSettings.php |
| Legacy Model | ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php |
| Status Enum | ecommercen/gift_cards/libraries/GiftCardStatus.php (Spatie Enum) |
| Settings Reader | ecommercen/gift_cards/libraries/AdvGiftCardSettingsReader.php |
| Email Job | ecommercen/gift_cards/jobs/AdvSendGiftCardsToEmails.php |
| SMS Job | ecommercen/gift_cards/jobs/AdvSendGiftCardsToPhones.php |
| Cancel Job | ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php |
| Resend Email Job | ecommercen/gift_cards/jobs/AdvResendGiftCardEmail.php |
| Resend SMS Job | ecommercen/gift_cards/jobs/AdvResendGiftCardSMS.php |
| SMS Sender | ecommercen/gift_cards/jobs/GiftCardSendSms.php |
Status values (GiftCardStatus Spatie Enum): Pending=1, Completed=10, Canceled=11.
Data Model
gift_card_orders
| Column | Type | Description |
|---|---|---|
id | int (PK, AI) | Gift card order ID |
customer_id | int (FK) | Purchasing customer |
amount | decimal(11,2) | Gift card face value |
currency_id | int (FK) | Currency reference |
currency_rate | decimal(11,4) | Exchange rate at time of purchase |
payway | varchar(255) | Payment method identifier |
gift_card_status | tinyint(1) | Status: 1=Pending, 10=Completed, 11=Canceled |
coupon_id | int (FK, nullable) | Generated coupon reference (set on completion) |
send_to_email | varchar(255, nullable) | Recipient email |
send_to_phone | varchar(255, nullable) | Recipient phone (for SMS) |
send_to_name | varchar(255, nullable) | Recipient first name |
send_to_surname | varchar(255, nullable) | Recipient last name |
message | text (nullable) | Personal message from purchaser |
email_sent | tinyint(1) | Whether email was delivered |
sms_sent | tinyint(1) | Whether SMS was delivered |
created_at | datetime | Order creation timestamp |
completed_at | datetime (nullable) | When payment was confirmed |
canceled_at | datetime (nullable) | When order was canceled |
order_serial | varchar(255, nullable) | Unique payment reference (prefix + ID + payway) |
tran_ticket | varchar(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
| Group | Key | Description |
|---|---|---|
GIFT_CARDS | ENABLED | Feature toggle (boolean) |
GIFT_CARDS | MIN_AMOUNT | Minimum gift card amount |
GIFT_CARDS | MAX_AMOUNT | Maximum gift card amount |
GIFT_CARDS | FREE_AMOUNT | Whether custom amounts are allowed |
GIFT_CARDS | GIFT_CARDS | Array of preset gift card amounts |
GIFT_CARDS | SMS | Whether SMS delivery is enabled |
GIFT_CARDS | ORDER_PREFIX | Prefix for order serial numbers |
PIRAEUSBANK_GIFTCARDS | ACQUIRER_ID | Gift-card POS acquirer id (dedicated Piraeus credentials) |
PIRAEUSBANK_GIFTCARDS | MERCHANT_ID | Gift-card POS merchant id |
PIRAEUSBANK_GIFTCARDS | POS_ID | Gift-card POS id (binds the pre-registered return URLs) |
PIRAEUSBANK_GIFTCARDS | USERNAME | Gift-card POS username |
PIRAEUSBANK_GIFTCARDS | PASSWORD | Gift-card POS password (MD5-hashed at request time) |
PIRAEUSBANK_GIFTCARDS | REQUEST_TYPE | Gift-card POS transaction type (02 Sale recommended, 00 Preauthorization) |
PIRAEUSBANK_GIFTCARDS | EXPIRE_PRE_AUTH | Pre-auth expiry (days); 0 for Sale |
PIRAEUSBANK_GIFTCARDS | CURRENCY_CODE | Transaction currency (e.g. 978 EUR) |
PIRAEUSBANK_GIFTCARDS | BNPL | Per-POS Buy-Now-Pay-Later flag (e.g. 0) |
PIRAEUSBANK_GIFTCARDS | PARAMETERS | Response passthrough |
PIRAEUSBANK_GIFTCARDS | POST_ACTION | Bank redirection URL (the gift-card POS's own; use the test endpoint when testing) |
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 |
Every
PIRAEUSBANK_GIFTCARDSkey is read in isolation — the gift-card POS configuration is fully independent of the checkoutPIRAEUSBANKPOS (mirrorsgetPiraeusBankSettings()).
Client Extension Points
acceptPostActions()/cancelPostActions()hooks: Custom logic on accept/cancelindexExtras()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 PiraeusIssueNewTicket. Called frompiraeusFormData()(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 returningIris\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 explicitPAIDreintroduces the payout risk fixed in Advisable-com/ecommercen#582.AdvGiftCardPage::acceptGiftCard()override (ecommercen/gift_cards/controllers/AdvGiftCardPage.php:1125-1128): protected seam widenedvoid->bool(see Step 4). A fork overriding it must returnbooland must not runacceptPostActions()onfalse.- Resend jobs:
AdvResendGiftCardEmail,AdvResendGiftCardSMSfor manual retry - Registry settings: All gift card behavior configurable via
GIFT_CARDSregistry group - Views:
{client_views}/gift_cards/-- purchase page, success/error views, email templates
Business Rules
- Coupon is single-use, non-refundable. Each gift card generates exactly one coupon with
max_usage=1, ensuring one redemption per card. - No partial redemption. The coupon
total_cart_fromrule equals the gift-card amount, so the full value must be redeemed or nothing. - Coupon validity spans 5 years. Generated on acceptance, valid
date_start=now,date_end=now+5 years(AdvGiftCardOrdersModel.php:117-121). - Auto-cancel pending orders after
giftCardDateTimeIntervalToDrop. DefaultPT180M(3 hours, fallback validated bydateTimeIntervalToDrop()inAdvCancelPendingGiftCards.php). Orders not paid within the cutoff are canceled (AdvCancelPendingGiftCards.php:36-49). - 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.
- 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. - 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 thePIRAEUSBANK_GIFTCARDSregistry group. - SMS delivery is optional, gated by
GIFT_CARDS.SMS. Default off; when enabled,AdvSendGiftCardsToPhonesjob dispatches the code tosend_to_phone.
Tests
| Test File | Lines | Coverage |
|---|---|---|
tests/Legacy/GiftCards/AdvGiftCardPageTest.php | 380 | 10 data-provider rows + 2 invariant guards for xpayWebhookAction; also covers piraeusSuccessAction / piraeusFailAction / piraeusHashKeyMatches / resolvePiraeusInstallments |
tests/Legacy/GiftCards/AdvGiftCardOrdersModelTest.php | 559 | New (#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): boolis now Pending-only (AdvGiftCardOrdersModel.php:49-51) and delegates to a sharedacceptGiftCardFrom($orderId, array $allowedFrom)(:70-133) that claims the order with a single conditionalUPDATE ... 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 seesaffected_rows() === 0, rolls back, and returnsfalsewithout 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 includeCanceled, 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
Pendingindefinitely — 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 asPAID. 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-181reads: "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 intests/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.
Related Flows
- CF-08 Payment Processing -- gift card payment flow (separate from cart checkout)
- CF-09 Payment Webhooks -- Viva/Iris handle gift card payment callbacks. XPay is the only gift-card gateway with a dedicated
gift-card/xPayHookendpoint; all other callbacks use return-URL redirects. - CF-13 Coupons -- gift cards generate single-use coupons on completion
- AD-23 Gift Cards Admin -- admin management of gift card orders
- SY-24 Email Dispatch --
adv_mailerdelivery infrastructure for gift card emails