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:
parent
2082783f03
commit
5d501652c2
26 changed files with 1627 additions and 7 deletions
79
docs/tickets/mps-10.md
Normal file
79
docs/tickets/mps-10.md
Normal 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
76
docs/tickets/mps-11.md
Normal 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
84
docs/tickets/mps-12.md
Normal 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
74
docs/tickets/mps-13.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue