Selling stuff on the Internet is pretty straightforward nowadays:
- Set up a Shopify account
- Start selling stuff
However, what if you need more granular control over multiple parts of the selling process because you want to set up, say, a custom shop app for your novel education/music festival where musicians can put together their course schedule based on their instruments and qualification? Coincidentally, a client of ours had exactly this problem! This post sheds light on the contact points between a custom web app and the extensive Stripe API by example of the Woodstock Academy Organizer.
Requirements
The Organizer is an offline-enabled PWA (Progressive Web App), that accompanies musicians before and during the Woodstock Academy festival week. Here, users can not only buy the festival pass, but also add additional courses afterwards. Moreover, the app serves as a schedule of booked courses during the festival as well as a management interface for admins and teachers. All in all, the Organizer has quite an extensive feature set, so for the purposes of this post I'll just focus on the payment-related requirements:
- Users should see a calendar-style overview of available courses, based on their played instrument and course spot availability.
- There are free courses and paid courses, both of which can only be booked after buying the festival pass.
- The festival pass price is tiered in three steps (early bird, standard, late bird) and gets more expensive towards the festival.
- Each course can cost a different amount, which is set by the admins (actually, one course can have multiple dates with the same configuration, but let’s skip this detail for the sake of simplicity).
- There should be a cart where the user can put multiple courses to purchase in one go.
- Courses can have limited spots; when courses are put into the cart, the user's spots are reserved for a certain amount of time, after which the spots need to be released again. This sounds somewhat tricky (foreshadowing 🌩️).
- Users should receive a proper VAT invoice after their purchase.
- There should be coupon codes that apply a predefined discount to the festival pass, but not the courses.
- Purchases should be refundable by admins, both on a per-course basis, but also for a whole festival pass (including all courses).
Basic checkout flow
Stripe has two options for checkout: A Stripe-hosted checkout page (Stripe Checkout) and a custom flow for integrating in your own website (Stripe Elements). The latter has the benefit of being customizable regarding appearance and not having to send the user away from your website, but we decided against it since it's difficult to set up for the plethora of different payment providers and checkout flows (e.g. payment authentication or coupons (more on that later)).
Essentially, Stripe Checkout is dead simple:
- Tell Stripe which items you want to sell
- Send the user to the checkout page
- Get notified once the payment goes through
- Add the user to the purchased course(s)
In the Stripe dashboard, there are several options for customizing the checkout and setting the available payment methods. All payments (pending, successful and failed) show up here as well, which brings us to an important point:
It's good to have references
There's a lot that can go wrong with payments. Things that you wouldn't even know were possible. Things like Error code 658: The user has inserted their credit card into their CD drive (wrong side up). (just kidding, it's mostly things like insufficient funds, failed auth check etc.). What I'm trying to say is: If something goes wrong, the more info there is on who did what when how, the better. There's two ways to associate our app data with Stripe data:
Customers
For each of our users, we can create a Stripe customer resource. This way, we can easily see all purchases of a specific user right in Stripe. Stripe customers also come in handy for creating PDF invoices (more on that later).
Metadata
For each payment, I'm also storing some app-specific IDs. This way, the system can e.g. be sure to clear the right cart of the right user when a payment goes through. Also, I can quickly copy-paste IDs from Stripe to my database in case I have to look something up in a pinch.
Reserving course spots
If you read that the course spots in the cart should be reserved for a certain amount of time and you got a sinking feeling and the urge to run away, that's completely normal. I, however, thought “all the event ticket platforms are doing it, how hard can it be?”. The truth is, it is a surprisingly difficult family of problems:
What should expire when?
For the most part, this is up to how the user flow should look like. We decided that as soon as the first course gets put into the cart, a 45-minute timer starts. When the timer ends, spots of any course dates that are still in the cart get released and the cart is emptied. So it’s not really the reserved course spots that expire, but rather the whole cart. Making expiry more granular may introduce even more problems.
The cart. Can expire. During checkout.
In an ideal world, the user would choose a handful of courses, go through checkout, find their credit card, and enter their data correctly, all before the timer ends. And that’s not counting the app-specific problems; for instance, many users simply weren’t aware that they could check out multiple times and tried to squeeze a whole week’s worth of courses into a single cart, which often expired (this is absolutely a communication/UX issue, which soon improved).
So what if the timer runs out during checkout? Luckily, it’s possible to cancel the Stripe payment intent while the session is running. If the user then tries to proceed with the payment, they get a message that the checkout session isn’t valid any more and get sent back to the Organizer, seeing the info that the cart has expired. Almost graceful 🩰.
The payment can be… somewhere.
Payment processing is a convoluted assortment of decades-old banking technologies, lots of SOAP, and mobile payment APIs, passing through many hands on the way (I once implemented payments at gateway level for a different project and my life hasn’t been the same since). So good thing Stripe offers dozens of payment methods straight out of the box—just enable all the options and you’re good to go, right? For our case, wrong.
Generally, payment methods can be categorized into immediate and delayed. Because of the expiring cart, we must try to get the payment over the finish line as fast as possible and to stay in control for as much of the time as possible, which can only be guaranteed with immediate payment methods, i.e. right after the user completes checkout, Stripe promises us that this money will go to our bank account.
When selecting the payment methods relevant to the project’s target market, we went for credit card, apple pay, google pay, and—given that not everybody in Austria and Germany has a credit card—the bank transfer services EPS, giropay, and SOFORT. Everything worked great in test mode, but after going to production, one of the payment methods totally screwed everything up because it was not immediate. Guess which one. It’s clearly SOFORT (which literally means “immediate”). Apparently, this irony wasn’t lost on Stripe because when I checked again recently, SOFORT was properly delayed in test mode as well. I could, of course, also have read the documentation more closely, but after disabling SOFORT and assigning a dozen of courses by hand, everything ran smoothly.
Almost. There is still a small time window where we’re not in control: for credit card payments, the bank may request strong customer authentication (SCA) via SMS or a mobile app. At this point, the payment has effectively been handed off to the bank and it’s impossible for us to cancel the checkout session. In the rare case that the cart expires exactly at this point, we receive a payment but there is no cart to apply. The solution we found is to also store a cart backup that persists after the cart expires, which can be manually applied by admins in this case.
I’m absolutely sure there is a better way to do all this, maybe a mix of heuristic and optimistic logic and/or a more granular integration of what’s happening on the payment provider side. A lot of these issues may be solved by just increasing the timeout or adding a grace period, but that still won’t help the handful of payments that took hours to go through authentication 🙄.
Coupons
At some point after initial release, the client requested a discount code feature for marketing purposes. Luckily, Stripe has this feature baked right in, with code redemption at checkout and everything! As mentioned above, the discount should only apply for festival passes, but not for courses, which meant that some things had to be adjusted—and then development got really Stripe-driven:
Moving to products
Stripe offers the (optional) concept of products: Predefined, priced items that can be used to model the business process right in the Stripe dashboard. When we first set up the project, we tried modeling courses this way, but we soon moved to manual configuration since every course was named and priced differently and maintaining them on Stripe lead to too much duplication. For the coupons feature though, products got interesting again: coupons can be configured to apply to specific products and not to others—just what we needed for discounting the festival pass but not the courses.
Moving the festival pass (passes, actually, there’s three different ones: full week and two halves, but that’s not relevant for this post) to Stripe products as a side effect enabled us to also manage prices for the festival pass on Stripe (i.e. the tiers of how the pass gets more expensive as the festival approaches). We’ve even found a way to set the dates on which the pricing tiers should come into effect right from the Stripe dashboard: we’ve simply put the timestamps in the price’s “description” field (say what you want about separation of concerns but I think this solution is brilliant 🙈). Our app is now able to fetch the full pricing config for the festival passes from Stripe, selecting the one that applies for the current date.
The actual coupons
Now that we have the products issue out of the way, creating the coupons is pretty straightforward and can be done 100% in the Stripe dashboard. For each coupon, options include the products it should apply to, as well as the absolute or relative amount of the discount. For the actual discount codes there’s lots of configuration options like whether the codes should be redeemable just once or multiple times, whether they should be only valid for a certain time or should only apply to first-time customers. There’s also detailed statistics on how often which code was redeemed. A pretty neat feature if you ask me 😎.
Invoices
This one is an odd duck. It’s of course perfectly possible to generate PDF invoices without Stripe. However, building beautiful PDF files is not very fun and I knew that Stripe had some kind of PDF invoice feature built in. It turned out that Stripe only generates nice PDF invoices for billing purposes, like here’s your bill, please pay me at some point in the future purposes. Okay, I guess VAT invoices are more of a European thing (in the US, a payment receipt is often enough), but I still didn’t give up and played around with their invoices API some more.
In the end I found out that it’s possible to create a billing invoice for an existing customer, add the items and discounts from checkout, set the due date to tomorrow (the smallest number of days possible), mark the bill paid “out of band” (i.e. not via Stripe) and just like that we’ve got Stripe to generate a nice PDF invoice for us. That’s definitely not how it’s meant to be used, but we made it work anyway 😄.
Refunds
Of course it’s also possible to create refunds via Stripe. However, it’s one more instance where it’s very important to keep app data and Stripe data consistent, so refunds are best initiated by admins from within the app back office and then carried out via Stripe instead of triggered from the Stripe dashboard. It’s also important to keep a paper trail of what happened (e.g. whether the refund included a discount), in case there are questions later on.
Conclusion
The Stripe API is well-documented, functional and stable in a way that makes it my go-to example for a good, developer-friendly product. A downside of Stripe is that being a US company, it has some blind spots regarding common EU use-cases (to their credit, they’ve recently introduced automated tax calculation, which I’ve yet to try) and might not be the best option for all regions and use-cases due to legal reasons (shout-out to mollie, which is a EU-based alternative with somewhat less features, but nonetheless great developer experience).
Stripe-driven development can be a cost-efficient way to give clients some amount of control over business logic like pricing and coupons. On the other hand, it should be made very clear to admins that changing or deleting certain resources may render the product inoperable or lead to unwanted side-effects. Documentation is indispensable here—take the “creative” use of the price description field for validity date: there is simply no way of knowing what this field does from the Stripe dashboard alone. There is also no way to disable dangerous or unused features, let alone introduce domain-specific wording or processes. However, the alternative would be to re-implement large parts of the Stripe dashboard functionality in-app, which could definitely be done given the broad public API, but may not be feasible depending on budget and project scope.
Anyway, if you find yourself using lots of Stripe features in your project, give Stripe-driven development a go and tell me what you think! Also, in case SDD becomes a thing, you’ve heard it here first 😄.