|
| 1 | +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors |
| 2 | +# Keeps a Subscription Plan's product_price_id in sync with its ERPNext cost. |
| 3 | + |
| 4 | +import frappe |
| 5 | + |
| 6 | +from payments.payment_gateways.stripe_utils import ( |
| 7 | + get_stripe_settings_for_gateway, |
| 8 | + to_minor_units, |
| 9 | +) |
| 10 | + |
| 11 | +INTERVAL_MAP = {"Day": "day", "Week": "week", "Month": "month", "Year": "year"} |
| 12 | + |
| 13 | + |
| 14 | +def sync_stripe_price(doc, method=None): |
| 15 | + """doc_event on Subscription Plan (erpnext) — owned by the payments app.""" |
| 16 | + if doc.price_determination not in ("Fixed Rate", "Monthly Rate", "Based On Price List"): |
| 17 | + return |
| 18 | + if not doc.payment_gateway: |
| 19 | + return |
| 20 | + |
| 21 | + settings = get_stripe_settings_for_gateway(doc.payment_gateway) |
| 22 | + if not settings: |
| 23 | + return # plan is not on a Stripe gateway |
| 24 | + |
| 25 | + if not settings.get("sync_subscription_price"): |
| 26 | + return # opt-in disabled on this Stripe account — keep the manual flow |
| 27 | + |
| 28 | + # Per-unit recurring amount. For "Based On Price List" this is resolved from |
| 29 | + # the plan's price list at qty=1 (Stripe multiplies by quantity at checkout); |
| 30 | + # Fixed/Monthly Rate use the plan's per-interval cost field. |
| 31 | + unit_cost = _plan_unit_cost(doc) |
| 32 | + if not unit_cost: |
| 33 | + if doc.price_determination == "Based On Price List": |
| 34 | + frappe.msgprint( |
| 35 | + "No rate found in the plan's Price List for its Item, so the " |
| 36 | + "Stripe price could not be synced.", |
| 37 | + indicator="orange", |
| 38 | + alert=True, |
| 39 | + ) |
| 40 | + return |
| 41 | + |
| 42 | + from payments.payment_gateways.stripe_utils import get_stripe_client |
| 43 | + |
| 44 | + client = get_stripe_client(settings) |
| 45 | + |
| 46 | + unit_amount = to_minor_units(unit_cost, doc.currency) |
| 47 | + recurring = { |
| 48 | + "interval": INTERVAL_MAP[doc.billing_interval], |
| 49 | + "interval_count": doc.billing_interval_count, |
| 50 | + } |
| 51 | + |
| 52 | + try: |
| 53 | + product_id = None |
| 54 | + if doc.product_price_id: |
| 55 | + existing = client.prices.retrieve(doc.product_price_id) |
| 56 | + if _matches(existing, unit_amount, doc.currency, recurring): |
| 57 | + return # already in sync — no API write |
| 58 | + product_id = existing.product # reuse same Product |
| 59 | + client.prices.update(doc.product_price_id, {"active": False}) # archive (prices are immutable) |
| 60 | + |
| 61 | + if not product_id: |
| 62 | + product_id = client.products.create( |
| 63 | + {"name": doc.plan_name, "metadata": {"erpnext_plan": doc.name}} |
| 64 | + ).id |
| 65 | + |
| 66 | + price = client.prices.create( |
| 67 | + { |
| 68 | + "product": product_id, |
| 69 | + "unit_amount": unit_amount, |
| 70 | + "currency": (doc.currency or "").lower(), |
| 71 | + "recurring": recurring, |
| 72 | + "metadata": {"erpnext_plan": doc.name}, |
| 73 | + } |
| 74 | + ) |
| 75 | + doc.product_price_id = price.id # persists: we run on validate |
| 76 | + |
| 77 | + except Exception: |
| 78 | + frappe.log_error(frappe.get_traceback(), "Stripe price sync failed") |
| 79 | + frappe.msgprint( |
| 80 | + "Could not sync this plan's price to Stripe; the Product Price ID " "may be stale. Please retry.", |
| 81 | + indicator="orange", |
| 82 | + alert=True, |
| 83 | + ) |
| 84 | + |
| 85 | + |
| 86 | +def _matches(price, unit_amount, currency, recurring): |
| 87 | + return bool( |
| 88 | + price.get("active") |
| 89 | + and price.unit_amount == unit_amount |
| 90 | + and (price.currency or "").upper() == (currency or "").upper() |
| 91 | + and price.get("recurring") |
| 92 | + and price.recurring.interval == recurring["interval"] |
| 93 | + and price.recurring.interval_count == recurring["interval_count"] |
| 94 | + ) |
| 95 | + |
| 96 | + |
| 97 | +def _plan_unit_cost(doc): |
| 98 | + """Per-unit recurring amount in the plan's currency (qty=1). |
| 99 | +
|
| 100 | + Stripe stores a single unit price and multiplies by quantity at checkout, |
| 101 | + so we always push the qty=1 rate. |
| 102 | + """ |
| 103 | + if doc.price_determination == "Based On Price List": |
| 104 | + from payments.utils import erpnext_app_import_guard |
| 105 | + |
| 106 | + with erpnext_app_import_guard(): |
| 107 | + from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate |
| 108 | + |
| 109 | + return get_plan_rate(doc.name, quantity=1) |
| 110 | + # Fixed Rate / Monthly Rate carry a per-interval cost on the plan itself. |
| 111 | + return doc.cost |
0 commit comments