Hey everyone! In the first part we agreed: redirects are evil, business logic stays on our side, and the payment system only sees the final amount. Now let me show you how that translates into working code.
We’ll use Stripe — flexible, but full of traps if you’re not careful. I’ll walk you through how we built recurring payments without losing customers, how we handle recurring charges via webhooks, and how we built a discount system that doesn’t make your marketing team hate you.
We started with a simple orders table. It holds everything about a payment attempt: user, final amount, currency, applied coupon, recurring flag, and status.
// Simplified structure
table orders {
id
// internal, not Stripe's id
user_id
amount
// after discounts, taxes, currency conversion
currency
coupon_code
discount_percent
is_recurring
status
// pending / paid / canceled
}
Why this matters: Stripe (or PayPal) shouldn’t know about your “invite a friend” campaigns or “first month for $1” promos. All price logic stays in your code; the payment gateway just gets the final amount.
When an order is created, a service:
Result — an orders record with status pending. Then we hand it over to the payment gateway.
Here’s the essence of the method that turns a cart into a final amount to charge:
// Pseudo-code — real logic, not a copy-paste from production
function
calculateOrderAmount(
$user,
$cartItems,
$couponCode,
$targetCurrency) {
$total =
0;
foreach (
$cartItems
as
$item) {
$total +=
$item->price *
$item->quantity;
}
$discountPercent =
$couponService->
getDiscount(
$user,
$couponCode);
$discountAmount =
$total * (
$discountPercent /
100);
$amountAfterDiscount =
$total -
$discountAmount;
$vat =
$user->vatRate;
$amountWithVat =
$amountAfterDiscount * (
1 +
$vat);
$exchangeRate =
getExchangeRate(
'USD',
$targetCurrency);
$finalAmount =
$amountWithVat *
$exchangeRate;
$cents = (
$finalAmount *
100);
// respecting zero-decimal currencies
return [
'amount_cents' =>
$cents,
'currency' =>
$targetCurrency];
}
No magic. What Stripe receives in the PaymentIntent is just amount_cents and currency. Everything else (discounts, VAT, what caused the price) stays inside our project.
Stripe has the Price object attached to a Product. In a perfect world, you have a few plans: Basic, Pro, Business. But once you add discounts, regional pricing, one‑time bonus purchases — the number of possible amounts explodes.
Our approach: before creating a PaymentIntent, we call a service that:
$priceId =
priceService->
findOrCreate(
amountCents:
1999,
currency:
'usd',
isRecurring:
true
);
Bonus trick: for recurring, we use interval day with interval_count=30. Formally it’s a month — but this makes a year 360 days instead of 365. The customer pays 12 times a year instead of 12.03. A tiny win, but scales nicely.
The classic Stripe Billing flow: create a Subscription → user pays → subscription becomes active. The problem: if the payment fails or the user closes the window, you end up with an incomplete subscription that you have to clean up.
We took a different path:
if (
$event ==
'payment_intent.succeeded' &&
$metadata[
'is_recurring']) {
$order =
findOrder(
$metadata[
'order_id']);
$subscription =
$stripe->subscriptions->
create([
'customer' =>
$customerId,
'items' => [[
'price' =>
$metadata[
'price_id']]],
'billing_cycle_anchor' =>
strtotime(
'+30 days'),
'metadata' => [
'order_id' =>
$order->id],
]);
$order->
update([
'recurring_id' =>
$subscription->id,
'status' =>
'paid']);
}
Why it’s great: no dead subscriptions, the first payment is just a one‑time charge (simpler logic), and Stripe is only needed for subsequent recurring charges.
Once the subscription exists, Stripe generates an invoice each month. A successful invoice payment triggers invoice.paid.
Inside that handler we do one simple thing: find the original order via the subscription’s metadata and fire the same event PaymentSucceeded that we used for the first payment. Whether it’s the first or tenth payment, the listeners (update balance, record transaction, extend access) just work.
if (
$event ==
'invoice.paid' &&
$invoice->subscription) {
$orderId =
$stripe->subscriptions->
retrieve(
$invoice->subscription)->metadata[
'order_id'];
event(
new
PaymentSucceeded(
findOrder(
$orderId),
$invoice->id));
}
Unified event model — no code duplication.
All discount logic stays in our territory. User enters a coupon, we validate it, recalculate the total via calculateOrderAmount, and send the final cents to Stripe. Stripe never even knows a discount was applied. This gives us freedom: any promo (“first month for $1”, “invite a friend”) works without touching the integration.
Imagine: a user buys SEO analytics on the 5th, adds backlink checker on the 12th, and another tool on the 20th. That’s three separate charges each month on different days. It’s annoying, confusing, and increases the risk of a decline (one small charge might fail due to insufficient funds).
We added a button: “Merge everything into one payment”. The system collects all active recurring subscriptions, sums their monthly price, and creates a single new subscription (using subscription.update with proration), then cancels the old ones. Voilà — one charge per month. Customers are happy, churn drops. We even made it an auto‑suggestion when buying a new service. This single feature increased LTV without a single change in Stripe itself.
Look at the chain we built:
No redirects, no lost context, no code duplication. Higher conversion, happier customers (no multiple monthly charges).
Next part — PayPal with its own quirks.
This guide explains how to properly connect Mailgun to a WordPress website to ensure reliable…
AI has made automation more accessible than ever. Tasks that previously required hours of manual…
Hey everyone! I’m starting a series of articles on integrating payments into a website. If…