Why paying from Settings → Billing never moves a trial to paid

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.

!
There is no “Subscribe” action inside the app. 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.”
The Scenario Checked
“When a user pays from Billing, the account should change from trial to paid — it doesn't, and the transaction id / subscription id never gets set in the DB.”
01 · WHAT THE BILLING TAB CAN ACTUALLY DO

components/billing/BillingClient.js renders three cards, each backed by exactly one route. Read the guard on each:

01 PAY DUES
app/api/billing/pay-dues  ·  requires getAccountPurchase(accountId)

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.”

02 UPDATE PAYMENT METHOD
app/api/billing/update-method  ·  requires getAccountPurchase(accountId)

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.”

03 BUY EXTRA SEATS
app/api/billing/seats  ·  requires getAccountPurchase(accountId)

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.

02 · WHY ACCOUNTS SHOW UP AS "FREE" INSTEAD OF "PAID"

How the CRM classifies an account in app/(super-admin)/super-admin/page.js and scripts/billing/billing-accounts.js:

01 DYNAMIC EVALUATION SINK

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

02 THE SETTINGS TOGGLE TRAP
app/(super-admin)/super-admin/accounts/[id]/EditAccountForm.js (Lines 66–70)

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.

03 THE INVOICE.PAID WEBHOOK HOLE
app/api/checkout/webhook/route.js (Lines 324–340)

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.

03 · HOW TO RESOLVE THIS CLEANLY

Three architectural adjustments to complete the trial-to-paid lifecycle:

A NATIVE IN-APP SUBSCRIBE BUTTON

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.

B PATCH INVOICE.PAID WEBHOOK

In app/api/checkout/webhook/route.js, ensure invoice.paid updates Purchase.status = 'paid', sets paidAt: new Date(), and triggers clearBillingSuspension(accountId).

C CORRECT SETTINGS COPY & ACTION

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'.