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