Stripe billing in a Lovable app
Payments are the part of an app where a silent bug costs real money and looks perfectly correct on screen. Most of what follows exists because something failed quietly first.
The rule that matters most
A successful charge and a delivered entitlement are two separate events, and the gap between them is where money disappears.
Stripe charges the card. Something in your app then has to grant the hours, extend the subscription or unlock the feature. If that second step fails, the customer has paid and has nothing, and nobody finds out until they complain.
So: never treat the checkout return page as proof of delivery, and never rely on a single mechanism to close the gap.
The delivery pattern: webhook plus reconcile
Use both, keyed on the same idempotency token.
- Webhook on the payment event writes the entitlement. This is the primary path.
- A reconcile fallback on the return page. When the buyer lands back in the app, look up the entitlement by the checkout session id. If it is not there yet, ask Stripe directly whether the session was paid, and if so write the entitlement yourself.
Key the write on stripe_checkout_session_id with a unique constraint. Then both paths can run and the second one is a no-op. Without that key you have built a double-credit bug.
A status lookup by session id is safe to expose publicly: the session id is the capability token. Only the buyer knows it, because Stripe substitutes it into the return URL. Return nothing from that endpoint that identifies the account.
Coupons and promotion codes are not the same thing
This trips up almost everyone.
- A coupon is the discount itself. Server-side only. Applied with
discounts: [{ coupon: ID }]. - A promotion code is a customer-facing string that maps to a coupon. Enabled with
allow_promotion_codes: true.
They are mutually exclusive on a session. If you pass discounts, the promo box is disabled. If you enable the promo box, you cannot also attach a discount server-side.
This forces a real decision:
Per-customer codes. Keep your own code on the customer record, validate it yourself, and attach the coupon server-side when it matches. Only your named customer can use their code. The cost: the discount exists only if the customer types the code, so if nobody tells them it exists, nobody ever gets it. Audit this: a code stored in a column and never communicated is a feature that does not exist.
Public codes. Enable allow_promotion_codes and let Stripe match. Simple, but anyone who learns the string can use it.
Never make a mistyped code a hard error. A wrong code means no discount, not a failed checkout.
Business buyers, VAT and tax IDs
For an EU business selling to businesses:
automatic_tax: { enabled: true }andbilling_address_collection: 'required'together, since tax cannot be computed without an address.customer_update: { address: 'auto', name: 'auto' }, otherwise Stripe refuses to write the collected address back to the customer and automatic tax fails on the next purchase.tax_id_collection: { enabled: true }only when the buyer says they are a business. Showing a VAT field to a consumer is friction for nothing. Reverse charge is then handled for you once a valid ID is entered.- Persist company name, tax id, the discount code used and the discount amount onto the purchase row, not only in Stripe. You will need them for your own invoices and reports, and reaching into Stripe for historical data is slow and rate-limited.
Remember that VAT is recoverable for a registered business, so when presenting cost to such a customer, the net figure is the honest one.
Guest checkout
Letting someone buy before they have an account raises conversion and creates one hard problem: whose account does the purchase belong to?
The safe shape:
- Collect name and email, validate the email format server-side.
- Put no account id in the metadata. A guest's claimed identity is unverified until the money lands.
- Resolve identity after payment, by matching the email to an existing account, in the same function that writes the entitlement.
- Match customers by email before creating a new one, or you will accumulate duplicate Stripe customers for the same person.
Hour banks and credit models
For a studio selling blocks of time, the model that survives:
- A purchases table and a usage table. Balance is
sum(purchased) - sum(used), computed, never stored. A stored balance will drift and you will not know when it started. - Freeze what has been invoiced. Once a usage row is attached to an invoice it must not be editable or deletable, or an invoice that already went out silently stops matching its own line items.
- Model the plan type explicitly: one-off, rollover, renewal. Enforce with a check constraint that a renewal has a period and an interval and a rollover has neither. Constraints at the database level catch what the UI forgets.
- Postpaid clients go negative, and that is not an error. A negative balance means work delivered and not yet invoiced. Do not warn about it the same way you warn about a prepaid bank running out; they are opposite situations.
Subscriptions and retainers: the gap nobody plans for
A recurring Stripe subscription will keep charging on its own forever. It will not grant anything in your app unless you build that.
Until the automation exists, someone must manually grant the entitlement every period, and they will forget. The customer pays and sees nothing.
If you ship the subscription before the grant automation, write the manual step down as a recurring task with a date, and treat it as a known liability rather than a detail. Then build:
- a webhook on the recurring invoice paid event that grants the period's entitlement,
- rollover computed at grant time with an explicit expiry rule, and
- an admin view showing, per subscriber, last charge and last grant side by side. A mismatch between those two columns is the whole bug class, visible at a glance.
Environments
Keep test and live strictly separated and stamp every purchase row with the environment it came from. Coupons, prices and webhook endpoints all exist twice. A row that does not know which world it came from will eventually be counted in a revenue figure it does not belong to.
Look up prices by a stable lookup key rather than a raw price id, so that repricing does not require a code change.
Before you call it done
- Buy something as a new customer, an existing customer, and a guest.
- Buy with a valid code, an invalid code, and no code.
- Buy as a business with a VAT id.
- Kill the webhook and buy again, then confirm the reconcile path still delivers.
- Fire the same webhook twice and confirm nothing is credited twice.
- Check that the amount recorded in your own tables matches the amount actually captured, net of discount.
The last one catches the most expensive class of bug there is: a checkout that displays one number and charges another. It throws no error and looks correct to everyone except the customer's bank.