Skip to content

Gift Cards

Production gating note: The Gift Cards back-office page is test-environment only, and it is now blocked at the route level, not merely hidden from the menu. /marketing/gift-cards declares testOnly: true route meta, and canNavigate returns false for any matched route carrying that meta whenever the build is not a test environment — so a production merchant who types the URL is redirected to /not-authorized. isTestEnv is a build-time constant, !['production', 'staging'].includes(import.meta.env.VITE_ENV), so the gate is closed in the production and staging builds. Separately, in src/navigation/vertical/index.ts the nav entry is pushed onto the Marketing menu only inside an if (isTestEnv) block; the accompanying code comment states Gift Cards (and Offers) must not be exposed in production because they still over-count POS revenue on Mplus/Kassanet (gift-card redemption reconciliation). testOnly is the layer that actually blocks: it is evaluated before the abilities-hydration early return, and the sibling CASL view/gift-cards meta is a no-op for MERCHANT_OWNER, whose backend permission list is ['*']. The customer-detail Gift Cards tab is hidden in production too (same isTestEnv check, component-level). Only the back-office UI is gated — the /back-office/gift-cards API is permission-gated, not env-gated. (Verified: upvendo-backoffice src/pages/marketing/gift-cards/index.vue lines 11-17; src/@layouts/plugins/casl.ts lines 12 and 149-167; src/plugins/1.router/guards.ts lines 33-45; src/navigation/vertical/index.ts lines 238-251; src/views/marketing/customers/Detail.vue lines 58-63; upvendo-backend app/Constants/Roles.php lines 181-184; routes/api/backoffice/gift-cards.php.)

Overview

Gift Cards lets a merchant define gift-card templates — named, priced gift-card products (e.g. "€50 Gift Card") that can be sold for a face value (value) at a chosen price, optionally at a discount. Selling a template produces an issued gift card with a unique code and a redeemable balance.

This page manages templates only (create / edit / activate / deactivate / archive / delete). It is not a settings page — there is no enable toggle, no preset-amount list, no custom min/max amount, no expiry-months setting, and no code-format selector.

Key Purpose: Create and manage gift-card templates (products) per location.

Purpose

The page lists gift-card templates for the currently selected location and lets a merchant create new templates and manage their lifecycle status. Each template defines the gift card's name, face value, sale price, and validity period; issued gift cards are generated from a template when one is purchased (purchase/checkout flow is not part of this page).

Key Concepts

  • Gift Card Template: A reusable product definition stored in the settings collection (model gift_card_template). It carries name, description, value (face value), price (sale price), validity_days, status, location_id, image_url, and terms_and_conditions, plus running counters sold_count and redeemed_count. A discount percentage is derived from value vs price.
  • Issued Gift Card: A purchased card stored in the menus collection (model gift_card). It carries a unique code, a link back to its template (template_id, template_name), original_value, current balance, status, timestamps (purchased_at, expires_at, last_used_at), and recipient/sender details. (The page covered here manages templates, not issued cards.)
  • Gift Card Code: A fixed-format uppercase alphanumeric code in the shape XXXX-XXXX-XXXX (three groups of four random characters), generated server-side and guaranteed unique. The format is not configurable.
  • Balance Management: An issued card's balance decreases on each redemption. Mutations are atomic (compare-and-set), so concurrent redemptions cannot double-spend the same balance. Redemption attempts the full requested amount; if the card no longer covers it, whatever remains is deducted and the difference is logged as a shortfall at error level — the merchant absorbs it, because payment has already settled. Partial redemptions leave a remaining balance; when the balance reaches zero the card's status becomes Used.
  • Customer Association: An issued card may link to a customer (customer_id / customer_email). The customer detail screen shows that customer's issued gift cards via a datatable.
  • Soft Delete: Both templates and issued cards use soft deletion; templates additionally support archive/unarchive.

Actions

Create / Edit a Gift Card Template

Open the form dialog to define a template: name, description, value (face value, must be greater than 0), price (sale price, 0 or greater), validity_days (1–3650), status, and terms_and_conditions. Saving issues a POST (create) or PUT (update) to /back-office/gift-cards. The value field is locked for editing once a template is no longer in Draft status.

Manage Template Lifecycle

From the row actions, depending on current status, a template can be activated, deactivated, archived, unarchived, or deleted. Delete is only offered when the template has not been sold (sold_count === 0). Each action is a dedicated endpoint (see Location).

Location

  • Backoffice Route: /marketing/gift-cards (test-env only — testOnly route meta blocks it in production; see gating note above)
  • Backoffice Page: src/pages/marketing/gift-cards/index.vue (wraps src/views/marketing/gift-cards/index.vue)
  • Form Dialog: src/views/marketing/gift-cards/components/GiftCardFormDialog.vue
  • Store Module: src/store/modules/giftCards.ts
  • Customer-detail issued-cards table: src/views/marketing/customers/components/GiftCardsTable.vue
  • Backend Controller: app/Http/Controllers/Api/GiftCardTemplateController.php
  • Backend Service: app/Services/GiftCard/GiftCardService.php (issued-card create/validate/redeem/refund)
  • Backend Routes: routes/api/backoffice/gift-cards.php (under the /back-office prefix)

Back-office API Endpoints (templates)

All endpoints are permission-gated.

MethodPathActionPermission
GET/back-office/gift-cardsList templates (datatable)VIEW_GIFT_CARDS
POST/back-office/gift-cardsCreate templateCREATE_GIFT_CARDS
GET/back-office/gift-cards/{id}Show templateVIEW_GIFT_CARDS
PUT/back-office/gift-cards/{id}Update templateEDIT_GIFT_CARDS
DELETE/back-office/gift-cards/{id}Soft-delete templateDELETE_GIFT_CARDS
POST/back-office/gift-cards/activate/{id}Set status ActiveEDIT_GIFT_CARDS
POST/back-office/gift-cards/deactivate/{id}Set status InactiveEDIT_GIFT_CARDS
POST/back-office/gift-cards/archive/{id}ArchiveEDIT_GIFT_CARDS
POST/back-office/gift-cards/unarchive/{id}UnarchiveEDIT_GIFT_CARDS

Concepts

Gift Card Template

A named, priced gift-card product definition (face value + sale price + validity).

Issued Gift Card

A purchased card generated from a template, with a unique code and a redeemable balance.

Gift Card Code

A fixed-format XXXX-XXXX-XXXX uppercase alphanumeric code identifying an issued card.

Balance

Remaining value on an issued gift card.


Gift Card Template Fields

Validation per StoreGiftCardTemplateRequest.

Name

PropertyValue
Field IDname
TypeString
Validationrequired, max 255

Description

PropertyValue
Field IDdescription
TypeString (textarea)
Validationnullable, max 1000

Value (Face Value)

PropertyValue
Field IDvalue
TypeCurrency / numeric
Validationrequired, greater than 0

