From 268c4b8e1e3112e032d0635bc71f1235b1f29d09 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 2 Apr 2026 13:09:04 +0200
Subject: [PATCH] prices
---
modules/datamodels/datamodelSubscription.py | 8 +-
.../serviceSubscription/stripeBootstrap.py | 118 +++++++++++++++---
2 files changed, 107 insertions(+), 19 deletions(-)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 8fcf10f2..227ba5eb 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -217,8 +217,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.MONTHLY,
- pricePerUserCHF=90.0,
- pricePerFeatureInstanceCHF=150.0,
+ pricePerUserCHF=19.0,
+ pricePerFeatureInstanceCHF=29.0,
maxDataVolumeMB=1024,
budgetAiCHF=10.0,
),
@@ -231,8 +231,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.YEARLY,
- pricePerUserCHF=1080.0,
- pricePerFeatureInstanceCHF=1800.0,
+ pricePerUserCHF=228.0,
+ pricePerFeatureInstanceCHF=348.0,
maxDataVolumeMB=1024,
budgetAiCHF=120.0,
),
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index 14e9424a..869ab52f 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -9,6 +9,12 @@ so that invoice line items show clear, descriptive names:
- "Feature-Instanzen"
Idempotent — safe to call on every startup.
+
+Source of truth for unit amounts is BUILTIN_PLANS (CHF). On each run, persisted
+Stripe Price IDs are reconciled: if Stripe's unit_amount differs from the
+catalog, a new Price is created, the old one is archived, and poweron_billing
+StripePlanPrice is updated. Other stale active Prices on the same Product
+(same recurring interval) are archived so only the catalog-matching Price stays active.
"""
import logging
@@ -93,12 +99,25 @@ def _createStripeProduct(stripe, name: str, description: str, planKey: str, line
return product.id
-def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval: str) -> Optional[str]:
+def _recurringMatches(recurring: Dict, interval: str, intervalCount: int) -> bool:
+ if not recurring:
+ return False
+ if recurring.get("interval") != interval:
+ return False
+ ic = recurring.get("interval_count")
+ if ic is None:
+ ic = 1
+ return int(ic) == int(intervalCount)
+
+
+def _findExistingStripePrice(
+ stripe, productId: str, unitAmount: int, interval: str, intervalCount: int = 1,
+) -> Optional[str]:
try:
prices = stripe.Price.list(product=productId, active=True, limit=50)
for p in prices.data:
recurring = p.get("recurring") or {}
- if p.get("unit_amount") == unitAmount and recurring.get("interval") == interval:
+ if p.get("unit_amount") == unitAmount and _recurringMatches(recurring, interval, intervalCount):
return p.id
except Exception:
pass
@@ -115,24 +134,43 @@ def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
return None
-def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float, interval: str, nickname: str) -> str:
+def _reconcilePrice(
+ stripe,
+ productId: str,
+ oldPriceId: str,
+ expectedCHF: float,
+ interval: str,
+ nickname: str,
+ intervalCount: int = 1,
+) -> str:
"""If the stored Stripe Price has a different amount, create a new one and deactivate the old."""
- expectedCents = int(expectedCHF * 100)
- actualCents = _getStripePriceAmount(stripe, oldPriceId)
+ from modules.shared.stripeClient import stripeToDict
- if actualCents == expectedCents:
+ expectedCents = int(round(expectedCHF * 100))
+ actualCents = _getStripePriceAmount(stripe, oldPriceId)
+ matchesRecurring = False
+ try:
+ raw = stripe.Price.retrieve(oldPriceId)
+ pd = stripeToDict(raw)
+ matchesRecurring = _recurringMatches(pd.get("recurring") or {}, interval, intervalCount)
+ except Exception:
+ pass
+
+ if actualCents == expectedCents and matchesRecurring:
return oldPriceId
logger.warning(
- "Price drift detected for %s: Stripe has %s Rappen, catalog expects %s Rappen. Rotating price.",
+ "Price drift or recurring mismatch for %s: Stripe amount=%s Rappen (expected %s). Rotating price.",
oldPriceId, actualCents, expectedCents,
)
- existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval)
+ existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
if existingMatch:
newPriceId = existingMatch
else:
- newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname)
+ newPriceId = _createStripePrice(
+ stripe, productId, expectedCHF, interval, nickname, intervalCount,
+ )
try:
stripe.Price.modify(oldPriceId, active=False)
@@ -143,18 +181,45 @@ def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float,
return newPriceId
-def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str:
+def _createStripePrice(
+ stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str, intervalCount: int = 1,
+) -> str:
price = stripe.Price.create(
product=productId,
- unit_amount=int(unitAmountCHF * 100),
+ unit_amount=int(round(unitAmountCHF * 100)),
currency="chf",
- recurring={"interval": interval},
+ recurring={"interval": interval, "interval_count": intervalCount},
nickname=nickname,
)
logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval)
return price.id
+def _archiveOtherRecurringPrices(
+ stripe, productId: Optional[str], keepPriceId: Optional[str], interval: str, intervalCount: int = 1,
+) -> None:
+ """Archive every other active recurring price on the product (same interval pattern)."""
+ if not productId or not keepPriceId:
+ return
+ try:
+ prices = stripe.Price.list(product=productId, active=True, limit=100)
+ for p in prices.data:
+ if p.id == keepPriceId:
+ continue
+ recurring = p.get("recurring") or {}
+ if not recurring:
+ continue
+ if not _recurringMatches(recurring, interval, intervalCount):
+ continue
+ try:
+ stripe.Price.modify(p.id, active=False)
+ logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
+ except Exception as ex:
+ logger.warning("Could not archive price %s: %s", p.id, ex)
+ except Exception as e:
+ logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
+
+
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
"""Quick check whether at least the stored product IDs still exist in Stripe.
Returns False when running against a different Stripe account or after DB copy."""
@@ -195,6 +260,7 @@ def bootstrapStripePrices() -> None:
continue
interval = stripePeriod["interval"]
+ intervalCount = int(stripePeriod.get("interval_count") or 1)
if planKey in existing:
mapping = existing[planKey]
@@ -206,6 +272,7 @@ def bootstrapStripePrices() -> None:
reconciledUsers = _reconcilePrice(
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
+ intervalCount,
)
if reconciledUsers != mapping.stripePriceIdUsers:
changed = True
@@ -213,16 +280,27 @@ def bootstrapStripePrices() -> None:
reconciledInstances = _reconcilePrice(
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
+ intervalCount,
)
if reconciledInstances != mapping.stripePriceIdInstances:
changed = True
+ _archiveOtherRecurringPrices(
+ stripe, mapping.stripeProductIdUsers, reconciledUsers, interval, intervalCount,
+ )
+ _archiveOtherRecurringPrices(
+ stripe, mapping.stripeProductIdInstances, reconciledInstances, interval, intervalCount,
+ )
+
if changed:
db.recordModify(StripePlanPrice, mapping.id, {
"stripePriceIdUsers": reconciledUsers,
"stripePriceIdInstances": reconciledInstances,
})
- logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances)
+ logger.info(
+ "Reconciled Stripe prices for plan %s to catalog (CHF): users=%s, instances=%s",
+ planKey, reconciledUsers, reconciledInstances,
+ )
else:
logger.debug("Stripe prices up-to-date for plan %s", planKey)
continue
@@ -245,11 +323,16 @@ def bootstrapStripePrices() -> None:
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}",
planKey, "users",
)
- priceIdUsers = _findExistingStripePrice(stripe, productIdUsers, int(plan.pricePerUserCHF * 100), interval)
+ userCents = int(round(plan.pricePerUserCHF * 100))
+ priceIdUsers = _findExistingStripePrice(
+ stripe, productIdUsers, userCents, interval, intervalCount,
+ )
if not priceIdUsers:
priceIdUsers = _createStripePrice(
stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
+ intervalCount,
)
+ _archiveOtherRecurringPrices(stripe, productIdUsers, priceIdUsers, interval, intervalCount)
if plan.pricePerFeatureInstanceCHF > 0:
productIdInstances = _findStripeProduct(stripe, planKey, "instances")
@@ -258,14 +341,19 @@ def bootstrapStripePrices() -> None:
stripe, "Feature-Instanzen", f"Feature-Instanzen für {plan.title.get('de', planKey)}",
planKey, "instances",
)
+ instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
priceIdInstances = _findExistingStripePrice(
- stripe, productIdInstances, int(plan.pricePerFeatureInstanceCHF * 100), interval,
+ stripe, productIdInstances, instCents, interval, intervalCount,
)
if not priceIdInstances:
priceIdInstances = _createStripePrice(
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
f"{planKey} — Feature-Instanz",
+ intervalCount,
)
+ _archiveOtherRecurringPrices(
+ stripe, productIdInstances, priceIdInstances, interval, intervalCount,
+ )
persistData = {
"stripeProductId": "",