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.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
}, },
billingPeriod=BillingPeriodEnum.MONTHLY, billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0, pricePerUserCHF=19.0,
pricePerFeatureInstanceCHF=150.0, pricePerFeatureInstanceCHF=29.0,
maxDataVolumeMB=1024, maxDataVolumeMB=1024,
budgetAiCHF=10.0, 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.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
}, },
billingPeriod=BillingPeriodEnum.YEARLY, billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0, pricePerUserCHF=228.0,
pricePerFeatureInstanceCHF=1800.0, pricePerFeatureInstanceCHF=348.0,
maxDataVolumeMB=1024, maxDataVolumeMB=1024,
budgetAiCHF=120.0, budgetAiCHF=120.0,
), ),

View file

@ -9,6 +9,12 @@ so that invoice line items show clear, descriptive names:
- "Feature-Instanzen" - "Feature-Instanzen"
Idempotent safe to call on every startup. 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 import logging
@ -93,12 +99,25 @@ def _createStripeProduct(stripe, name: str, description: str, planKey: str, line
return product.id 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: try:
prices = stripe.Price.list(product=productId, active=True, limit=50) prices = stripe.Price.list(product=productId, active=True, limit=50)
for p in prices.data: for p in prices.data:
recurring = p.get("recurring") or {} 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 return p.id
except Exception: except Exception:
pass pass
@ -115,24 +134,43 @@ def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
return None 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.""" """If the stored Stripe Price has a different amount, create a new one and deactivate the old."""
expectedCents = int(expectedCHF * 100) from modules.shared.stripeClient import stripeToDict
actualCents = _getStripePriceAmount(stripe, oldPriceId)
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 return oldPriceId
logger.warning( 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, oldPriceId, actualCents, expectedCents,
) )
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval) existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
if existingMatch: if existingMatch:
newPriceId = existingMatch newPriceId = existingMatch
else: else:
newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname) newPriceId = _createStripePrice(
stripe, productId, expectedCHF, interval, nickname, intervalCount,
)
try: try:
stripe.Price.modify(oldPriceId, active=False) stripe.Price.modify(oldPriceId, active=False)
@ -143,18 +181,45 @@ def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float,
return newPriceId 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( price = stripe.Price.create(
product=productId, product=productId,
unit_amount=int(unitAmountCHF * 100), unit_amount=int(round(unitAmountCHF * 100)),
currency="chf", currency="chf",
recurring={"interval": interval}, recurring={"interval": interval, "interval_count": intervalCount},
nickname=nickname, nickname=nickname,
) )
logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval) logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval)
return price.id 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: def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
"""Quick check whether at least the stored product IDs still exist in Stripe. """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.""" Returns False when running against a different Stripe account or after DB copy."""
@ -195,6 +260,7 @@ def bootstrapStripePrices() -> None:
continue continue
interval = stripePeriod["interval"] interval = stripePeriod["interval"]
intervalCount = int(stripePeriod.get("interval_count") or 1)
if planKey in existing: if planKey in existing:
mapping = existing[planKey] mapping = existing[planKey]
@ -206,6 +272,7 @@ def bootstrapStripePrices() -> None:
reconciledUsers = _reconcilePrice( reconciledUsers = _reconcilePrice(
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers, stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz", plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
intervalCount,
) )
if reconciledUsers != mapping.stripePriceIdUsers: if reconciledUsers != mapping.stripePriceIdUsers:
changed = True changed = True
@ -213,16 +280,27 @@ def bootstrapStripePrices() -> None:
reconciledInstances = _reconcilePrice( reconciledInstances = _reconcilePrice(
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances, stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz", plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
intervalCount,
) )
if reconciledInstances != mapping.stripePriceIdInstances: if reconciledInstances != mapping.stripePriceIdInstances:
changed = True changed = True
_archiveOtherRecurringPrices(
stripe, mapping.stripeProductIdUsers, reconciledUsers, interval, intervalCount,
)
_archiveOtherRecurringPrices(
stripe, mapping.stripeProductIdInstances, reconciledInstances, interval, intervalCount,
)
if changed: if changed:
db.recordModify(StripePlanPrice, mapping.id, { db.recordModify(StripePlanPrice, mapping.id, {
"stripePriceIdUsers": reconciledUsers, "stripePriceIdUsers": reconciledUsers,
"stripePriceIdInstances": reconciledInstances, "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: else:
logger.debug("Stripe prices up-to-date for plan %s", planKey) logger.debug("Stripe prices up-to-date for plan %s", planKey)
continue continue
@ -245,11 +323,16 @@ def bootstrapStripePrices() -> None:
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}", stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}",
planKey, "users", 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: if not priceIdUsers:
priceIdUsers = _createStripePrice( priceIdUsers = _createStripePrice(
stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz", stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
intervalCount,
) )
_archiveOtherRecurringPrices(stripe, productIdUsers, priceIdUsers, interval, intervalCount)
if plan.pricePerFeatureInstanceCHF > 0: if plan.pricePerFeatureInstanceCHF > 0:
productIdInstances = _findStripeProduct(stripe, planKey, "instances") 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)}", stripe, "Feature-Instanzen", f"Feature-Instanzen für {plan.title.get('de', planKey)}",
planKey, "instances", planKey, "instances",
) )
instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
priceIdInstances = _findExistingStripePrice( priceIdInstances = _findExistingStripePrice(
stripe, productIdInstances, int(plan.pricePerFeatureInstanceCHF * 100), interval, stripe, productIdInstances, instCents, interval, intervalCount,
) )
if not priceIdInstances: if not priceIdInstances:
priceIdInstances = _createStripePrice( priceIdInstances = _createStripePrice(
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval, stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
f"{planKey} — Feature-Instanz", f"{planKey} — Feature-Instanz",
intervalCount,
) )
_archiveOtherRecurringPrices(
stripe, productIdInstances, priceIdInstances, interval, intervalCount,
)
persistData = { persistData = {
"stripeProductId": "", "stripeProductId": "",