Skip to main content

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.

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.
PlanMonthly price (placeholder)Free trial
Free$0None
Pro$29/mo14 days
Business$79/mo14 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.
EventWhat AspFox does
checkout.session.completedCreates 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.updatedUpdates local subscription status, current period end, plan, and cancel_at_period_end flag. Idempotency: compares incoming values to current values before writing.
customer.subscription.deletedSets local status to Canceled. Idempotency: if already Canceled, no-op.
invoice.payment_failedSets local status to PastDue. Sends payment failed email. Creates SubscriptionPastDue notification.
invoice.payment_succeededIf status was PastDue, sets to Active. Sends payment recovered email. Creates PaymentRecovered notification. If already Active, no-op.
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.

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

FieldTypeDescription
TenantIdGuidWhich tenant this subscription belongs to
StripeCustomerIdstringStripe Customer ID (cus_…)
StripeSubscriptionIdstring?Stripe Subscription ID (sub_…); null on Free plan
PlanenumFree, Pro, or Business
StatusenumActive, Trialing, PastDue, Canceled, Paused
CurrentPeriodEndDateTime?When the current billing period ends
CancelAtPeriodEndboolWhether the subscription will cancel at period end
TrialEndsAtDateTime?When the trial expires; null if no trial
UpdatedAtDateTimeWhen 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.