Description: The amount loaded onto a card when this template is purchased (becomes the issued card's original_value and starting balance). Locked once the template leaves Draft status.

Price (Sale Price)

PropertyValue
Field IDprice
TypeCurrency / numeric
Validationrequired, 0 or greater

Description: What the customer pays for the card. A discount percentage is derived as (value - price) / value * 100.

Validity (Days)

PropertyValue
Field IDvalidity_days
TypeInteger
UnitDays
Validationrequired, min 1, max 3650

Description: How long an issued card remains valid after purchase. On purchase, expires_at is set to purchase time plus validity_days.

Status

PropertyValue
Field IDstatus
TypeSelect (enum)
OptionsDraft, Active, Inactive, Archived
Validationnullable; must be a valid GiftCardTemplateStatuses value

Description: Template lifecycle status. Only Active templates are available for purchase.

Image URL

PropertyValue
Field IDimage_url
TypeString
Validationnullable, max 500

Terms & Conditions

PropertyValue
Field IDterms_and_conditions
TypeString (textarea)
Validationnullable, max 5000

Location

PropertyValue
Field IDlocation_id
TypeString (location reference)
Validationrequired on create (exists:locations,_id); nullable on update

Counters (read-only)

Field IDDescription
sold_countNumber of cards issued from this template; delete is blocked unless 0
redeemed_countNumber of issued cards fully redeemed

Issued Gift Card Fields

Stored in the menus collection (model gift_card, connection tenant). These are generated on purchase, not edited from the templates page.

Field IDTypeDescription
codeStringUnique XXXX-XXXX-XXXX code (auto-generated)
template_idStringSource template id
template_nameStringSource template name (snapshot)
customer_idStringAssociated customer (nullable)
customer_emailStringPurchaser/customer email (nullable)
location_idStringLocation the card belongs to
original_valueCurrencyFace value at issue
balanceCurrencyCurrent remaining balance
statusEnumActive / Used / Expired / Cancelled
purchased_atDateTimeWhen the card was purchased
expires_atDateTimeWhen the card expires (nullable if validity is 0)
last_used_atDateTimeLast redemption time (nullable)
transaction_idStringOriginating purchase transaction (nullable)
recipient_emailStringRecipient email (nullable)
recipient_nameStringRecipient name (nullable)
sender_nameStringSender name (nullable)
personal_messageStringGift message (nullable)

Issued-card statuses (GiftCardStatuses, capitalized):

  • Active: Usable
  • Used: Fully redeemed (balance reached 0)
  • Expired: Past expires_at
  • Cancelled: Cancelled

Business Logic

Issuing a Card (from purchase)

GiftCardService::createGiftCardFromPurchase requires an existing template whose status is Active (otherwise 404 / 400). It computes expires_at from the template's validity_days (only when greater than 0), copies the template's value into both original_value and balance, sets status Active, stamps purchased_at, generates a unique code, persists the card, and increments the template's sold_count.

Purchase references an Active template


expires_at = now + validity_days (if validity_days > 0)


original_value = balance = template value, status = Active


Generate unique XXXX-XXXX-XXXX code, save card


Increment template sold_count

Validation (validateGiftCardCode)

Looks up the card by code and rejects it when:

  • not found → "Gift card not found" (404)
  • not active → "Gift card is not active" (400)
  • expired (expires_at in the past) → "Gift card has expired" (400)
  • no balance remaining → "Gift card has no remaining balance" (400)

Redemption (redeemGiftCard)

Look up issued card by id


Reject if not found / not active / expired / no balance (400)


Attempt an ATOMIC deduct of the FULL requested amount

        ├── succeeded → amount_deducted = requested

        └── failed (card no longer covers it)


            Re-read the fresh balance and deduct min(requested, remaining),
            retrying if a concurrent writer spends it first


            Log the shortfall at ERROR with reconciliation context
            (card id + code, requested vs deducted, shortfall, transaction id)


If new balance <= 0 → status = Used, increment template redeemed_count


Return { success, amount_deducted, remaining_balance, shortfall }

Balance mutations are atomic. deductBalance and addBalance are conditional compare-and-set loops guarded on the stored balance, not read-modify-write — two concurrent redemptions can no longer both read the same balance and double-spend it. (A blind increment is not usable while balances exist in two storage formats, legacy float euros and integer cents.)

A shortfall is loud, and the order still completes. Redemption used to silently cap the deduction at whatever balance was left, even though the order total had already been reduced by the full amount — unlogged merchant loss. It now attempts the full amount and, on shortfall, deducts what remains, records the difference at ERROR level with everything needed to reconcile, and returns it as shortfall. The order is not rolled back: payment has already settled by this point, so the difference is merchant loss that has to be visible rather than prevented.

The fallback never deducts more than the requested amount. A first-attempt failure can also mean a raced attempt or a concurrent refund, where the balance still covers the request — deducting the full fresh balance there would over-charge the customer.

Refunds (refundGiftCard / addBalance) add the amount back to the balance and re-set the card's status to Active.


Customer Impact

Customer-facing purchase, email delivery, and storefront balance-check UX are not verified in these repositories and are omitted here.

What is verifiable in-app:

  • A customer's issued gift cards are listed on the customer detail screen (GiftCardsTable.vue) with columns: code, gift-card (template) name, original value, balance, status, purchased-at, expires-at. This tab is test-env only as well — it is appended to the tab list only when isTestEnv is true, so a production merchant does not see it (src/views/marketing/customers/Detail.vue lines 58-63).
  • Storefront/online-ordering serialization of issued cards exposes: id, code, template_name, balance, original_value, status, and expires_at (date only).

Relations

Depends On

  • Locations: A template is scoped to a location_id (selected location drives the list).
  • Permissions: VIEW/CREATE/EDIT/DELETE_GIFT_CARDS gate the endpoints.

Affects

  • Customers: Issued cards appear on the customer detail screen.

Business Rules

  • Templates live in the settings collection (model gift_card_template); issued cards live in the menus collection (model gift_card). Both use the tenant connection, so they are scoped per merchant.
  • Only Active templates can be purchased; issuing from a non-active template is rejected.
  • A template's value must be greater than 0; price must be 0 or greater; validity_days must be between 1 and 3650.
  • A template can only be deleted when sold_count is 0.
  • A template's value cannot be edited once it leaves Draft status.
  • Issued-card codes are fixed XXXX-XXXX-XXXX uppercase alphanumeric and unique; the format is not configurable.
  • Redemption deducts min(amount, balance); when balance hits 0 the card becomes Used and the template's redeemed_count increments.
  • Both templates and issued cards are soft-deleted (templates also support archive/unarchive).

FAQs

  • "What does this page manage?" Gift-card templates (the products), not a settings/configuration page and not individual issued cards.
  • "Why isn't Gift Cards visible in production?" The nav entry is wrapped in an isTestEnv check and the route carries testOnly meta, so in production the page is both hidden from the menu and blocked by the router (typing /marketing/gift-cards lands on /not-authorized). It stays that way until POS revenue over-counting (Mplus/Kassanet gift-card redemption reconciliation) is fixed. The customer-detail Gift Cards tab is hidden in production for the same reason.
  • "Can a gift card be used across multiple orders?" Yes — redemption deducts min(amount, balance), so partial redemptions leave a remaining balance until the card is fully used.
  • "What happens when a card is fully redeemed?" Its status becomes Used and the source template's redeemed_count is incremented.
  • "Can I delete a template after selling cards?" No — delete is only available while sold_count is 0.
  • "Can I change a template's face value later?" Only while it is in Draft; the value field is locked once the template leaves Draft.

Troubleshooting

Problem: Gift card code rejected at redemption

Causes (from validateGiftCardCode):

  1. Code not found (typo / wrong card)
  2. Card not in Active status
  3. Card expired (past expires_at)
  4. No remaining balance

Solutions:

  1. Re-check the exact XXXX-XXXX-XXXX code
  2. Confirm the card is Active (not Used/Expired/Cancelled)
  3. Check the expiry date
  4. Check the remaining balance

Problem: Cannot delete a template

Cause: The template has issued cards (sold_count > 0); delete is hidden in that case.

Solution: Archive (or deactivate) the template instead of deleting it.


Problem: Gift Cards page not visible in production (or the URL redirects to Not Authorized)

Cause: The nav entry is registered only when isTestEnv is true, and the route declares testOnly meta, which canNavigate rejects outside a test environment.

Solution: This is intentional and cannot be worked around by permissions or by typing the URL — the check is on the build's environment, not the user's role. The page is test-env only until the POS over-counting issue is resolved; it is not a misconfiguration.


Examples

Gift Card Template (datatable serialization)

json
{
  "id": "664f0a1b2c3d4e5f60718293",
  "name": "€50 Gift Card",
  "description": "Perfect for any occasion",
  "value": 50.00,
  "price": 45.00,
  "discount": 10.0,
  "validity_days": 365,
  "status": "Active",
  "image_url": null,
  "sold_count": 12,
  "redeemed_count": 4,
  "created_at": "Jan 15, 2026"
}

Issued Gift Card

json
{
  "id": "664f0b2c3d4e5f6071829304",
  "code": "A1B2-C3D4-E5F6",
  "template_id": "664f0a1b2c3d4e5f60718293",
  "template_name": "€50 Gift Card",
  "customer_id": null,
  "customer_email": "jan@example.com",
  "location_id": "664e00112233445566778899",
  "original_value": 50.00,
  "balance": 35.50,
  "status": "Active",
  "purchased_at": "2026-01-15 10:30:00",
  "expires_at": "2027-01-15 10:30:00",
  "last_used_at": "2026-02-01 18:05:00",
  "transaction_id": null,
  "recipient_email": "marie@example.com",
  "recipient_name": "Marie de Vries",
  "sender_name": "Jan de Vries",
  "personal_message": "Happy Birthday! Enjoy a nice dinner on me."
}

Redemption result (redeemGiftCard)

json
{
  "success": true,
  "amount_deducted": 14.50,
  "remaining_balance": 35.50
}