# Subscription Billing Flow

> Last updated: 13 March 2026

---

## Overview

Products can be purchased on either a **monthly** or **yearly** subscription. Both plans are
processed through Stripe. Tax is calculated server-side via `TaxService`. The **gross amount
(subtotal + tax)** is baked into the Stripe recurring unit price so the customer is charged the
correct total on every renewal cycle. Tax is **never** used as the basis for affiliate commission.

---

## Variables involved at checkout

| Variable | Value | Purpose |
|---|---|---|
| `$baseSubtotal` | `Cart::total()` | Sum of all cart item unit prices (monthly rate) |
| `$subtotal` | monthly: `$baseSubtotal` / yearly: `$baseSubtotal × 10` | Plan-adjusted pre-tax product amount |
| `$taxAmount` | `TaxService::calculateTaxAmount($subtotal, $tax->rate)` | Tax owed based on billing country/state |
| `$total` | `$subtotal + $taxAmount` | Total actually owed by the customer |
| `$amountInCents` | `round($total, 2) × 100` | **Gross** amount sent to Stripe as the subscription unit price (subtotal + tax) |

---

## What a monthly subscription charge includes

When a customer selects **monthly**, Stripe creates a recurring price with:

```
unit_amount = $total (subtotal + tax, in cents)
interval    = month
```

**The customer is charged `$total` per billing cycle by Stripe.**

Tax (`$taxAmount`) is:
- Calculated by `TaxService` at checkout time using the customer's billing country and province
- Stored on the `Payment` record in the `tax_amount` and `total_amount` columns
- Shown to the customer at checkout (the "Total" line)
- **Embedded in the Stripe subscription unit price** so renewals charge the same gross amount
- Locked at the rate assessed at first purchase — rate changes require recreating the subscription

The **tax rate** (as a decimal, e.g. `0.13`) is stored in the Stripe subscription's `metadata`
field (`metadata.tax_rate`) so that `CashierRenewalBridge` can back-calculate the pre-tax subtotal
on each renewal invoice for affiliate commission purposes.

### Where tax is collected / stored

| Location | What is stored |
|---|---|
| `payments.amount` | Pre-tax subtotal (`$subtotal`) |
| `payments.tax_amount` | Tax amount at time of purchase (`$taxAmount`) |
| `payments.total_amount` | Full amount shown to customer (`$total = $subtotal + $taxAmount`) |
| `payments.tax_rate_name` | Name of the tax rate applied (e.g. "CA GST") |
| `payments.amount_paid` | Gross amount charged (`$total = $subtotal + $taxAmount`) — matches what Stripe charges |
| Stripe subscription price | Gross amount (`$amountInCents = $total × 100`) |
| Stripe subscription metadata | `tax_rate` decimal string (e.g. `"0.13"`) — written at creation, read by renewal bridge |

Stripe charges the gross amount on every renewal cycle. The tax rate is fixed at first-purchase
time. If a customer's applicable rate changes, the subscription must be cancelled and recreated
to update the baked-in tax component. **Stripe Tax is not used.**

---

## What a yearly subscription charge includes

Yearly is identical in structure but `$subtotal = $baseSubtotal × 10`:

```
unit_amount = ($baseSubtotal × 10 + $taxAmount) in cents  (gross)
interval    = year
```

The `billing_interval` column on the `orders` table records which interval was chosen. Each
`OrderItem.price` also stores the plan-adjusted pre-tax unit price so `order->total()` always
reflects the actual charge.

---

## Affiliate commission — what amount is used

Commission is calculated exclusively on the **pre-tax subtotal** at every touch point:

### Initial purchase (`OrderCompletedBridge` → `PaymentReceived`)

```
order->total() = sum of OrderItem prices (all stored pre-tax)
               = $subtotal
```

`PaymentReceived.amount` = `$subtotal` → commission calculated on that. ✅

### Subscription renewal (`CashierRenewalBridge` → `SubscriptionRenewalPaid`)

Stripe's `invoice.payment_succeeded` webhook carries the **gross** amount (subtotal + tax) in
`invoice['amount_due']`. The bridge retrieves the Stripe subscription object to read
`metadata.tax_rate`, then back-calculates the pre-tax subtotal:

```php
$stripeSub = $stripe->subscriptions->retrieve($invoice['subscription']);
$taxRate   = (float) $stripeSub->metadata['tax_rate'];

// Gross = subtotal × (1 + rate)  →  subtotal = gross / (1 + rate)
$subtotalAmount = ($amountDueCents / (1 + $taxRate)) / 100;
```

**If `metadata.tax_rate` is absent, commission is skipped entirely. There is no fallback.**
This guarantees affiliate commission is never accidentally calculated on a gross amount. ✅

---

## OrderItem and Order

| Column | Value written |
|---|---|
| `order_items.price` | Plan-adjusted pre-tax unit price (`$unitPrice`) — either `item->price` or `item->price × 10` |
| `orders.billing_interval` | `'monthly'` or `'yearly'` |
| `orders.total()` (computed) | Sum of `order_items.price × quantity` — always pre-tax |

`orders.total()` is used as the commission base for the initial purchase. It must never include tax.

---

## Summary: what goes to Stripe vs. what stays in the app

```
Customer pays:   $total = $subtotal + $taxAmount   (shown on checkout, charged by Stripe)
                 │                                  └── stored in payments.amount_paid / total_amount
                 │          $taxAmount              └── stored in payments.tax_amount
                 └──────────────────────────── $total × 100 → Stripe subscription unit price
                                              recurring each billing cycle forever

Affiliate earns: commission on $subtotal only (back-calculated from gross via metadata tax_rate)
```

---

## Files involved

| File | Role |
|---|---|
| `app/Http/Controllers/PaymentController.php` | Calculates `$subtotal`, `$taxAmount`, `$total`; creates Stripe price from `$total` (gross); stores `tax_rate` in Stripe subscription `metadata`; writes `Payment` record with full tax breakdown |
| `app/Listeners/Affiliate/OrderCompletedBridge.php` | Fires `PaymentReceived` with `order->total()` (pre-tax sum of order items) |
| `app/Listeners/Affiliate/CashierRenewalBridge.php` | Retrieves Stripe subscription metadata to read `tax_rate`; fires `SubscriptionRenewalPaid` with `(amount_due / (1 + tax_rate)) / 100`; skips commission entirely if `tax_rate` is absent |
| `packages/agencypulse/affiliate/src/Services/AffiliateTracker.php` | Calculates commission from the pre-tax amount it receives; has no knowledge of tax |
| `app/Services/TaxService.php` | Looks up applicable tax rate for a country/province; used only at checkout time |
| `database/migrations/2026_03_13_000002_add_billing_interval_to_orders_table.php` | Adds `billing_interval` to `orders` |
