feat: generic checkout system — products table, orders table, dynamic form fields #127

Closed
opened 2026-03-20 07:08:55 +00:00 by forgejo_admin · 0 comments

Type

Feature

Lineage

plan-wkq → Phase 11 → girls jersey order + Phase 14 → contracts/billing foundation

Repo

forgejo_admin/basketball-api

User Story

As an admin
I want a reusable checkout system where I define products with custom form fields
So that every future payment type (jerseys, contracts, tournament fees) uses the same flow without new code

Context

Currently jersey ordering is hardcoded in jersey.py with fixed options and no custom field collection (number, sizes). Contracts (Phase 14) will need their own payment flow. Instead of building one-offs, we're building a generic system:

  • Products table: defines purchasable items with price, category, and a JSON custom_fields spec that drives the frontend form
  • Orders table: records every purchase with status tracking and JSON custom_data for form responses
  • Generic checkout endpoint: takes product_id + token + custom_data → Stripe session
  • Dynamic frontend: /checkout?token=...&category=jersey renders products + form fields from the DB

Jersey ordering becomes the first consumer. Contracts and billing tiers will follow using the same system.

Decisions made:

  • custom_fields is JSON on products — no separate fields table. Each entry: {key, label, type, required, options?}
  • custom_data is JSON on orders — stores form responses keyed by field key
  • Categories: jersey, contract, tournament, equipment (enum, extensible)
  • Product types: one_time, subscription, opt_out
  • Existing jersey_option/jersey_order_status on Player will be deprecated in favor of orders table
  • Keep existing registration_token auth pattern for tokenized links

File Targets

Files the agent should modify or create:

  • src/basketball_api/models.py — add Product and Order models, ProductType/ProductCategory/OrderStatus enums
  • alembic/versions/xxx_generic_checkout.py — create products + orders tables, seed jersey products, migrate existing jersey data to orders
  • src/basketball_api/routes/checkout.py — new generic checkout routes: GET /checkout/products?category=, POST /checkout/create-session, GET /checkout/orders
  • src/basketball_api/routes/webhooks.py — update checkout.session.completed handler to create/update orders
  • src/basketball_api/main.py — register checkout router

Files the agent should NOT touch:

  • src/basketball_api/routes/jersey.py — keep for backwards compat, deprecate later
  • src/basketball_api/services/email.py — email changes are separate
  • Frontend — separate issue

Acceptance Criteria

  • When I create a product with custom_fields JSON, it persists correctly
  • When I POST /checkout/create-session with product_id + token + custom_data, a Stripe Checkout Session is created
  • When a parent completes Stripe payment, the webhook creates an order with status=paid and custom_data preserved
  • When I GET /checkout/orders, I see all orders with product info and custom_data
  • Opt-out products skip Stripe and create order with status=paid directly
  • Jersey products are seeded: reversible ($90), warmup ($130), opt_out ($0) with custom_fields for number/top_size/shorts_size
  • Existing jersey data (5 players with jersey_option set) migrated to orders table

Test Expectations

  • Unit test: product CRUD with custom_fields JSON
  • Unit test: order creation with custom_data validation
  • Unit test: opt-out flow skips Stripe, creates paid order
  • Integration test: full checkout flow — create session → webhook → order paid
  • Integration test: products endpoint filters by category
  • Run command: pytest tests/ -v

