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).

View file

@ -531,3 +531,50 @@ def send_invite_email(request, to_email, user, shop):
message_text = INVITE_1_TEXT.format(user.email, shop.name, join_link)
message_html = INVITE_1_HTML.format(subject, user.email, shop.name, join_link)
send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_gift_card_email(request, gift_card):
"""Send gift card code to the recipient email."""
from ..lib.currency import cents_to_dollars
to_email = gift_card.gift_email
shop_name = gift_card.shop.name
amount = f"${cents_to_dollars(gift_card.initial_amount_in_cents):,.2f}"
code = gift_card.code
shop_url = gift_card.shop.absolute_url(request)
gift_message = gift_card.gift_message or ""
subject = f"You received a {amount} gift card for {shop_name}"
message_parts = [
f"You received a {amount} gift card for {shop_name}!",
f"",
f"Your gift card code: {code}",
f"",
]
if gift_message:
message_parts.append(f"Message: {gift_message}")
message_parts.append("")
message_parts.extend([
f"To redeem, enter the code at checkout when shopping at {shop_name}.",
f"",
f"Visit: {shop_url}",
f"",
f"This gift card never expires.",
])
message_text = "\n".join(message_parts)
html_parts = [
f"<h2>You received a {amount} gift card for {shop_name}!</h2>",
f"<p><strong>Your gift card code:</strong></p>",
f"<p style='font-size: 24px; font-family: monospace; background: #f0f0f0; padding: 12px; display: inline-block;'>{code}</p>",
]
if gift_message:
html_parts.append(f"<p><em>{gift_message}</em></p>")
html_parts.extend([
f"<p>To redeem, enter the code at checkout when shopping at <a href='{shop_url}'>{shop_name}</a>.</p>",
f"<p><small>This gift card never expires.</small></p>",
])
message_html = "\n".join(html_parts)
send_pyramid_email(request, to_email, subject, message_text, message_html)

View file

@ -32,6 +32,10 @@ from .comment import *
from .shop_subscription import *
from .page_session import *
from .gift_card import *
from .gift_card_transaction import *
from .cart_gift_card import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.
configure_mappers()

View file

