From 5d501652c2ac8f4b59fd31854cd1773162c4e1df Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 7 Mar 2026 15:38:40 -0500 Subject: [PATCH] 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). --- docs/tickets/mps-10.md | 79 ++++++ docs/tickets/mps-11.md | 76 ++++++ docs/tickets/mps-12.md | 84 +++++++ docs/tickets/mps-13.md | 74 ++++++ make_post_sell/lib/mail.py | 47 ++++ make_post_sell/models/__init__.py | 4 + make_post_sell/models/cart.py | 74 +++++- make_post_sell/models/cart_gift_card.py | 34 +++ make_post_sell/models/gift_card.py | 108 ++++++++ .../models/gift_card_transaction.py | 46 ++++ make_post_sell/models/meta.py | 3 + make_post_sell/models/shop.py | 13 + make_post_sell/routes.py | 9 + ..._add_gift_card_tables_and_shop_settings.py | 108 ++++++++ make_post_sell/static/css/common.css | 72 ++++++ make_post_sell/templates/actions_view.j2 | 6 + make_post_sell/templates/cart.j2 | 52 +++- make_post_sell/templates/gift_card.j2 | 107 ++++++++ make_post_sell/templates/gift_card_detail.j2 | 72 ++++++ make_post_sell/templates/gift_card_manage.j2 | 69 +++++ make_post_sell/templates/shop_settings.j2 | 41 +++ make_post_sell/templates/snippets/footer.j2 | 3 + make_post_sell/tests/test_models.py | 94 +++++++ make_post_sell/views/cart.py | 89 +++++++ make_post_sell/views/gift_card.py | 236 ++++++++++++++++++ make_post_sell/views/shop.py | 34 +++ 26 files changed, 1627 insertions(+), 7 deletions(-) create mode 100644 docs/tickets/mps-10.md create mode 100644 docs/tickets/mps-11.md create mode 100644 docs/tickets/mps-12.md create mode 100644 docs/tickets/mps-13.md create mode 100644 make_post_sell/models/cart_gift_card.py create mode 100644 make_post_sell/models/gift_card.py create mode 100644 make_post_sell/models/gift_card_transaction.py create mode 100644 make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py create mode 100644 make_post_sell/templates/gift_card.j2 create mode 100644 make_post_sell/templates/gift_card_detail.j2 create mode 100644 make_post_sell/templates/gift_card_manage.j2 create mode 100644 make_post_sell/views/gift_card.py diff --git a/docs/tickets/mps-10.md b/docs/tickets/mps-10.md new file mode 100644 index 0000000..a831d92 --- /dev/null +++ b/docs/tickets/mps-10.md @@ -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. diff --git a/docs/tickets/mps-11.md b/docs/tickets/mps-11.md new file mode 100644 index 0000000..b5e0c35 --- /dev/null +++ b/docs/tickets/mps-11.md @@ -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). diff --git a/docs/tickets/mps-12.md b/docs/tickets/mps-12.md new file mode 100644 index 0000000..b7d8989 --- /dev/null +++ b/docs/tickets/mps-12.md @@ -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). diff --git a/docs/tickets/mps-13.md b/docs/tickets/mps-13.md new file mode 100644 index 0000000..0f62558 --- /dev/null +++ b/docs/tickets/mps-13.md @@ -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). diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 5cf97ca..2a85317 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -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"

You received a {amount} gift card for {shop_name}!

", + f"

Your gift card code:

", + f"

{code}

", + ] + if gift_message: + html_parts.append(f"

{gift_message}

") + html_parts.extend([ + f"

To redeem, enter the code at checkout when shopping at {shop_name}.

", + f"

This gift card never expires.

", + ]) + message_html = "\n".join(html_parts) + + send_pyramid_email(request, to_email, subject, message_text, message_html) diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index 067b50a..5a31d77 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -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() diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index 0a10338..8dcc254 100644 --- a/make_post_sell/models/cart.py +++ b/make_post_sell/models/cart.py @@ -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. diff --git a/make_post_sell/models/cart_gift_card.py b/make_post_sell/models/cart_gift_card.py new file mode 100644 index 0000000..bbf2162 --- /dev/null +++ b/make_post_sell/models/cart_gift_card.py @@ -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() diff --git a/make_post_sell/models/gift_card.py b/make_post_sell/models/gift_card.py new file mode 100644 index 0000000..4474713 --- /dev/null +++ b/make_post_sell/models/gift_card.py @@ -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()) + ) diff --git a/make_post_sell/models/gift_card_transaction.py b/make_post_sell/models/gift_card_transaction.py new file mode 100644 index 0000000..c609577 --- /dev/null +++ b/make_post_sell/models/gift_card_transaction.py @@ -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) diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index b949635..e60f67b 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -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", } diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 2c82433..827b40c 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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( diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 86fa8da..7d9f88f 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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:.*}") diff --git a/make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py b/make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py new file mode 100644 index 0000000..d1e5ac1 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py @@ -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") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 8130776..322bc67 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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; +} diff --git a/make_post_sell/templates/actions_view.j2 b/make_post_sell/templates/actions_view.j2 index 8d53372..eb134d2 100644 --- a/make_post_sell/templates/actions_view.j2 +++ b/make_post_sell/templates/actions_view.j2 @@ -17,6 +17,12 @@

  View Coupons + + {% if request.shop.gift_card_enabled %} +
+
+   Gift Cards + {% endif %} {% endif %} {% if request.is_saas_domain %} diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 index c806004..810bc4f 100644 --- a/make_post_sell/templates/cart.j2 +++ b/make_post_sell/templates/cart.j2 @@ -62,6 +62,25 @@
{% endfor %} + {% for gift_card in cart.gift_cards %} +
+ Gift Card: {{ gift_card.code }}
+ Balance: ${{ '{:,.2f}'.format(gift_card.balance) }} + ({{ gift_card.shop.name }}) + +
+
+ {% include "snippets/csrf.j2" %} + + + +
+
+
+
+
+ {% endfor %} + {% if cart.is_empty %}
@@ -186,10 +205,27 @@ {% endfor %} - {% endif %} - + {% for gc_item in cart.gift_card_purchases %} +
+
+ Gift Card +
+
+
+ Gift Card{% if gc_item.gift_email %} for {{ gc_item.gift_email }}{% endif %} + {% if gc_item.gift_message %}
{{ gc_item.gift_message }}{% endif %} +
+
+ ${{ '{:,.2f}'.format(gc_item.amount_in_cents / 100) }} +
- +
+ {% endfor %} + + {% endif %} + + + {% if not cart.is_empty %}
@@ -275,6 +311,16 @@ {% endif %} +
+

Gift Card

+
+ {% include "snippets/csrf.j2" %} + + +
+
+
+
Continue shopping
diff --git a/make_post_sell/templates/gift_card.j2 b/make_post_sell/templates/gift_card.j2 new file mode 100644 index 0000000..ae494a5 --- /dev/null +++ b/make_post_sell/templates/gift_card.j2 @@ -0,0 +1,107 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
+ +
+

Gift Card for {{ shop.name }}

+

Choose an amount and purchase a gift card. The recipient can redeem it at checkout.

+ +
+ {% include "snippets/csrf.j2" %} + + +
+ +
+ $ + +
+
+ + + + If provided, the gift card code will be emailed to this address. + +

+ + + + +

+ + +
+
+ +
+ +
+

Check Gift Card Balance

+
+ + +
+ + {% if balance_result %} + {% if balance_result.error %} +

{{ balance_result.error }}

+ {% else %} +

+ {{ balance_result.code }}
+ Balance: ${{ '{:,.2f}'.format(balance_result.balance) }} + (original: ${{ '{:,.2f}'.format(balance_result.initial_amount) }}) +

+ {% endif %} + {% endif %} +
+ +
+ + + +{%- endblock -%} diff --git a/make_post_sell/templates/gift_card_detail.j2 b/make_post_sell/templates/gift_card_detail.j2 new file mode 100644 index 0000000..607d111 --- /dev/null +++ b/make_post_sell/templates/gift_card_detail.j2 @@ -0,0 +1,72 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +All Gift Cards +{%- endblock call_to_action -%} + +{% block content -%} + +
+
+

Gift Card: {{ gift_card.code }}

+ + + + + + + + {% if gift_card.gift_message %} + + {% endif %} + +
Code{{ gift_card.code }}
Initial Amount${{ '{:,.2f}'.format(gift_card.initial_amount) }}
Balance${{ '{:,.2f}'.format(gift_card.balance) }}
Purchaser{{ gift_card.purchaser_email or '-' }}
Recipient{{ gift_card.gift_email or '-' }}
Message{{ gift_card.gift_message }}
Status + {% if gift_card.disabled %} + Disabled + {% elif gift_card.is_fully_redeemed %} + Fully Redeemed + {% else %} + Active + {% endif %} +
+ +
+ +
+ {% include "snippets/csrf.j2" %} + +
+
+ +
+ +
+

Transaction History

+ {% if transactions %} + + + + + + + + + + {% for txn in transactions %} + + + + + + {% endfor %} + +
DateAmountInvoice
{{ txn.created_timestamp }}-${{ '{:,.2f}'.format(txn.amount) }}{{ txn.invoice.uuid_str[:8] }}...
+ {% else %} +

No transactions yet.

+ {% endif %} +
+
+ +{%- endblock -%} diff --git a/make_post_sell/templates/gift_card_manage.j2 b/make_post_sell/templates/gift_card_manage.j2 new file mode 100644 index 0000000..52ecc7e --- /dev/null +++ b/make_post_sell/templates/gift_card_manage.j2 @@ -0,0 +1,69 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +Settings +{%- endblock call_to_action -%} + +{% block content -%} + +
+
+

Gift Cards

+

+ Total issued: ${{ '{:,.2f}'.format(total_issued_dollars) }} | + Outstanding balance: ${{ '{:,.2f}'.format(total_balance_dollars) }} +

+
+ +
+ + {% if gift_cards %} +
+ + + + + + + + + + + + + {% for gc in gift_cards %} + + + + + + + + + {% endfor %} + +
CodeAmountBalanceRecipientStatusActions
{{ gc.code }}${{ '{:,.2f}'.format(gc.initial_amount) }}${{ '{:,.2f}'.format(gc.balance) }}{{ gc.gift_email or gc.purchaser_email or '-' }} + {% if gc.disabled %} + Disabled + {% elif gc.is_fully_redeemed %} + Redeemed + {% else %} + Active + {% endif %} + +
+ {% include "snippets/csrf.j2" %} + +
+
+
+ {% else %} +
+

No gift cards issued yet.

+
+ {% endif %} +
+ +{%- endblock -%} diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index 824e65a..03b08a6 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -1122,6 +1122,47 @@ Existing sales honored for download buy purchasers.
+
+

Gift Cards

+ +
+ {% include "snippets/csrf.j2" %} + + + + +

+ + + + +

+ + + + +

+ + + +

+ + {% if gift_card_enabled %} + Manage Gift Cards + {% endif %} + +
+ +
+ diff --git a/make_post_sell/templates/snippets/footer.j2 b/make_post_sell/templates/snippets/footer.j2 index 7849bda..1151fd4 100644 --- a/make_post_sell/templates/snippets/footer.j2 +++ b/make_post_sell/templates/snippets/footer.j2 @@ -64,6 +64,9 @@