stripe fix
This commit is contained in:
parent
d0735ad342
commit
5ef311a82e
3 changed files with 86 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue