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).
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
34
make_post_sell/models/cart_gift_card.py
Normal file
34
make_post_sell/models/cart_gift_card.py
Normal 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()
|
||||
108
make_post_sell/models/gift_card.py
Normal file
108
make_post_sell/models/gift_card.py
Normal 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())
|
||||
)
|
||||
46
make_post_sell/models/gift_card_transaction.py
Normal file
46
make_post_sell/models/gift_card_transaction.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:.*}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@
|
|||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">  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">  Gift Cards</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if request.is_saas_domain %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
107
make_post_sell/templates/gift_card.j2
Normal file
107
make_post_sell/templates/gift_card.j2
Normal 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 -%}
|
||||
72
make_post_sell/templates/gift_card_detail.j2
Normal file
72
make_post_sell/templates/gift_card_detail.j2
Normal 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 -%}
|
||||
69
make_post_sell/templates/gift_card_manage.j2
Normal file
69
make_post_sell/templates/gift_card_manage.j2
Normal 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 -%}
|
||||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
236
make_post_sell/views/gift_card.py
Normal file
236
make_post_sell/views/gift_card.py
Normal 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))
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue