""" Fix annual subscription billing dates. For each annual subscription (billing_frequency='A'): 1. Create an annual copy of its subscription plan (billing_interval=Year) 2. Re-link the subscription to the annual plan 3. Set current_invoice_start/end from legacy date_next_invoice 4. ERPNext scheduler uses plan's billing_interval to determine period length Run inside erpnext-backend-1: /home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_annual_billing_dates.py """ import frappe import pymysql import os from datetime import datetime, timezone, timedelta DRY_RUN = False os.chdir("/home/frappe/frappe-bench/sites") frappe.init(site="erp.gigafibre.ca", sites_path=".") frappe.connect() print("Connected:", frappe.local.site) conn = pymysql.connect( host="legacy-db", user="facturation", password="VD67owoj", database="gestionclient", cursorclass=pymysql.cursors.DictCursor ) # ═══════════════════════════════════════════════════════════════ # LOAD LEGACY DATA # ═══════════════════════════════════════════════════════════════ print("Loading legacy annual services...") with conn.cursor() as cur: cur.execute(""" SELECT s.id, s.date_next_invoice, s.date_orig, s.payment_recurrence, s.hijack_price, s.hijack, p.price as base_price, p.sku FROM service s JOIN product p ON p.id = s.product_id WHERE s.status = 1 AND s.payment_recurrence = 0 """) annual_services = cur.fetchall() conn.close() print("Annual services in legacy: {}".format(len(annual_services))) legacy_annual = {s["id"]: s for s in annual_services} def ts_to_date(ts): if not ts or ts <= 0: return None try: return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") except (ValueError, OSError): return None # ═══════════════════════════════════════════════════════════════ # LOAD ANNUAL SUBSCRIPTIONS FROM ERPNEXT # ═══════════════════════════════════════════════════════════════ print("\nLoading annual subscriptions from ERPNext...") annual_subs = frappe.db.sql(""" SELECT s.name, s.legacy_service_id, s.party, s.status, s.current_invoice_start, s.current_invoice_end, s.billing_frequency, s.start_date, spd.plan, spd.name as spd_name FROM "tabSubscription" s LEFT JOIN "tabSubscription Plan Detail" spd ON spd.parent = s.name WHERE s.billing_frequency = 'A' ORDER BY s.name """, as_dict=True) print("Annual subscriptions: {}".format(len(annual_subs))) # ═══════════════════════════════════════════════════════════════ # LOAD EXISTING PLANS — need to create annual copies # ═══════════════════════════════════════════════════════════════ plan_names = set(s["plan"] for s in annual_subs if s["plan"]) print("Distinct plans used by annual subs: {}".format(len(plan_names))) existing_plans = {} if plan_names: placeholders = ",".join(["%s"] * len(plan_names)) plans = frappe.db.sql(""" SELECT name, plan_name, billing_interval, billing_interval_count, cost, currency, item, price_determination, price_list FROM "tabSubscription Plan" WHERE plan_name IN ({}) """.format(placeholders), list(plan_names), as_dict=True) existing_plans = {p["plan_name"]: p for p in plans} for p in plans: print(" {} — {}, cost={}, interval={} x{}".format( p["plan_name"], p["item"], p["cost"], p["billing_interval"], p["billing_interval_count"])) # Check which annual plan copies already exist all_annual_plans = frappe.db.sql(""" SELECT name, plan_name FROM "tabSubscription Plan" WHERE billing_interval = 'Year' """, as_dict=True) existing_annual_names = {p["plan_name"] for p in all_annual_plans} print("\nExisting annual plans: {}".format(len(all_annual_plans))) # ═══════════════════════════════════════════════════════════════ # BUILD UPDATES # ═══════════════════════════════════════════════════════════════ print("\n" + "=" * 70) print("BUILDING UPDATES") print("=" * 70) updates = [] issues = [] plans_to_create = {} # monthly_plan_name → annual_plan_name for sub in annual_subs: sid = sub["legacy_service_id"] legacy = legacy_annual.get(sid) if not legacy: issues.append((sub["name"], sid, "No legacy service found")) continue # Determine billing period from legacy next_inv_date = ts_to_date(legacy["date_next_invoice"]) start_date = ts_to_date(legacy["date_orig"]) if next_inv_date: inv_start_dt = datetime.strptime(next_inv_date, "%Y-%m-%d") today_dt = datetime.now() # Roll forward past dates to next cycle while inv_start_dt < today_dt - timedelta(days=365): inv_start_dt = inv_start_dt.replace(year=inv_start_dt.year + 1) inv_start = inv_start_dt.strftime("%Y-%m-%d") inv_end = (inv_start_dt.replace(year=inv_start_dt.year + 1) - timedelta(days=1)).strftime("%Y-%m-%d") elif start_date: start_dt = datetime.strptime(start_date, "%Y-%m-%d") today_dt = datetime.now() candidate = start_dt.replace(year=today_dt.year) if candidate < today_dt: candidate = candidate.replace(year=today_dt.year + 1) inv_start = candidate.strftime("%Y-%m-%d") inv_end = (candidate.replace(year=candidate.year + 1) - timedelta(days=1)).strftime("%Y-%m-%d") else: issues.append((sub["name"], sid, "No dates in legacy")) continue # Figure out the annual plan name: PLAN-AN-{SKU} monthly_plan = sub["plan"] if monthly_plan: # Extract SKU from plan name (PLAN-FTTH80I → FTTH80I) sku_part = monthly_plan.replace("PLAN-", "") annual_plan = "PLAN-AN-" + sku_part if monthly_plan not in plans_to_create and annual_plan not in existing_annual_names: plans_to_create[monthly_plan] = annual_plan else: annual_plan = None updates.append({ "sub_name": sub["name"], "spd_name": sub["spd_name"], "legacy_id": sid, "party": sub["party"], "monthly_plan": monthly_plan, "annual_plan": annual_plan, "inv_start": inv_start, "inv_end": inv_end, "sku": legacy.get("sku", "?"), "price": float(legacy["hijack_price"]) if legacy["hijack"] else float(legacy["base_price"] or 0), }) print("\nUpdates to apply: {}".format(len(updates))) print("Annual plans to create: {}".format(len(plans_to_create))) print("Issues: {}".format(len(issues))) # Show plans to create for monthly, annual in plans_to_create.items(): orig = existing_plans.get(monthly, {}) print(" {} → {} (item: {}, cost: {})".format( monthly, annual, orig.get("item", "?"), orig.get("cost", "?"))) # Show sample updates print("\nSample updates:") for u in updates[:15]: print(" {} svc#{} {} — {} → period {}/{} plan:{} (${:.2f})".format( u["sub_name"], u["legacy_id"], u["sku"], u["party"], u["inv_start"], u["inv_end"], u["annual_plan"] or "NONE", u["price"])) if issues: print("\nIssues:") for name, sid, reason in issues[:10]: print(" {} svc#{}: {}".format(name, sid, reason)) # ═══════════════════════════════════════════════════════════════ # APPLY # ═══════════════════════════════════════════════════════════════ if DRY_RUN: print("\n*** DRY RUN — no changes made ***") print("Set DRY_RUN = False to apply {} updates + {} new plans".format( len(updates), len(plans_to_create))) else: print("\n" + "=" * 70) print("APPLYING CHANGES") print("=" * 70) ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Step 0: Fix existing plan names — migration stored SP-xxx but autoname expects plan_name # The SPD plan field already stores plan_name, so name must match for Frappe ORM to work mismatched = frappe.db.sql(""" SELECT name, plan_name FROM "tabSubscription Plan" WHERE name != plan_name """, as_dict=True) if mismatched: print("Fixing {} plan names (SP-xxx → plan_name)...".format(len(mismatched))) for p in mismatched: frappe.db.sql(""" UPDATE "tabSubscription Plan" SET name = %s WHERE name = %s """, (p["plan_name"], p["name"])) frappe.db.commit() print(" Done — plan names now match plan_name field") # Step 1: Create annual plan copies for monthly_name, annual_name in plans_to_create.items(): orig = existing_plans.get(monthly_name) if not orig: print(" SKIP {} — original plan not found".format(monthly_name)) continue try: plan = frappe.get_doc({ "doctype": "Subscription Plan", "plan_name": annual_name, "item": orig["item"], "price_determination": orig.get("price_determination") or "Fixed Rate", "cost": orig["cost"], "currency": orig.get("currency") or "CAD", "billing_interval": "Year", "billing_interval_count": 1, }) plan.insert(ignore_permissions=True) print(" Created annual plan: {} (item: {}, cost: {})".format( annual_name, orig["item"], orig["cost"])) except Exception as e: print(" ERR creating plan {}: {}".format(annual_name, str(e)[:100])) frappe.db.commit() # Step 2: Update subscriptions — billing dates + re-link to annual plan updated = 0 plan_switched = 0 for u in updates: # Set billing dates frappe.db.sql(""" UPDATE "tabSubscription" SET current_invoice_start = %s, current_invoice_end = %s WHERE name = %s """, (u["inv_start"], u["inv_end"], u["sub_name"])) # Switch plan in Subscription Plan Detail if u["annual_plan"] and u["spd_name"]: frappe.db.sql(""" UPDATE "tabSubscription Plan Detail" SET plan = %s WHERE name = %s """, (u["annual_plan"], u["spd_name"])) plan_switched += 1 updated += 1 frappe.db.commit() print("\nUpdated {} subscriptions with billing dates".format(updated)) print("Switched {} subscriptions to annual plans".format(plan_switched)) print("\n" + "=" * 70) print("DONE") print("=" * 70)