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.",
|
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
|
||||||
},
|
},
|
||||||
billingPeriod=BillingPeriodEnum.MONTHLY,
|
billingPeriod=BillingPeriodEnum.MONTHLY,
|
||||||
pricePerUserCHF=90.0,
|
pricePerUserCHF=19.0,
|
||||||
pricePerFeatureInstanceCHF=150.0,
|
pricePerFeatureInstanceCHF=29.0,
|
||||||
maxDataVolumeMB=1024,
|
maxDataVolumeMB=1024,
|
||||||
budgetAiCHF=10.0,
|
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.",
|
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
|
||||||
},
|
},
|
||||||
billingPeriod=BillingPeriodEnum.YEARLY,
|
billingPeriod=BillingPeriodEnum.YEARLY,
|
||||||
pricePerUserCHF=1080.0,
|
pricePerUserCHF=228.0,
|
||||||
pricePerFeatureInstanceCHF=1800.0,
|
pricePerFeatureInstanceCHF=348.0,
|
||||||
maxDataVolumeMB=1024,
|
maxDataVolumeMB=1024,
|
||||||
budgetAiCHF=120.0,
|
budgetAiCHF=120.0,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ so that invoice line items show clear, descriptive names:
|
||||||
- "Feature-Instanzen"
|
- "Feature-Instanzen"
|
||||||
|
|
||||||
Idempotent — safe to call on every startup.
|
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
|
import logging
|
||||||
|
|
@ -93,12 +99,25 @@ def _createStripeProduct(stripe, name: str, description: str, planKey: str, line
|
||||||
return product.id
|
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:
|
try:
|
||||||
prices = stripe.Price.list(product=productId, active=True, limit=50)
|
prices = stripe.Price.list(product=productId, active=True, limit=50)
|
||||||
for p in prices.data:
|
for p in prices.data:
|
||||||
recurring = p.get("recurring") or {}
|
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
|
return p.id
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -115,24 +134,43 @@ def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
|
||||||
return None
|
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."""
|
"""If the stored Stripe Price has a different amount, create a new one and deactivate the old."""
|
||||||
expectedCents = int(expectedCHF * 100)
|
from modules.shared.stripeClient import stripeToDict
|
||||||
actualCents = _getStripePriceAmount(stripe, oldPriceId)
|
|
||||||
|
|
||||||
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
|
return oldPriceId
|
||||||
|
|
||||||
logger.warning(
|
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,
|
oldPriceId, actualCents, expectedCents,
|
||||||
)
|
)
|
||||||
|
|
||||||
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval)
|
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
|
||||||
if existingMatch:
|
if existingMatch:
|
||||||
newPriceId = existingMatch
|
newPriceId = existingMatch
|
||||||
else:
|
else:
|
||||||
newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname)
|
newPriceId = _createStripePrice(
|
||||||
|
stripe, productId, expectedCHF, interval, nickname, intervalCount,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stripe.Price.modify(oldPriceId, active=False)
|
stripe.Price.modify(oldPriceId, active=False)
|
||||||
|
|
@ -143,18 +181,45 @@ def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float,
|
||||||
return newPriceId
|
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(
|
price = stripe.Price.create(
|
||||||
product=productId,
|
product=productId,
|
||||||
unit_amount=int(unitAmountCHF * 100),
|
unit_amount=int(round(unitAmountCHF * 100)),
|
||||||
currency="chf",
|
currency="chf",
|
||||||
recurring={"interval": interval},
|
recurring={"interval": interval, "interval_count": intervalCount},
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
)
|
)
|
||||||
logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval)
|
logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval)
|
||||||
return price.id
|
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:
|
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
|
||||||
"""Quick check whether at least the stored product IDs still exist in Stripe.
|
"""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."""
|
Returns False when running against a different Stripe account or after DB copy."""
|
||||||
|
|
@ -195,6 +260,7 @@ def bootstrapStripePrices() -> None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
interval = stripePeriod["interval"]
|
interval = stripePeriod["interval"]
|
||||||
|
intervalCount = int(stripePeriod.get("interval_count") or 1)
|
||||||
|
|
||||||
if planKey in existing:
|
if planKey in existing:
|
||||||
mapping = existing[planKey]
|
mapping = existing[planKey]
|
||||||
|
|
@ -206,6 +272,7 @@ def bootstrapStripePrices() -> None:
|
||||||
reconciledUsers = _reconcilePrice(
|
reconciledUsers = _reconcilePrice(
|
||||||
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
|
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
|
||||||
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
|
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
|
||||||
|
intervalCount,
|
||||||
)
|
)
|
||||||
if reconciledUsers != mapping.stripePriceIdUsers:
|
if reconciledUsers != mapping.stripePriceIdUsers:
|
||||||
changed = True
|
changed = True
|
||||||
|
|
@ -213,16 +280,27 @@ def bootstrapStripePrices() -> None:
|
||||||
reconciledInstances = _reconcilePrice(
|
reconciledInstances = _reconcilePrice(
|
||||||
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
|
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
|
||||||
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
|
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
|
||||||
|
intervalCount,
|
||||||
)
|
)
|
||||||
if reconciledInstances != mapping.stripePriceIdInstances:
|
if reconciledInstances != mapping.stripePriceIdInstances:
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
_archiveOtherRecurringPrices(
|
||||||
|
stripe, mapping.stripeProductIdUsers, reconciledUsers, interval, intervalCount,
|
||||||
|
)
|
||||||
|
_archiveOtherRecurringPrices(
|
||||||
|
stripe, mapping.stripeProductIdInstances, reconciledInstances, interval, intervalCount,
|
||||||
|
)
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
db.recordModify(StripePlanPrice, mapping.id, {
|
db.recordModify(StripePlanPrice, mapping.id, {
|
||||||
"stripePriceIdUsers": reconciledUsers,
|
"stripePriceIdUsers": reconciledUsers,
|
||||||
"stripePriceIdInstances": reconciledInstances,
|
"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:
|
else:
|
||||||
logger.debug("Stripe prices up-to-date for plan %s", planKey)
|
logger.debug("Stripe prices up-to-date for plan %s", planKey)
|
||||||
continue
|
continue
|
||||||
|
|
@ -245,11 +323,16 @@ def bootstrapStripePrices() -> None:
|
||||||
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}",
|
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}",
|
||||||
planKey, "users",
|
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:
|
if not priceIdUsers:
|
||||||
priceIdUsers = _createStripePrice(
|
priceIdUsers = _createStripePrice(
|
||||||
stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
|
stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
|
||||||
|
intervalCount,
|
||||||
)
|
)
|
||||||
|
_archiveOtherRecurringPrices(stripe, productIdUsers, priceIdUsers, interval, intervalCount)
|
||||||
|
|
||||||
if plan.pricePerFeatureInstanceCHF > 0:
|
if plan.pricePerFeatureInstanceCHF > 0:
|
||||||
productIdInstances = _findStripeProduct(stripe, planKey, "instances")
|
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)}",
|
stripe, "Feature-Instanzen", f"Feature-Instanzen für {plan.title.get('de', planKey)}",
|
||||||
planKey, "instances",
|
planKey, "instances",
|
||||||
)
|
)
|
||||||
|
instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
|
||||||
priceIdInstances = _findExistingStripePrice(
|
priceIdInstances = _findExistingStripePrice(
|
||||||
stripe, productIdInstances, int(plan.pricePerFeatureInstanceCHF * 100), interval,
|
stripe, productIdInstances, instCents, interval, intervalCount,
|
||||||
)
|
)
|
||||||
if not priceIdInstances:
|
if not priceIdInstances:
|
||||||
priceIdInstances = _createStripePrice(
|
priceIdInstances = _createStripePrice(
|
||||||
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
|
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
|
||||||
f"{planKey} — Feature-Instanz",
|
f"{planKey} — Feature-Instanz",
|
||||||
|
intervalCount,
|
||||||
)
|
)
|
||||||
|
_archiveOtherRecurringPrices(
|
||||||
|
stripe, productIdInstances, priceIdInstances, interval, intervalCount,
|
||||||
|
)
|
||||||
|
|
||||||
persistData = {
|
persistData = {
|
||||||
"stripeProductId": "",
|
"stripeProductId": "",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue