Pricing in the DB, prices in Stripe: sync by command
BusiKM keeps its pricing in the database while Stripe holds its own immutable prices. One command creates them idempotently; another only reads and verifies.
Published: 7 min read
Author: Łukasz Głowacz, Full-Stack Engineer · Czytaj po polsku

Contents
In BusiKM, pricing lives in the database and Stripe handles payments. Stripe doesn't read my database. It keeps its own product and price records, and the app points to them with IDs stored in environment variables. The Stripe test catalog is separate from the production one, and each environment has its own variables. In this post I describe how I keep the database pricing and the Stripe prices in sync: one command creates the catalog, the other only reads and tells me whether the configuration matches.
Why pricing is not a constant in code
Pricing moved into the database in September, as the PlanPrice model. I wrote the rationale in the model itself: a price change must not require a deploy, and old subscriptions must still be billable at the price from the day of purchase. That's why a pricing row has valid_from and valid_to and nobody overwrites it. A new price is a new row.
Stripe has a similar rule, only stricter. You can't change the amount of an existing price; you can create a new one and deactivate the old one. So I have two sources, both immutable, and five identifiers linking them: four plan prices (Start and Firma, monthly and yearly) and a surcharge per vehicle beyond the plan's allowance.
The Stripe integration landed at the end of September, but the code didn't create anything in Stripe. Prices had to be created in the dashboard and their IDs copied into variables, and the only check was a list of missing settings in the admin panel. The list told me whether a variable was set, but not whether it pointed to the right amount.
The key that links pricing to Stripe
On October 21 the stripe_sync_catalog command arrived. The source of truth is the active pricing in the database, and Stripe gets a copy of it. So the command knows which Stripe price corresponds to which row, every price has a stable lookup_key key in the form busikm_<plan>_<billing_period>, for example busikm_company_yearly.

Thanks to this key the command is idempotent. A second run changes nothing. When an amount in the pricing changes, the command creates a new price, moves the key to it and deactivates the old one:
def sync_catalog(client, *, dry_run: bool) -> SyncResult:
"""Brings Stripe products and prices in line with the PlanPrice table."""
desired = desired_catalog() # plan x period from PlanPrice, plus the vehicle overage
existing = {p["lookup_key"]: p for p in list_prices(client, desired)}
for item in desired:
product_id = ensure_product(client, item, dry_run)
current = existing.get(item.lookup_key)
if current and not price_mismatches(current, item) and current["product"] == product_id:
result.settings[item.setting] = current["id"] # matching price, nothing to do
continue
if dry_run:
continue
created = client.Price.create(
product=product_id, currency=item.currency, unit_amount=item.unit_amount,
tax_behavior="exclusive", recurring={"interval": item.interval},
lookup_key=item.lookup_key,
transfer_lookup_key=current is not None, # the key moves to the new price
)
if current is not None:
client.Price.modify(current["id"], active=False)
result.settings[item.setting] = created["id"]
return resultAt the end the command prints ready-made STRIPE_PRICE_*=price_... lines to copy into the environment variables. The same command with the --webhook-url parameter creates or fixes the webhook endpoint with exactly the five events the backend handles. The event list is taken from the webhook handler code, so after adding a new event I just run the command again, without retyping the list in the Stripe dashboard.
Safeguards
The command writes to an external payment system, so it has three safeguards:
--dry-runshows what would change and writes nothing,- a production Stripe key,
sk_live_orrk_live_, is rejected until I add--live, so a mistake with the key won't touch real payments, - the key comes in through the
--api-keyparameter or an environment variable, and the catalog module doesn't use global configuration but receives a ready-made client. This lets tests inject a Stripe fake so nothing goes out to the network.
There is also one business rule enforced in code. Stripe has a single vehicle surcharge price, so if the plans in the pricing get different surcharges, the command fails instead of silently picking one.
A check that only reads
The second command, stripe_check_config, creates nothing. For every price in the pricing, it checks whether the variable is set, whether it points to an existing price in Stripe, and whether that price matches. At the end it returns exit code 1 if anything is out of sync, so it works in scripts and in CI.
def price_mismatches(price, desired: CatalogPrice) -> list[str]:
"""Differences between a Stripe price and the pricing table, empty when it matches."""
problems = []
if not price.get("active"):
problems.append("price is inactive")
if price.get("currency") != desired.currency:
problems.append(f"currency {price.get('currency')} instead of {desired.currency}")
if price.get("unit_amount") != desired.unit_amount:
problems.append(f"net amount {price.get('unit_amount')} instead of {desired.unit_amount}")
if (price.get("recurring") or {}).get("interval") != desired.interval:
problems.append("wrong billing interval")
if price.get("tax_behavior") != "exclusive":
problems.append("wrong tax_behavior")
return problemsThe key point is that both commands use the same price_mismatches function. Sync and check share one definition of a matching price, so it can't happen that sync considers a price good and the check considers it bad. The check only reads, so it's safe to run in any environment, including those where sync can't run without --live.
The fake that let a bug through
The command had 16 tests and all of them passed against a Stripe fake. The fake returned plain dictionaries, and the code read fields through price.get("active"). But since version 15 of stripe-python, API objects are not dictionaries and .get() raises an error. So the code only worked in tests.

The fix had two parts, and the second was more important. In the catalog, the check and the surcharge calculation, every object from Stripe goes through as_plain, which recursively converts it into a dictionary. And the fake now returns a real library object instead of a dictionary, so the same bug can no longer get past the tests:
def _api_object(values: dict):
"""Like the real library since v15: an API object, not a dict (.get raises)."""
return stripe.StripeObject.construct_from(dict(values), "sk_test_fake")This is the same lesson I described in the post about E2E tests on a real backend, just on a smaller scale. A fake verifies your code only where it behaves like the real dependency. If it returns a more convenient type than the library does, the tests verify code nobody will ever run.
Business decisions that change the configuration
Along the way, two things came up where Stripe forces a product decision.
The first is the vehicle surcharge. A yearly plan with a monthly surcharge for each vehicle beyond the allowance is a subscription with mixed billing intervals. Stripe accepts that only in billing_mode: flexible mode. Without it, adding a surcharge item to a yearly plan would fail, so Checkout creates subscriptions in that mode from the start.
The second is VAT. The subscription seller, MOVGRANTO, uses the small-business VAT exemption, so the prices in the pricing are final amounts, and the invoice shows the "zw." (exempt) rate with a note on the legal basis for the exemption. Stripe doesn't calculate tax, because automatic VAT is turned off by the STRIPE_AUTOMATIC_TAX variable. The VAT rate in the pricing stays for the future. After registering as an active VAT payer, I only need to flip two variables, with no pricing migration and no price changes in Stripe.
What it costs
The command doesn't set the variables itself. It prints them, and I copy them into the web and worker services and redeploy. That's one manual step, and its cost is that after an amount changes in the pricing, I have to remember to swap the ID. The config check will catch it if I forget, but only if I run it.
The second issue is the boundary of what Stripe knows. Stripe only gets amounts. The vehicle limits in the plans live in the pricing in the database, and Stripe doesn't know them. While writing a runbook for staging, I found a mismatch between the database pricing and the business model document: different vehicle limits in both plans. Sync didn't catch it because it had nothing to compare. I fixed the document to match the code.
Still, the most valuable thing was separating the write from the check. The command that writes has safeguards and requires explicit consent for a production key. The command that only reads can run anywhere, often. It's the same idea as in the API contract between three repositories: one source of truth and an automatic drift check wherever a copy lives outside my code. If you're building payments in your SaaS and want to talk about where the pricing should live, get in touch.