@ -22,6 +22,7 @@ from .product import get_products_by_ids
from .shop import get_shops_by_ids
from .cart_coupon import CartCoupon
from .cart_gift_card import CartGiftCard
from .inventory import get_inventory_by_product_and_shop_location
@ -47,6 +48,9 @@ class Cart(RBase, Base):
handling_option = Column(Unicode(64), nullable=True)
handling_cost_in_cents = Column(BigInteger, nullable=True, default=0)
# Gift card purchase items (variable-priced, not regular products)
json_gift_cards = Column(UnicodeText, default=unicode("[]"))
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
@ -57,6 +61,11 @@ class Cart(RBase, Base):
"cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c)
)
# many to many uses association_proxy.
gift_cards = association_proxy(
"cart_gift_cards", "gift_card", creator=lambda gc: CartGiftCard(gift_card=gc)
)
user = relationship(argument="User", uselist=False, lazy="joined")
shop = relationship(argument="Shop", uselist=False, lazy="joined")
@ -67,6 +76,7 @@ class Cart(RBase, Base):
self.id = uuid.uuid4()
self.user = user
self.json_cart = unicode("{}")
self.json_gift_cards = unicode("[]")
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
@ -93,6 +103,10 @@ class Cart(RBase, Base):
del self._discounted_shop_totals
if hasattr(self, "_line_totals"):
del self._line_totals
if hasattr(self, "_gift_card_deductions"):
del self._gift_card_deductions
if hasattr(self, "_gift_card_purchases"):
del self._gift_card_purchases
def set_cart(self, cart_dict):
"""Save cart_dict as JSON into json_cart."""
@ -155,10 +169,22 @@ class Cart(RBase, Base):
# this busts memoization.
self.cart = tmp_cart
@property
def gift_card_purchases(self):
"""Return list of gift card purchase items from json_gift_cards."""
if not hasattr(self, "_gift_card_purchases"):
self._gift_card_purchases = json.loads(self.json_gift_cards or "[]")
return self._gift_card_purchases
@property
def gift_card_purchases_total_in_cents(self):
"""Total cost of gift card purchases in this cart."""
return sum(item["amount_in_cents"] for item in self.gift_card_purchases)
@property
def count(self):
if hasattr(self, "_count") == False:
self._count = sum(self.cart.values())
self._count = sum(self.cart.values()) + len(self.gift_card_purchases)
return self._count
@property
@ -267,7 +293,7 @@ class Cart(RBase, Base):
@property
def discounted_shop_totals_in_cents(self):
"""Discounted shop totals in cents after applying coupons."""
"""Discounted shop totals in cents after applying coupons and gift cards."""
if hasattr(self, "_discounted_shop_totals_in_cents") == False:
from copy import deepcopy
@ -282,8 +308,27 @@ class Cart(RBase, Base):
self._discounted_shop_totals_in_cents[shop_uuid] = (
coupon.compute_discount(shop_total_in_cents)
)
# Apply gift card balances after coupons
self._gift_card_deductions = {}
if len(self.gift_cards) > 0:
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
self._gift_card_deductions[gift_card.uuid_str] = deduction
return self._discounted_shop_totals_in_cents
@property
def gift_card_deductions(self):
"""Dict of gift_card_uuid_str -> deduction amount in cents.
Populated as a side effect of discounted_shop_totals_in_cents."""
# Ensure discounted totals are computed first
_ = self.discounted_shop_totals_in_cents
return getattr(self, "_gift_card_deductions", {})
@property
def discounted_shop_totals(self):
if hasattr(self, "_discounted_shop_totals") == False:
@ -300,9 +345,10 @@ class Cart(RBase, Base):
@property
def total_price_in_cents(self):
"""
Calculate the total price in cents, including handling cost if applicable.
Calculate the total price in cents, including handling cost and gift card purchases.
"""
total = sum(self.line_totals_in_cents.values())
total += self.gift_card_purchases_total_in_cents
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@ -317,9 +363,10 @@ class Cart(RBase, Base):
@property
def total_discounted_price_in_cents(self):
"""
Calculate the total discounted price in cents, including handling cost if applicable.
Calculate the total discounted price in cents, including handling cost and gift card purchases.
"""
total = sum(self.discounted_shop_totals_in_cents.values())
total += self.gift_card_purchases_total_in_cents
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@ -418,6 +465,25 @@ class Cart(RBase, Base):
)
return error_messages
def validate_attached_gift_cards(self):
"""Make sure all attached gift cards are valid for this cart."""
error_messages = []
if self.gift_cards:
for gift_card in self.gift_cards:
if gift_card.disabled:
error_messages.append(
f"Gift card '{gift_card.code}' has been disabled."
)
if gift_card.balance_in_cents <= 0:
error_messages.append(
f"Gift card '{gift_card.code}' has no remaining balance."
)
if gift_card.shop_uuid_str not in self.shop_totals_in_cents:
error_messages.append(
f"Gift card '{gift_card.code}' is not valid for any shop in your cart."
)
return error_messages
def check_inventory(self, shop_location):
"""
Check if the shop location has enough quantity for each physical product in the cart.

View file

@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship, backref
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class CartGiftCard(RBase, Base):
"""
Many to many, Carts to GiftCards.
A relationship signifies the application of a gift card to a cart.
"""
id = Column(UUIDType, primary_key=True, index=True)
cart_id = Column(UUIDType, foreign_key("Cart", "id"), nullable=False)
gift_card_id = Column(UUIDType, foreign_key("GiftCard", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
cart = relationship(
argument="Cart",
backref=backref("cart_gift_cards", cascade="all, delete-orphan"),
)
gift_card = relationship(
argument="GiftCard",
backref=backref("gift_card_carts", cascade="all, delete-orphan"),
)
def __init__(self, cart=None, gift_card=None):
self.id = uuid.uuid1()
self.cart = cart
self.gift_card = gift_card
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,108 @@
import secrets
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Unicode
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
)
from ..lib.currency import cents_to_dollars
class GiftCard(RBase, Base):
"""
A gift card is scoped to a single shop. The code IS the value
no account required to redeem. Balance decrements across purchases.
Gift cards never expire (permacomputer rules).
"""
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
code = Column(Unicode(64), nullable=False, unique=True, index=True)
initial_amount_in_cents = Column(BigInteger, nullable=False)
balance_in_cents = Column(BigInteger, nullable=False)
purchaser_email = Column(Unicode(256), nullable=True)
gift_email = Column(Unicode(256), nullable=True)
gift_message = Column(Unicode(512), nullable=True)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
disabled = Column(Boolean, default=False, nullable=False)
shop = relationship(argument="Shop", uselist=False, lazy="joined")
invoice = relationship(argument="Invoice", uselist=False, lazy="joined")
transactions = relationship(
argument="GiftCardTransaction",
lazy="dynamic",
back_populates="gift_card",
)
def __init__(self, shop, amount_in_cents, purchaser_email=None,
gift_email=None, gift_message=None, invoice=None):
self.id = uuid.uuid1()
self.shop = shop
self.code = generate_gift_card_code()
self.initial_amount_in_cents = amount_in_cents
self.balance_in_cents = amount_in_cents
self.purchaser_email = purchaser_email
self.gift_email = gift_email
self.gift_message = gift_message
self.invoice = invoice
self.created_timestamp = now_timestamp()
@property
def is_valid(self):
return not self.disabled and self.balance_in_cents > 0
@property
def balance(self):
return cents_to_dollars(self.balance_in_cents)
@property
def initial_amount(self):
return cents_to_dollars(self.initial_amount_in_cents)
@property
def shop_uuid_str(self):
return self.id_to_uuid_str(self.shop_id)
@property
def is_fully_redeemed(self):
return self.balance_in_cents <= 0
def deduct(self, amount_in_cents):
"""Deduct amount from balance. Returns actual amount deducted."""
deduction = min(amount_in_cents, self.balance_in_cents)
self.balance_in_cents -= deduction
return deduction
def generate_gift_card_code():
"""Generate a unique gift card code: GC- prefix + 16 hex chars uppercase."""
return "GC-" + secrets.token_hex(8).upper()
def get_gift_card_by_id(dbsession, gift_card_id):
return get_object_by_id(dbsession, gift_card_id, GiftCard)
def get_gift_card_by_code(dbsession, code, shop=None):
query = dbsession.query(GiftCard).filter(GiftCard.code == code.strip().upper())
if shop is not None:
query = query.filter(GiftCard.shop_id == shop.id)
return query.one_or_none()
def get_gift_cards_by_shop(dbsession, shop):
return (
dbsession.query(GiftCard)
.filter(GiftCard.shop_id == shop.id)
.order_by(GiftCard.created_timestamp.desc())
)

View file

@ -0,0 +1,46 @@
import uuid
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
)
from ..lib.currency import cents_to_dollars
class GiftCardTransaction(RBase, Base):
"""Tracks each time a gift card balance is used at checkout."""
id = Column(UUIDType, primary_key=True, index=True)
gift_card_id = Column(UUIDType, foreign_key("GiftCard", "id"), nullable=False)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
amount_in_cents = Column(BigInteger, nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
gift_card = relationship(
argument="GiftCard", uselist=False, lazy="joined",
back_populates="transactions",
)
invoice = relationship(argument="Invoice", uselist=False, lazy="joined")
def __init__(self, gift_card, invoice, amount_in_cents):
self.id = uuid.uuid1()
self.gift_card = gift_card
self.invoice = invoice
self.amount_in_cents = amount_in_cents
self.created_timestamp = now_timestamp()
@property
def amount(self):
return cents_to_dollars(self.amount_in_cents)
def get_gift_card_transaction_by_id(dbsession, transaction_id):
return get_object_by_id(dbsession, transaction_id, GiftCardTransaction)

View file

@ -43,6 +43,9 @@ CLASS_TO_TABLE = {
"UserCryptoRefundAddress": "mps_user_crypto_refund_address",
"ShopSubscription": "mps_shop_subscription",
"PageSession": "mps_page_session",
"GiftCard": "mps_gift_card",
"GiftCardTransaction": "mps_gift_card_transaction",
"CartGiftCard": "mps_cart_gift_card",
}

View file

@ -161,6 +161,11 @@ class Shop(RBase, Base):
# Sandbox mode: floating creative filter toolbar for visitors
sandbox_mode = Column(Boolean, default=False)
# Gift card settings
gift_card_enabled = Column(Boolean, default=False)
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)
@ -191,6 +196,14 @@ class Shop(RBase, Base):
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
invoices = relationship("Invoice", back_populates="shop", lazy="dynamic")
# lazy='dynamic' returns a query object instead of collection.
gift_cards = relationship(
argument="GiftCard",
back_populates="shop",
lazy="dynamic",
order_by="desc(GiftCard.created_timestamp)",
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
shop_locations = relationship(

View file

@ -106,6 +106,15 @@ def includeme(config):
config.add_route("coupon_apply_to_cart", "/coupon/apply")
config.add_route("coupon_remove_from_cart", "/coupon/remove")
# gift card routes.
config.add_route("gift_card_page", "/s/{shop_id}/gift-card")
config.add_route("gift_card_add_to_cart", "/gift-card/add-to-cart")
config.add_route("gift_card_apply_to_cart", "/gift-card/apply")
config.add_route("gift_card_remove_from_cart", "/gift-card/remove")
config.add_route("gift_card_manage", "/s/{shop_id}/gift-cards/manage")
config.add_route("gift_card_detail", "/s/{shop_id}/gift-cards/{gift_card_id}")
config.add_route("gift_card_toggle", "/s/{shop_id}/gift-cards/{gift_card_id}/toggle")
config.add_route("coupons", "/s/{shop_id}/coupons")
config.add_route("coupon1", "/s/{shop_id}/coupon/{coupon_id}")
config.add_route("coupon2", "/s/{shop_id}/coupon/{coupon_id}/{slug:.*}")

View file

@ -0,0 +1,108 @@
"""add gift card tables and shop settings
Revision ID: f8201a9ba045
Revises: 6b516114c393
Create Date: 2026-03-07 15:09:21.421155
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f8201a9ba045'
down_revision = '6b516114c393'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def _table_exists(name):
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"),
{"name": name},
)
return result.fetchone() is not None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
# Gift card table
if not _table_exists("mps_gift_card"):
op.create_table(
"mps_gift_card",
sa.Column("id", UUIDType, primary_key=True),
sa.Column("shop_id", UUIDType, sa.ForeignKey("mps_shop.id"), nullable=False),
sa.Column("code", sa.Unicode(64), nullable=False, unique=True),
sa.Column("initial_amount_in_cents", sa.BigInteger(), nullable=False),
sa.Column("balance_in_cents", sa.BigInteger(), nullable=False),
sa.Column("purchaser_email", sa.Unicode(256), nullable=True),
sa.Column("gift_email", sa.Unicode(256), nullable=True),
sa.Column("gift_message", sa.Unicode(512), nullable=True),
sa.Column("invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=True),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("disabled", sa.Boolean(), nullable=False, server_default="0"),
)
op.create_index("ix_mps_gift_card_code", "mps_gift_card", ["code"])
op.create_index("ix_mps_gift_card_shop_id", "mps_gift_card", ["shop_id"])
# Gift card transaction table
if not _table_exists("mps_gift_card_transaction"):
op.create_table(
"mps_gift_card_transaction",
sa.Column("id", UUIDType, primary_key=True),
sa.Column("gift_card_id", UUIDType, sa.ForeignKey("mps_gift_card.id"), nullable=False),
sa.Column("invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=False),
sa.Column("amount_in_cents", sa.BigInteger(), nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
)
# Cart-to-gift-card association table
if not _table_exists("mps_cart_gift_card"):
op.create_table(
"mps_cart_gift_card",
sa.Column("id", UUIDType, primary_key=True),
sa.Column("cart_id", UUIDType, sa.ForeignKey("mps_cart.id"), nullable=False),
sa.Column("gift_card_id", UUIDType, sa.ForeignKey("mps_gift_card.id"), nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
)
# Cart gift card purchase items column
if not _column_exists("mps_cart", "json_gift_cards"):
op.add_column(
"mps_cart",
sa.Column("json_gift_cards", sa.UnicodeText(), nullable=True, server_default="[]"),
)
# Shop gift card settings
if not _column_exists("mps_shop", "gift_card_enabled"):
op.add_column(
"mps_shop",
sa.Column("gift_card_enabled", sa.Boolean(), nullable=False, server_default="0"),
)
if not _column_exists("mps_shop", "gift_card_min_in_cents"):
op.add_column(
"mps_shop",
sa.Column("gift_card_min_in_cents", sa.BigInteger(), nullable=False, server_default="500"),
)
if not _column_exists("mps_shop", "gift_card_max_in_cents"):
op.add_column(
"mps_shop",
sa.Column("gift_card_max_in_cents", sa.BigInteger(), nullable=False, server_default="25000"),
)
def downgrade():
op.drop_table("mps_cart_gift_card")
op.drop_table("mps_gift_card_transaction")
op.drop_table("mps_gift_card")
op.drop_column("mps_shop", "gift_card_enabled")
op.drop_column("mps_shop", "gift_card_min_in_cents")
op.drop_column("mps_shop", "gift_card_max_in_cents")

View file

@ -3877,3 +3877,75 @@ html[data-color-filter="7"] {
border: 1px solid #ccc;
border-radius: 4px;
}
/* Gift Card */
.gift-card-form {
display: grid;
gap: var(--space-4, 16px);
}
.gift-card-slider-group {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-4, 16px);
align-items: center;
}
.gift-card-slider {
width: 100%;
}
.gift-card-amount-input-group {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: var(--space-1, 4px);
}
.gift-card-currency {
font-size: var(--text-lg, 1.25rem);
font-weight: bold;
}
.gift-card-amount-input {
width: 100px;
font-size: var(--text-lg, 1.25rem);
padding: var(--space-2, 8px);
border: 1px solid var(--border-default, #ccc);
border-radius: var(--radius-sm, 4px);
background: var(--surface-default, #fff);
color: var(--text-primary, #333);
}
.gift-card-balance-form {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-3, 12px);
align-items: center;
}
.cart-gift-card-apply {
margin-top: var(--space-3, 12px);
}
.cart-gift-card-apply form {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-3, 12px);
align-items: center;
}
.badge-success {
color: var(--success, #28a745);
font-weight: bold;
}
.badge-error {
color: var(--error, #dc3545);
font-weight: bold;
}
.badge-info {
color: var(--info, #17a2b8);
font-weight: bold;
}

View file

@ -17,6 +17,12 @@
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">&nbsp View Coupons</a>
{% if request.shop.gift_card_enabled %}
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">&nbsp Gift Cards</a>
{% endif %}
{% endif %}
{% if request.is_saas_domain %}

View file

@ -62,6 +62,25 @@
<br/>
{% endfor %}
{% for gift_card in cart.gift_cards %}
<section class="coupon">
<b>Gift Card: {{ gift_card.code }}</b><br/>
Balance: <strong>${{ '{:,.2f}'.format(gift_card.balance) }}</strong>
({{ gift_card.shop.name }})
<div class="cart-float-right">
<form method="post" action="/gift-card/remove" onsubmit="submit.disabled = true; return true;">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.uuid_str }}" />
<input type="hidden" name="gift_card_id" value="{{ gift_card.uuid_str }}" />
<input type="submit" name="submit" class="mps-submit" value="remove gift card" />
</form>
</div>
</section>
<br/>
<br/>
{% endfor %}
{% if cart.is_empty %}
<center>
@ -186,10 +205,27 @@
{% endfor %}
{% endif %}
{% for gc_item in cart.gift_card_purchases %}
<section class="cart-left-grid well">
<div class="cart-shop-grid-span">
<b class="cart-shop-name">Gift Card</b>
</div>
<div class="cart-item"></div>
<div class="cart-left-forms">
<span class="cart-left-item-title">Gift Card{% if gc_item.gift_email %} for {{ gc_item.gift_email }}{% endif %}</span>
{% if gc_item.gift_message %}<br/><em>{{ gc_item.gift_message }}</em>{% endif %}
</div>
<div class="cart-total-section">
<b>${{ '{:,.2f}'.format(gc_item.amount_in_cents / 100) }}</b>
</div>
</section>
<br>
{% endfor %}
{% endif %}
</section>
{% if not cart.is_empty %}
<section class="cart-right well">
@ -275,6 +311,16 @@
{% endif %}
<section class="cart-gift-card-apply">
<h3>Gift Card</h3>
<form method="POST" action="/gift-card/apply" class="cart-inline-form">
{% include "snippets/csrf.j2" %}
<input type="text" name="gift_card_code" placeholder="GC-XXXXXXXXXXXXXXXX" class="mps-text-input" />
<button type="submit" class="mps-button mps-button-small">Apply</button>
</form>
</section>
<br/>
<center>
<a href="/" class="mps-button cart-continue-shopping-button">Continue shopping</a>
</center>

View file

@ -0,0 +1,107 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h2>Gift Card for {{ shop.name }}</h2>
<p>Choose an amount and purchase a gift card. The recipient can redeem it at checkout.</p>
<form method="POST" action="/gift-card/add-to-cart" class="gift-card-form">
{% include "snippets/csrf.j2" %}
<label for="gift-card-amount">Amount</label>
<div class="gift-card-slider-group">
<input type="range"
id="gift-card-slider"
min="{{ min_dollars }}"
max="{{ max_dollars }}"
step="1"
value="{{ ((min_dollars + max_dollars) / 2)|round|int }}"
class="gift-card-slider" />
<div class="gift-card-amount-input-group">
<span class="gift-card-currency">$</span>
<input type="number"
id="gift-card-amount"
name="amount"
min="{{ min_dollars }}"
max="{{ max_dollars }}"
step="0.01"
value="{{ ((min_dollars + max_dollars) / 2)|round|int }}.00"
class="gift-card-amount-input"
required />
</div>
</div>
<label for="gift-email">Recipient Email (optional)</label>
<input type="email"
id="gift-email"
name="gift_email"
placeholder="friend@example.com"
class="mps-text-input" />
<small>If provided, the gift card code will be emailed to this address.</small>
<br/><br/>
<label for="gift-message">Gift Message (optional)</label>
<input type="text"
id="gift-message"
name="gift_message"
maxlength="500"
placeholder="Happy birthday!"
class="mps-text-input" />
<br/><br/>
<button type="submit" class="mps-button mps-button-green">Add Gift Card to Cart</button>
</form>
</section>
<br/>
<section class="well">
<h3>Check Gift Card Balance</h3>
<form method="GET" action="/s/{{ shop.uuid_str }}/gift-card" class="gift-card-balance-form">
<input type="text"
name="check_code"
placeholder="GC-XXXXXXXXXXXXXXXX"
class="mps-text-input"
value="{{ request.params.get('check_code', '') }}" />
<button type="submit" class="mps-button mps-button-small">Check Balance</button>
</form>
{% if balance_result %}
{% if balance_result.error %}
<p class="alert-error">{{ balance_result.error }}</p>
{% else %}
<p class="alert-success">
<strong>{{ balance_result.code }}</strong><br/>
Balance: <strong>${{ '{:,.2f}'.format(balance_result.balance) }}</strong>
(original: ${{ '{:,.2f}'.format(balance_result.initial_amount) }})
</p>
{% endif %}
{% endif %}
</section>
</section>
<script>
(function() {
var slider = document.getElementById('gift-card-slider');
var input = document.getElementById('gift-card-amount');
if (slider && input) {
slider.addEventListener('input', function() {
input.value = parseFloat(slider.value).toFixed(2);
});
input.addEventListener('input', function() {
var val = parseFloat(input.value);
if (!isNaN(val)) {
slider.value = val;
}
});
}
})();
</script>
{%- endblock -%}

View file

@ -0,0 +1,72 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
<a href="/s/{{ request.shop.uuid_str }}/gift-cards/manage" class="mps-button mps-button-small">All Gift Cards</a>
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h2>Gift Card: {{ gift_card.code }}</h2>
<table class="mps-table">
<tr><td>Code</td><td><strong>{{ gift_card.code }}</strong></td></tr>
<tr><td>Initial Amount</td><td>${{ '{:,.2f}'.format(gift_card.initial_amount) }}</td></tr>
<tr><td>Balance</td><td>${{ '{:,.2f}'.format(gift_card.balance) }}</td></tr>
<tr><td>Purchaser</td><td>{{ gift_card.purchaser_email or '-' }}</td></tr>
<tr><td>Recipient</td><td>{{ gift_card.gift_email or '-' }}</td></tr>
{% if gift_card.gift_message %}
<tr><td>Message</td><td>{{ gift_card.gift_message }}</td></tr>
{% endif %}
<tr><td>Status</td><td>
{% if gift_card.disabled %}
<span class="badge-error">Disabled</span>
{% elif gift_card.is_fully_redeemed %}
<span class="badge-info">Fully Redeemed</span>
{% else %}
<span class="badge-success">Active</span>
{% endif %}
</td></tr>
</table>
<br/>
<form method="POST" action="/s/{{ request.shop.uuid_str }}/gift-cards/{{ gift_card.uuid_str }}/toggle">
{% include "snippets/csrf.j2" %}
<button type="submit" class="mps-button mps-button-small">
{{ "Enable" if gift_card.disabled else "Disable" }} Gift Card
</button>
</form>
</section>
<br/>
<section class="well">
<h3>Transaction History</h3>
{% if transactions %}
<table class="mps-table">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Invoice</th>
</tr>
</thead>
<tbody>
{% for txn in transactions %}
<tr>
<td>{{ txn.created_timestamp }}</td>
<td>-${{ '{:,.2f}'.format(txn.amount) }}</td>
<td><a href="/invoice/{{ txn.invoice.uuid_str }}">{{ txn.invoice.uuid_str[:8] }}...</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No transactions yet.</p>
{% endif %}
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,69 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
<a href="/s/{{ request.shop.uuid_str }}/settings" class="mps-button mps-button-small">Settings</a>
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h2>Gift Cards</h2>
<p>
Total issued: <strong>${{ '{:,.2f}'.format(total_issued_dollars) }}</strong> |
Outstanding balance: <strong>${{ '{:,.2f}'.format(total_balance_dollars) }}</strong>
</p>
</section>
<br/>
{% if gift_cards %}
<section class="well">
<table class="mps-table">
<thead>
<tr>
<th>Code</th>
<th>Amount</th>
<th>Balance</th>
<th>Recipient</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for gc in gift_cards %}
<tr>
<td><a href="/s/{{ request.shop.uuid_str }}/gift-cards/{{ gc.uuid_str }}">{{ gc.code }}</a></td>
<td>${{ '{:,.2f}'.format(gc.initial_amount) }}</td>
<td>${{ '{:,.2f}'.format(gc.balance) }}</td>
<td>{{ gc.gift_email or gc.purchaser_email or '-' }}</td>
<td>
{% if gc.disabled %}
<span class="badge-error">Disabled</span>
{% elif gc.is_fully_redeemed %}
<span class="badge-info">Redeemed</span>
{% else %}
<span class="badge-success">Active</span>
{% endif %}
</td>
<td>
<form method="POST" action="/s/{{ request.shop.uuid_str }}/gift-cards/{{ gc.uuid_str }}/toggle" style="display:inline;">
{% include "snippets/csrf.j2" %}
<button type="submit" class="mps-button mps-button-small">
{{ "Enable" if gc.disabled else "Disable" }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% else %}
<section class="well">
<p>No gift cards issued yet.</p>
</section>
{% endif %}
</section>
{%- endblock -%}

View file

@ -1122,6 +1122,47 @@ Existing sales honored for download buy purchasers.
</section>
<section class="well">
<h3>Gift Cards</h3>
<form action="/s/{{ request.shop.uuid_str }}/settings" method="POST">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="form_section" value="gift-card-settings" />
<label>
<input type="checkbox" name="gift-card-enabled-checkbox"
{% if gift_card_enabled %}checked{% endif %} />
Enable Gift Cards
</label>
<br /><br />
<label for="gift_card_min">Minimum Amount ($)</label>
<input type="number" name="gift_card_min" id="gift_card_min"
value="{{ '{:.2f}'.format(gift_card_min_dollars) }}"
min="1.00" step="0.01" class="mps-text-input" />
<br /><br />
<label for="gift_card_max">Maximum Amount ($)</label>
<input type="number" name="gift_card_max" id="gift_card_max"
value="{{ '{:.2f}'.format(gift_card_max_dollars) }}"
min="1.00" max="10000.00" step="0.01" class="mps-text-input" />
<br /><br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br /><br />
{% if gift_card_enabled %}
<a href="/s/{{ request.shop.uuid_str }}/gift-cards/manage" class="mps-button mps-button-small">Manage Gift Cards</a>
{% endif %}
</form>
</section>
</section>

View file

@ -64,6 +64,9 @@
<nav class="mps-footer-links">
<a href="/">Home</a>
<a href="/cart">Cart</a>
{% if request.shop.gift_card_enabled %}
<a href="/s/{{ request.shop.uuid_str }}/gift-card">Gift Cards</a>
{% endif %}
{% if request.shop.subscriptions_enabled %}
<a href="/subscribe">Subscribe</a>
{% endif %}

View file

@ -3399,3 +3399,97 @@ class TestSentiment(unittest.TestCase):
def test_short_negative(self):
self.assertEqual(self._classify("hate it"), -1)
class TestGiftCard(unittest.TestCase):
def test_gift_card_creation(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertEqual(gc.initial_amount_in_cents, 5000)
self.assertEqual(gc.balance_in_cents, 5000)
self.assertFalse(gc.disabled)
self.assertTrue(gc.code.startswith("GC-"))
self.assertEqual(len(gc.code), 19) # GC- + 16 hex chars
def test_gift_card_is_valid(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertTrue(gc.is_valid)
def test_gift_card_disabled_not_valid(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
gc.disabled = True
self.assertFalse(gc.is_valid)
def test_gift_card_zero_balance_not_valid(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
gc.balance_in_cents = 0
self.assertFalse(gc.is_valid)
def test_gift_card_deduct(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
deducted = gc.deduct(2000)
self.assertEqual(deducted, 2000)
self.assertEqual(gc.balance_in_cents, 3000)
def test_gift_card_deduct_more_than_balance(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=1000)
deducted = gc.deduct(5000)
self.assertEqual(deducted, 1000)
self.assertEqual(gc.balance_in_cents, 0)
def test_gift_card_balance_property(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertEqual(gc.balance, 50.00)
self.assertEqual(gc.initial_amount, 50.00)
def test_gift_card_is_fully_redeemed(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertFalse(gc.is_fully_redeemed)
gc.balance_in_cents = 0
self.assertTrue(gc.is_fully_redeemed)
def test_gift_card_code_uniqueness(self):
from make_post_sell.models.gift_card import generate_gift_card_code
codes = set()
for _ in range(100):
codes.add(generate_gift_card_code())
self.assertEqual(len(codes), 100)
def test_gift_card_with_gift_email(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(
shop=shop,
amount_in_cents=2500,
purchaser_email="buyer@test.com",
gift_email="friend@test.com",
gift_message="Happy birthday!",
)
self.assertEqual(gc.gift_email, "friend@test.com")
self.assertEqual(gc.gift_message, "Happy birthday!")
self.assertEqual(gc.purchaser_email, "buyer@test.com")

View file

@ -9,6 +9,8 @@ from . import (
from ..models.cart import get_cart_by_id
from ..models.product import get_product_by_id
from ..models.invoice import Invoice, InvoiceLineItem
from ..models.gift_card import GiftCard, get_gift_card_by_code
from ..models.gift_card_transaction import GiftCardTransaction
from pyramid.httpexceptions import HTTPFound
@ -22,6 +24,76 @@ import traceback
from datetime import datetime
def _create_gift_card_transactions(cart, invoices, request):
"""After successful checkout, deduct gift card balances and create transaction records."""
import json
if cart.gift_cards:
deductions = cart.gift_card_deductions
for gift_card in cart.gift_cards:
deduction = deductions.get(gift_card.uuid_str, 0)
if deduction > 0:
# Find the invoice for this gift card's shop
invoice = None
for inv in invoices:
if inv.shop and str(inv.shop.id) == str(gift_card.shop_id):
invoice = inv
break
# Also check shop_id directly
if hasattr(inv, 'shop_id') and str(inv.shop_id) == str(gift_card.shop_id):
invoice = inv
break
if invoice:
gift_card.deduct(deduction)
txn = GiftCardTransaction(
gift_card=gift_card,
invoice=invoice,
amount_in_cents=deduction,
)
request.dbsession.add(gift_card)
request.dbsession.add(txn)
# Create GiftCard records for gift card purchases in the cart
gc_purchases = json.loads(cart.json_gift_cards or "[]")
created_gift_cards = []
if gc_purchases:
from ..models.shop import get_shop_by_id
from ..lib.mail import send_gift_card_email
for gc_item in gc_purchases:
shop = get_shop_by_id(request.dbsession, gc_item["shop_id"])
if shop:
# Find the invoice for this shop
invoice = None
for inv in invoices:
if str(inv.shop_id) == str(shop.id):
invoice = inv
break
new_gc = GiftCard(
shop=shop,
amount_in_cents=gc_item["amount_in_cents"],
purchaser_email=request.user.email if request.user else None,
gift_email=gc_item.get("gift_email"),
gift_message=gc_item.get("gift_message"),
invoice=invoice,
)
request.dbsession.add(new_gc)
created_gift_cards.append(new_gc)
# Send gift card email to recipient if gift_email is set
if gc_item.get("gift_email"):
try:
send_gift_card_email(request, new_gc)
except Exception:
pass # Don't fail checkout over email
# Clear gift card purchases from cart
cart.json_gift_cards = "[]"
return created_gift_cards
def get_cart_from_matchdict(request):
"""
This function uses the cart_id from the url path
@ -478,6 +550,13 @@ def cart_checkout(request):
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# Validate gift cards
gc_errors = cart.validate_attached_gift_cards()
if gc_errors:
for error_message in gc_errors:
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# Check handling options, inventory, and address for physical products
if cart.physical_products:
inventory_errors = cart.check_inventory(request.shop_location)
@ -638,6 +717,7 @@ def cart_complete_checkout(request):
return HTTPFound("/billing")
error_messages = cart.validate_attached_coupons()
error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@ -708,6 +788,7 @@ def cart_complete_checkout(request):
request.dbsession.add(invoice)
cart.update_inventory(request.shop_location)
_create_gift_card_transactions(cart, invoices, request)
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
@ -768,6 +849,7 @@ def paypal_complete_checkout(request):
return HTTPFound(get_referer_or_home(request))
error_messages = cart.validate_attached_coupons()
error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@ -920,6 +1002,9 @@ def paypal_complete_checkout(request):
invoice.total,
)
if successful_invoices:
_create_gift_card_transactions(cart, successful_invoices, request)
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:
@ -1046,6 +1131,7 @@ def adyen_complete_checkout(request):
return HTTPFound(get_referer_or_home(request))
error_messages = cart.validate_attached_coupons()
error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@ -1147,6 +1233,9 @@ def adyen_complete_checkout(request):
invoice.total,
)
if successful_invoices:
_create_gift_card_transactions(cart, successful_invoices, request)
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:

View file

@ -0,0 +1,236 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_owner_required,
get_referer_or_home,
)
from ..models.gift_card import (
GiftCard,
get_gift_card_by_id,
get_gift_card_by_code,
get_gift_cards_by_shop,
)
from ..models.cart import get_cart_by_id
from ..lib.currency import validate_int, cents_to_dollars, dollars_to_cents
@view_config(route_name="gift_card_page", renderer="gift_card.j2")
def gift_card_page(request):
"""Gift card purchase page for a shop."""
shop = request.shop
if not shop or not shop.gift_card_enabled:
request.session.flash(("Gift cards are not available for this shop.", "error"))
return HTTPFound(get_referer_or_home(request))
# Handle balance check
balance_result = None
check_code = request.params.get("check_code", "").strip()
if check_code:
card = get_gift_card_by_code(request.dbsession, check_code, shop=shop)
if card and card.is_valid:
balance_result = {
"code": card.code,
"balance": card.balance,
"initial_amount": card.initial_amount,
}
elif card and card.disabled:
balance_result = {"error": "This gift card has been disabled."}
elif card and card.is_fully_redeemed:
balance_result = {"error": "This gift card has been fully redeemed."}
else:
balance_result = {"error": "Gift card not found for this shop."}
return {
"shop": shop,
"min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
"max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
"balance_result": balance_result,
}
@view_config(route_name="gift_card_add_to_cart", request_method="POST", require_csrf=True)
@user_required()
def gift_card_add_to_cart(request):
"""Add a gift card to the active cart."""
shop = request.shop
if not shop or not shop.gift_card_enabled:
request.session.flash(("Gift cards are not available for this shop.", "error"))
return HTTPFound(get_referer_or_home(request))
try:
amount_dollars = float(request.params.get("amount", "0"))
amount_in_cents = int(amount_dollars * 100)
except (ValueError, TypeError):
request.session.flash(("Invalid amount.", "error"))
return HTTPFound(get_referer_or_home(request))
if amount_in_cents < shop.gift_card_min_in_cents:
request.session.flash(
(f"Minimum gift card amount is ${cents_to_dollars(shop.gift_card_min_in_cents):,.2f}.", "error")
)
return HTTPFound(get_referer_or_home(request))
if amount_in_cents > shop.gift_card_max_in_cents:
request.session.flash(
(f"Maximum gift card amount is ${cents_to_dollars(shop.gift_card_max_in_cents):,.2f}.", "error")
)
return HTTPFound(get_referer_or_home(request))
gift_email = request.params.get("gift_email", "").strip() or None
gift_message = request.params.get("gift_message", "").strip() or None
# Store gift card purchase intent in cart's json_gift_cards
import json
cart = request.active_cart
gift_cards_data = json.loads(cart.json_gift_cards) if hasattr(cart, 'json_gift_cards') and cart.json_gift_cards else []
gift_cards_data.append({
"shop_id": shop.uuid_str,
"amount_in_cents": amount_in_cents,
"gift_email": gift_email,
"gift_message": gift_message,
})
cart.json_gift_cards = json.dumps(gift_cards_data)
request.dbsession.add(cart)
request.dbsession.flush()
request.session.flash(
(f"${amount_dollars:,.2f} gift card added to cart.", "success")
)
return HTTPFound(f"/cart/{cart.id}")
@view_config(route_name="gift_card_apply_to_cart", request_method="POST", require_csrf=True)
def gift_card_apply_to_cart(request):
"""Apply a gift card code to the active cart (for redemption at checkout)."""
code = request.params.get("gift_card_code", "").strip()
if not code:
request.session.flash(("Please enter a gift card code.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
gift_card = get_gift_card_by_code(request.dbsession, code)
if gift_card is None:
request.session.flash(("That gift card code does not exist.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
if gift_card.disabled:
request.session.flash(("That gift card has been disabled.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
if gift_card.balance_in_cents <= 0:
request.session.flash(("That gift card has no remaining balance.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
# Check if gift card's shop is in the cart
if gift_card.shop_uuid_str not in request.active_cart.shop_totals_in_cents:
request.session.flash(
("That gift card is not valid for any shop in your cart.", "error")
)
return HTTPFound(f"/cart/{request.active_cart.id}")
if gift_card not in request.active_cart.gift_cards:
request.active_cart.gift_cards.append(gift_card)
request.active_cart._bust_memoized_attributes()
request.dbsession.add(request.active_cart)
request.dbsession.flush()
msg = (
f"Gift card applied! Balance: ${gift_card.balance:,.2f}",
"success",
)
else:
msg = ("That gift card is already applied to your cart.", "info")
request.session.flash(msg)
return HTTPFound(f"/cart/{request.active_cart.id}")
@view_config(route_name="gift_card_remove_from_cart", request_method="POST", require_csrf=True)
def gift_card_remove_from_cart(request):
"""Remove a gift card from a cart."""
cart_id = request.params.get("cart_id")
gift_card_id = request.params.get("gift_card_id")
cart = get_cart_by_id(request.dbsession, cart_id)
gift_card = get_gift_card_by_id(request.dbsession, gift_card_id)
if cart is None:
msg = ("That cart does not exist.", "error")
elif gift_card is None:
msg = ("That gift card does not exist.", "error")
elif request.user and request.user.does_not_own_cart(cart):
msg = ("You do not own that cart.", "error")
elif request.user is None and cart.user is not None:
msg = ("You do not own this cart.", "error")
else:
if gift_card in cart.gift_cards:
cart.gift_cards.remove(gift_card)
cart._bust_memoized_attributes()
request.dbsession.add(cart)
request.dbsession.flush()
msg = ("Gift card removed from your cart.", "success")
else:
msg = ("That gift card was already removed from your cart.", "info")
request.session.flash(msg)
return HTTPFound(f"/cart/{cart_id}")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
# --- Shop Admin Views ---
@view_config(route_name="gift_card_manage", renderer="gift_card_manage.j2")
@shop_owner_required()
def gift_card_manage(request):
"""Gift card management page for shop owners."""
shop = request.shop
gift_cards = get_gift_cards_by_shop(request.dbsession, shop).all()
total_issued = sum(gc.initial_amount_in_cents for gc in gift_cards)
total_balance = sum(gc.balance_in_cents for gc in gift_cards)
return {
"gift_cards": gift_cards,
"total_issued_dollars": cents_to_dollars(total_issued),
"total_balance_dollars": cents_to_dollars(total_balance),
}
@view_config(route_name="gift_card_detail", renderer="gift_card_detail.j2")
@shop_owner_required()
def gift_card_detail(request):
"""Gift card detail page for shop owners."""
gift_card = get_gift_card_by_id(request.dbsession, request.matchdict["gift_card_id"])
if gift_card is None:
request.session.flash(("Gift card not found.", "error"))
return HTTPFound(get_referer_or_home(request))
from ..models.gift_card_transaction import GiftCardTransaction as GCT
transactions = list(gift_card.transactions)
return {
"gift_card": gift_card,
"transactions": transactions,
}
@view_config(route_name="gift_card_toggle", request_method="POST", require_csrf=True)
@shop_owner_required()
def gift_card_toggle(request):
"""Enable/disable a gift card (admin kill switch)."""
gift_card = get_gift_card_by_id(request.dbsession, request.matchdict["gift_card_id"])
if gift_card is None:
request.session.flash(("Gift card not found.", "error"))
return HTTPFound(get_referer_or_home(request))
gift_card.disabled = not gift_card.disabled
request.dbsession.add(gift_card)
request.dbsession.flush()
status = "disabled" if gift_card.disabled else "enabled"
request.session.flash((f"Gift card {gift_card.code} {status}.", "success"))
return HTTPFound(get_referer_or_home(request))

View file

@ -1022,6 +1022,37 @@ def shop_settings(request):
except (ValueError, TypeError):
request.session.flash(("Invalid high risk threshold", "error"))
# Handle gift card settings
if form_section == "gift-card-settings":
gc_enabled_checkbox = request.params.get("gift-card-enabled-checkbox", "off")
gc_enabled = checkbox_to_bool(gc_enabled_checkbox)
if shop.gift_card_enabled != gc_enabled:
shop.gift_card_enabled = gc_enabled
status = "enabled" if gc_enabled else "disabled"
request.session.flash((f"Gift cards {status}.", "success"))
try:
gc_min = float(request.params.get("gift_card_min", "5.00"))
gc_max = float(request.params.get("gift_card_max", "250.00"))
gc_min_cents = int(gc_min * 100)
gc_max_cents = int(gc_max * 100)
if gc_min_cents < 100:
request.session.flash(("Minimum gift card amount must be at least $1.00.", "error"))
elif gc_max_cents < gc_min_cents:
request.session.flash(("Maximum must be greater than or equal to minimum.", "error"))
elif gc_max_cents > 1000000:
request.session.flash(("Maximum gift card amount cannot exceed $10,000.", "error"))
else:
if shop.gift_card_min_in_cents != gc_min_cents:
shop.gift_card_min_in_cents = gc_min_cents
request.session.flash((f"Gift card minimum set to ${gc_min:,.2f}.", "success"))
if shop.gift_card_max_in_cents != gc_max_cents:
shop.gift_card_max_in_cents = gc_max_cents
request.session.flash((f"Gift card maximum set to ${gc_max:,.2f}.", "success"))
except (ValueError, TypeError):
request.session.flash(("Invalid gift card amount.", "error"))
# If we processed any form submission, respond accordingly
if form_section:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
@ -1214,6 +1245,9 @@ def shop_settings(request):
"mirror_s3_access_key": shop.mirror_s3_access_key or "",
"mirror_s3_secret_key": shop.mirror_s3_secret_key or "",
"mirror_s3_enabled": shop.mirror_s3_enabled,
"gift_card_enabled": shop.gift_card_enabled,
"gift_card_min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
"gift_card_max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}