Skip to content
Mon-Fri: 9:00 - 18:00 Engineering Intelligent CRM, SaaS & Scalable AI Infrastructure

Payment Integration — Part 2: Recurring Payments, Webhooks & Flexible Discounts (Stripe Edition)

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.

One order table to rule them all

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:

  • Builds the user’s cart
  • Applies the best available coupon (or the one the user entered)
  • Recalculates prices in the target currency (including exchange rates and VAT)
  • Rounds to cents (or integers for zero‑decimal currencies)

Result — an orders record with status pending. Then we hand it over to the payment gateway.

How we calculate the total: a peek into calculateOrderAmount

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.

Dynamic prices: how to avoid creating thousands of products

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:

  • Looks in our own DB ( price_links) for an existing price_id matching the exact amount, currency, and type (one‑time or recurring).
  • If not found, creates a new Price in Stripe with a readable lookup_key ( one_time_usd_4999 or recurring_usd_1999_monthly).
  • Stores the mapping stripe_price_id → order_id in our table.

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

First payment without creating the subscription object

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:

  • Create only a PaymentIntent with setup_future_usage = ‘off_session’.
  • User pays on our page (no redirects, Stripe Elements).
  • After success, we receive the payment_intent.succeeded webhook.
  • Inside the webhook, we create the subscription with billing_cycle_anchor = now + 30 days.

    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.

The invoice.paid webhook — unified payment handling

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.

Discounts that live on your side

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.

The “one wallet” pain: how we stopped creating multiple charges

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.

What’s left behind the scenes

  • Cancellation — webhook customer.subscription.deleted, we set the cancellation date in our DB.
  • Failed invoices — invoice.payment_failed, retry after a few days, notify the user.
  • Idempotency — Stripe sends an idempotency key; we store it to avoid double processing.

Instead of a conclusion

Look at the chain we built:

  1. Our own orders table — all pricing logic there.
  2. Dynamic Price objects in Stripe — only the final amount.
  3. First payment via PaymentIntent without a subscription.
  4. Subscription created after success, inside a webhook.
  5. Recurring payments via invoice.paid fire the same event.
  6. Discounts and charge merging — on our side, Stripe has no clue.

No redirects, no lost context, no code duplication. Higher conversion, happier customers (no multiple monthly charges).

Next part — PayPal with its own quirks.

Other blogs
How to Connect Mailgun to WordPress (Step-by-Step Guide)

This guide explains how to properly connect Mailgun to a WordPress website to ensure reliable…

3 Mistakes I Made Automating Workflows with AI, and What They Taught Me

AI has made automation more accessible than ever. Tasks that previously required hours of manual…

Payment Integration

Hey everyone! I’m starting a series of articles on integrating payments into a website. If…