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