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)