From 5ef311a82eac83d835801bf03fc4af246f1409ea Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 21 Apr 2026 08:57:43 +0200
Subject: [PATCH] stripe fix
---
modules/interfaces/interfaceDbChat.py | 18 +++++++-
.../mainServiceSubscription.py | 45 +++++++++++++++++++
.../serviceSubscription/stripeBootstrap.py | 43 ++++++++++--------
3 files changed, 86 insertions(+), 20 deletions(-)
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index 3614d04b..be097263 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -219,6 +219,22 @@ class ChatObjects:
# Everything else is an object
return True
+ def _unwrapOptional(self, fieldType):
+ """Unwrap ``Optional[X]`` / ``Union[X, None]`` to ``X``.
+
+ The generic JSONB detection in ``_separateObjectFields`` checks
+ ``__origin__`` against ``(dict, list)``. For ``Optional[List[str]]``
+ the origin is ``Union``, so JSONB fields declared as ``Optional[...]``
+ would silently fall through to ``objectFields`` and be dropped on
+ write. Unwrapping the Optional first keeps the existing detection
+ intact while supporting nullable JSONB columns.
+ """
+ if getattr(fieldType, '__origin__', None) is Union:
+ nonNone = [a for a in getattr(fieldType, '__args__', ()) if a is not type(None)]
+ if len(nonNone) == 1:
+ return nonNone[0]
+ return fieldType
+
def _separateObjectFields(self, model_class, data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]:
"""Separate simple fields from object fields based on Pydantic model structure."""
simpleFields = {}
@@ -232,7 +248,7 @@ class ChatObjects:
if fieldName in modelFields:
fieldInfo = modelFields[fieldName]
# Pydantic v2 only
- fieldType = fieldInfo.annotation
+ fieldType = self._unwrapOptional(fieldInfo.annotation)
# Always route relational/object fields to object_fields for separate handling
# These fields are stored in separate normalized tables, not as JSONB
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 8a2ff8d5..c9ba5f54 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -246,6 +246,30 @@ class SubscriptionService:
if not priceMapping or (not priceMapping.stripePriceIdUsers and not priceMapping.stripePriceIdInstances):
raise ValueError(f"Stripe Price IDs not provisioned for plan {plan.planKey}")
+ # Defense in depth: if either of the persisted Stripe Price IDs has been
+ # archived in Stripe in the meantime (e.g. another environment's bootstrap
+ # rotated them on a shared Stripe account), the upcoming
+ # ``checkout.Session.create`` would fail with "The price specified is
+ # inactive". Trigger a one-shot bootstrap re-run to rotate inactive prices,
+ # then reload the mapping. This is idempotent and cheap when nothing
+ # changed.
+ if not self._areStripePricesActive(stripe, priceMapping):
+ logger.warning(
+ "Stripe Price(s) for plan %s are no longer active in Stripe — "
+ "running bootstrap to rotate.", plan.planKey,
+ )
+ try:
+ from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
+ bootstrapStripePrices()
+ priceMapping = getStripePricesForPlan(plan.planKey)
+ except Exception as ex:
+ logger.error("Inline Stripe bootstrap failed for plan %s: %s", plan.planKey, ex)
+ if not priceMapping or not self._areStripePricesActive(stripe, priceMapping):
+ raise ValueError(
+ f"Stripe Price IDs for plan {plan.planKey} are inactive and "
+ "could not be rotated automatically."
+ )
+
stripeCustomerId = self._resolveStripeCustomer(mandateId)
if not stripeCustomerId:
raise ValueError(f"Could not resolve Stripe customer for mandate {mandateId}")
@@ -353,6 +377,27 @@ class SubscriptionService:
except Exception as e:
logger.error("Failed to clear stripeCustomerId for mandate %s: %s", mandateId, e)
+ def _areStripePricesActive(self, stripe, priceMapping) -> bool:
+ """Verify that every persisted Stripe Price ID for the plan is still
+ ``active`` in Stripe. ``stripe.Price.retrieve`` returns archived prices
+ too, so we must inspect the ``active`` flag explicitly. Returns True
+ only when ALL non-empty price IDs resolve to active prices."""
+ priceIds = [pid for pid in (
+ getattr(priceMapping, "stripePriceIdUsers", None),
+ getattr(priceMapping, "stripePriceIdInstances", None),
+ ) if pid]
+ if not priceIds:
+ return False
+ for pid in priceIds:
+ try:
+ price = stripe.Price.retrieve(pid)
+ if not bool(getattr(price, "active", False) if not isinstance(price, dict) else price.get("active")):
+ return False
+ except Exception as ex:
+ logger.warning("Stripe Price %s could not be retrieved: %s", pid, ex)
+ return False
+ return True
+
def _resolveStripeCustomer(self, mandateId: str) -> Optional[str]:
try:
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index d26ef50e..ce63a43d 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -124,16 +124,6 @@ def _findExistingStripePrice(
return None
-def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
- """Retrieve the unit_amount (in Rappen) of an existing Stripe Price."""
- try:
- from modules.shared.stripeClient import stripeToDict
- price = stripeToDict(stripe.Price.retrieve(priceId))
- return price.get("unit_amount") if price else None
- except Exception:
- return None
-
-
def _reconcilePrice(
stripe,
productId: str,
@@ -143,25 +133,35 @@ def _reconcilePrice(
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, is no longer active,
+ or has a different recurring interval, create/find a new active one and
+ deactivate the old."""
from modules.shared.stripeClient import stripeToDict
expectedCents = int(round(expectedCHF * 100))
- actualCents = _getStripePriceAmount(stripe, oldPriceId)
+ actualCents: Optional[int] = None
matchesRecurring = False
+ isActive = False
+ retrieveFailed = False
try:
raw = stripe.Price.retrieve(oldPriceId)
pd = stripeToDict(raw)
+ actualCents = pd.get("unit_amount")
matchesRecurring = _recurringMatches(pd.get("recurring") or {}, interval, intervalCount)
- except Exception:
- pass
+ # Stripe.Price.retrieve returns archived prices too, so we MUST check
+ # `active` explicitly. Subscription.create rejects inactive prices with
+ # "The price specified is inactive. This field only accepts active prices."
+ isActive = bool(pd.get("active"))
+ except Exception as ex:
+ retrieveFailed = True
+ logger.warning("Could not retrieve Stripe Price %s: %s", oldPriceId, ex)
- if actualCents == expectedCents and matchesRecurring:
+ if not retrieveFailed and isActive and actualCents == expectedCents and matchesRecurring:
return oldPriceId
logger.warning(
- "Price drift or recurring mismatch for %s: Stripe amount=%s Rappen (expected %s). Rotating price.",
- oldPriceId, actualCents, expectedCents,
+ "Rotating Stripe Price %s on product %s: active=%s amount=%s (expected %s) recurringMatches=%s retrieveFailed=%s.",
+ oldPriceId, productId, isActive, actualCents, expectedCents, matchesRecurring, retrieveFailed,
)
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
@@ -221,8 +221,13 @@ def _archiveOtherRecurringPrices(
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."""
+ """Quick check whether the stored Stripe product IDs still exist.
+
+ Returns False when running against a different Stripe account or after a
+ DB copy from another environment. Price-level validation (active flag,
+ drift) is handled by ``_reconcilePrice``; we don't fail here on archived
+ prices, otherwise we'd needlessly re-provision products on every rotation.
+ """
try:
if mapping.stripeProductIdUsers:
stripe.Product.retrieve(mapping.stripeProductIdUsers)