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": "",