Merge pull request #136 from valueonag/feat/demo-system-readieness

stripe fix
This commit is contained in:
Patrick Motsch 2026-04-21 08:58:37 +02:00 committed by GitHub
commit e3c74329e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 86 additions and 20 deletions

View file

@ -219,6 +219,22 @@ class ChatObjects:
# Everything else is an object # Everything else is an object
return True 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]]: 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.""" """Separate simple fields from object fields based on Pydantic model structure."""
simpleFields = {} simpleFields = {}
@ -232,7 +248,7 @@ class ChatObjects:
if fieldName in modelFields: if fieldName in modelFields:
fieldInfo = modelFields[fieldName] fieldInfo = modelFields[fieldName]
# Pydantic v2 only # Pydantic v2 only
fieldType = fieldInfo.annotation fieldType = self._unwrapOptional(fieldInfo.annotation)
# Always route relational/object fields to object_fields for separate handling # Always route relational/object fields to object_fields for separate handling
# These fields are stored in separate normalized tables, not as JSONB # These fields are stored in separate normalized tables, not as JSONB

View file

@ -246,6 +246,30 @@ class SubscriptionService:
if not priceMapping or (not priceMapping.stripePriceIdUsers and not priceMapping.stripePriceIdInstances): if not priceMapping or (not priceMapping.stripePriceIdUsers and not priceMapping.stripePriceIdInstances):
raise ValueError(f"Stripe Price IDs not provisioned for plan {plan.planKey}") 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) stripeCustomerId = self._resolveStripeCustomer(mandateId)
if not stripeCustomerId: if not stripeCustomerId:
raise ValueError(f"Could not resolve Stripe customer for mandate {mandateId}") raise ValueError(f"Could not resolve Stripe customer for mandate {mandateId}")
@ -353,6 +377,27 @@ class SubscriptionService:
except Exception as e: except Exception as e:
logger.error("Failed to clear stripeCustomerId for mandate %s: %s", mandateId, 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]: def _resolveStripeCustomer(self, mandateId: str) -> Optional[str]:
try: try:
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface

View file

@ -124,16 +124,6 @@ def _findExistingStripePrice(
return None 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( def _reconcilePrice(
stripe, stripe,
productId: str, productId: str,
@ -143,25 +133,35 @@ def _reconcilePrice(
nickname: str, nickname: str,
intervalCount: int = 1, intervalCount: int = 1,
) -> str: ) -> 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 from modules.shared.stripeClient import stripeToDict
expectedCents = int(round(expectedCHF * 100)) expectedCents = int(round(expectedCHF * 100))
actualCents = _getStripePriceAmount(stripe, oldPriceId) actualCents: Optional[int] = None
matchesRecurring = False matchesRecurring = False
isActive = False
retrieveFailed = False
try: try:
raw = stripe.Price.retrieve(oldPriceId) raw = stripe.Price.retrieve(oldPriceId)
pd = stripeToDict(raw) pd = stripeToDict(raw)
actualCents = pd.get("unit_amount")
matchesRecurring = _recurringMatches(pd.get("recurring") or {}, interval, intervalCount) matchesRecurring = _recurringMatches(pd.get("recurring") or {}, interval, intervalCount)
except Exception: # Stripe.Price.retrieve returns archived prices too, so we MUST check
pass # `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 return oldPriceId
logger.warning( logger.warning(
"Price drift or recurring mismatch for %s: Stripe amount=%s Rappen (expected %s). Rotating price.", "Rotating Stripe Price %s on product %s: active=%s amount=%s (expected %s) recurringMatches=%s retrieveFailed=%s.",
oldPriceId, actualCents, expectedCents, oldPriceId, productId, isActive, actualCents, expectedCents, matchesRecurring, retrieveFailed,
) )
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount) existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
@ -221,8 +221,13 @@ def _archiveOtherRecurringPrices(
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 the stored Stripe product IDs still exist.
Returns False when running against a different Stripe account or after DB copy."""
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: try:
if mapping.stripeProductIdUsers: if mapping.stripeProductIdUsers:
stripe.Product.retrieve(mapping.stripeProductIdUsers) stripe.Product.retrieve(mapping.stripeProductIdUsers)