feat: add gift card system for shops (MPS-10 through MPS-13)

Variable-amount gift cards purchasable with any payment method.
Code-based redemption at checkout (applied to cart like coupons).
Partial use across multiple purchases, never expire. Shop owners
control min/max amounts and can disable individual cards.

Models: GiftCard, GiftCardTransaction, CartGiftCard + migration.
Views: purchase page, cart apply/remove, shop admin manage/detail/toggle.
Templates: gift_card.j2, gift_card_manage.j2, gift_card_detail.j2.
Cart integration: gift cards deduct after coupons in all checkout paths.
Tests: 10 new unit tests covering model logic (677 total pass).
This commit is contained in:
russell@unturf.com 2026-03-07 15:38:40 -05:00
parent 2082783f03
commit 5d501652c2
26 changed files with 1627 additions and 7 deletions

79
docs/tickets/mps-10.md Normal file
View file

@ -0,0 +1,79 @@
# MPS-10: Gift Card System — Models & Migration
## Problem
Shops want to sell variable-amount gift cards. Buyers pick an amount (slider),
purchase with any payment method (including crypto), and receive a code. The
recipient enters the code at checkout (like a coupon) and the balance decrements
across purchases. Gift cards never expire (permacomputer rules).
This ticket covers the data layer only. Purchase flow (MPS-11), redemption flow
(MPS-12), and shop admin UI (MPS-13) are separate tickets.
## Solution
### New model: `MpsGiftCard`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `UUIDType` | PK (uuid1) |
| `shop_id` | `UUIDType` | FK to Shop — card is scoped to one shop |
| `code` | `Unicode(64)` | Unique redemption code, uppercase alphanumeric |
| `initial_amount_in_cents` | `BigInteger` | Amount at time of purchase |
| `balance_in_cents` | `BigInteger` | Current remaining balance |
| `purchaser_email` | `Unicode(256)` | Email of the buyer |
| `gift_email` | `Unicode(256)` | Optional recipient email |
| `invoice_id` | `UUIDType` | FK to Invoice — the purchase transaction |
| `created_timestamp` | `BigInteger` | Milliseconds |
| `disabled` | `Boolean` | Admin kill switch, default False |
Properties:
- `is_valid` — not disabled and balance > 0
- `balance``cents_to_dollars(balance_in_cents)`
- `initial_amount``cents_to_dollars(initial_amount_in_cents)`
- `shop_uuid_str` — string form of shop_id
Code generation: 16-char uppercase alphanumeric (`secrets.token_hex(8).upper()`),
prefixed with `GC-` for human readability. Example: `GC-A1B2C3D4E5F6G7H8`.
### New model: `MpsGiftCardTransaction`
Tracks every time a gift card balance is used at checkout.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `UUIDType` | PK (uuid1) |
| `gift_card_id` | `UUIDType` | FK to MpsGiftCard |
| `invoice_id` | `UUIDType` | FK to Invoice — the purchase that used the card |
| `amount_in_cents` | `BigInteger` | Amount deducted from balance |
| `created_timestamp` | `BigInteger` | Milliseconds |
### Shop settings columns
Add to Shop model:
| Column | Type | Default |
|--------|------|---------|
| `gift_card_enabled` | `Boolean` | `False` |
| `gift_card_min_in_cents` | `BigInteger` | `500` ($5.00) |
| `gift_card_max_in_cents` | `BigInteger` | `25000` ($250.00) |
### Helper functions
- `get_gift_card_by_code(dbsession, code, shop=None)` — lookup by code, optionally scoped to shop
- `get_gift_card_by_id(dbsession, gift_card_id)` — standard ID lookup
- `get_gift_cards_by_shop(dbsession, shop)` — all cards for a shop (admin view)
## Files Changed
| File | Change |
|------|--------|
| `models/gift_card.py` | New: MpsGiftCard model |
| `models/gift_card_transaction.py` | New: MpsGiftCardTransaction model |
| `models/shop.py` | Add gift_card_enabled, gift_card_min_in_cents, gift_card_max_in_cents |
| `models/__init__.py` | Import new models |
| `scripts/alembic/versions/*_gift_card_tables.py` | Migration: new tables + shop columns |
## Depends On
Nothing. Foundation ticket.

