21 min readAug 11, 2026by jakub

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 onto invoice_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 raw qty_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 / _refunded accumulators 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 conditional UPDATE only 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 code column 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/RenderAccount now 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-pepper refuses 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_attempt past a configurable window (default 60 days), with a supporting index.
  • Design SVG hardening: the renderer allowlists image src (rejects javascript:, external SVG, data:image/svg+xml; permits raster data-URIs + same-origin media), drops external Google-Font imports, and the SVG preview responses carry nosniff + 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 audit is a blocking CI gate. Qoliber_GiftCardsLoki is 0.1.0-dev (development-only), not a stable module.
  • See docs/SECURITY-OPERATIONS.md for 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_at column); Hyvä PDP x-data is escaped so add-to-cart works.
  • Delivery scheduled_at is 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 item discount_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 audit advisories are platform/tooling transitive deps — none are required by any module), tracked via the non-blocking composer audit CI 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 giftcard product 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 via Model/ResourceModel/Indexer/Price (indexed price = min available amount) so the card appears with a "From $X" badge in category/search listings; always-salable plugin Plugin/InventorySales/AlwaysSalableGiftCard.
  • Model/Product/Type/GiftCard::_prepareProduct validates the chosen qoliber_giftcard_amount against 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 in app/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/GiftCard supporting 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 does addTotalAmount to subtract the principal; post-tax mode adjusts the grand total directly.
  • Multi-currency safe at apply time — Model/Total/QuoteApplicationRepository stores both applied_amount (quote currency) and base_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/RestrictionEvaluator framework with two built-in rules (Model/Restriction/Rule/CategoryRule, Model/Restriction/Rule/CustomerGroupRule); custom rules implement Api/RestrictionRuleInterface and 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 + plain update(['current_balance' => $after])) — replaces the prior unsafe col = col - ? SQL pattern. Idempotency guards prevent double-apply on event re-dispatch.

Added (issuance)

  • Api/IssuerInterface::issueForOrderItem(OrderItemInterface $item, ?int $qty = null): array — one GiftCardAccount per unit; optional $qty overrides getQtyOrdered() so the observer can issue partial-invoice deltas.
  • Observer/IssueOnInvoicePay::execute — for each visible gift-card order item, issues (int) $orderItem->getQtyInvoiced() - alreadyIssuedCount accounts (idempotent + partial-invoice-safe). Records each issued account in qoliber_giftcard_history (action = 'issued').
  • The product's qoliber_giftcard_design_id is copied onto the account at issuance (loose-coupled int; no hard dep on the designer module).

Added (delivery, Phase 1b)

  • qoliber_giftcard_delivery table + Model/Delivery/Delivery model + Model/ResourceModel/Delivery/Collection.
  • Model/Delivery/DeliveryManager::createForAccount(account, orderItem) — creates a pending row 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 to NULL (= send immediately).
  • Model/Delivery/DeliverySender::send(Delivery $delivery) — builds the transactional email (qoliber_giftcard_delivery template) with the FULL redeemable code, balance, message, expiry, and (when an artwork provider supplies it) the rendered SVG. Never throws — failures set status=failed, retry_count++, last_error; the row is always persisted.
  • Cron/SendScheduledDeliveries (every 5 min) — selects rows where status != sent, retry_count < 5, and scheduled_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 through TransportBuilderMock).

Added (extension points — keep core decoupled from satellites)

  • Api/GiftCardArtworkProviderInterface::getArtworkSvg(GiftCardAccountInterface, array $context = []): ?string — supplies rendered card SVG for the delivery email. Default Model/Delivery/NullArtworkProvider returns null. Qoliber_GiftCardsCreator overrides 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}}). Default Model/Delivery/NullDeliveryLinkProvider returns []. Qoliber_GiftCardsWallet overrides 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: lockoutMinutesRemaining parses the stored UTC created_at via strtotime($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() with CurrencyMismatchException (a LocalizedException subclass). 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::getByCode returns 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 before product.info.addtocart.
  • ViewModel/Product/GiftCardOptions exposes 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) calls BalanceServiceInterface::getByCode and renders the masked balance / status / expiry through ViewModel/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/Index redirects guests to customer/account/login; Model/Customer/CardListService::listForCustomer(customerId, customerEmail) joins qoliber_giftcard_account against sales_order (purchased) and qoliber_giftcard_delivery (received, lowercased email compare), dedupes by account_id with purchased-wins, sorts by issued_at DESC. view/frontend/layout/qoliber_giftcards_cards_index.xml uses <update handle="customer_account"/> so the standard customer-account nav + breadcrumb wrap the page; a "My gift cards" link is injected into customer_account_navigation (sortOrder 190) via view/frontend/layout/customer_account.xml. ViewModel/Customer/CardList exposes the list to view/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 id qoliber_giftcards / frontName qoliber-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 ACL Qoliber_GiftCards::accounts).
  • POST /V1/guest-carts/:cartId/qoliber-giftcards + DELETE …/:accountIdCartGiftCardServiceInterface::applyToCart / removeFromCart. Mirrored at /V1/carts/mine/qoliber-giftcards for logged-in customers.

