Gift cards module

Module id: giftcard. Dependencies: cart (and through it catalog and customer).

A gift card (cadeaubon) sold in the shop and spent at checkout. Cards are made when an order that bought one is paid, and spent as a balance over as many orders as it takes. checkout is not a declared dependency, but without it nothing is ever paid, so nothing is minted or spent either.

On the Modules tab it sits in the Discounts group with voucher codes and catalog discounts, whose master switch turns all three on. The sidebar item (Gift cards, Commerce section) lists every card the website has.

Not a discount

A promo code (discount.md) lowers what an order costs. A gift card is money the shop has already been paid and now owes back: the order costs what it costs, and the card pays part or all of it. So a card never touches the subtotal, the VAT or the order total. It lowers the amount due, which is what the payment gateway is charged.

A promo code and gift cards can be used on one order: the code lowers the total, the cards pay what is left.

Selling gift cards

A gift card is an ordinary catalog product with This product is a gift card switched on (product edit screen, Gift card card). Give it one variant per amount you want to offer (25, 50, 100) and the shopper picks one. Everything else about selling it is the catalog as usual: it lists, adds to the cart, becomes an order line and prints on the receipt.

  • Stock is not counted. Switching the flag on turns stock tracking off, since a card is made on demand.
  • The card is worth what the line was sold at, incl. VAT: the price the buyer paid is what the recipient gets to spend.
  • VAT. With the VAT engine on, a gift card line is sold outside the scope of VAT; VAT is charged later on what it is spent on.
  • With the module off, the product is an ordinary product: it sells, but nothing is minted when the order is paid.

The add-to-cart form of a gift card product also asks who it is for. All four fields are optional:

Field Notes
Recipient name Up to 191 characters. Used in the mail's greeting and subject.
Recipient email Where the code is mailed. Blank, or not a valid address, means the buyer gets it.
Personal message Up to 500 characters, printed in the mail.
Send on A future date. Blank, today or a past date means "as soon as the order is paid".

A line with any of these filled in stays its own cart line, so two cards for two people are never merged into one line mailed to one address.

When a card is made and sent

Made on payment, never on order. The cards are minted when the order is paid, one per unit: three €50 cards on one line are three separate codes. An order that is never paid, or is cancelled before payment, has no cards.

Codes look like BON4-KQX7-M2PQ-R8T: BON and twelve random characters, with no 0/O, 1/I/L or U. A customer may type them with or without the dashes, in any case.

The mail is the card. It goes to the recipient, or to the buyer when none was named, in the language the buyer was shopping in. The buyer is not copied (a surprise should not land in their inbox); they see the purchase on their order confirmation. Bought cards appear in the order's Emails tab as Gift card. The sender is the order mail sender, see checkout.md.

  • No send date: mailed straight after payment.
  • With a send date: mailed by a daily job at 04:30 UTC on that day.

A card whose mail cannot go out stays unsent: no sender address on the website, or no deliverable address on the card. The next daily run tries every bought card that is still unsent again, with or without a send date, so once the cause is fixed it goes out the next morning. To send it sooner, use the Gift cards list (see below). A card minted by hand in the admin is never mailed by the daily run; send it from the list when you want it to go.

Spending a card

The cart and the checkout carry a gift card box. A customer enters a code and the card is held on the cart:

  • Several cards can be held at once. They are spent smallest balance first, so the small leftovers get used up.
  • Shown masked (•••• M2PQ), with the amount it pays and a remove button, below the total, followed by Still to pay.
  • Held when the order is placed. The split shown in the cart is recomputed at checkout, once shipping is known, and that amount comes off the card the moment the order is placed. A second order cannot be promised the same balance while the first is still at the payment provider. If another order took the balance in the seconds between the checkout page and the click, the order is not placed: the customer is sent back to the checkout with a message and sees the new amount to pay first.
  • Given back if the order is not paid. A cancelled order (payment failed or expired at the provider, the customer started over, or an operator cancelled it) puts the held amount back on the card. An abandoned payment costs the customer nothing, though the balance is held until the provider reports it expired.
  • Covered in full: when the cards pay the whole order, there is no payment step. The order is marked paid at once.

A card left with a balance keeps it for the next order. Cards do not expire.

A code is refused, with a message in the cart, when the cart is empty, the code does not exist on this website, the card is deactivated or spent, it is in another currency than the cart, or it is already on the cart.

The Gift cards admin

The list shows every card with its full code (so it can be read back to a customer on the phone who lost the mail), balance, status (Unused, Part spent, Spent, Disabled), recipient and delivery. Per card:

  • Where the money went: the ledger, one row per order that spent from it, with the balance after. A row for an order still awaiting payment is marked held, awaiting payment; a row for a cancelled order is struck through and marked returned with the date, because that amount went back on the card.
  • Send this card by email: mails it now, to a corrected address if you enter one. It clears any send date, refuses a deactivated card, and tells you on the spot whether the mail went.
  • Active switch: deactivate a card reported lost or issued in error. A deactivated card is refused at checkout but keeps its balance, and can be switched back on.

Issue a card makes one by hand, with no purchase behind it (goodwill, a promotion, a physical card sold over the counter): amount, recipient name and email. It is not mailed on creation; use Send this card by email.

There is no way to edit a balance or delete a card, on purpose. A balance is money owed, moved only by an order with a ledger row behind it. To give a customer more, issue a second card; to stop one, deactivate it.

Theme helpers

Helper Returns
cms_gift_card_summary() {enabled, cards, total, amountDue, currency} for the current cart

enabled is the module state, so a theme can hide the whole gift card block (entry box included) on a shop without the module; an empty cards list only means none was entered yet. Each card is {id, code, amount, balanceAfter}, with code already masked. Amounts are in cents, for cms_money:

{% set gc = cms_gift_card_summary() %}
{% if gc.enabled %}
    {% for card in gc.cards %}
        {{ card.code }}: −{{ card.amount|cms_money(gc.currency) }}
    {% endfor %}
{% endif %}

The cms_cart_view() summary carries the same as giftCard, giftCards and amountDue; an Order has giftCardCents and amountDueCents, and an order line isGiftCardLine and the recipient fields. No getter leads from an order to a card code: a code is a bearer instrument.

A theme that overrides pages/product.html.twig loses the recipient fields, and every card bought through it is mailed to the buyer. The fields are plain inputs posted with the add-to-cart form: recipient_name, recipient_email, gift_message and gift_send_on (a Y-m-d date). They are ignored on a product that is not a gift card. Show them only when product.isGiftCard() is true, as the default template does:

{% if product.isGiftCard() and is_module_enabled('giftcard') %}
    {# recipient_name, recipient_email, gift_message, gift_send_on #}
{% endif %}

isGiftCard() is allowlisted since ceems 1fcfacd. Before that the sandbox refused it, which also broke cms-core's own product page, so ship a theme that calls it only to a cms-public that has it.

Public routes

Route Method What
/cart/gift-card POST Apply a code (code). Redirects to the cart with a flash.
/cart/gift-card/remove POST Drop one card (gift_card_id, the id from the summary).

Both 404 while the module is off.

Overridable templates

Template What
giftcard/email/gift_card.html.twig (+ .txt.twig) The card mail. Inputs: card, website, shop_url, locale.

Scheduled job

cms-cron-giftcard-dispatch in cms-k8s runs app:schedule:dispatch giftcard:dispatch daily at 04:30 UTC, so a card dated for a birthday is in the inbox in the morning. It only queues the work; cms-worker sends the mails.