76
docs/tickets/mps-11.md Normal file
View file

@ -0,0 +1,76 @@
# MPS-11: Gift Card System — Purchase Flow
## Problem
Buyers need a way to purchase gift cards for a shop. The shop owner sets a
min/max amount range, and the buyer picks any amount within that range using a
slider. The buyer can optionally enter a recipient email address so the gift
card code is delivered to someone else.
## Solution
### Gift card "product" page
Gift cards are not regular products — they are a shop-level feature. A shop with
`gift_card_enabled=True` gets a `/shop/{slug}/gift-card` page.
The page contains:
- Shop name and branding
- Amount slider (range input) with min/max from shop settings
- Manual amount text input (synced with slider for precise entry)
- Optional "Gift to" email field
- Optional gift message (short text, stored on the card)
- "Add to Cart" button
### Cart integration
Gift cards are added to the cart as a special line item. Since they have variable
pricing and are not regular products, they need a different storage approach in
`json_cart`:
Option A: Store gift card items in a separate `json_gift_cards` column on Cart.
Format: `[{"shop_id": "...", "amount_in_cents": 2500, "gift_email": "...", "gift_message": "..."}]`
This keeps gift cards cleanly separated from product line items and avoids
polluting the existing `json_cart` dictionary (which maps product UUIDs to
quantities).
### Checkout
When the cart contains gift card items:
1. Gift card amounts are included in the cart total
2. After successful payment (Stripe, PayPal, or crypto), generate a
`MpsGiftCard` record for each gift card line item
3. Generate the unique code (`GC-` prefix + 16 hex chars)
4. If `gift_email` is provided, send the code to the recipient
5. Always show the code to the purchaser in the order confirmation
### Email delivery
When `gift_email` is set, send a simple email to the recipient containing:
- Shop name
- Gift card amount
- The redemption code
- Optional gift message
- Link to the shop
The email is informational only — the code IS the value. No account required.
## Files Changed
| File | Change |
|------|--------|
| `views/gift_card.py` | New: gift card page + add-to-cart handler |
| `templates/gift_card.j2` | New: gift card purchase page with slider |
| `static/js/gift_card.js` | New: slider/input sync, amount formatting |
| `static/css/common.css` | Gift card page styles (using tokens) |
| `models/cart.py` | Add `json_gift_cards` column, gift card total methods |
| `views/cart.py` | Include gift card totals in checkout flow |
| `views/checkout.py` | Generate MpsGiftCard records after payment |
| `routes.py` | Add `/shop/{slug}/gift-card` route |
| `lib/email.py` | Gift card delivery email template |
| `templates/shop.j2` | "Gift Cards" link when enabled |
## Depends On
MPS-10 (models and migration).

84
docs/tickets/mps-12.md Normal file
View file

