> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stripe Billing

> Three plans with free trials, Stripe Checkout, idempotent webhook processing, and Redis-cached subscription status.

## Plans and pricing

AspFox ships with three plans. The amounts are placeholders — you set your own prices in Stripe and update the Price ID environment variables. No code changes are needed to change pricing.

| Plan     | Monthly price (placeholder) | Free trial |
| -------- | --------------------------- | ---------- |
| Free     | \$0                         | None       |
| Pro      | \$29/mo                     | 14 days    |
| Business | \$79/mo                     | 14 days    |

Plans are configured in Stripe Dashboard. The Price IDs for Pro and Business are stored in `STRIPE_PRO_PRICE_ID` and `STRIPE_BUSINESS_PRICE_ID` in your `.env`. The Free plan has no Stripe Price ID — it uses local state only.

## Checkout flow

```
User clicks "Upgrade to Pro"
    │
    ▼
POST /api/v1/billing/checkout  { "planName": "Pro" }
    │
    ▼
BillingCheckoutCommandHandler
    │  looks up or creates Stripe Customer for this tenant
    │  stores customerId on the local Subscription record
    │  creates Stripe Checkout Session with:
    │    price_id: STRIPE_PRO_PRICE_ID
    │    trial_period_days: 14
    │    metadata: { tenantId: "<tenant-id>" }
    │    success_url: https://app.yourdomain.com/billing?success=true
    │    cancel_url: https://app.yourdomain.com/billing
    │
    ▼
Returns { checkoutUrl: "https://checkout.stripe.com/…" }
    │
    ▼
Frontend redirects to Stripe's hosted checkout page
    │
    ▼
User completes payment on Stripe
    │
    ▼
Stripe sends webhook: checkout.session.completed
    │
    ▼
POST /api/v1/webhooks/stripe
    │
    ▼
StripeWebhookHandler
    │  verifies Stripe-Signature header
    │  reads tenantId from session.metadata
    │  creates or updates local Subscription record
    │  sets status = Trialing (if trial) or Active
    │  caches status in Redis (5-minute TTL)
    │
    ▼
User redirected back to /billing?success=true
Frontend shows updated plan.
```

The `tenantId` in the Checkout Session metadata is what ties the Stripe event back to the right tenant in AspFox. Without it, webhooks would have no way to update the correct tenant's subscription.

## Webhook processing and idempotency

Stripe can deliver the same webhook more than once. It also retries on any non-2xx response. AspFox handles this with an idempotent processing pattern: every handler checks current state before making changes, so running the same handler twice with the same event produces the same result.

| Event                           | What AspFox does                                                                                                                                                                       |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `checkout.session.completed`    | Creates local Subscription record; sets status to `Trialing` or `Active` depending on whether a trial was started. Idempotency: checks if subscription already exists before creating. |
| `customer.subscription.updated` | Updates local subscription status, current period end, plan, and cancel\_at\_period\_end flag. Idempotency: compares incoming values to current values before writing.                 |
| `customer.subscription.deleted` | Sets local status to `Canceled`. Idempotency: if already `Canceled`, no-op.                                                                                                            |
| `invoice.payment_failed`        | Sets local status to `PastDue`. Sends payment failed email. Creates `SubscriptionPastDue` notification.                                                                                |
| `invoice.payment_succeeded`     | If status was `PastDue`, sets to `Active`. Sends payment recovered email. Creates `PaymentRecovered` notification. If already `Active`, no-op.                                         |

<Note>
  AspFox returns `200 OK` for all webhook events, including events it does not handle. Stripe requires a 2xx response to consider a webhook delivery successful. Returning non-2xx for unhandled events causes Stripe to retry indefinitely.
</Note>

## Subscription status middleware

`SubscriptionMiddleware` runs on every authenticated request and reads the current tenant's subscription status. It does not query the database on every request — it reads from a Redis cache with a 5-minute TTL.

```
Authenticated request
    │
    ▼
SubscriptionMiddleware
    │  Redis.Get("subscription:{tenantId}")
    │
    ├─ Cache HIT: deserializes and attaches to HttpContext.Items["Subscription"]
    │
    └─ Cache MISS: queries database, caches result, attaches to HttpContext.Items
    │
    ▼
Controller or handler can read:
    var sub = context.Items["Subscription"] as SubscriptionStatus;
    if (sub?.Plan == Plan.Free) { return PaymentRequired(); }
```

## Subscription entity fields

| Field                  | Type      | Description                                           |
| ---------------------- | --------- | ----------------------------------------------------- |
| `TenantId`             | Guid      | Which tenant this subscription belongs to             |
| `StripeCustomerId`     | string    | Stripe Customer ID (`cus_…`)                          |
| `StripeSubscriptionId` | string?   | Stripe Subscription ID (`sub_…`); null on Free plan   |
| `Plan`                 | enum      | `Free`, `Pro`, or `Business`                          |
| `Status`               | enum      | `Active`, `Trialing`, `PastDue`, `Canceled`, `Paused` |
| `CurrentPeriodEnd`     | DateTime? | When the current billing period ends                  |
| `CancelAtPeriodEnd`    | bool      | Whether the subscription will cancel at period end    |
| `TrialEndsAt`          | DateTime? | When the trial expires; null if no trial              |
| `UpdatedAt`            | DateTime  | When this record was last modified by a webhook       |

## Admin manual override

The admin panel has a manual subscription status toggle at `PATCH /api/v1/admin/tenants/{tenantId}/subscription`. This updates the local subscription record only — it does not touch Stripe.

Use this for support cases where Stripe and local state have diverged (e.g., a webhook was missed, or you manually refunded a charge in Stripe). The `SubscriptionSyncJob` runs every 2 hours and will re-sync with Stripe, so manual overrides may be reverted unless you also correct the state in Stripe.
