This commit is contained in:
ValueOn AG 2026-04-02 13:09:04 +02:00
parent 93f28f57df
commit 268c4b8e1e
2 changed files with 107 additions and 19 deletions

View file

@ -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,
),

View file

@ -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,13 +341,18 @@ 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 = {