@ -0,0 +1,84 @@
# MPS-12: Gift Card System — Redemption at Checkout
## Problem
Recipients need to apply a gift card code at checkout, just like a coupon code.
The gift card balance should reduce the cart total for that shop. Partial use is
supported — remaining balance stays on the card for future purchases.
## Solution
### Cart: gift card code entry
Add a "Gift Card" code input field alongside the existing coupon code field on
the cart/checkout page. The flow mirrors coupon application:
1. User enters gift card code
2. Server validates: code exists, belongs to a shop in the cart, has balance, not disabled
3. If valid, attach to cart and show the discount
4. If invalid, show error message
### Cart model changes
Add gift card tracking to the Cart model, parallel to how coupons work:
- New association: `MpsCartGiftCard` (cart_id, gift_card_id) — many-to-many
- `cart.gift_cards` — association proxy to attached gift cards
- Gift card discount is applied AFTER coupon discounts (coupons reduce the
price first, then gift card balance covers the remainder)
### Discount calculation
In `Cart.discounted_shop_totals_in_cents`, after coupon discounts:
```python
# After coupon discounts are applied...
for gift_card in self.gift_cards:
shop_uuid = gift_card.shop_uuid_str
if shop_uuid in self._discounted_shop_totals_in_cents:
current = self._discounted_shop_totals_in_cents[shop_uuid]
deduction = min(gift_card.balance_in_cents, current)
self._discounted_shop_totals_in_cents[shop_uuid] = current - deduction
# Store deduction amount for checkout to record transaction
gift_card._pending_deduction = deduction
```
### Checkout: balance deduction
After successful payment (or if `requires_payment` is False because gift card
covered the full amount):
1. For each attached gift card, deduct `_pending_deduction` from `balance_in_cents`
2. Create a `MpsGiftCardTransaction` record
3. Detach gift card from cart
### Validation
`cart.validate_attached_gift_cards()` checks:
- Gift card is not disabled
- Gift card has balance > 0
- Gift card belongs to a shop in the cart
### Edge cases
- **Gift card covers full amount**: `requires_payment` returns False (total <= 64 cents
after gift card). Checkout proceeds without charging a card, same as a 100% coupon.
- **Gift card + coupon stacking**: Allowed. Coupon applies first (percentage or
dollar off), then gift card balance covers the remaining amount.
- **Multiple gift cards**: A buyer can apply one gift card per shop in the cart
(same constraint as coupons — one per shop keeps it simple).
## Files Changed
| File | Change |
|------|--------|
| `models/cart_gift_card.py` | New: MpsCartGiftCard association model |
| `models/cart.py` | Gift card association proxy, discount integration, validation |
| `views/cart.py` | Gift card code apply/remove handlers |
| `templates/cart.j2` | Gift card code input field, balance display |
| `templates/checkout.j2` | Show gift card discount in order summary |
| `views/checkout.py` | Deduct balance, create transactions on successful checkout |
## Depends On
MPS-10 (models), MPS-11 (gift card records exist to redeem).

74
docs/tickets/mps-13.md Normal file
View file

@ -0,0 +1,74 @@
# MPS-13: Gift Card System — Shop Admin & Settings
## Problem
Shop owners need to enable/configure gift cards and view issued cards with
their balances and transaction history.
## Solution
### Shop settings: gift card configuration
New `gift-card-settings` form section in shop settings:
- **Enable Gift Cards** toggle (`gift_card_enabled`)
- **Minimum Amount** input (`gift_card_min_in_cents`, displayed as dollars)
- **Maximum Amount** input (`gift_card_max_in_cents`, displayed as dollars)
Validation:
- Min must be >= $1.00 (100 cents)
- Max must be >= min
- Max must be <= $10,000.00 (1000000 cents) — reasonable upper bound
### Gift card management page
New route: `/shop/{slug}/gift-cards/manage` (shop owner only)
Displays a table of all issued gift cards:
| Code | Amount | Balance | Purchaser | Recipient | Date | Status |
|------|--------|---------|-----------|-----------|------|--------|
Features:
- Sort by date (newest first)
- Show active vs fully redeemed vs disabled
- Click a card to see its transaction history
- Disable/enable toggle per card (admin kill switch)
### Gift card detail view
`/shop/{slug}/gift-cards/{card_id}` (shop owner only)
Shows:
- Card details (code, amounts, emails)
- Transaction history table (date, invoice, amount deducted, remaining balance)
- Disable toggle
### Buyer's gift card view
Buyers who purchase gift cards can see their purchased cards and remaining
balances on their account page. Recipients (who redeem codes) can check balance
by entering the code on the shop's gift card page.
### Balance check
On the gift card purchase page (`/shop/{slug}/gift-card`), add a "Check Balance"
section where anyone can enter a code and see the remaining balance. No account
required — the code IS the identity.
## Files Changed
| File | Change |
|------|--------|
| `views/shop.py` | `gift-card-settings` form handler |
| `views/gift_card.py` | Management page, detail view, balance check |
| `templates/shop_settings.j2` | Gift card settings form section |
| `templates/gift_card_manage.j2` | New: gift card list for shop owner |
| `templates/gift_card_detail.j2` | New: single card detail + transactions |
| `templates/gift_card.j2` | Add balance check section |
| `routes.py` | Add management + detail routes |
| `tests/test_functional.py` | Settings save, gift card CRUD, balance check |
## Depends On
MPS-10 (models), MPS-11 (purchase flow creates cards to manage).