Subscription Apps Built With AI: The Billing Edge Cases Your Generator Skipped
Proration on upgrade, cancel at period end versus immediate, trial expiry, failed payments, and the reactivation door that swings one way. The Stripe subscription paths your AI builder never wrote.
Every SaaS that ships out of Lovable, Cursor, Bolt, or v0 in 2026 has the same Stripe integration. A checkout button, a webhook endpoint that listens for checkout.session.completed, a row in the database that flips a user's role to "pro" or "team" when the event lands, and a pricing page that reads from a couple of hardcoded price IDs. The code compiles, the test card goes through, the user shows up on the dashboard, and the founder moves on to the next feature.
Nothing about that integration is wrong. It is just incomplete in ways that will not surface until a real customer upgrades mid-period, churns because their card failed, tries to come back after cancelling, or burns through their trial on a holiday weekend when nobody is reading Slack. These are the moments where the generator's happy-path code meets the messy reality of recurring revenue, and the moments where most AI-built SaaS products quietly leak money, access, and trust.
This article walks through the edge cases a Stripe subscription has to survive in production. It is written for the founder who already has a working checkout and now has to decide how much of the rest they need before their first upgrade, their first chargeback, and their first churn-and-return user. The code examples use the Stripe Node SDK but the concepts are identical in every language.
The Subscription States Your Webhook Handler Has Never Seen
A Stripe Subscription object has a status field that can hold nine different values. The two your AI-generated code handles are active and canceled. The other seven are where production bugs live.
trialing is the state during a free trial. The subscription exists, the customer is a real record, but no money has moved yet and the status transitions to active automatically when trial_end passes if a valid payment method is on file. incomplete is the first 23 hours of a subscription whose initial payment has not yet succeeded, often because of a 3D Secure challenge the customer has not completed. If those 23 hours elapse without a successful charge, the subscription moves to incomplete_expired, which is terminal. past_due means the most recent invoice has failed and Smart Retries are attempting to recover. unpaid means retries have exhausted and the subscription is being kept alive as a zombie, accessible for reactivation but with access revoked in your application. paused is a state your app enters when you use Stripe's pause-collection feature, typically after a trial ends with no payment method. canceled is the terminal state.
The mental model that matters for application code is that active and trialing both grant access, past_due is a grace-period decision you own, and everything else revokes it. Writing a single helper like the following, used consistently across your backend, is worth more than almost any other billing code you will write.
function hasAccess(sub: Stripe.Subscription): boolean {
if (sub.status === 'active' || sub.status === 'trialing') return true;
if (sub.status === 'past_due') {
const graceHours = 48;
const since = Date.now() - sub.current_period_end * 1000;
return since < graceHours * 60 * 60 * 1000;
}
return false;
}
The grace-period choice is yours. Revoking access the instant a card fails is technically correct and practically cruel, because roughly a third of failed initial retries succeed within two days and those users will have already churned in frustration by the time the retry lands. A 48 to 72 hour grace window paired with in-app banners and email reminders recovers meaningful revenue with almost no fraud exposure.
Proration on Upgrade: Why proration_behavior Is Quietly Costing You
Suppose your customer is on the $29 plan, they are 10 days into a 30-day billing cycle, and they click Upgrade to the $99 plan. What should happen? If you call stripe.subscriptions.update with just the new price and nothing else, Stripe will charge them the difference on their next monthly invoice, 20 days from now. No immediate charge. The upgrade effectively costs nothing today. Most founders are surprised by this, and a meaningful number of them have shipped exactly this flow without realizing it.
The reason is the proration_behavior parameter, which defaults to create_prorations. Stripe has three options and each one produces a different cash-flow outcome:
// Default: queues proration, customer is charged the delta on next invoice
await stripe.subscriptions.update(subId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
// No proration: new price takes effect at next cycle, no delta charge
await stripe.subscriptions.update(subId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'none',
});
// Immediate invoice: charges the delta right now and switches the plan
await stripe.subscriptions.update(subId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'always_invoice',
});
For an upgrade, always_invoice is almost always the right answer. The customer made a commitment, the value is real today, and waiting three weeks to collect turns into a measurable revenue line item when you multiply it across a year of upgrades. For a downgrade, create_prorations is usually correct because you want the credit to reduce the next invoice rather than cutting a refund. none is only correct in cases where you deliberately want a clean switch at the cycle boundary, which is rare.
A common pattern we retrofit into AI-built apps is to preview the invoice before confirming the change, so the customer sees exactly what they will pay. Stripe supports this through invoices.retrieveUpcoming with a subscription_proration_date parameter. Showing the user a clear "You will be charged $46.33 today to upgrade for the rest of this cycle" modal eliminates the support tickets that otherwise follow every proration event.
Cancel at Period End Versus Cancel Immediately
When a user clicks Cancel, your code has three reasonable things it can do, and picking the wrong one causes a noticeable share of billing disputes.
The soft cancel, and the one you almost always want, is to set cancel_at_period_end to true. The subscription keeps running, the user keeps access until the period they have already paid for ends, and no refund is issued. When the period ends, Stripe transitions the subscription to canceled and fires customer.subscription.deleted.
await stripe.subscriptions.update(subId, { cancel_at_period_end: true });
The hard cancel is to terminate immediately. By default no refund is issued, which means the customer loses access to time they have paid for, which leads to chargebacks. If you intend immediate cancellation, you should almost always pair it with a prorated refund.
await stripe.subscriptions.cancel(subId, {
invoice_now: true,
prorate: true,
});
The third option, which is under-used, is a scheduled future cancel using cancel_at set to a specific timestamp. This is useful when a customer says "cancel at the end of the year" or when you negotiate a contracted wind-down date.
The important application-layer behavior is that a subscription with cancel_at_period_end: true is still active and still grants access. Your access helper needs to treat it the same as any other active subscription until current_period_end actually passes. A surprising number of AI-generated handlers see cancel_at_period_end in the webhook payload and revoke access immediately, which is the exact opposite of what the customer expects and what they have paid for.
Trial Expiry: The Two Webhook Sequence Nobody Handles
Trials are where subscription code starts to look like state machines. A properly handled trial flow listens to at least three webhooks and updates application state on each one.
Three days before the trial ends, Stripe fires customer.subscription.trial_will_end. This is the signal to send a reminder email, prompt the customer in-app to confirm their payment method, and surface a "your trial ends in 3 days" banner. AI-generated apps almost never listen to this event, which is why trials feel abrupt and churn-inducing even when the product is good.
When trial_end actually arrives, two things happen in rapid succession. First, customer.subscription.updated fires with the status transitioning from trialing to active and previous_attributes.status: 'trialing' in the payload. Second, if a payment method is on file, Stripe attempts the first real charge and fires invoice.payment_succeeded or invoice.payment_failed depending on the result. If no payment method is on file, the behavior depends on your trial_settings.end_behavior.missing_payment_method configuration, which can be cancel, create_invoice, or pause.
A robust handler looks at previous_attributes on customer.subscription.updated to tell what actually changed. The same webhook fires for plan changes, cancel-scheduled, status transitions, and a dozen other events, and naive code that just re-reads the whole subscription without diffing misses the specific transition.
if (event.type === 'customer.subscription.updated') {
const sub = event.data.object as Stripe.Subscription;
const prev = event.data.previous_attributes as Partial<Stripe.Subscription>;
if (prev.status === 'trialing' && sub.status === 'active') {
await onTrialConverted(sub);
}
if (prev.status === 'active' && sub.status === 'past_due') {
await onPaymentFailed(sub);
}
if (prev.cancel_at_period_end === false && sub.cancel_at_period_end === true) {
await onCancelScheduled(sub);
}
if (prev.cancel_at_period_end === true && sub.cancel_at_period_end === false) {
await onCancelRescinded(sub);
}
}
Treating customer.subscription.updated as a single-purpose event is the most common bug we find in AI-generated billing code. It fires for everything, and the previous_attributes diff is the only reliable way to route it.
Failed Payments, Smart Retries, and the Dunning Schedule
When a recurring charge fails, Stripe does not immediately cancel the subscription. It moves into past_due and Smart Retries takes over. Smart Retries is Stripe's machine-learned retry schedule that tries the card again at statistically optimal intervals, typically four attempts spread over roughly three weeks. You can see the configuration in Billing settings and adjust the maximum number of retries and the final outcome.
The final outcome is the setting most AI-built apps have never looked at. In the Stripe dashboard under Billing then Automatic collection, there is a dropdown labeled "If all retries for a payment fail". The three options are to mark the subscription as unpaid, to cancel it, or to leave it as past_due. The default in new accounts is usually to mark as unpaid, which keeps the customer record alive but the subscription in a state where your application should revoke access. This is what enables a recovery flow where the customer can come back, update their card, and resume without creating a new subscription.
Parallel to Smart Retries is the dunning email schedule. Stripe can send emails to the customer on each failed attempt, and the schedule is configurable under Billing then Customer emails. The default cadence of an initial failure notice, a reminder at day three, a second at day seven, and a final notice before cancellation recovers a meaningful percentage of failed payments without any code on your side. If you have not turned these on, you are leaving money on the table.
Inside your application, the events to listen for are invoice.payment_failed on each retry, invoice.payment_action_required when 3D Secure is needed and the customer must return to authenticate, and invoice.payment_succeeded on eventual recovery. The 3D Secure one is the stealth bug: it looks like any other failure, but it requires user action rather than a new card, and the recovery path is an emailed confirmation link rather than a card update. If your app treats it the same as a generic failure, the customer will replace a perfectly good card and get frustrated when the new one fails too.
if (event.type === 'invoice.payment_action_required') {
const invoice = event.data.object as Stripe.Invoice;
const hostedUrl = invoice.hosted_invoice_url;
await sendActionRequiredEmail(invoice.customer as string, hostedUrl);
}
The One-Way Door Nobody Warned You About
A customer cancels. A week later they change their mind and email support asking to come back. What your code does in the next three minutes determines whether they end up on their old plan with their old history, or a duplicate Stripe customer with a fresh trial they should not get.
There are two reactivation paths and they are not interchangeable.
If the subscription still has cancel_at_period_end: true and current_period_end is in the future, the customer has not actually been cancelled yet. They are still active and still paying. The reactivation is a one-line update that flips the flag back:
await stripe.subscriptions.update(subId, { cancel_at_period_end: false });
No new charge, no new billing cycle, no disruption. The customer keeps their original period, their original price, and their original history.
If the subscription status is already canceled, the door has closed. You cannot reactivate a cancelled Stripe subscription. The object is terminal. To bring the customer back you must create a new subscription on the same customer record. This is where the bugs compound. You need to reuse the existing customer.id rather than creating a new customer. You need to suppress the trial that your default subscription-creation code would grant, because this customer has already used their trial. You need to decide whether to offer their old plan, a new plan, or a win-back discount. And you need to make sure your user record in your own database is linked to the new subscription ID, not the old one, or access checks will silently fail.
async function winBackCustomer(userId: string, priceId: string) {
const user = await db.users.findOne({ id: userId });
const sub = await stripe.subscriptions.create({
customer: user.stripe_customer_id,
items: [{ price: priceId }],
trial_period_days: 0, // no repeat trial
metadata: { winback: 'true', previous_sub: user.stripe_subscription_id },
});
await db.users.update({ id: userId }, { stripe_subscription_id: sub.id });
return sub;
}
The two-path logic, the no-repeat-trial enforcement, and the customer-ID reuse are almost always missing from AI-generated code, because the generator has no concept of the subscription lifecycle beyond the initial creation.
The Billing Hardening Pass
If you have shipped a subscription app with an AI builder and read this far, there is a concrete list of changes that transforms the integration from a demo into something that survives a year of real customers.
Event ordering is not guaranteed, so your handler needs to be idempotent. Store the Stripe event ID in a deduplication table and reject duplicates. Never trust the webhook payload as the source of truth for current subscription state. Always re-fetch from the Stripe API when deciding what the user should see. Route customer.subscription.updated using previous_attributes, not by re-reading the whole object. Turn on Stripe's dunning emails and Smart Retries and pick the failure-end-behavior that matches your product. Preview invoices before any plan change so the customer sees the exact charge. Treat cancel_at_period_end: true as still-active in your access checks. And write the two-path reactivation flow so returning customers land on their old plan without getting a second trial.
This is the last-mile work we do as WitsCode. The AI-generated checkout gets you to revenue. The hardening pass gets you to retention. If you are sitting on a Lovable or Cursor-generated Stripe integration and any of the sections above described your code, we audit the webhook handlers, wire the missing events, and ship the reactivation path in a week.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
MVP Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss vibe coders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.