Constraints

  • Follow existing SQLAlchemy 2.0 mapped_column style
  • Follow existing route patterns (Depends(get_db), Depends(require_admin) for admin endpoints)
  • JSON fields use sqlalchemy.dialects.postgresql.JSONB
  • Keep jersey.py working during transition (don't break existing links)
  • Migration must seed the 3 jersey products with correct custom_fields spec:
    [
      {"key": "jersey_number", "label": "Jersey Number", "type": "number", "required": true},
      {"key": "top_size", "label": "Jersey Top Size", "type": "select", "options": ["YS","YM","YL","S","M","L","XL"], "required": true},
      {"key": "shorts_size", "label": "Shorts Size", "type": "select", "options": ["YS","YM","YL","S","M","L","XL"], "required": true}
    ]
    
  • tofu plan -lock=false N/A — pure API change

Checklist

  • PR opened
  • Tests pass
  • No unrelated changes
  • Westside Basketball — project
  • Issue #126 — team announcement email (uses this for jersey links)
  • Issue #119 — contracts (next consumer of this system)
  • Phase 14 — billing tiers (future consumer)
### Type Feature ### Lineage `plan-wkq` → Phase 11 → girls jersey order + Phase 14 → contracts/billing foundation ### Repo `forgejo_admin/basketball-api` ### User Story As an admin I want a reusable checkout system where I define products with custom form fields So that every future payment type (jerseys, contracts, tournament fees) uses the same flow without new code ### Context Currently jersey ordering is hardcoded in `jersey.py` with fixed options and no custom field collection (number, sizes). Contracts (Phase 14) will need their own payment flow. Instead of building one-offs, we're building a generic system: - **Products table**: defines purchasable items with price, category, and a JSON `custom_fields` spec that drives the frontend form - **Orders table**: records every purchase with status tracking and JSON `custom_data` for form responses - **Generic checkout endpoint**: takes product_id + token + custom_data → Stripe session - **Dynamic frontend**: `/checkout?token=...&category=jersey` renders products + form fields from the DB Jersey ordering becomes the first consumer. Contracts and billing tiers will follow using the same system. **Decisions made:** - `custom_fields` is JSON on products — no separate fields table. Each entry: `{key, label, type, required, options?}` - `custom_data` is JSON on orders — stores form responses keyed by field key - Categories: jersey, contract, tournament, equipment (enum, extensible) - Product types: one_time, subscription, opt_out - Existing `jersey_option`/`jersey_order_status` on Player will be deprecated in favor of orders table - Keep existing `registration_token` auth pattern for tokenized links ### File Targets Files the agent should modify or create: - `src/basketball_api/models.py` — add Product and Order models, ProductType/ProductCategory/OrderStatus enums - `alembic/versions/xxx_generic_checkout.py` — create products + orders tables, seed jersey products, migrate existing jersey data to orders - `src/basketball_api/routes/checkout.py` — new generic checkout routes: `GET /checkout/products?category=`, `POST /checkout/create-session`, `GET /checkout/orders` - `src/basketball_api/routes/webhooks.py` — update `checkout.session.completed` handler to create/update orders - `src/basketball_api/main.py` — register checkout router Files the agent should NOT touch: - `src/basketball_api/routes/jersey.py` — keep for backwards compat, deprecate later - `src/basketball_api/services/email.py` — email changes are separate - Frontend — separate issue ### Acceptance Criteria - [ ] When I create a product with custom_fields JSON, it persists correctly - [ ] When I POST `/checkout/create-session` with product_id + token + custom_data, a Stripe Checkout Session is created - [ ] When a parent completes Stripe payment, the webhook creates an order with status=paid and custom_data preserved - [ ] When I GET `/checkout/orders`, I see all orders with product info and custom_data - [ ] Opt-out products skip Stripe and create order with status=paid directly - [ ] Jersey products are seeded: reversible ($90), warmup ($130), opt_out ($0) with custom_fields for number/top_size/shorts_size - [ ] Existing jersey data (5 players with jersey_option set) migrated to orders table ### Test Expectations - [ ] Unit test: product CRUD with custom_fields JSON - [ ] Unit test: order creation with custom_data validation - [ ] Unit test: opt-out flow skips Stripe, creates paid order - [ ] Integration test: full checkout flow — create session → webhook → order paid - [ ] Integration test: products endpoint filters by category - Run command: `pytest tests/ -v` ### Constraints - Follow existing SQLAlchemy 2.0 mapped_column style - Follow existing route patterns (Depends(get_db), Depends(require_admin) for admin endpoints) - JSON fields use `sqlalchemy.dialects.postgresql.JSONB` - Keep jersey.py working during transition (don't break existing links) - Migration must seed the 3 jersey products with correct custom_fields spec: ```json [ {"key": "jersey_number", "label": "Jersey Number", "type": "number", "required": true}, {"key": "top_size", "label": "Jersey Top Size", "type": "select", "options": ["YS","YM","YL","S","M","L","XL"], "required": true}, {"key": "shorts_size", "label": "Shorts Size", "type": "select", "options": ["YS","YM","YL","S","M","L","XL"], "required": true} ] ``` - `tofu plan -lock=false` N/A — pure API change ### Checklist - [ ] PR opened - [ ] Tests pass - [ ] No unrelated changes ### Related - `Westside Basketball` — project - Issue #126 — team announcement email (uses this for jersey links) - Issue #119 — contracts (next consumer of this system) - Phase 14 — billing tiers (future consumer)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/basketball-api#127
No description provided.