Building software has become faster. A feature that took days to put together a few years ago can now be built in hours or a few days with mature frameworks, third-party APIs, and AI coding assistants.
That speed is useful, but it also makes it easier to move from "this works" to "let's ship it" before thinking through everything that has to happen around the feature.
I ran into this recently while working on subscription upgrades and downgrades. The first version of billing is usually straightforward. You may find yourself just defining the plans, connecting the payment provider, handling successful payments, and giving customers the right access.
That can work perfectly for months until someone needs to change plans or a payment fails in a way you did not account for.
The dangerous part is that these problems do not always break the application. Your endpoint can return 200 OK, the database can update correctly, and the UI can show the right plan while the customer is still in the wrong billing state.
That is where the interesting engineering starts.
The hardest single part of building a software system is deciding precisely what to build.
Start with what must remain true
Before writing an upgrade or downgrade flow, or really any flow that changes money or important state, I think it helps to forget the endpoint for a moment and write down what should be true when the change is finished.
For example:
- If a customer upgrades halfway through the month, they should not pay twice for the same period.
- If the upgrade payment fails, they should not lose the subscription they already paid for.
- If the change completes successfully, there should not be an older recurring subscription somewhere else that can still renew later.
Those sound obvious once they are written down, but they are easy to skip when you start from the implementation.

There is also no default answer that is correct for every product.
For the flow I was working on, we apply the new plan immediately, keep the same renewal date, and charge only the difference for the remaining time. If the charge fails, nothing changes. For downgrades, we start a new billing cycle immediately, with no refund for unused time on the previous plan.
Once those decisions were clear, the actual proration code becomes straightforward:
const unusedCredit = Math.round((fromPrice / daysInCycle) * daysRemaining);
const newPlanCostForRemaining = Math.round(
(toPrice / daysInCycle) * daysRemaining,
);
const netCharge = Math.max(0, newPlanCostForRemaining - unusedCredit);
Another product could reasonably make different choices. What matters is that those choices are made before the code starts making them for you.
A plan change is several operations pretending to be one
Once the rules are clear, the next thing to look at is how many systems have to agree before you can say the plan change actually happened.
The image below shows what a simple upgrade might involve:

From the UI, all of that is one button click. But from the backend, it is a sequence of side effects that can fail independently.
A simplified version might look harmless:
const charge = await chargeUpgrade(amount);
if (!charge.success) {
return;
}
await cancelCurrentSubscription();
await createReplacementSubscription();
await updateLocalPlan();
The interesting part of those few lines is what happens between them. For example, what if all the provider calls succeed and your database goes down before updateLocalPlan() completes?
Moving the charge first avoids one problem, but it does not eliminate failure entirely. The payment can succeed and the cancellation request can fail afterward. At that point, the customer has paid for the upgrade, but you may still have an older recurring subscription that can charge them again.
This is why the happy path is not enough when you review a flow like this. You need to stop after each external operation and ask what state the customer would be left in if the next line never ran.
There is also no ordering that makes two independent systems atomic. What you can do is choose safer failure states, make failures visible, and know how you will recover when one occurs.
Know what your billing provider already owns
Before you build all of this yourself, check how much of the plan-change lifecycle your billing provider already handles.
A full billing system such as Stripe Billing or Paddle can own a lot more than payment collection. They already have concepts for proration, billing-cycle changes, scheduled downgrades, failed-payment behavior, and customer-facing subscription management.
If those rules match your product, there is little value in recreating them inside your application.
The boundary is different when your provider gives you lower-level payment and subscription primitives.
With Paystack, for example, you can create subscriptions, charge a reusable authorization, set when recurring billing should begin, and disable an existing subscription. Those pieces are enough to build a mid-cycle upgrade flow, but the provider doesn't decide what an upgrade means for your product. Your application still has to coordinate the transition.
That distinction matters because the amount of code you need to write is not the same as the amount of billing logic you own.
A provider might reduce your implementation to three or four API calls, but if your application decides the order of those calls, you still own the partial states between them.
The bugs that still return 200 OK
While reviewing existing cancellation logic, I found that we were looking up the customer's current subscription with an identifier the provider didn't expect for that request.
The call returned 200 OK with an empty result, so our code assumed there was no subscription to cancel. But there was!
I only caught it because I ran an upgrade against the sandbox and then checked the payment provider separately. From our application's point of view, everything had worked, the customer's plan changed, the endpoint returned successfully, and nothing obvious showed up in the logs.
But… the previous recurring subscription was still active.
That is a more dangerous failure mode than an exception. An exception stops the flow and gives you something to investigate. A successful response can let the application continue with a bad assumption.
This is also where "almost right" code worries me more than obviously broken code.
Stack Overflow's 2025 Developer Survey found that the most common frustration developers reported with AI tools was getting solutions that were "almost right, but not quite." Sixty-six percent of respondents selected it, while 45% also reported that debugging AI-generated code could take more time.
In billing, almost right can be worse than code that simply throws an error.
This is why I try to separate request success from business success when reviewing a flow that moves money. For a plan change, we should not only care that each API call returned successfully. We need to be sure that the right amount was charged, the correct plan is active, and the previous subscription can no longer renew.
Money changes how you think about retries and stale state
In a normal request, retrying after a timeout can feel harmless. If the first attempt failed, the second one completes the job.
With a payment request, you might not know that the first attempt failed. The provider may have processed the charge successfully, and only the response failed to reach your server. If you retry without knowing that, you can charge the customer twice.
The same issue appears higher up in the flow. A customer can double-click an upgrade button, refresh after waiting too long, or submit another plan change before the first one has fully settled. Your infrastructure can also retry work through queues, background jobs, or network retry logic.
You need some way to tell whether two requests represent two decisions by the customer or two attempts to complete the same decision.

