prices
This commit is contained in:
parent
93f28f57df
commit
268c4b8e1e
2 changed files with 107 additions and 19 deletions
|
|
@ -217,8 +217,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
|||
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
|
||||
},
|
||||
billingPeriod=BillingPeriodEnum.MONTHLY,
|
||||
pricePerUserCHF=90.0,
|
||||
pricePerFeatureInstanceCHF=150.0,
|
||||
pricePerUserCHF=19.0,
|
||||
pricePerFeatureInstanceCHF=29.0,
|
||||
maxDataVolumeMB=1024,
|
||||
budgetAiCHF=10.0,
|
||||
),
|
||||
|
|
@ -231,8 +231,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
|||
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
|
||||
},
|
||||
billingPeriod=BillingPeriodEnum.YEARLY,
|
||||
pricePerUserCHF=1080.0,
|
||||
pricePerFeatureInstanceCHF=1800.0,
|
||||
pricePerUserCHF=228.0,
|
||||
pricePerFeatureInstanceCHF=348.0,
|
||||
maxDataVolumeMB=1024,
|
||||
budgetAiCHF=120.0,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ so that invoice line items show clear, descriptive names:
|
|||
- "Feature-Instanzen"
|
||||
|
||||
Idempotent — safe to call on every startup.
|
||||
|
||||
Source of truth for unit amounts is BUILTIN_PLANS (CHF). On each run, persisted
|
||||
Stripe Price IDs are reconciled: if Stripe's unit_amount differs from the
|
||||
catalog, a new Price is created, the old one is archived, and poweron_billing
|
||||
StripePlanPrice is updated. Other stale active Prices on the same Product
|
||||
(same recurring interval) are archived so only the catalog-matching Price stays active.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -93,12 +99,25 @@ def _createStripeProduct(stripe, name: str, description: str, planKey: str, line
|
|||
return product.id
|
||||
|
||||
|
||||
def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval: str) -> Optional[str]:
|
||||
def _recurringMatches(recurring: Dict, interval: str, intervalCount: int) -> bool:
|
||||
if not recurring:
|
||||
return False
|
||||
if recurring.get("interval") != interval:
|
||||
return False
|
||||
ic = recurring.get("interval_count")
|
||||
if ic is None:
|
||||
ic = 1
|
||||
return int(ic) == int(intervalCount)
|
||||
|
||||
|
||||
def _findExistingStripePrice(
|
||||
stripe, productId: str, unitAmount: int, interval: str, intervalCount: int = 1,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
prices = stripe.Price.list(product=productId, active=True, limit=50)
|
||||
for p in prices.data:
|
||||
recurring = p.get("recurring") or {}
|
||||
if p.get("unit_amount") == unitAmount and recurring.get("interval") == interval:
|
||||
if p.get("unit_amount") == unitAmount and _recurringMatches(recurring, interval, intervalCount):
|
||||
return p.id
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -115,24 +134,43 @@ def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
|
|||
return None
|
||||
|
||||
|
||||
def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float, interval: str, nickname: str) -> str:
|
||||
def _reconcilePrice(
|
||||
stripe,
|
||||
productId: str,
|
||||
oldPriceId: str,
|
||||
expectedCHF: float,
|
||||
interval: str,
|
||||
nickname: str,
|
||||
intervalCount: int = 1,
|
||||
) -> str:
|
||||
"""If the stored Stripe Price has a different amount, create a new one and deactivate the old."""
|
||||
expectedCents = int(expectedCHF * 100)
|
||||
actualCents = _getStripePriceAmount(stripe, oldPriceId)
|
||||
from modules.shared.stripeClient import stripeToDict
|
||||
|
||||
if actualCents == expectedCents:
|
||||
expectedCents = int(round(expectedCHF * 100))
|
||||
actualCents = _getStripePriceAmount(stripe, oldPriceId)
|
||||
matchesRecurring = False
|
||||
try:
|
||||
raw = stripe.Price.retrieve(oldPriceId)
|
||||
pd = stripeToDict(raw)
|
||||
matchesRecurring = _recurringMatches(pd.get("recurring") or {}, interval, intervalCount)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if actualCents == expectedCents and matchesRecurring:
|
||||
return oldPriceId
|
||||
|
||||
logger.warning(
|
||||
"Price drift detected for %s: Stripe has %s Rappen, catalog expects %s Rappen. Rotating price.",
|
||||
"Price drift or recurring mismatch for %s: Stripe amount=%s Rappen (expected %s). Rotating price.",
|
||||
oldPriceId, actualCents, expectedCents,
|
||||
)
|
||||
|
||||
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval)
|
||||
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
|
||||
if existingMatch:
|
||||
newPriceId = existingMatch
|
||||
else:
|
||||
newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname)
|
||||
newPriceId = _createStripePrice(
|
||||
stripe, productId, expectedCHF, interval, nickname, intervalCount,
|
||||
)
|
||||
|
||||
try:
|
||||
stripe.Price.modify(oldPriceId, active=False)
|
||||
|
|
@ -143,18 +181,45 @@ def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float,
|
|||
return newPriceId
|
||||
|
||||
|
||||
def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str:
|
||||
def _createStripePrice(
|
||||
stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str, intervalCount: int = 1,
|
||||
) -> str:
|
||||
price = stripe.Price.create(
|
||||
product=productId,
|
||||
unit_amount=int(unitAmountCHF * 100),
|
||||
unit_amount=int(round(unitAmountCHF * 100)),
|
||||
currency="chf",
|
||||
recurring={"interval": interval},
|
||||
recurring={"interval": interval, "interval_count": intervalCount},
|
||||
nickname=nickname,
|
||||
)
|
||||
logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval)
|
||||
return price.id
|
||||
|
||||
|
||||
def _archiveOtherRecurringPrices(
|
||||
stripe, productId: Optional[str], keepPriceId: Optional[str], interval: str, intervalCount: int = 1,
|
||||
) -> None:
|
||||
"""Archive every other active recurring price on the product (same interval pattern)."""
|
||||
if not productId or not keepPriceId:
|
||||
return
|
||||
try:
|
||||
prices = stripe.Price.list(product=productId, active=True, limit=100)
|
||||
for p in prices.data:
|
||||
if p.id == keepPriceId:
|
||||
continue
|
||||
recurring = p.get("recurring") or {}
|
||||
if not recurring:
|
||||
continue
|
||||
if not _recurringMatches(recurring, interval, intervalCount):
|
||||
continue
|
||||
try:
|
||||
stripe.Price.modify(p.id, active=False)
|
||||
logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
|
||||
except Exception as ex:
|
||||
logger.warning("Could not archive price %s: %s", p.id, ex)
|
||||
except Exception as e:
|
||||
logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
|
||||
|
||||
|
||||
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."""
|
||||
|
|
@ -195,6 +260,7 @@ def bootstrapStripePrices() -> None:
|
|||
continue
|
||||
|
||||
interval = stripePeriod["interval"]
|
||||
intervalCount = int(stripePeriod.get("interval_count") or 1)
|
||||
|
||||
if planKey in existing:
|
||||
mapping = existing[planKey]
|
||||
|
|
@ -206,6 +272,7 @@ def bootstrapStripePrices() -> None:
|
|||
reconciledUsers = _reconcilePrice(
|
||||
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
|
||||
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
|
||||
intervalCount,
|
||||
)
|
||||
if reconciledUsers != mapping.stripePriceIdUsers:
|
||||
changed = True
|
||||
|
|
@ -213,16 +280,27 @@ def bootstrapStripePrices() -> None:
|
|||
reconciledInstances = _reconcilePrice(
|
||||
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
|
||||
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
|
||||
intervalCount,
|
||||
)
|
||||
if reconciledInstances != mapping.stripePriceIdInstances:
|
||||
changed = True
|
||||
|
||||
_archiveOtherRecurringPrices(
|
||||
stripe, mapping.stripeProductIdUsers, reconciledUsers, interval, intervalCount,
|
||||
)
|
||||
_archiveOtherRecurringPrices(
|
||||
stripe, mapping.stripeProductIdInstances, reconciledInstances, interval, intervalCount,
|
||||
)
|
||||
|
||||
if changed:
|
||||
db.recordModify(StripePlanPrice, mapping.id, {
|
||||
"stripePriceIdUsers": reconciledUsers,
|
||||
"stripePriceIdInstances": reconciledInstances,
|
||||
})
|
||||
logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances)
|
||||
logger.info(
|
||||
"Reconciled Stripe prices for plan %s to catalog (CHF): users=%s, instances=%s",
|
||||
planKey, reconciledUsers, reconciledInstances,
|
||||
)
|
||||
else:
|
||||
logger.debug("Stripe prices up-to-date for plan %s", planKey)
|
||||
continue
|
||||
|
|
@ -245,11 +323,16 @@ def bootstrapStripePrices() -> None:
|
|||
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}",
|
||||
planKey, "users",
|
||||
)
|
||||
priceIdUsers = _findExistingStripePrice(stripe, productIdUsers, int(plan.pricePerUserCHF * 100), interval)
|
||||
userCents = int(round(plan.pricePerUserCHF * 100))
|
||||
priceIdUsers = _findExistingStripePrice(
|
||||
stripe, productIdUsers, userCents, interval, intervalCount,
|
||||
)
|
||||
if not priceIdUsers:
|
||||
priceIdUsers = _createStripePrice(
|
||||
stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
|
||||
intervalCount,
|
||||
)
|
||||
_archiveOtherRecurringPrices(stripe, productIdUsers, priceIdUsers, interval, intervalCount)
|
||||
|
||||
if plan.pricePerFeatureInstanceCHF > 0:
|
||||
productIdInstances = _findStripeProduct(stripe, planKey, "instances")
|
||||
|
|
@ -258,14 +341,19 @@ def bootstrapStripePrices() -> None:
|
|||
stripe, "Feature-Instanzen", f"Feature-Instanzen für {plan.title.get('de', planKey)}",
|
||||
planKey, "instances",
|
||||
)
|
||||
instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
|
||||
priceIdInstances = _findExistingStripePrice(
|
||||
stripe, productIdInstances, int(plan.pricePerFeatureInstanceCHF * 100), interval,
|
||||
stripe, productIdInstances, instCents, interval, intervalCount,
|
||||
)
|
||||
if not priceIdInstances:
|
||||
priceIdInstances = _createStripePrice(
|
||||
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
|
||||
f"{planKey} — Feature-Instanz",
|
||||
intervalCount,
|
||||
)
|
||||
_archiveOtherRecurringPrices(
|
||||
stripe, productIdInstances, priceIdInstances, interval, intervalCount,
|
||||
)
|
||||
|
||||
persistData = {
|
||||
"stripeProductId": "",
|
||||
|
|
|
|||
Loading…
Reference in a new issue