Changelog
Current version: 1.0.0
1.0.0 — 2026-08-11
Qoliber_GiftCards
The first release of the Qoliber Gift Cards suite. The core gift-card engine and
its satellite modules (Creator / GraphQl / Hyvä / Hyvä Checkout / Wallet;
Loki placeholder) — see the "Core feature set" section below — hardened before
first release per the production-readiness review (PROD-battleplan.md).
Hardened before release
Fixed (money path)
- The gift-card debit runs on
sales_order_invoice_save_after(not_pay, which fired before the invoice had an id): partial invoices, and a card reused across orders, now debit independently instead of collapsing ontoinvoice_id = 0. - Issuance (which creates accounts and sends the bearer-code email) runs on
sales_order_invoice_save_COMMIT_after, only once the invoice is durably committed, so a rolled-back invoice can never leave an orphan card or a sent code email it cannot retract. - The reconciliation cron issues only against PAID invoiced qty (
sales_invoice.state = PAID), never rawqty_invoiced, so an unpaid/offline invoice never produces a live card. - Partial invoices / credit-memos can never compute a negative gift-card amount: the collectors derive the ratio from the pre-gift-card order total and cap at the document's own total.
- Invoice/credit-memo total collectors derive the previously-invoiced/refunded total from the ledger (the
qoliber_giftcard_amount_invoiced/_refundedaccumulators were never written), and the debit/credit observers now distribute the collector's computed amount so card movement matches the document's grand-total reduction. - A replayed invoice-pay event is a no-op:
debit()reports whether it acted, so the reservation is drawn down exactly once. - Sequential partial refunds advance the FIFO window (by the order item's
qty_refunded) so each refunded unit deactivates a distinct card — the sold-card refund collector and the deactivation flow select the same cards. - Refunding a sold gift card reduces the credit memo by its spent + still-reserved value (not spent only), so value another placed order has reserved is not cash-refunded and then captured too; the base total is reduced by the base-currency equivalent (via the credit memo's base-to-order rate).
- Cancel rollback restores invoiced minus already-refunded (netted), locks the account before the idempotency check, and skips a missing account without fataling.
- One card's use is capped across quote addresses (mixed virtual/physical carts); stale quote rows for deleted/deactivated cards are dropped; all balance math is rounded to 4dp.
Added (placement reservation & issuance recovery)
- Gift-card balance is reserved atomically at order placement (
sales_model_service_quote_submit_before) with an overspend hard-reject: two concurrent carts can no longer both spend the same card. A conditionalUPDATEonly holds the amount when the still-available balance covers it; a placement that fails after reserving releases the hold (submit_failure), and a partial reservation is rolled back before the order is rejected. - The reservation is idempotent per quote: each quote row records its hold in
reserved_amount, so a retried submit (process died after reserving, before placing) does not double-reserve, and the failure release is idempotent (releases the recorded amount once). The reconcile cron releases a hold left by a crashed submit that never became an order and was not retried (older than 6h). - A reconciliation cron (
qoliber_giftcards_reconcile_issuance, every 15 min) recovers issuance the invoice observer could not complete: paid gift-card items with fewer accounts than their paid invoiced qty are issued, and issued accounts missing a delivery row are backfilled so the send cron can dispatch them.
Fixed (security)
- The gift-card
codecolumn is encrypted at rest (transparent encrypt-on-save / decrypt-on-load) so a DB dump no longer leaks bearer codes; a data patch migrates existing rows. - Guest cart apply/remove resolve the masked cart id (closing a cross-cart IDOR); redemption is scoped to the card's issuing website.
Design/RenderAccountnow requires the accounts ACL (was the weaker designs ACL); the admin grid exposes masked codes only.- Wallet signing secrets are stored encrypted; a deactivated card's wallet pass stops resolving.
- Balance-inquiry code moved to the POST body (out of access logs);
init-pepperrefuses to overwrite an existing pepper without--force.
Security & supply-chain hardening (pre-release review)
- Reservation release is race-free. The stale-hold reconciliation releases each hold with its own atomic guarded UPDATE (a single multi-table UPDATE across holds under-released an account shared by two stale quotes, because MySQL updates a joined target row once); reserve and failure-release are atomic conditional UPDATEs. Invariant:
reserved_balance = SUM(active holds). - Minimum code entropy is enforced in
PatternParser(≥80-bit random floor; the default{A16}≈ 82.7 bits stays valid; weak/short/numeric patterns and expansion-bomb patterns are rejected with a clear message). - Admin cannot deactivate a card with an active reservation (
reserved_balance > 0) — the linked order must be captured/cancelled/refunded first. - Attempt-log retention: a daily cron prunes
qoliber_giftcard_attemptpast a configurable window (default 60 days), with a supporting index. - Design SVG hardening: the renderer allowlists image
src(rejectsjavascript:, external SVG,data:image/svg+xml; permits raster data-URIs + same-origin media), drops external Google-Font imports, and the SVG preview responses carrynosniff+ a restrictive CSP. (Bundled icons inlined as SVG data-URIs are dropped from delivered artwork — a documented follow-up to rasterize them.) - Wallet tokens fail closed: dedicated encrypted signing secret (throws when unset — no hardcoded fallback), versioned/time-stamped tokens with optional expiry, full HMAC via
hash_equals; rotation invalidates old tokens. - Dependencies: module manifests pin
magento/*to tested caret families (no wildcards); the platform advisory count is patched to zero blocking (three Mage-OS-2.2.1-pinned GraphQL DoS advisories are ignored-with-reason until the host reaches Mage-OS ≥ 2.3.0).composer auditis a blocking CI gate.Qoliber_GiftCardsLokiis0.1.0-dev(development-only), not a stable module. - See
docs/SECURITY-OPERATIONS.mdfor the host-upgrade path, edge/WAF guidance, pepper-rotation procedure, cron-health monitors, and the signed-tag/SBOM release checklist.
Fixed (concurrency & correctness)
- Issuance is serialized per order item (advisory lock) against double-issue; account deactivation uses a status-only conditional UPDATE (no lost-update of a concurrent debit).
- Admin accounts grid renders correctly (payload shape +
issued_atcolumn); Hyvä PDPx-datais escaped so add-to-cart works. - Delivery
scheduled_atis interpreted in the store timezone; manual-issue currency/amount validation hardened.
Apply modes
- Tender (default, EU-safe) reduces the amount payable; post-tax discount reduces the subtotal like a coupon. Pre-tax discount (
apply_before_tax) is not supported in 1.0.0 — it writes the gift-card value into itemdiscount_amount, which Magento's native invoice/credit-memo Discount collectors already subtract, so the card would be applied twice on documents. The option is removed; re-enable is gated on making the document collectors mode-aware with a full tax-lifecycle test.
Known limitations & follow-ups (post-release)
- Single-currency cards. A card's balance is held in one currency (its order currency); cross-currency redemption is rejected rather than converted.
- Admin deactivation vs. reservation. Deactivating a card that has an active placement reservation is not blocked; the reservation is still honoured at capture (the funds were committed at placement).
- Runtime dependency advisories are in the pinned Mage-OS platform (all
composer auditadvisories are platform/tooling transitive deps — none are required by any module), tracked via the non-blockingcomposer auditCI step. - Net-new features deferred: admin deliveries grid (delivery-status visibility), optional Hyvä Tailwind token cleanup, and the Loki checkout adapter. See
PROD-battleplan.md.
Core feature set (drafted 2026-05-29)
The core gift-card engine of the Qoliber Gift Cards suite. Sets up the giftcard product type, account / balance / redemption domain, sales-total collectors, the issuance lifecycle, the delivery pipeline, and the public REST WebAPI. Satellite modules (Creator / GraphQl / Hyva / HyvaCheckout / Loki / Wallet) plug into the extension points this module defines.
Added (gift-card domain)
- Custom
giftcardproduct type with EAV attributes (qoliber_giftcard_amounts,qoliber_giftcard_allow_open_amount,qoliber_giftcard_open_min/open_max,qoliber_giftcard_types_allowed,qoliber_giftcard_lifetime_days,qoliber_giftcard_design_id); price-indexed viaModel/ResourceModel/Indexer/Price(indexed price = min available amount) so the card appears with a "From $X" badge in category/search listings; always-salable pluginPlugin/InventorySales/AlwaysSalableGiftCard. Model/Product/Type/GiftCard::_prepareProductvalidates the chosenqoliber_giftcard_amountagainst the preset list / open-amount range — the line price equals the validated amount (closes the fraud vector where the line price could diverge from the issued balance).- Gift-card account entity (
qoliber_giftcard_account) + repository + masker/hasher:code(plaintext, used only for the recipient's email + Wallet pass payload),code_masked(the only form exposed via REST/GraphQL),code_hash = sha256(code + pepper)for O(1) lookup; status enum (active/used/expired/deactivated); initial + current balance, currency, base currency, store, website, customer link, issued_from_order/order-item, design_id. bin/magento qoliber:giftcard:init-pepper— initialises the code pepper inapp/etc/env.php(without it, code generation/redemption throws "code pepper is not configured").- Code generator + pattern parser; default code pattern configurable.
Added (sales totals + redemption)
- Total collector
Model/Total/Quote/GiftCardsupporting two modes:- Tender (default, EU VAT-safe) — reduces the amount payable after tax via
setGrandTotal(max(0.0, grandTotal - applied)). - Discount — before-tax (sort 301, per-item discount distribution) or after-tax (sort 825,
setGrandTotal). Pre-tax mode doesaddTotalAmountto subtract the principal; post-tax mode adjusts the grand total directly.
- Tender (default, EU VAT-safe) — reduces the amount payable after tax via
- Multi-currency safe at apply time —
Model/Total/QuoteApplicationRepositorystores bothapplied_amount(quote currency) andbase_applied_amount(base) with$quote->getBaseToQuoteRate(). Model/Redemption/Applier::apply(CartInterface $quote, string $code)— loads account by code hash, validates (Model/Redemption/Validator::assertApplicable), evaluates restrictions, then writes the application row; wrapped in a lockout-recording try/catch.Model/Redemption/RestrictionEvaluatorframework with two built-in rules (Model/Restriction/Rule/CategoryRule,Model/Restriction/Rule/CustomerGroupRule); custom rules implementApi/RestrictionRuleInterfaceand register in the di evaluator pool.- Debit-on-invoice + credit-on-creditmemo via observers
Observer/InvoicePay+Observer/CreditmemoSaveAfter, with row-locked balance mutations (SELECT … FOR UPDATE+ plainupdate(['current_balance' => $after])) — replaces the prior unsafecol = col - ?SQL pattern. Idempotency guards prevent double-apply on event re-dispatch.
Added (issuance)
Api/IssuerInterface::issueForOrderItem(OrderItemInterface $item, ?int $qty = null): array— oneGiftCardAccountper unit; optional$qtyoverridesgetQtyOrdered()so the observer can issue partial-invoice deltas.Observer/IssueOnInvoicePay::execute— for each visible gift-card order item, issues(int) $orderItem->getQtyInvoiced() - alreadyIssuedCountaccounts (idempotent + partial-invoice-safe). Records each issued account inqoliber_giftcard_history(action = 'issued').- The product's
qoliber_giftcard_design_idis copied onto the account at issuance (loose-coupled int; no hard dep on the designer module).
Added (delivery, Phase 1b)
qoliber_giftcard_deliverytable +Model/Delivery/Deliverymodel +Model/ResourceModel/Delivery/Collection.Model/Delivery/DeliveryManager::createForAccount(account, orderItem)— creates apendingrow from the order item's buy request (qoliber_giftcard_recipient_name/email,qoliber_giftcard_sender_name,qoliber_giftcard_message,qoliber_giftcard_delivery_scheduled_at); normalises empty/past schedules toNULL(= send immediately).Model/Delivery/DeliverySender::send(Delivery $delivery)— builds the transactional email (qoliber_giftcard_deliverytemplate) with the FULL redeemable code, balance, message, expiry, and (when an artwork provider supplies it) the rendered SVG. Never throws — failures setstatus=failed,retry_count++,last_error; the row is always persisted.Cron/SendScheduledDeliveries(every 5 min) — selects rows wherestatus != sent,retry_count < 5, andscheduled_at IS NULL OR scheduled_at <= now. Both failed scheduled and immediate-failed rows are retried under the cap (transient SMTP blips recover).- Recipient personalization tokens (
{{recipient_name}},{{sender_name}},{{message}}) are plumbed from the Delivery row →GiftCardArtworkProviderInterface::getArtworkSvg($account, $context)→ the designer's renderer. - Email template escapes user input via
{{var}}and renders trusted artwork via{{var artwork_svg|raw}}(regression-tested throughTransportBuilderMock).
Added (extension points — keep core decoupled from satellites)
Api/GiftCardArtworkProviderInterface::getArtworkSvg(GiftCardAccountInterface, array $context = []): ?string— supplies rendered card SVG for the delivery email. DefaultModel/Delivery/NullArtworkProviderreturnsnull.Qoliber_GiftCardsCreatoroverrides the di preference with a real renderer.Api/DeliveryLinkProviderInterface::getLinks(GiftCardAccountInterface): array— supplies["label","url"]entries the delivery email renders as buttons (via{{for link in wallet_links}}). DefaultModel/Delivery/NullDeliveryLinkProviderreturns[].Qoliber_GiftCardsWalletoverrides it with "Add to Apple/Google Wallet" links.
Added (security)
Api/LockoutGuardInterface+Model/Lockout/Guard— configurable threshold / window / duration; recorded on every balance lookup + apply attempt; clean translation to API errors. UTC-correct:lockoutMinutesRemainingparses the stored UTCcreated_atviastrtotime($latestFailure . ' UTC')so the math is correct regardless of the server's default timezone (fixed a real production bug that miscomputed the lockout duration on non-UTC servers).- Currency-match guard in
Applier::apply— rejects when$quote->getQuoteCurrencyCode() !== $account->getCurrency()withCurrencyMismatchException(aLocalizedExceptionsubclass). 1.0.0 single-currency rule; cross-currency conversion is a 1.1.0 follow-up. - No plaintext code ever leaves via REST/GraphQL —
BalanceServiceInterface::getByCodereturns the masked balance only; the full code lives only in the recipient's delivery email + the Wallet pass payload.
Added (Luma storefront)
view/frontend/templates/product/view/giftcard.phtml— Luma PDP for the gift-card product type: amount selector (presets + open amount), card type radio, recipient name/email (required), sender name, personal message, scheduled delivery datetime.view/frontend/layout/catalog_product_view_type_giftcard.xml— wires the form block beforeproduct.info.addtocart.ViewModel/Product/GiftCardOptionsexposes presets + open-amount range + allowed card types to the template (also reused by the Hyvä PDP).- Public balance-check page at
/qoliber-giftcards/balance(Luma).Controller/Balance/Index(HttpGet + HttpPost, form-key validated) callsBalanceServiceInterface::getByCodeand renders the masked balance / status / expiry throughViewModel/Balance/CheckForm+view/frontend/templates/balance.phtml. Privacy-aware error messages: lockout → "Too many attempts. Please try again later." (no exact minutes leaked); unknown code → generic not-found notice; any other\Throwable→ logged via PSR-3 + generic "Sorry, something went wrong" notice (no internal detail leaks). No plaintext code is ever rendered. - Customer "My gift cards" page at
/qoliber-giftcards/cards(Luma, login required).Controller/Cards/Indexredirects guests tocustomer/account/login;Model/Customer/CardListService::listForCustomer(customerId, customerEmail)joinsqoliber_giftcard_accountagainstsales_order(purchased) andqoliber_giftcard_delivery(received, lowercased email compare), dedupes byaccount_idwith purchased-wins, sorts byissued_at DESC.view/frontend/layout/qoliber_giftcards_cards_index.xmluses<update handle="customer_account"/>so the standard customer-account nav + breadcrumb wrap the page; a "My gift cards" link is injected intocustomer_account_navigation(sortOrder 190) viaview/frontend/layout/customer_account.xml.ViewModel/Customer/CardListexposes the list toview/frontend/templates/customer/cards.phtml(order-history-styled table; Card / Balance / Status / Expires / Origin; empty state when no cards). etc/frontend/routes.xml— registers route idqoliber_giftcards/ frontNameqoliber-giftcards(used by both storefront pages above).
Added (REST WebAPI)
GET /V1/qoliber-giftcards/balance/:code(anonymous, lockout-guarded) →BalanceServiceInterface::getByCode→ masked balance (code_masked,current_balance,currency,status,expires_at).GET /V1/qoliber-giftcards/accounts/:entityId+GET /V1/qoliber-giftcards/accounts(admin ACLQoliber_GiftCards::accounts).POST /V1/guest-carts/:cartId/qoliber-giftcards+DELETE …/:accountId—CartGiftCardServiceInterface::applyToCart/removeFromCart. Mirrored at/V1/carts/mine/qoliber-giftcardsfor logged-in customers.
Added (admin)
- Accounts grid + detail view at
admin/qoliber_giftcards/accounts/indexand…/accounts/view/id/<id>(Admin → Gift Cards → Accounts). Magento UI Component listing (view/adminhtml/ui_component/qoliber_giftcards_account_listing.xml,Ui/Component/Listing/Account/DataProvider) with bookmarks / columns chooser / sticky toolbar / filters on status (select sourceUi/Component/Listing/Account/StatusOptionsover the 5 status constants), currency, code_masked, customer_id (textRange), expires_at + created_at (dateRange). Action column adds "View" for every row and "Deactivate" only when status === active (Ui/Component/Listing/Account/Actions). Default sortcreated_at DESC. Detail view (Block/Adminhtml/Account/View+view/adminhtml/templates/account/view.phtml) renders an account summary<dl>(id, masked code, status, balances, currency, expiry-or-Never, customer id, issued-from-order link tosales/order/view, design id) plus a full history table fromqoliber_giftcard_historyorderedentity_id DESC, created_at DESCviaModel/ResourceModel/Account/HistoryProvider. A status-gated Deactivate form (form-key validated, optionalreasonfield) POSTs to…/accounts/deactivate. Bulk Deactivate mass action POSTs through\Magento\Ui\Component\MassAction\Filterto…/accounts/massDeactivate. Both single + bulk funnel throughModel/Account/AccountDeactivator::deactivate($accountId, $reason?)— sole source of truth: throwsLocalizedExceptionif the account isn't ACTIVE, sets status to DEACTIVATED, saves via repository, and inserts anactor_type='admin',actor_id=<admin user id or null>history row withamount_delta=0,balance_before=balance_after=current_balance, reason = posted text or "Manually deactivated by admin". Bulk action reports "Deactivated N of M selected accounts; K were skipped (not active).". ACL:Qoliber_GiftCards::accounts. - Manual issue admin form at
admin/qoliber_giftcards/manual/index(Admin → Gift Cards → Issue New Card).Model/Issuance/ManualIssuer::issue(amount, currency, lifetimeDays?, designId?, customerId?, storeId?, reason?)validates inputs (amount > 0; 3-letter currency), generates a code, builds + saves aGiftCardAccount(status active, balance = amount, currency = base currency, expiry =now + lifetimeDays * 86400sor null), and writes anactor_type=adminhistory row noting the manual issue + optional reason. The admin form (controllerAdminhtml/Manual/Index+Adminhtml/Manual/Issue, blockBlock/Adminhtml/Manual/Form, templateview/adminhtml/templates/manual/form.phtml) collects card details (amount / currency / lifetime / design / customer / reason) plus optional delivery details (recipient email/name, sender, message, scheduled-at, send-email checkbox). On submit, ifsend_emailis on andrecipient_emailis set, the controller calls a newDeliveryManager::createManual(account, recipientEmail, recipientName, senderName, message, scheduledAt)and (for null/past schedules) immediatelyDeliverySender::send($delivery). Form-key validated,dataPersistorrepopulates inputs on validation error. ACL:Qoliber_GiftCards::accounts. Menu link slotted under the existing parentQoliber_GiftCards::menu(declared byQoliber_GiftCardsCreator). - System config
qoliber_giftcards/sales/apply_mode(Tender / Discount Before Tax / Discount After Tax) + lockout settings + delivery sender identity + email template. - ACL
Qoliber_GiftCards::managetree (::accounts,::balance,::config).
Added (refund handling)
- Auto-deactivate issued cards on credit memo — when a Magento credit memo is saved for an order item that produced gift-card accounts, those accounts are now auto-handled (config-gated). Observer
Observer/CreditmemoIssuedCardsHandlerlistens onsales_order_creditmemo_save_after, readsqoliber_giftcards/refund/mode(scope = store) and delegates toModel/Refund/RefundProcessor::processCreditmemo($creditmemo, $mode). Three modes via the new system-config field "Stores → Configuration → Qoliber Gift Cards → Refund handling → On credit memo for gift-card item" (sourceModel/Config/Source/RefundMode):deactivate(default) — FIFO across the per-item gift-card accounts up to the credit-memo qty: each ACTIVE one is sent throughModel/Account/AccountDeactivator::deactivate($id, 'Refunded via credit memo #<incrementId>', actorType: 'system', actorId: null)— same single source of truth used by the admin Deactivate action.history_only— leaves the card ACTIVE; writes aqoliber_giftcard_historyrow noting "Credit memo #X recorded; account left ACTIVE per configuration."leave— early return; never touches the cards.
- USED cards (partially or fully redeemed) are always skipped regardless of mode — a history row is written ("Credit memo #X processed but card was already redeemed; no change.") so the audit trail captures the refund event without breaking the in-flight redemption. EXPIRED / DEACTIVATED / PENDING cards similarly get a history row noting the status they were in when the credit memo arrived.
Model/ResourceModel/Account/AccountFinder::findByOrderItemId($orderItemId)is the new internal correlation lookup — raw rows orderedissued_at ASC, entity_id ASC(oldest issued first). Used only inside the refund processor; not part of the public service-contract surface.AccountDeactivator::deactivategained two optional trailing parameters (actorType = 'admin',actorId = null) without breaking back-compat: every existing call site continues to pass only(int $id, ?string $reason). WhenactorType !== 'admin', the admin auth session is never touched — making the deactivator portable from cron / CLI / observer contexts.- Observer swallows any
\Throwableraised by the processor and logs via\Psr\Log\LoggerInterface::error— the credit memo itself must succeed; gift-card cleanup failing is a non-blocking secondary effect.
Added (CLI)
bin/magento qoliber:giftcard:resend <account-id>(Console/Command/ResendDeliveryCommand) — load the most recentqoliber_giftcard_deliveryrow for the account, reset it tostatus=pending(+ retry_count=0, last_error=null, delivered_at=null), and re-fireDeliverySender::send. Reports the resulting status (sent/failed+last_error) via output. Returns a non-zero exit code only on bad input (negative/zero id) or when no delivery row exists for that account (with an actionable hint pointing at the Manual Issue admin form). Useful for support workflows where a recipient reports not receiving the email. Wired viaetc/di.xmlunderMagento\Framework\Console\CommandList.
Technical details
- PHP 8.1 promoted ctors throughout; FQDN PHPDoc; single-line
/** @var */for class properties. - PHP_CodeSniffer (PSR-12 + Magento2) 0 errors repo-wide; PHPStan level 8 with
bitexpert/phpstan-magentoextension + a narrow baseline for irreducible framework friction. - Database schema:
qoliber_giftcard_account,qoliber_giftcard_history,qoliber_giftcard_quote,qoliber_giftcard_delivery,qoliber_giftcard_pattern, declarative schema viadb_schema.xml+db_schema_whitelist.json. - Integration tests use the
gift_cards_integrationDB; the suite includesEndToEndPurchaseRedeemTest,PartialInvoiceIssuanceTest,CurrencyMismatchTest,DeliveryEmailRenderTest(throughTransportBuilderMock),SendScheduledDeliveriesRetryTest, and full collector coverage in all three modes.
Qoliber_GiftCardsCreator
The 1.0.0 release of Qoliber_GiftCardsCreator — the gift-card designer + server-side artwork renderer. Provides an admin Fabric.js canvas editor for merchants to design cards, and a server-side DesignRenderer that produces the SVG artwork embedded in the recipient's delivery email and Wallet passes. Forked from a generic creator base and decoupled — Qoliber_GiftCards (core) has zero references to this module; integration goes through the GiftCardArtworkProviderInterface di preference.
Added (designer)
- Admin canvas editor (
Block/Adminhtml/Editor+view/adminhtml/templates/editor.phtml) built on Fabric.js v7 with modular JS underview/adminhtml/web/js/editor/:canvas,utils,fonts,text,shapes,images,controls,resize,snapping,templates,library,background,giftcard(gift-card-specific token + QR/barcode tools). RequireJS map (no shim — Fabric v7's UMD callsdefine()under RequireJS; a shim makes it resolve toundefined). - Token tool — inserts a Textbox containing one of
{{recipient_name}},{{sender_name}},{{message}},{{amount}},{{balance}},{{code}},{{expires_at}}(substituted by the server renderer at delivery time). - QR / barcode tool — inserts a
qoliberCodeimage node bound to a token (default{{code}}); a controller endpoint returns a preview data-URI for live editor display. - Gradient backgrounds — solid + linear + radial; Type segmented control (Solid / Linear / Radial / None) + From/To swatches + angle slider; serialised onto the canvas
backgroundkey as Fabric's native gradient object ({type, coords, colorStops}). - Background presets library (
Model/Config/BackgroundPresetProvider) — 5 occasion-themed sets (Birthday, Holiday, Thank You, Wedding, Minimal) plus a free-form 18-item grid; click a swatch → applies as the canvas background. - Icon library (
Model/Config/IconLibraryProvider) — 18 hand-authored single-color SVG icons bundled underview/adminhtml/web/images/icons/library/(own art — fully redistributable; gift / heart / star / balloon / cake / sparkles / snowflake / etc., grouped by category Celebration / Love / Seasonal / Shopping). Editor inserts a clicked icon as a self-contained data-URIfabric.Image, socanvas_datastays portable and the server renderer reproduces it without external fetches. - Starter templates (
Model/Config/StarterTemplateProvider) — 4 ready-made designs (Birthday, Thank You, Holiday, Minimal) with valid Fabriccanvas_data(gradient background + token-using text); applied viacanvas.loadFromJSON. Each template ships an SVG thumbnail rendered via the sameDesignRenderer(with sample tokens), shown in the Templates panel — no async client-side render = no first-paint flicker. - Aspect-ratio lock —
shared.lockAspect(inutils.js) is called frombindObjectEvents, so both inserts and design-load apply it. For text + image-type objects (QR, barcode, icons, uploaded images), it setslockUniScaling = trueand hides the middle side-handles viasetControlsVisibility— only the proportional corner handles remain. Shapes stay freely scalable. Globalcanvas.uniformScaling = true(withuniScaleKey: shiftKey) makes corner-drag proportional for every object (Shift overrides). - "Preview as delivered" admin link (
Controller/Adminhtml/Design/PreviewRender) — opens the actual server-rendered SVG in a new tab with sample tokens substituted; instant editor↔delivered-card parity check without buying/invoicing a card. Link surfaces in the editor toolbar once a design is saved.
Added (server-side renderer)
Api/DesignRendererInterface+Model/Render/DesignRenderer::renderSvg($canvasData, $values)andrenderForAccount($designId, $account, $context = [])— parses Fabric canvas JSON and emits a self-contained<svg>document.- Token substitution:
{{code}},{{amount}},{{balance}}(alias of amount),{{currency}},{{recipient_name}},{{sender_name}},{{message}},{{expires_at}}. Personalization tokens come from the delivery context (plumbed byQoliber_GiftCards'DeliverySender). - Solid + gradient backgrounds —
renderGradientDefemits a<defs><linearGradient>/<radialGradient>withgradientUnits="userSpaceOnUse"and the canvasbackgroundrect fills withurl(#qol-bg-grad). A string background still renders as a solid<rect fill="...">. - Object rendering — Textbox / IText / FabricText (with the substituted text, font-family, size, fill, text-anchor, rotation), Rect (with optional rx/ry), Circle/Ellipse (when
rx ≠ ry), Image (<image href>— data-URIs inlined unchanged), andqoliberCode(callsCodeImageGenerator::generateDataUriso QR/barcode SVG is embedded inline). - QR / barcode generator (
Model/Code/CodeImageGenerator,Api/CodeImageGeneratorInterface) — supportsqr(viaendroid/qr-code),code128andean13(viapicqer/php-barcode-generator); SVG-first; throwsUnsupportedCodeTypeExceptionfor unknown types. - Google Fonts embedded — the renderer scans text-node
fontFamilyvalues, filters against a known Google Fonts whitelist (Roboto, Open Sans, Lato, Montserrat, Poppins, Bebas Neue, Playfair Display, Lora, Source Code Pro, etc.) and emits a single<defs><style>@import url('https://fonts.googleapis.com/css2?family=…')</style></defs>so SVG viewers and the admin preview render with the same fonts as the editor. (HTML email font support is client-dependent — known caveat.) - Artwork provider (
Model/Delivery/ArtworkProvider implements GiftCardArtworkProviderInterface) — looks up the account'sdesign_id, callsrenderForAccount, returns the SVG; gracefully returnsnullon missing design or render failure (logged at WARNING; never blocks delivery).etc/di.xmloverrides the core null provider.
Added (design entity + admin)
qoliber_giftcardscreator_designtable (renamed from the source_label_design) + Design model / repository / collection / Data interface / SearchResults.- Admin grid + form (Designs CRUD) under Gift Cards → Designs, ACL resource
Qoliber_GiftCardsCreator::designs. qoliber_giftcard_design_idEAV attribute on gift-card products (Source: Design list); on issuance, the coreIssuercopies this id onto the account.- Admin render-an-issued-account preview controller (
Controller/Adminhtml/Design/RenderAccount) — preview a card for a real issued account (uses the account's actual code/balance).
Added (regression coverage)
Test/Integration/Render/SampleCardsTest— renders the 4 starter templates + a hand-built "kitchen sink" design + dedicated showcases for codes (QR + Code128 + EAN-13), every token, every gradient type, and the full icon library, to real.svgfiles undervar/qoliber-giftcards-preview/+ anindex.htmlviewer with all of them scaled to a usable size. Doubles as a regression net (asserts every renderer code path emits expected elements) and as a visual-review surface for the merchant.
Technical details
- PHP 8.1 promoted ctors; FQDN PHPDoc; single-line
/** @var */for class properties. - composer deps:
endroid/qr-code ^6.0,picqer/php-barcode-generator ^3.0. - Module sequence:
Qoliber_GiftCards,Magento_Backend,Magento_Ui,Magento_Store. - phpcs/phpstan clean on PHP + phtml; JS/CSS/LESS excluded from phpcs (the editor JS is structured around Fabric v7 idioms not subject to the Magento JS sniffs).
Qoliber_GiftCardsGraphQl
The 1.0.0 release of Qoliber_GiftCardsGraphQl — the GraphQL surface for the gift-card extension. A thin resolver layer over the service contracts defined in Qoliber_GiftCards (no business logic duplication). Built to be the checkout-agnostic backend the Hyvä / Hyvä Checkout / Loki adapters consume.
Added (schema)
Query.qoliberGiftCardBalance(code: String!): QoliberGiftCardBalance— look up a gift card balance by code; returns the masked code only.type QoliberGiftCardBalance { code_masked, current_balance, currency, status, expires_at }— the public balance projection.type QoliberAppliedGiftCard { account_id, code_masked, applied_amount, base_applied_amount, currency }— the per-cart applied projection.type Cart { applied_qoliber_gift_cards: [QoliberAppliedGiftCard!] }— extension on the Quote GraphQlCarttype, listing the gift cards currently applied.Mutation.applyQoliberGiftCardToCart(input: ApplyQoliberGiftCardToCartInput!): ApplyQoliberGiftCardToCartOutput— apply a gift card code to a cart; returns the updatedCart.Mutation.removeQoliberGiftCardFromCart(input: RemoveQoliberGiftCardFromCartInput!): RemoveQoliberGiftCardFromCartOutput— remove a previously applied gift card; returns the updatedCart.type QoliberCustomerGiftCard { account_id, code_masked, current_balance, currency, status, expires_at, origin }— the per-customer card projection (origin=purchased|received).type Customer { qoliber_gift_cards: [QoliberCustomerGiftCard!] }— extension on the Customer GraphQlCustomertype; lists the current customer's purchased + received gift cards. Requires a customer token.
Added (resolvers)
Model/Resolver/GiftCardBalance— callsBalanceServiceInterface::getByCode; blank-code →GraphQlInputException; domain (LocalizedExceptionsubclass) →GraphQlInputExceptionso the lockout-guard message surfaces cleanly. Anonymous-safe (lockout-guarded in the service).Model/Resolver/ApplyGiftCardToCartandModel/Resolver/RemoveGiftCardFromCart— resolve the masked cart id via\Magento\QuoteGraphQl\Model\Cart\GetCartForUser::execute($cartHash, $userId, $storeId)(ownership-checked), callCartGiftCardServiceInterface::applyToCart/removeFromCartwith the numeric quote id (the service casts(int)$cartIdinternally), then re-fetch the cart so the returned model carries the recalculated totals. Domain errors becomeGraphQlInputException.Model/Resolver/AppliedGiftCards— value resolver onCart; reads$value['model'](theQuote), loads applied rows viaQoliber\GiftCards\Model\Total\QuoteApplicationRepository::loadForQuote, projects each row throughGiftCardAccountRepositoryInterface::getByIdfor the masked code + currency. Cards whose account can no longer be loaded (e.g. admin-deleted) are silently skipped so a single bad row never breaks a Cart query.Model/Resolver/CustomerGiftCards— resolves theCustomer.qoliber_gift_cardsfield. Rejects guests + admin contexts withGraphQlAuthorizationException; loads the customer's email via\Magento\Customer\Api\CustomerRepositoryInterface::getByIdand delegates to\Qoliber\GiftCards\Model\Customer\CardListService::listForCustomer($customerId, $customerEmail)— the SAME projection the/qoliber-giftcards/cardsstorefront page uses, so the HTML list and the GraphQL response always agree. SurfacesNoSuchEntityException(deleted customer) asGraphQlInputException.
Added (regression coverage)
Test/Integration/SchemaTest— compiles the merged GraphQL schema in-process via\Magento\Framework\GraphQl\Schema\SchemaGeneratorInterface::generate()under thegraphqlarea (loads area-specific DI preferences insetUp/tearDown), then asserts the new query / mutation / Cart field / types are present. The schema fails to compile if any resolver class is missing or any type is malformed, so this test is the structural regression net for the whole module.- Unit coverage for every resolver (mock service contracts +
ResolveInfo/Field/ context). Resolvers'$contextis documented asmixedin PHPDoc and narrowed inside the body with a single-line/** @var \Magento\GraphQl\Model\Query\ContextInterface $context */— keeps phpstan L8 clean without baseline entries (the generatedContextExtensionInterfaceisn't mockable standalone).
Technical details
- PHP 8.1 promoted ctors; FQDN PHPDoc; single-line
/** @var */. - Module sequence:
Qoliber_GiftCards,Magento_GraphQl,Magento_QuoteGraphQl,Magento_CustomerGraphQl. - Web-API auth: balance query is anonymous (lockout-guarded inside the service); mutations route through
GetCartForUserwhich enforces masked-id ownership for both guest and customer contexts.
Qoliber_GiftCardsHyva
The 1.0.0 release of Qoliber_GiftCardsHyva — the Hyvä storefront adapter for the gift-card extension. Provides a Tailwind / Alpine PDP for the giftcard product type that submits the same qoliber_giftcard_* buy-request the server's _prepareProduct validates, so the cart / order / issuance / delivery path is identical to the Luma flow.
Added (Hyvä gift-card PDP)
view/frontend/layout/hyva_catalog_product_view_type_giftcard.xml— Hyvä-specific layout override (loaded only when a Hyvä theme is active, via Hyvä'shyva_<handle>convention; on Luma the file is never loaded and the Luma jQuery PDP shipped inQoliber_GiftCardsremains in effect). Adds aproduct.info.giftcard.options.hyvablock to theproduct.info.form.contentcontainer, anchoredbefore="product.info.addtocart". Reuses the existingQoliber\GiftCards\ViewModel\Product\GiftCardOptionsview model (no Hyvä-specific view model needed).view/frontend/templates/product/view/giftcard.phtml— Tailwind + Alpine.js template (no jQuery / RequireJS). Renders:- Amount selector — one button per preset amount; selected button styled
bg-primary text-on-primary border-primary, unselectedbg-container hover:border-primary. When open amount is allowed, an extra "Other amount" button reveals a<input type="number">honouringqoliber_giftcard_open_min/_max. - Card type — button group, shown only if multiple types are allowed; otherwise a hidden input carries the single allowed type.
- Recipient name + email — required inputs with native
required+type="email"validation. - Sender name — optional.
- Personal message — textarea,
maxlength="500". - Delivery datetime —
<input type="datetime-local">; empty value means "send immediately when the order is paid".
- Amount selector — one button per preset amount; selected button styled
- Alpine
x-data="qoliberGiftCardOptions(...)"holds client state (selectedPreset,openAmount,cardType); a computedeffectiveAmountgetter feeds a hiddenqoliber_giftcard_amountinput via:value, so the buy-request always carries the resolved amount whether the merchant picked a preset or typed an open value. - Server config (presets, openAllowed, min/max, default card type) is rendered once into the Alpine
x-datapayload as JSON via<?= /* @noEscape */ $alpineJson ?>— no client-side fetches, no FOUC. - Submits to the standard Magento
addToCartform; the existingQoliber\GiftCards\Model\Product\Type\GiftCard::_prepareProductvalidates the amount against the product's presets / open-amount range server-side (closes the fraud vector where a client could post an arbitrary price).
Added (Hyvä storefront pages)
view/frontend/layout/hyva_qoliber_giftcards_balance_index.xml+view/frontend/templates/balance.phtml— Hyvä-themed override of the public balance-check page (route/qoliber-giftcards/balancedefined inQoliber_GiftCards).referenceBlockswaps the Luma form template for a Tailwind / Alpine version (rounded input withfocus:ring-2 focus:ring-primary, primary-coloured action button; success panel with masked code / balance / status / expiry in a two-column<dl>; error notice inbg-critical/10 text-critical). Reuses the existingQoliber\GiftCards\ViewModel\Balance\CheckFormview model — no Hyvä-specific PHP needed.view/frontend/layout/hyva_qoliber_giftcards_cards_index.xml+view/frontend/templates/customer/cards.phtml— Hyvä-themed override of the customer "My gift cards" page (route/qoliber-giftcards/cards). Responsive card grid styled with the Hyvä theme tokens; origin tag rendered as a coloured badge; empty state as a centered panel. Reuses the existingQoliber\GiftCards\ViewModel\Customer\CardListview model and the coreModel/Customer/CardListService— guarantees the storefront list and the GraphQLCustomer.qoliber_gift_cardsfield always agree.
Technical details
- PHP 8.1 promoted ctors; FQDN PHPDoc.
- Module sequence:
Qoliber_GiftCards,Qoliber_GiftCardsGraphQl,Hyva_Theme. - The module ships no PHP business logic — it is a pure view layer over the core's product type, view model, and validation. The same module pattern can be cloned to support other Tailwind-based front-ends.
Qoliber_GiftCardsWallet
Security (wallet token signing — fail closed)
Model/WalletServiceno longer signs tokens with the deploymentcrypt/keyand removes the hardcoded development fallback secret. It now uses a dedicated, encrypted secretqoliber_giftcards/wallet/signing_secret(read at global scope viaModel/Config/WalletConfig::getSigningSecret()). When that secret is unset the service fails closed:getTokenForAccount()/resolveToken()throw\Magento\Framework\Exception\LocalizedExceptioninstead of minting/validating with a guessable key.- Token payload is now versioned and time-stamped:
base64url("<v1>.<accountId>.<iat>.<exp>.<hmac>").iat(issuance) andexp(optional expiry,0= never) are covered by the signature and enforced on resolve, so tokens can be reasoned about and rotated. Rotating the configured secret invalidates every previously issued token. - The HMAC is the full
hash_hmac('sha256', ...)(64 hex chars) — the previous 32-char truncation is gone — still compared withhash_equals()(constant time). Deactivated-card rejection is preserved. etc/adminhtml/system.xml— adds the required, encryptedsigning_secretfield (type="obscure",Magento\Config\Model\Config\Backend\Encrypted) to the Wallet Passes group. No default is shipped so an unconfigured store fails closed.
Qoliber_GiftCardsHyvaCheckout
The 1.0.0 release of Qoliber_GiftCardsHyvaCheckout — the Hyvä Checkout adapter for the gift-card extension. Adds an "Apply Gift Card" section to the Hyvä Checkout price summary backed by a Magewire component that calls the same CartGiftCardServiceInterface the REST + GraphQL surfaces use, so apply/remove behaviour is identical across every client.
Added (Magewire component)
Magewire/Checkout/GiftCardCode extends Magewirephp\Magewire\Component— mirrors Hyvä's ownHyva\Checkout\Magewire\Checkout\CouponCodepattern. Public state:?string $code(the input the visitor types) +array $appliedCards(projection for the template).boot()— populates$appliedCardsfromQoliber\GiftCards\Model\Total\QuoteApplicationRepository::loadForQuote+GiftCardAccountRepositoryInterface::getByIdfor the masked code + currency. Cards whose account can't be loaded (admin-deleted) are silently skipped so a single bad row never breaks the checkout view.applyGiftCardCode()— validates non-empty input, callsCartGiftCardServiceInterface::applyToCart((string) $sessionCheckout->getQuoteId(), $code), refreshes$appliedCards, dispatches a success message via Hyvä'sdispatchSuccessMessage, emitsqoliber_gift_card_applied. Domain failures (LocalizedException) become warning messages — never throws into the response.removeGiftCardCode(int $accountId)— same flow withremoveFromCart; emitsqoliber_gift_card_removed.
Added (template + layout)
view/frontend/templates/checkout/gift-card-code.phtml— Tailwind / Alpine UI matching Hyvä Checkout'scoupon-code.phtmlidiom:- Collapsible section header with the Hyvä sparkles icon (
HeroiconsOutline::sparklesHtml) and a count badge when one or more cards are applied. Auto-expanded when$appliedCardsis non-empty. - List of applied cards — each row shows the masked code (monospace), the applied amount (
-XX.XX CUR), and a Remove button (wire:click="removeGiftCardCode(<accountId>)"+wire:loading.attr="disabled"). - Input + Apply button —
wire:model.defer="code",wire:keydown.enter="applyGiftCardCode",wire:click="applyGiftCardCode", with thewire:loading.block/wire:loading.remove"Processing…" / "Apply" swap.
- Collapsible section header with the Hyvä sparkles icon (
view/frontend/layout/hyva_checkout_components.xml— adds theqoliber.gift-card-codeblock to thecheckout.price-summary.sectioncontainer, anchoredafter="coupon-code"so it lines up with Hyvä's own discount section.
Technical details
- PHP 8.1 promoted ctors; FQDN PHPDoc; single-line
/** @var */. - Module sequence:
Qoliber_GiftCards,Qoliber_GiftCardsHyva,Qoliber_GiftCardsGraphQl,Hyva_Checkout. - Runtime requires
magewirephp/magento2-magewire+hyva-themes/magento2-hyva-checkout(declared assuggestbecause they're licensed deps not installable from public packagist; statically verified inside the merchant's Hyvä install). - Uses Magento's
CartGiftCardServiceInterfacedirectly rather than calling GraphQL over HTTP — Magewire runs server-side, so a service call is simpler, faster, and uses the same code path. The GraphQL surface remains available for external clients (PWA / mobile). - No plaintext code is ever exposed to the client —
code_maskedis the only form rendered.
Qoliber_GiftCardsLoki
The 1.0.0 release of Qoliber_GiftCardsLoki — placeholder scaffold for a future Loki Checkout adapter. The Loki adapter itself ships in 1.1.0.
Added
- Module scaffolding:
registration.php,etc/module.xml(sequencingQoliber_GiftCards+Qoliber_GiftCardsGraphQl), composer manifest, README, LICENSE — so the package can be installed alongside the rest of the 1.0.0 suite without any code being active. Enabling it has no runtime effect until 1.1.0 lands the actualLoki_Components-based apply/remove component.
Planned for 1.1.0
- Loki Checkout apply/remove gift card via
Loki_Components(Alpine + layout XML + ViewModel) — calling the sameCartGiftCardServiceInterfacethe Hyvä Checkout adapter uses, over the GraphQL backend already shipped inQoliber_GiftCardsGraphQl1.0.0. - Applied-cards list in the Loki order summary via the
Cart.applied_qoliber_gift_cardsfield.