Merge pull request #136 from valueonag/feat/demo-system-readieness
stripe fix
This commit is contained in:
commit
e3c74329e5
3 changed files with 86 additions and 20 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue