← ALL POSTS
21 SEPTEMBER 2026

Charging A Card Exactly Once, And The Billing Engine That Was Faking It

Before you build anything that takes a payment off the back of a text message, you have to be honest about the payment code you already have. We were not, so we went and read it.

Three things. All ours. All now fixed.

One Stripe key for everybody

The token service had a method called GetClientStripeKeyAsync. Reasonable name. Inside it:

var client = await _dbContext.Clients.FindAsync(clientId);
if (client?.HighLevelLocationId != null)
{
    // TODO: Get Stripe key from HighLevel integration
    // For now, fall back to default configuration
}
return _configuration["Stripe:SecretKey"];

An empty if body, a TODO, and a single platform-wide key returned for every tenant.

So every client's customer charges would have landed in our Stripe account. Which makes us merchant of record for other people's sales, and that is not a code smell, it is a tax position, a chargeback liability and a refund obligation we never agreed to take on.

Now it is Stripe Connect with direct charges: our key, the merchant's account, merchant stays merchant of record and the money lands where it always should have. Clients who would rather hand over their own restricted key can do that instead.

One subtlety that bit us on the way: a saved card is a customer id and a payment method id on a specific Stripe account. A cus_ from the platform account does not exist on a connected account. So each saved card now records which account it was vaulted on, and a card is never charged against an account where its ids are meaningless.

A process-global static in a multi-tenant service

The Stripe .NET SDK has StripeConfiguration.ApiKey, which is static. Our code assigned to it on every call, from inside a scoped, per-request service.

That was harmless while every tenant shared one key, because the value never changed. The moment keys differ per merchant it is a live race that can bill the wrong account. Two concurrent requests, one sets the key, the other sets it, the first one charges.

All per-call overrides now go through RequestOptions with an explicit account, and the static is set once at startup. If you are using the Stripe SDK in a multi-tenant app, go and grep for StripeConfiguration.ApiKey right now.

The branch that faked success

This is the one that made me wince.

The subscription billing engine had two payment paths. The Stripe path was real. The other path looked like this:

// TODO: Charge via Shopify
paymentSuccess = true;
paymentTransactionId = $"SHOPIFY-{Guid.NewGuid():N}";
_logger.LogWarning("Shopify payment not yet implemented - simulating success");

And then, a few lines later, it minted a fake order id: mock-{guid}.

PaymentSource defaults to Shopify. So the default path recorded revenue that was never collected, against orders that do not exist, and logged the whole thing as a win. A warning in a log file is not a safeguard. Nobody reads warnings on a nightly job that reports success.

It now fails loudly and takes no money. That is worse for the metrics and infinitely better for reality.

Two smaller ones found in the same read

Billing dates were drifting. On success it set the next billing date to today plus the frequency, rather than to the due date plus the frequency. Every late run pushed a customer's billing day further out, permanently, for the life of the subscription. Now it advances from the date the order was due.

The retry job could never run. It selected subscriptions with FailedPaymentAttempts < MaxPaymentAttempts, but the status only becomes PaymentFailed once attempts reach the max. The predicate could not match anything. A nightly job that had been a no-op since it was written, reporting zero failures found and looking healthy.

The one that made the text-ordering flow possible

ChargeTokenAsync now takes an idempotency key and passes it to Stripe. Two calls with the same key return the same charge instead of taking the money twice.

That is the single most important line in the whole feature. Everything else in Buy via SMS is conversation design. That is the bit that means a retried webhook does not cost a customer thirty dollars twice.

Why post this

Because "we found three serious problems in our own payment code" reads badly and is true, and because the alternative was building a text-to-purchase product on top of it and finding out later. Reading your money path with fresh eyes before you extend it is the cheapest work you will ever do.

Want this working on your store? Aiva does the sales, marketing and service work - in your voice, around the clock.
Get Aiva