Back

Complex Payment System with C# .Net and Stripe

Complex Payment System with C# .Net and Stripe

Our subscription model at Booqer offers flexibility and comprehensiveness, addressing the unique needs The platform’s modular structure allows businesses to subscribe only to the services they need. Each module comes with its own independent free trial period and seat allocation. Once users subscribe, the system combines all selected modules into a single monthly invoice through Stripe, which greatly simplifies the billing process. As a result, users have the freedom to scale their usage while ensuring they only pay for the services they actively use.

Also, Stripe’s webhook events automate important parts of this process, such as billing updates, trial period management, and handling failed payments. These events automatically keep all subscription activities in sync between the user and the Stripe billing system, ensuring a smooth and efficient experience.

The flowchart below outlines the key steps in this subscription process.

How to Implement Subscription Creation in Stripe using C# .Net?

So, how do we implement this?

To begin, let’s start by creating a subscription. First, convert any DTOs or domain entities into Stripe’s DTOs because this ensures compatibility between our data structures and Stripe’s API. For instance, SubscriptionItemPriceDataOptions holds the necessary data for subscription creation. In our case, we create a new SubscriptionItem with Price, Quantity, and Metadata.

You can set the Price by using an existing Stripe ID or by defining new PriceData, which creates a new price for the subscription item. The Metadata field allows you to store additional information on almost any Stripe object, such as matching Stripe’s objects with domain entities.

var subscriptionItems = currentlyCycleActiveSubscriptions.ConvertAll(moduleSubscription => 
    new SubscriptionItemOptions() 
    { 
        PriceData = new SubscriptionItemPriceDataOptions() 
        { 
            Currency = "eur", 
            UnitAmount = (int)(moduleSubscription.Model.Price * 100), 
            Product = moduleSubscription.Model.StripeProductId, 
            Recurring = new SubscriptionItemPriceDataRecurringOptions() 
            { 
                Interval = "month", 
            } 
        }, 
        Quantity = moduleSubscription.Seats, 
        Metadata = new Dictionary<string, string> 
        { 
            { SubscriptionMetadataModuleSubscriptionKey, 
                moduleSubscription.Id.ToString() 
            }, 
        }, 
    });

Next, map the discounts alongside subscription items. When applying discounts for the first time, pass them as a Coupon. For subsequent updates, reference them by their ID as an existing Discount to avoid accidental deletion.

Each Module has a defined price and specific free trial duration. When you create or edit Modules, sync the changes to their corresponding Stripe Product or Discount. Use the existing coupon ID during updates to ensure that ongoing discounts remain intact.

var discounts = currentlyCycleActiveSubscriptions 
    .Where(subscription => subscription.UsingTrial && 
subscription.Model.StripeCouponId != null) 
    .Select(subscription => new SubscriptionDiscountOptions()) 
    { 
        Coupon = subscription.Model.StripeCouponId, 
    }) 
    .ToList();

Finally, create the subscription by calling SubscriptionService.CreateAsync. Use the Expand property to immediately retrieve the latest invoice generated with the subscription.

var discounts = currentlyCycleActiveSubscriptions 
    .Where(subscription => subscription.UsingTrial && 
subscription.Model.StripeCouponId != null) 
    .Select(subscription => new SubscriptionDiscountOptions()) 
    { 
        Coupon = subscription.Model.StripeCouponId, 
    }) 
    .ToList();

Invoice Processing with Stripe

When processing this invoice, first check its status. If the status is “paid,” return an OK to the client. This typically occurs when the user is on a free trial, as no payment is required and the invoice amount is zero, which Stripe marks as “paid.”

If the invoice status is “draft,” it means the subscription has items that need to be paid for. Stripe usually waits one hour before finalizing invoices and attempting payments. Once you confirm the invoice is valid, finalize it and attempt immediate payment. This usually works smoothly since the client’s card is already set up for future use.

However, the card might be declined, or a 3DS check might be required. Verifying the invoice status first is important, as it dictates the next steps—whether we proceed with payment, notify the client, or handle exceptions.

switch (subscription.LatestInvoice.Status) 
{ 
    case "paid": 
        return new InvoicePaymentDto
        { 
            Success = true, 
            RequiresAction = false 
        }; 
    case "draft": 
        await _billingService.FinalizeInvoice(subscription.LatestInvoiceId, 
            cancellationToken); 
        break; 
} 

var paymentResult = await _billingService.HandleInvoicePayment( 
    subscription.LatestInvoiceId, 
    tenant.StripePaymentId!, 
    cancellationToken); 

return paymentResult;

How Stripe Handles Upgrades and Downgrades at the Same Time

Stripe uses a proration system to manage upgrades and downgrades. It calculates the unused time on the customer’s current plan and adjusts the billing based on the new plan for the rest of the billing cycle. Customers only pay for the exact time spent on each plan when switching between tiers, ensuring accurate and fair billing, even during mid-cycle changes.

In our payment model, we avoid issuing refunds for mid-term downgrades. Instead, we focus on upgrading subscriptions, such as adding new modules or seats. For downgrades, we create a subscription schedule with an additional downgrade phase, allowing us to bill for upgrades while including prorations.

Stripe Subscription Downgrades Without Proration

To handle downgrades without prorations, we include discounts in the Expand property to ensure that the system can apply discounts immediately during the downgrade phase of the subscription schedule. If the subscription doesn’t have an existing schedule, create one from the current subscription first.

var subscriptionSchedule = await _subscriptionScheduleService.CreateAsync(
    new SubscriptionScheduleCreateOptions() 
    { 
        FromSubscription = subscription.Id, 
    }, 
    cancellationToken: cancellationToken
);

After creating the schedule, map all items and discounts from the subscription into SubscriptionSchedulePhaseOptions and SubscriptionSchedulePhaseDiscountOptions. For the second downgrade phase, create new items. Reusing the same Price ID is crucial; otherwise, the schedule may behave unpredictably and show incorrect price updates.

var downgradePhaseItems = currentPhaseItems
    .Where(item => 
        nextCycleActiveSubscriptions.Exists(nextCycleActiveSubscription => 
            nextCycleActiveSubscription.Id.ToString() == 
            item.Metadata[SubscriptionMetadataModuleSubscriptionKey]))
    .Select(item => 
    {
        var moduleSubscription = payingSubscriptions.First(ms => 
            item.Metadata[SubscriptionMetadataModuleSubscriptionKey] == 
            ms.Id.ToString());

        return new SubscriptionSchedulePhaseItemOptions() 
        { 
            Price = item.Price, 
            Quantity = moduleSubscription.SeatsNextBillingPeriod, 
            Metadata = item.Metadata, 
        };
    })
    .ToList();

After creating the subscription downgradePhaseItems, update the subscription schedule with the new phase items. This update will ensure that the downgrade phase is correctly reflected in the subscription schedule, preventing any discrepancies in billing.

var updatedSubscriptionSchedule = await _subscriptionScheduleService.UpdateAsync(
    subscriptionSchedule.Id,
    new SubscriptionScheduleUpdateOptions() 
    {
        Phases = new List<SubscriptionSchedulePhaseOptions>() 
        {
            new SubscriptionSchedulePhaseOptions() 
            {
                Items = currentPhaseItems,
                Discounts = currentPhaseDiscounts,
                ProrationBehavior = "none",
                StartDate = subscriptionSchedule.CurrentPhase.StartDate,
                EndDate = subscriptionSchedule.CurrentPhase.EndDate,
            },
            new SubscriptionSchedulePhaseOptions() 
            {
                Items = downgradePhaseItems,
                Discounts = downgradePhaseDiscounts,
                ProrationBehavior = "none",
                StartDate = subscriptionSchedule.CurrentPhase.EndDate,
                Iterations = 1,
            },
        },
        Expand = new[] { "subscription", "subscription.latest_invoice" },
    }, 
    cancellationToken: cancellationToken
);

How to Handle Client Actions on Client Side?

Now, on the client side, start by checking if any action is required. If so, use the provided client secret to process the 3DS check. Obtain the client secret from the backend by refetching the invoice if InvoiceService.PayAsync returns an error code “invoice_payment_intent_requires_action.” This simple call opens the 3DS confirmation dialog.

const { error } = await stripe.handleNextAction({
    clientSecret,
});

Of course, it is a bit more complicated than this. First, before calling this, we have to check that the invoice is in the correct state—it is possible that the Payment Intent is not in status "requires_action" but in "requires_payment_method", in which case a new Payment Method will have to be used to attempt payment. It is also possible for the Payment Intent/Invoice to be canceled after too many failed attempts. Failed attempts are generated each time the bank declines the card or the user fails the 3DS check.


Furthermore, each time the user fails 3DS, the bank revokes Stripe’s access to the card, so a new Payment Method has to be set up by the user. Fortunately, existing card details can be reused to create a new Payment Method.

Note that “Payment Successful” here means we expect success, but the bank might still decline the card due to insufficient funds or suspected fraud. Handle these cases the same way as a failed 3DS check.

Why Not Use Just the Subscription Schedule?

We initially used the subscription schedule to manage changes and offer customers more flexibility. While this approach worked in most situations, it caused problems when applying an upgrade, a discount, and a downgrade simultaneously. Since newly created discounts can’t be applied across multiple phases, complications arose. Updating the schedule repeatedly could fix the issue, but this method proved cumbersome and error-prone.

Conclusion: Essential Tips for Managing SaaS Subscriptions

Managing subscriptions in a SaaS platform involves several technical challenges. Here are key tips to help you handle these complexities:

  • Embrace Complex Code: Don’t shy away from writing complex code when necessary. Handling subscriptions, billing, and proration correctly often requires intricate logic, especially when dealing with upgrades, downgrades, and various edge cases. Ensure your code accurately reflects the business rules and handles all possible scenarios.
  • Address All Edge Cases: Subscriptions can have many edge cases, such as handling upgrades and downgrades at the same time, applying discounts mid-cycle, or dealing with failed payments. To handle this, map out scenarios and implement solutions like subscription schedules, managing discounts across phases, or creating fallback methods for payment retries.
  • Implement Comprehensive Unit Tests: Given the complexity of subscription management, it’s crucial to have thorough unit tests. Test all edge cases, including different subscription scenarios, payment failures, 3DS checks, and proration calculations. Unit tests should cover both success paths and failure conditions to ensure that your system behaves as expected in all situations.
  • Handle Payments and 3DS with Care: Payments and 3DS checks can introduce significant complexity. Ensure that your code handles various payment statuses correctly, such as “requires_action” and “requires_payment_method” . When dealing with 3DS, make sure your client-side logic is robust and can gracefully handle failures, prompting users to reattempt payments or select new payment methods as needed.
  • Optimize for Real-Time Processing: Whenever possible, optimize your system for real- time processing. Use features like Stripe’s Expand property to fetch relevant invoice data immediately and process payments without unnecessary delays. This approach ensures that users experience minimal friction during payment and subscription changes.

By following these tips, you can create a flexible subscription system that handles SaaS billing complexities, ensures a smooth user experience, and maintains operational integrity.

Back
Do you have a project you need help with?
Get in Touch

By using this website, you agree to our use of cookies. We use cookies to provide you with a great experience and to help our website run effectively.

Accept