Added (admin)

  • Accounts grid + detail view at admin/qoliber_giftcards/accounts/index and …/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 source Ui/Component/Listing/Account/StatusOptions over 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 sort created_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 to sales/order/view, design id) plus a full history table from qoliber_giftcard_history ordered entity_id DESC, created_at DESC via Model/ResourceModel/Account/HistoryProvider. A status-gated Deactivate form (form-key validated, optional reason field) POSTs to …/accounts/deactivate. Bulk Deactivate mass action POSTs through \Magento\Ui\Component\MassAction\Filter to …/accounts/massDeactivate. Both single + bulk funnel through Model/Account/AccountDeactivator::deactivate($accountId, $reason?) — sole source of truth: throws LocalizedException if the account isn't ACTIVE, sets status to DEACTIVATED, saves via repository, and inserts an actor_type='admin', actor_id=<admin user id or null> history row with amount_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 a GiftCardAccount (status active, balance = amount, currency = base currency, expiry = now + lifetimeDays * 86400s or null), and writes an actor_type=admin history row noting the manual issue + optional reason. The admin form (controller Adminhtml/Manual/Index + Adminhtml/Manual/Issue, block Block/Adminhtml/Manual/Form, template view/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, if send_email is on and recipient_email is set, the controller calls a new DeliveryManager::createManual(account, recipientEmail, recipientName, senderName, message, scheduledAt) and (for null/past schedules) immediately DeliverySender::send($delivery). Form-key validated, dataPersistor repopulates inputs on validation error. ACL: Qoliber_GiftCards::accounts. Menu link slotted under the existing parent Qoliber_GiftCards::menu (declared by Qoliber_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::manage tree (::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/CreditmemoIssuedCardsHandler listens on sales_order_creditmemo_save_after, reads qoliber_giftcards/refund/mode (scope = store) and delegates to Model/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" (source Model/Config/Source/RefundMode):
    • deactivate (default) — FIFO across the per-item gift-card accounts up to the credit-memo qty: each ACTIVE one is sent through Model/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 a qoliber_giftcard_history row 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 ordered issued_at ASC, entity_id ASC (oldest issued first). Used only inside the refund processor; not part of the public service-contract surface.
  • AccountDeactivator::deactivate gained two optional trailing parameters (actorType = 'admin', actorId = null) without breaking back-compat: every existing call site continues to pass only (int $id, ?string $reason). When actorType !== 'admin', the admin auth session is never touched — making the deactivator portable from cron / CLI / observer contexts.
  • Observer swallows any \Throwable raised 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 recent qoliber_giftcard_delivery row for the account, reset it to status=pending (+ retry_count=0, last_error=null, delivered_at=null), and re-fire DeliverySender::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 via etc/di.xml under Magento\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-magento extension + 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 via db_schema.xml + db_schema_whitelist.json.
  • Integration tests use the gift_cards_integration DB; the suite includes EndToEndPurchaseRedeemTest, PartialInvoiceIssuanceTest, CurrencyMismatchTest, DeliveryEmailRenderTest (through TransportBuilderMock), 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 under view/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 calls define() under RequireJS; a shim makes it resolve to undefined).
  • 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 qoliberCode image 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 background key 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 under view/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-URI fabric.Image, so canvas_data stays 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 Fabric canvas_data (gradient background + token-using text); applied via canvas.loadFromJSON. Each template ships an SVG thumbnail rendered via the same DesignRenderer (with sample tokens), shown in the Templates panel — no async client-side render = no first-paint flicker.
  • Aspect-ratio lockshared.lockAspect (in utils.js) is called from bindObjectEvents, so both inserts and design-load apply it. For text + image-type objects (QR, barcode, icons, uploaded images), it sets lockUniScaling = true and hides the middle side-handles via setControlsVisibility — only the proportional corner handles remain. Shapes stay freely scalable. Global canvas.uniformScaling = true (with uniScaleKey: 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) and renderForAccount($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 by Qoliber_GiftCards' DeliverySender).
  • Solid + gradient backgroundsrenderGradientDef emits a <defs><linearGradient> / <radialGradient> with gradientUnits="userSpaceOnUse" and the canvas background rect fills with url(#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), and qoliberCode (calls CodeImageGenerator::generateDataUri so QR/barcode SVG is embedded inline).
  • QR / barcode generator (Model/Code/CodeImageGenerator, Api/CodeImageGeneratorInterface) — supports qr (via endroid/qr-code), code128 and ean13 (via picqer/php-barcode-generator); SVG-first; throws UnsupportedCodeTypeException for unknown types.
  • Google Fonts embedded — the renderer scans text-node fontFamily values, 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's design_id, calls renderForAccount, returns the SVG; gracefully returns null on missing design or render failure (logged at WARNING; never blocks delivery). etc/di.xml overrides the core null provider.

Added (design entity + admin)

  • qoliber_giftcardscreator_design table (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_id EAV attribute on gift-card products (Source: Design list); on issuance, the core Issuer copies 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 .svg files under var/qoliber-giftcards-preview/ + an index.html viewer 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 GraphQl Cart type, listing the gift cards currently applied.
  • Mutation.applyQoliberGiftCardToCart(input: ApplyQoliberGiftCardToCartInput!): ApplyQoliberGiftCardToCartOutput — apply a gift card code to a cart; returns the updated Cart.
  • Mutation.removeQoliberGiftCardFromCart(input: RemoveQoliberGiftCardFromCartInput!): RemoveQoliberGiftCardFromCartOutput — remove a previously applied gift card; returns the updated Cart.
  • 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 GraphQl Customer type; lists the current customer's purchased + received gift cards. Requires a customer token.

Added (resolvers)

  • Model/Resolver/GiftCardBalance — calls BalanceServiceInterface::getByCode; blank-code → GraphQlInputException; domain (LocalizedException subclass) → GraphQlInputException so the lockout-guard message surfaces cleanly. Anonymous-safe (lockout-guarded in the service).
  • Model/Resolver/ApplyGiftCardToCart and Model/Resolver/RemoveGiftCardFromCart — resolve the masked cart id via \Magento\QuoteGraphQl\Model\Cart\GetCartForUser::execute($cartHash, $userId, $storeId) (ownership-checked), call CartGiftCardServiceInterface::applyToCart / removeFromCart with the numeric quote id (the service casts (int)$cartId internally), then re-fetch the cart so the returned model carries the recalculated totals. Domain errors become GraphQlInputException.
  • Model/Resolver/AppliedGiftCards — value resolver on Cart; reads $value['model'] (the Quote), loads applied rows via Qoliber\GiftCards\Model\Total\QuoteApplicationRepository::loadForQuote, projects each row through GiftCardAccountRepositoryInterface::getById for 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 the Customer.qoliber_gift_cards field. Rejects guests + admin contexts with GraphQlAuthorizationException; loads the customer's email via \Magento\Customer\Api\CustomerRepositoryInterface::getById and delegates to \Qoliber\GiftCards\Model\Customer\CardListService::listForCustomer($customerId, $customerEmail) — the SAME projection the /qoliber-giftcards/cards storefront page uses, so the HTML list and the GraphQL response always agree. Surfaces NoSuchEntityException (deleted customer) as GraphQlInputException.

Added (regression coverage)

  • Test/Integration/SchemaTest — compiles the merged GraphQL schema in-process via \Magento\Framework\GraphQl\Schema\SchemaGeneratorInterface::generate() under the graphql area (loads area-specific DI preferences in setUp/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' $context is documented as mixed in 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 generated ContextExtensionInterface isn'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 GetCartForUser which 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ä's hyva_<handle> convention; on Luma the file is never loaded and the Luma jQuery PDP shipped in Qoliber_GiftCards remains in effect). Adds a product.info.giftcard.options.hyva block to the product.info.form.content container, anchored before="product.info.addtocart". Reuses the existing Qoliber\GiftCards\ViewModel\Product\GiftCardOptions view 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, unselected bg-container hover:border-primary. When open amount is allowed, an extra "Other amount" button reveals a <input type="number"> honouring qoliber_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".
  • Alpine x-data="qoliberGiftCardOptions(...)" holds client state (selectedPreset, openAmount, cardType); a computed effectiveAmount getter feeds a hidden qoliber_giftcard_amount input 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-data payload as JSON via <?= /* @noEscape */ $alpineJson ?> — no client-side fetches, no FOUC.
  • Submits to the standard Magento addToCart form; the existing Qoliber\GiftCards\Model\Product\Type\GiftCard::_prepareProduct validates 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/balance defined in Qoliber_GiftCards). referenceBlock swaps the Luma form template for a Tailwind / Alpine version (rounded input with focus:ring-2 focus:ring-primary, primary-coloured action button; success panel with masked code / balance / status / expiry in a two-column <dl>; error notice in bg-critical/10 text-critical). Reuses the existing Qoliber\GiftCards\ViewModel\Balance\CheckForm view 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 existing Qoliber\GiftCards\ViewModel\Customer\CardList view model and the core Model/Customer/CardListService — guarantees the storefront list and the GraphQL Customer.qoliber_gift_cards field 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/WalletService no longer signs tokens with the deployment crypt/key and removes the hardcoded development fallback secret. It now uses a dedicated, encrypted secret qoliber_giftcards/wallet/signing_secret (read at global scope via Model/Config/WalletConfig::getSigningSecret()). When that secret is unset the service fails closed: getTokenForAccount() / resolveToken() throw \Magento\Framework\Exception\LocalizedException instead of minting/validating with a guessable key.
  • Token payload is now versioned and time-stamped: base64url("<v1>.<accountId>.<iat>.<exp>.<hmac>"). iat (issuance) and exp (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 with hash_equals() (constant time). Deactivated-card rejection is preserved.
  • etc/adminhtml/system.xml — adds the required, encrypted signing_secret field (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 own Hyva\Checkout\Magewire\Checkout\CouponCode pattern. Public state: ?string $code (the input the visitor types) + array $appliedCards (projection for the template).
  • boot() — populates $appliedCards from Qoliber\GiftCards\Model\Total\QuoteApplicationRepository::loadForQuote + GiftCardAccountRepositoryInterface::getById for 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, calls CartGiftCardServiceInterface::applyToCart((string) $sessionCheckout->getQuoteId(), $code), refreshes $appliedCards, dispatches a success message via Hyvä's dispatchSuccessMessage, emits qoliber_gift_card_applied. Domain failures (LocalizedException) become warning messages — never throws into the response.
  • removeGiftCardCode(int $accountId) — same flow with removeFromCart; emits qoliber_gift_card_removed.

Added (template + layout)

  • view/frontend/templates/checkout/gift-card-code.phtml — Tailwind / Alpine UI matching Hyvä Checkout's coupon-code.phtml idiom:
    • Collapsible section header with the Hyvä sparkles icon (HeroiconsOutline::sparklesHtml) and a count badge when one or more cards are applied. Auto-expanded when $appliedCards is 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 the wire:loading.block / wire:loading.remove "Processing…" / "Apply" swap.
  • view/frontend/layout/hyva_checkout_components.xml — adds the qoliber.gift-card-code block to the checkout.price-summary.section container, anchored after="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 as suggest because they're licensed deps not installable from public packagist; statically verified inside the merchant's Hyvä install).
  • Uses Magento's CartGiftCardServiceInterface directly 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_masked is 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 (sequencing Qoliber_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 actual Loki_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 same CartGiftCardServiceInterface the Hyvä Checkout adapter uses, over the GraphQL backend already shipped in Qoliber_GiftCardsGraphQl 1.0.0.
  • Applied-cards list in the Loki order summary via the Cart.applied_qoliber_gift_cards field.
Changelog — Gift Cards Suite — Sales & Payments — Extensions | qoliber Docs