gigafibre-fsm/scripts/migration/fix_annual_billing_dates.py
louispaulb 101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:34:41 -04:00

273 lines
11 KiB
Python

"""
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="10.100.80.100", 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)