This is where idempotency matters. The same logical upgrade should not quietly become two charges or two replacement subscriptions because a network call was retried.
Stale state creates a related problem. For an upgrade, the frontend usually needs to show the customer how much they are about to pay. The server calculates a quote, returns it to the browser, and waits for confirmation.
The number displayed in the browser should never become the amount your server blindly charges later.
For the flow I worked on, the browser sends the quoted amount back, but confirmation reads the current subscription again and reruns the proration calculation:
const proration = computeProration({
fromPrice: currentPlan.price,
toPrice: targetPlan.price,
currentPeriodStart: sub.current_period_start,
currentPeriodEnd: sub.current_period_end,
});
The client-provided amount is used to detect that the quote has become stale. It is not trusted as the amount to charge.
That turned out not to be a theoretical case. One customer upgraded from Starter to Pro and then from Pro to Premium less than four minutes later. The second change had to be calculated from the state created by the first one, not from the subscription state that existed a few minutes earlier.
There are plenty of similar cases once a product has real usage.
- A discount code can expire while a checkout page is still open.
- Pricing can change.
- A customer can start a second action in another tab.
- Your database can be unavailable after the provider has already collected the money.
Real users do not execute your code in the neat sequence you used when testing the first version.
Billing is also communication
Another part of billing is easy to treat as secondary because it doesn't sit in the payment flow itself: communication.
A customer can be in the correct billing state and still have no idea what just happened.
If they upgrade, they should know what changed, what they were charged, and when the next renewal will happen. If a renewal fails, they need to know whether their access is still active and what they need to do next. If they cancel, they should know whether access ends immediately or at the end of the billing period.

These messages are part of the product behavior because they explain the state your billing system has put the customer in.
It is easy to build the payment path first and leave the emails for later. But once customers start changing plans, renewing, canceling, or hitting failed payments, those messages become part of the flow. The first failed renewal is a bad time to realize nobody has written the email that explains what happened.
Some providers handle parts of this for you. Others give you webhook events and leave the communication entirely to your application. Even when a provider sends its own billing emails, you still need to decide whether those messages are enough for the experience you want.
This is another reason to define the billing rules before building everything around them. The backend, dashboard, emails, support responses, and terms should all describe the same behavior.
Otherwise, you can end up with a technically correct payment flow and a customer who still thinks they were charged incorrectly because every part of the product is telling a slightly different story.
AI can help you find missing cases, but it can also make you overbuild
Jensen Huang has said that "everybody is a programmer" and that the programming language of the future is human. That is true. Natural language is now a practical way to tell a computer what to build.
What interests me more is what happens now that producing the code has become easy. Coding agents are supposed to take more of the implementation work so people can focus on requirements, architecture, product decisions, and judgment.
That only works if we are actually making those decisions.
You do not have to come up with every failure case from memory. AI can help you pressure-test a billing flow by asking what can fail, which states can drift, and what happens between each external call.
The risk is going too far in the other direction. When you ask for every possible failure, AI can quickly give you queues, retries, locks, reconciliation jobs, fallback paths, audit tables, and abstractions. Some of these may just make the system harder to understand.
Truth is that, AI makes overengineering cheap to generate and expensive to maintain.
There is a security version of this too. Researchers have documented "slopsquatting," where models hallucinate package names that attackers can later register.

Stack Overflow's finding about AI producing code that is "almost right" captures this well. Almost right is often more dangerous than obviously broken code because it is easier to trust and ship.
The engineer still has to understand the business, decide which failure cases matter, and take responsibility when the system behaves differently than intended.
Test the business outcome
For a billing flow, I no longer think a test like this tells me enough:
POST /upgrade → 200 OK
database.plan → "pro"
Both can be true while the customer is still in the wrong state.
If the upgrade touches a payment provider, I also want to know what happened there. Was the right amount charged? Is the old recurring subscription still active? Which plan will renew on the next billing date? Does the access in our application match what the provider thinks the customer has?
For anything that moves money, make sure there are no loose ends. The test should confirm the final state across the systems involved, not just that your endpoint completed successfully.




