380 lines
14 KiB
Markdown
380 lines
14 KiB
Markdown
Music Store Management Platform
|
|
|
|
Domain Design: Payments & Billing
|
|
|
|
|
|
|
|
# 1. Overview
|
|
|
|
The Payments domain describes how the application integrates with Stripe for all payment flows. It covers one-time payments at POS, recurring subscription billing for lessons and rentals, webhook event handling, and the AIM migration payment transition strategy.
|
|
|
|
|
|
|
|
# 2. Processor Entity Model
|
|
|
|
Payment processor customer and payment method references are stored in processor-agnostic tables, not directly on the account. This supports running multiple processors simultaneously (e.g. during migration) and adding new processors without schema changes.
|
|
|
|
Processor Concept
|
|
|
|
Maps To
|
|
|
|
Notes
|
|
|
|
Customer (Stripe cus_xxx, GP customer token)
|
|
|
|
account_processor_link
|
|
|
|
One link per processor per account — multiple links allowed during migration
|
|
|
|
PaymentMethod (Stripe pm_xxx, GP payment token)
|
|
|
|
account_payment_method
|
|
|
|
Processor-agnostic — `processor` enum + `processor_payment_method_id`
|
|
|
|
Subscription
|
|
|
|
enrollment / rental
|
|
|
|
One per billing group or standalone
|
|
|
|
SubscriptionItem
|
|
|
|
enrollment / rental line
|
|
|
|
One per enrollment/rental within subscription
|
|
|
|
Invoice
|
|
|
|
monthly bill
|
|
|
|
Auto-generated by Stripe on billing date
|
|
|
|
PaymentIntent
|
|
|
|
transaction
|
|
|
|
One-time charges at POS
|
|
|
|
Reader
|
|
|
|
terminal device
|
|
|
|
Physical Stripe Terminal reader
|
|
|
|
|
|
|
|
# 3. Recurring Billing Flow
|
|
|
|
- Account created in app → Stripe Customer created via API → stripe_customer_id stored
|
|
|
|
- Enrollment or rental activated → Stripe Subscription created or line item added
|
|
|
|
- Billing date arrives → Stripe automatically charges payment method
|
|
|
|
- Stripe sends webhook: invoice.paid → app updates subscription status, records payment
|
|
|
|
- Stripe sends webhook: invoice.payment_failed → app flags account, triggers follow-up
|
|
|
|
- Stripe card updater silently updates expired/replaced cards → reduces failed payments
|
|
|
|
|
|
|
|
# 4. Webhook Events
|
|
|
|
Event
|
|
|
|
Action
|
|
|
|
invoice.paid
|
|
|
|
Record payment, update subscription status, generate receipt
|
|
|
|
invoice.payment_failed
|
|
|
|
Flag account, notify store staff, trigger retry logic
|
|
|
|
customer.subscription.deleted
|
|
|
|
Update enrollment/rental status to cancelled
|
|
|
|
payment_intent.succeeded
|
|
|
|
Confirm POS transaction, update inventory status
|
|
|
|
payment_intent.payment_failed
|
|
|
|
Revert cart, notify staff
|
|
|
|
charge.dispute.created
|
|
|
|
Alert store management, flag transaction
|
|
|
|
|
|
|
|
# 5. AIM Migration — Payment Transition
|
|
|
|
## 5.1 The Problem
|
|
|
|
Existing AIM customers on recurring billing (rentals and lessons) are on a legacy payment processor. Stripe tokens are processor-specific — they cannot be transferred. Customers must re-enter their card to move to Stripe.
|
|
|
|
|
|
|
|
## 5.2 Transition Strategy
|
|
|
|
- New customers: go directly to Stripe from day one
|
|
|
|
- Migrated customers: flagged requires_payment_update = true on payment method
|
|
|
|
- At each monthly renewal point, system prompts staff to collect new card
|
|
|
|
- Customer re-enters card via Stripe Elements — new Stripe subscription created
|
|
|
|
- Old processor subscription cancelled after successful Stripe charge confirmed
|
|
|
|
- Both processors run in parallel until all active recurring billing migrated
|
|
|
|
|
|
|
|
## 5.3 Old Processor Wind-Down
|
|
|
|
- Do not close old processor account until 120 days after last transaction
|
|
|
|
- Chargebacks can arrive up to 120 days after transaction — account must remain open
|
|
|
|
- Keep old processor credentials in Secrets Manager until confirmed closed
|
|
|
|
- All historical transaction IDs preserved in database with processor field
|
|
|
|
|
|
|
|
# 6. Payment Provider Billing Ownership
|
|
|
|
The two supported payment processors have fundamentally different recurring billing models. This difference is the most important architectural consideration in the payment provider abstraction.
|
|
|
|
## 6.1 Stripe — Provider-Managed Billing
|
|
|
|
Stripe owns the billing schedule. When a rental or enrollment is activated, the platform creates a Stripe Subscription object. Stripe automatically charges the customer's payment method on each billing cycle. The platform learns about charges via webhooks (`invoice.paid`, `invoice.payment_failed`).
|
|
|
|
- Platform creates/cancels subscriptions — Stripe handles the schedule and charging
|
|
- Stripe Card Updater silently refreshes expired cards — reduces failed payments
|
|
- Proration calculated by Stripe when billing dates change or subscriptions are modified mid-cycle — platform does not need its own proration logic for Stripe stores
|
|
- Platform is reactive — it responds to webhook events, not proactive billing
|
|
|
|
## 6.2 Global Payments — Platform-Managed Billing
|
|
|
|
Global Payments provides tokenized card storage and on-demand charge capability, but does not offer a subscription/recurring billing API. The platform must own the billing schedule entirely.
|
|
|
|
- Platform stores GP payment tokens on `account_payment_method`
|
|
- BullMQ cron job runs daily — queries all rentals/enrollments with `billing_anchor_day = today` for GP stores
|
|
- Job calls `provider.charge()` for each due item
|
|
- Failed charges retry with exponential backoff (3 attempts over 7 days)
|
|
- GP's Decline Minimizer helps reduce false declines but does not auto-update expired cards
|
|
- Proration and partial-month charges calculated by the platform's billing service, not the processor
|
|
- Mid-cycle additions, cancellations, and billing date changes all require the platform to compute prorated amounts and charge/credit accordingly
|
|
|
|
## 6.3 PaymentProvider Interface Impact
|
|
|
|
The `PaymentProvider` interface must account for this split:
|
|
|
|
- `managedSubscriptions: boolean` — Stripe: true, GP: false
|
|
- If true: `createSubscription()`, `addSubscriptionItem()`, `removeSubscriptionItem()`, `cancelSubscription()`, `changeBillingAnchor()` — the platform delegates billing lifecycle to the provider
|
|
- If false: `charge()` is the only billing method — the platform's BullMQ scheduler triggers charges on anchor days, handles retries, and manages the billing lifecycle internally
|
|
- Both providers share: `createCustomer()`, `charge()`, `refund()`, `collectPayment()` (terminal), `parseWebhookEvent()`
|
|
|
|
## 6.4 Billing Service Routing
|
|
|
|
The billing service checks `store.payment_processor` to determine the flow:
|
|
|
|
- Stripe store — enrollment/rental activation calls `provider.createSubscription()`. Billing is hands-off after that.
|
|
- GP store — enrollment/rental activation records the billing anchor day. The daily BullMQ job picks it up on the correct day and calls `provider.charge()`.
|
|
- Both paths produce the same internal records (`rental_payment`, `lesson_payment`) and trigger the same accounting journal entries.
|
|
|
|
|
|
|
|
# 7. Database Schema
|
|
|
|
## 6.1 stripe_webhook_event
|
|
|
|
id, event_id (Stripe's ID), event_type, payload (jsonb),processed_at, status (received|processed|failed), error_message, created_at
|
|
|
|
All incoming webhook events are stored before processing. This enables replay if processing fails.
|
|
|
|
|
|
|
|
# 8. Gift Cards & Store Credits (MOD-GIFTCARD)
|
|
|
|
Gift cards and store credits are a premium module. Gift cards are purchasable/redeemable stored-value instruments. Store credits are system-issued balances (from returns, trade-ins, or adjustments).
|
|
|
|
## 8.1 gift_card
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
company_id | uuid FK | Tenant scoping
|
|
card_number | varchar | Unique card identifier (printed on physical card or emailed)
|
|
pin | varchar | Nullable — optional PIN for security (hashed)
|
|
type | enum | physical | digital
|
|
initial_balance | numeric(10,2) | Original loaded amount
|
|
current_balance | numeric(10,2) | Remaining balance
|
|
status | enum | active | redeemed | expired | disabled
|
|
purchased_by_account_id | uuid FK | Nullable — who bought it
|
|
recipient_email | varchar | Nullable — for digital cards
|
|
recipient_name | varchar | Nullable
|
|
purchase_transaction_id | uuid FK | The sale transaction where card was purchased
|
|
expiry_date | date | Nullable — some jurisdictions prohibit expiry
|
|
is_reloadable | boolean | Default false
|
|
created_at | timestamptz |
|
|
updated_at | timestamptz |
|
|
|
|
## 8.2 gift_card_transaction
|
|
|
|
Append-only ledger of all balance changes on a gift card.
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
gift_card_id | uuid FK |
|
|
transaction_type | enum | purchase | reload | redemption | refund | adjustment | expiry
|
|
amount | numeric(10,2) | Positive for loads, negative for redemptions
|
|
balance_after | numeric(10,2) | Running balance after this transaction
|
|
related_transaction_id | uuid FK | Nullable — the POS transaction where redeemed/purchased
|
|
performed_by | uuid FK | Employee
|
|
notes | text |
|
|
created_at | timestamptz |
|
|
|
|
## 8.3 store_credit
|
|
|
|
Store credits are account-level balances issued by the system, not purchasable products.
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
company_id | uuid FK |
|
|
account_id | uuid FK | Account that holds the credit
|
|
reason | enum | return | trade_in | adjustment | promotion | gift_card_conversion
|
|
original_amount | numeric(10,2) |
|
|
remaining_balance | numeric(10,2) |
|
|
issued_by | uuid FK | Employee who issued
|
|
related_return_id | uuid FK | Nullable — if from a return
|
|
related_trade_in_id | uuid FK | Nullable — if from a trade-in
|
|
expires_at | timestamptz | Nullable — configurable expiry
|
|
status | enum | active | depleted | expired | voided
|
|
created_at | timestamptz |
|
|
updated_at | timestamptz |
|
|
|
|
## 8.4 store_credit_transaction
|
|
|
|
Append-only ledger for store credit balance changes.
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
store_credit_id | uuid FK |
|
|
transaction_type | enum | issued | applied | adjustment | expired | voided
|
|
amount | numeric(10,2) |
|
|
balance_after | numeric(10,2) |
|
|
related_transaction_id | uuid FK | Nullable
|
|
performed_by | uuid FK |
|
|
created_at | timestamptz |
|
|
|
|
## 8.5 POS Integration
|
|
|
|
- Gift card sold as a product at POS — triggers gift_card creation with initial balance
|
|
- Gift card redemption is a payment method: customer presents card, balance checked, amount deducted
|
|
- Partial redemption supported — remaining balance stays on card
|
|
- Split tender: gift card covers part, remaining on card/cash
|
|
- Store credit auto-applied: when account has credit balance, POS prompts "Apply $X.XX store credit?"
|
|
- Both gift card and store credit balances visible on account summary
|
|
|
|
## 8.6 Business Rules
|
|
|
|
- Gift card numbers generated with check digit to prevent typos
|
|
- Physical cards activated at POS — not active until purchased (prevents theft of unactivated cards)
|
|
- Digital cards emailed immediately with card number and optional message
|
|
- Gift card balance inquiries available at POS and customer portal
|
|
- Expiry rules vary by jurisdiction — configurable per company, default: no expiry
|
|
- Store credits cannot be cashed out — applied to purchases only
|
|
- All balance changes are append-only ledger entries — no direct balance edits
|
|
- Gift card liability tracked for accounting: total outstanding balances reported as liability
|
|
- Reloadable gift cards allow additional value to be added after purchase
|
|
|
|
|
|
|
|
# 9. Layaway & Payment Plans (MOD-LAYAWAY)
|
|
|
|
Layaway allows a customer to reserve an item with a deposit and pay it off over time. The item is held (not available for sale) until fully paid.
|
|
|
|
## 9.1 layaway
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
company_id | uuid FK | Tenant scoping
|
|
location_id | uuid FK |
|
|
layaway_number | varchar | Human-readable ID (auto-generated)
|
|
account_id | uuid FK |
|
|
status | enum | active | completed | defaulted | cancelled
|
|
total_price | numeric(10,2) | Full price of items on layaway
|
|
deposit_amount | numeric(10,2) | Initial deposit collected
|
|
amount_paid | numeric(10,2) | Total paid to date (including deposit)
|
|
balance_remaining | numeric(10,2) | total_price - amount_paid
|
|
payment_frequency | enum | weekly | biweekly | monthly
|
|
next_payment_date | date | Next scheduled payment
|
|
payment_amount | numeric(10,2) | Scheduled payment amount per period
|
|
max_duration_days | integer | Maximum days to complete layaway (e.g. 90)
|
|
expires_at | date | Deposit date + max_duration_days
|
|
cancellation_fee | numeric(10,2) | Fee charged if customer cancels (configurable)
|
|
notes | text |
|
|
created_by | uuid FK |
|
|
created_at | timestamptz |
|
|
updated_at | timestamptz |
|
|
|
|
## 9.2 layaway_item
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
layaway_id | uuid FK |
|
|
product_id | uuid FK |
|
|
inventory_unit_id | uuid FK | Nullable — for serialized items, unit is reserved
|
|
description | varchar |
|
|
qty | integer |
|
|
unit_price | numeric(10,2) |
|
|
line_total | numeric(10,2) |
|
|
created_at | timestamptz |
|
|
|
|
## 9.3 layaway_payment
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
layaway_id | uuid FK |
|
|
transaction_id | uuid FK | The POS transaction for this payment
|
|
amount | numeric(10,2) |
|
|
balance_after | numeric(10,2) | Remaining balance after payment
|
|
payment_number | integer | 1, 2, 3... sequential
|
|
is_deposit | boolean | True for initial deposit
|
|
paid_at | timestamptz |
|
|
created_at | timestamptz |
|
|
|
|
## 9.4 Layaway Workflow
|
|
|
|
1. Customer selects items — staff creates layaway with deposit (minimum deposit configurable, e.g. 20%)
|
|
2. Serialized items: inventory_unit.status set to "layaway" (held, not available for sale)
|
|
3. Non-serialized items: qty reserved (decremented from available)
|
|
4. Customer makes scheduled payments at POS — each payment recorded as layaway_payment
|
|
5. When balance reaches $0: layaway completed, items released to customer, standard sale transaction created
|
|
6. Missed payments: system sends reminders at configurable intervals
|
|
7. Default: after configurable missed payments or past expiry — manager reviews for cancellation
|
|
|
|
## 9.5 Cancellation
|
|
|
|
- Customer-initiated cancellation: deposit refunded minus cancellation fee, items returned to available inventory
|
|
- Default cancellation (non-payment): same as above, but store may retain more of deposit per policy
|
|
- Cancellation fee configurable per company — can be fixed amount or percentage of amount paid
|
|
- All payments refunded via original payment method or store credit (store's choice per policy)
|
|
|
|
## 9.6 Business Rules
|
|
|
|
- Minimum deposit enforced (configurable — default 20% of total)
|
|
- Items on layaway are held — not available for sale to other customers
|
|
- Price locked at layaway creation — price changes don't affect existing layaways
|
|
- Payment reminders sent before due date (configurable: 3 days before default)
|
|
- Overdue payments flagged on dashboard — staff follows up
|
|
- Layaway report: active layaways, total held value, upcoming payments, overdue accounts
|
|
- Maximum layaway duration configurable per company (default 90 days)
|
|
- Layaway items can be exchanged (size swap) with manager approval — price difference adjusted |