Appearance
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-cardsdeclarestestOnly: trueroute meta, andcanNavigatereturnsfalsefor 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.isTestEnvis a build-time constant,!['production', 'staging'].includes(import.meta.env.VITE_ENV), so the gate is closed in the production and staging builds. Separately, insrc/navigation/vertical/index.tsthe nav entry is pushed onto the Marketing menu only inside anif (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).testOnlyis the layer that actually blocks: it is evaluated before the abilities-hydration early return, and the sibling CASLview/gift-cardsmeta is a no-op for MERCHANT_OWNER, whose backend permission list is['*']. The customer-detail Gift Cards tab is hidden in production too (sameisTestEnvcheck, component-level). Only the back-office UI is gated — the/back-office/gift-cardsAPI is permission-gated, not env-gated. (Verified:upvendo-backofficesrc/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-backendapp/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
settingscollection (modelgift_card_template). It carriesname,description,value(face value),price(sale price),validity_days,status,location_id,image_url, andterms_and_conditions, plus running counterssold_countandredeemed_count. Adiscountpercentage is derived fromvaluevsprice. - Issued Gift Card: A purchased card stored in the
menuscollection (modelgift_card). It carries a uniquecode, a link back to its template (template_id,template_name),original_value, currentbalance,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
balancedecreases 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 becomesUsed. - 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 —testOnlyroute meta blocks it in production; see gating note above) - Backoffice Page:
src/pages/marketing/gift-cards/index.vue(wrapssrc/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-officeprefix)
Back-office API Endpoints (templates)
All endpoints are permission-gated.
| Method | Path | Action | Permission |
|---|---|---|---|
| GET | /back-office/gift-cards | List templates (datatable) | VIEW_GIFT_CARDS |
| POST | /back-office/gift-cards | Create template | CREATE_GIFT_CARDS |
| GET | /back-office/gift-cards/{id} | Show template | VIEW_GIFT_CARDS |
| PUT | /back-office/gift-cards/{id} | Update template | EDIT_GIFT_CARDS |
| DELETE | /back-office/gift-cards/{id} | Soft-delete template | DELETE_GIFT_CARDS |
| POST | /back-office/gift-cards/activate/{id} | Set status Active | EDIT_GIFT_CARDS |
| POST | /back-office/gift-cards/deactivate/{id} | Set status Inactive | EDIT_GIFT_CARDS |
| POST | /back-office/gift-cards/archive/{id} | Archive | EDIT_GIFT_CARDS |
| POST | /back-office/gift-cards/unarchive/{id} | Unarchive | EDIT_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
| Property | Value |
|---|---|
| Field ID | name |
| Type | String |
| Validation | required, max 255 |
Description
| Property | Value |
|---|---|
| Field ID | description |
| Type | String (textarea) |
| Validation | nullable, max 1000 |
Value (Face Value)
| Property | Value |
|---|---|
| Field ID | value |
| Type | Currency / numeric |
| Validation | required, 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)
| Property | Value |
|---|---|
| Field ID | price |
| Type | Currency / numeric |
| Validation | required, 0 or greater |
Description: What the customer pays for the card. A discount percentage is derived as (value - price) / value * 100.
Validity (Days)
| Property | Value |
|---|---|
| Field ID | validity_days |
| Type | Integer |
| Unit | Days |
| Validation | required, 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
| Property | Value |
|---|---|
| Field ID | status |
| Type | Select (enum) |
| Options | Draft, Active, Inactive, Archived |
| Validation | nullable; must be a valid GiftCardTemplateStatuses value |
Description: Template lifecycle status. Only Active templates are available for purchase.
Image URL
| Property | Value |
|---|---|
| Field ID | image_url |
| Type | String |
| Validation | nullable, max 500 |
Terms & Conditions
| Property | Value |
|---|---|
| Field ID | terms_and_conditions |
| Type | String (textarea) |
| Validation | nullable, max 5000 |
Location
| Property | Value |
|---|---|
| Field ID | location_id |
| Type | String (location reference) |
| Validation | required on create (exists:locations,_id); nullable on update |
Counters (read-only)
| Field ID | Description |
|---|---|
sold_count | Number of cards issued from this template; delete is blocked unless 0 |
redeemed_count | Number 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 ID | Type | Description |
|---|---|---|
code | String | Unique XXXX-XXXX-XXXX code (auto-generated) |
template_id | String | Source template id |
template_name | String | Source template name (snapshot) |
customer_id | String | Associated customer (nullable) |
customer_email | String | Purchaser/customer email (nullable) |
location_id | String | Location the card belongs to |
original_value | Currency | Face value at issue |
balance | Currency | Current remaining balance |
status | Enum | Active / Used / Expired / Cancelled |
purchased_at | DateTime | When the card was purchased |
expires_at | DateTime | When the card expires (nullable if validity is 0) |
last_used_at | DateTime | Last redemption time (nullable) |
transaction_id | String | Originating purchase transaction (nullable) |
recipient_email | String | Recipient email (nullable) |
recipient_name | String | Recipient name (nullable) |
sender_name | String | Sender name (nullable) |
personal_message | String | Gift 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_countValidation (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_atin 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 whenisTestEnvis true, so a production merchant does not see it (src/views/marketing/customers/Detail.vuelines 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.
Related Features
Business Rules
- Templates live in the
settingscollection (modelgift_card_template); issued cards live in themenuscollection (modelgift_card). Both use thetenantconnection, so they are scoped per merchant. - Only
Activetemplates can be purchased; issuing from a non-active template is rejected. - A template's
valuemust be greater than 0;pricemust be 0 or greater;validity_daysmust be between 1 and 3650. - A template can only be deleted when
sold_countis 0. - A template's
valuecannot be edited once it leavesDraftstatus. - Issued-card codes are fixed
XXXX-XXXX-XXXXuppercase alphanumeric and unique; the format is not configurable. - Redemption deducts
min(amount, balance); when balance hits 0 the card becomesUsedand the template'sredeemed_countincrements. - 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
isTestEnvcheck and the route carriestestOnlymeta, so in production the page is both hidden from the menu and blocked by the router (typing/marketing/gift-cardslands 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
Usedand the source template'sredeemed_countis incremented. - "Can I delete a template after selling cards?" No — delete is only available while
sold_countis 0. - "Can I change a template's face value later?" Only while it is in
Draft; thevaluefield is locked once the template leaves Draft.
Troubleshooting
Problem: Gift card code rejected at redemption
Causes (from validateGiftCardCode):
- Code not found (typo / wrong card)
- Card not in
Activestatus - Card expired (past
expires_at) - No remaining balance
Solutions:
- Re-check the exact
XXXX-XXXX-XXXXcode - Confirm the card is Active (not Used/Expired/Cancelled)
- Check the expiry date
- 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
}