Updated finding, after re-reading the request: the trial doesn't get overwritten back to free by anything — it never leaves trial in the first place, because the in-app Billing tab has no code path that creates a subscription. That's also exactly why no transaction id or subscription id ever lands in the Purchase table for these accounts.
components/billing/BillingClient.js only wires three buttons — pay dues, update payment method, buy extra seats — and all three require purchase?.razorpaySubscriptionId to already exist. A trial account has no purchase row yet, so none of them apply; the tab just renders “No active subscription on file. Contact support to set one up.”
components/billing/BillingClient.js renders three cards, each backed by exactly one route. Read the guard on each:
Only rendered inside the red “payment pending” banner, which only shows when data.dues.hasDues is true — itself derived from an existing subscription's status. No subscription, no banner, no button. If hit directly via POST, lines 26–28 abort with 404: “No subscription found for this account.”
Hidden completely whenever sub.hasSubscription is false. A trial account has no purchase row, so it only sees “No active subscription on file. Contact support to set one up.” If invoked directly via POST, lines 19–21 reject with 404: “No subscription found for this account.”
The stepper and button do render on screen, but lines 49–51 immediately abort with 409: “An active subscription is required to add seats. Please contact support.” It cannot create a base subscription; it only modifies an existing mandate.
How the CRM classifies an account in app/(super-admin)/super-admin/page.js and scripts/billing/billing-accounts.js:
There is no isPaid column on the Account table. The system evaluates:
1. isPaid = !!purchaseMap[account.id] (where Purchase.status === 'paid')
2. isTrialActive = isTrial && trialEndsAt > now → Trial
3. isTrialExpired = isTrial && trialEndsAt <= now → Trial Expired
4. Fallthrough default → Free
The UI tells admins: “Turn off to mark as paid — clears trial expiry and stops the expire-trials cron from suspending it.” But turning it off only sets account.isTrial = false. It creates no Purchase row. The account has isPaid: false and isTrial: false, which immediately defaults to Free.
When a customer pays a subscription invoice from a hosted link, Razorpay fires invoice.paid. The webhook handler attaches accountId, but never updates Purchase.status = 'paid'. Because status remains 'created', the super-admin query ignores it, leaving the account labeled Free.
Three architectural adjustments to complete the trial-to-paid lifecycle:
In components/billing/BillingClient.js, when !sub.hasSubscription, render a direct “Subscribe to Professional Plan” button that calls /api/checkout/order with the tenant's existing accountId and email bound to the subscription notes.
In app/api/checkout/webhook/route.js, ensure invoice.paid updates Purchase.status = 'paid', sets paidAt: new Date(), and triggers clearBillingSuspension(accountId).
Replace the ambiguous toggle in EditAccountForm.js with an explicit “Provision Paid Subscription” action so manual promotions insert a verified Purchase row with status: 'paid'.