| 1 | # Copyright (C) 2006 Rural Link Ltd. |
|---|
| 2 | # |
|---|
| 3 | # This file is an extension to crcnetd - CRCnet Configuration System Daemon |
|---|
| 4 | # |
|---|
| 5 | # RuralLink Billing Module - Provides logic for manipulating customer accounts |
|---|
| 6 | # according to the Rural Link business rules. |
|---|
| 7 | # |
|---|
| 8 | # Author: Matt Brown <matt@crc.net.nz> |
|---|
| 9 | # Ivan Meredith <ivan@ivan.net.nz> |
|---|
| 10 | # Version: $Id$ |
|---|
| 11 | # |
|---|
| 12 | # No usage or redistribution rights are granted for this file unless |
|---|
| 13 | # otherwise confirmed in writing by the copyright holder. |
|---|
| 14 | from crcnetd._utils.ccsd_common import * |
|---|
| 15 | from crcnetd._utils.ccsd_config import config_getboolean, config_get |
|---|
| 16 | from crcnetd._utils.ccsd_log import * |
|---|
| 17 | from crcnetd._utils.ccsd_events import * |
|---|
| 18 | from crcnetd._utils.ccsd_session import getSession, getSessionE |
|---|
| 19 | from datetime import datetime, timedelta |
|---|
| 20 | from crcnetd._utils.ccsd_server import exportViaXMLRPC |
|---|
| 21 | |
|---|
| 22 | from crcnetd.modules.ccs_billing import billing_email |
|---|
| 23 | from Cheetah.Template import Template |
|---|
| 24 | |
|---|
| 25 | ccs_mod_type = CCSD_SERVER |
|---|
| 26 | |
|---|
| 27 | ##################################################################### |
|---|
| 28 | # Scheduled event handling |
|---|
| 29 | ##################################################################### |
|---|
| 30 | @catchEvent("billingRegularUpdate") |
|---|
| 31 | def checkUsage(*args, **kwargs): |
|---|
| 32 | """Check customer data usage. Perform cap actions and warnings as |
|---|
| 33 | necessary""" |
|---|
| 34 | session = getSession(ADMIN_SESSION_ID) |
|---|
| 35 | |
|---|
| 36 | #Do warnings first to ensure that a warning email in generated |
|---|
| 37 | sql = "SELECT cs.*, c.* FROM broadband_account cs, customers c WHERE " \ |
|---|
| 38 | "cs.login_id=c.login_id AND cs.used_mb > cs.cap_at_mb*0.8 " \ |
|---|
| 39 | "AND cs.warning_sent=false AND cs.charged=true" |
|---|
| 40 | res = session.query(sql, ()) |
|---|
| 41 | for state in res: |
|---|
| 42 | doCapWarn(state) |
|---|
| 43 | |
|---|
| 44 | #Do any cap ations required |
|---|
| 45 | sql = "SELECT cs.*, c.* FROM broadband_account cs, v_customers c WHERE " \ |
|---|
| 46 | "cs.login_id=c.login_id AND cs.used_mb > cs.cap_at_mb" |
|---|
| 47 | res = session.query(sql, ()) |
|---|
| 48 | for state in res: |
|---|
| 49 | doCapAction(state) |
|---|
| 50 | |
|---|
| 51 | |
|---|
| 52 | |
|---|
| 53 | @catchEvent("billingDaily") |
|---|
| 54 | def rurallinkBillingDaily(*args, **kwargs): |
|---|
| 55 | """Peforms plan rollover for any daily plans""" |
|---|
| 56 | doRollover("daily") |
|---|
| 57 | |
|---|
| 58 | @catchEvent("billingWeekly") |
|---|
| 59 | def rurallinkBillingWeekly(*args, **kwargs): |
|---|
| 60 | """Peforms plan rollover for any weekly plans""" |
|---|
| 61 | doRollover("weekly") |
|---|
| 62 | |
|---|
| 63 | @catchEvent("billingMonthly") |
|---|
| 64 | def rurallinkBillingMonthly(*args, **kwargs): |
|---|
| 65 | """Peforms plan rollover for any monthly plans""" |
|---|
| 66 | doRollover("monthly") |
|---|
| 67 | |
|---|
| 68 | @catchEvent("billingMonthEnd") |
|---|
| 69 | def ruralllinkBillingMonthEnd(*args, **kwargs): |
|---|
| 70 | """Generate billing records for all plans and users at the end of month""" |
|---|
| 71 | doBilling() |
|---|
| 72 | |
|---|
| 73 | ##################################################################### |
|---|
| 74 | # Rural Link billing logic implementation |
|---|
| 75 | ##################################################################### |
|---|
| 76 | @exportViaXMLRPC(SESSION_RW, AUTH_ADMINISTRATOR) |
|---|
| 77 | def processBilling(auth, as_at=None): |
|---|
| 78 | doBilling(as_at) |
|---|
| 79 | return True |
|---|
| 80 | |
|---|
| 81 | def doBilling(as_at=None): |
|---|
| 82 | """Called on the monthly event to charge the customer for their plan |
|---|
| 83 | |
|---|
| 84 | The monthly event is called on the last day of the month, so we add one |
|---|
| 85 | to the current date to report the month that we're billing the customer |
|---|
| 86 | for. |
|---|
| 87 | """ |
|---|
| 88 | session = getSession(ADMIN_SESSION_ID) |
|---|
| 89 | print "DOING BILLING" |
|---|
| 90 | # Generate a billing record for each plan that is active in the system |
|---|
| 91 | sql = "SELECT * FROM rollover" |
|---|
| 92 | res = session.query(sql, ()) |
|---|
| 93 | |
|---|
| 94 | n = timedelta(1) |
|---|
| 95 | # Select all unbilled records up till NOW or the time specified, |
|---|
| 96 | # date the invoice one day earlier |
|---|
| 97 | if as_at is None: |
|---|
| 98 | datestr = datetime.strftime(datetime.now() + n, "%b %Y") |
|---|
| 99 | db_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S") |
|---|
| 100 | else: |
|---|
| 101 | datestr = datetime.fromtimestamp(as_at).strftime("%b %Y") |
|---|
| 102 | db_time = datetime.fromtimestamp(as_at).strftime("%Y-%m-%d %H:%M:%S") |
|---|
| 103 | |
|---|
| 104 | |
|---|
| 105 | for ro in res: |
|---|
| 106 | if ro['charged'] == 'f': |
|---|
| 107 | continue |
|---|
| 108 | |
|---|
| 109 | sql = "INSERT INTO billing_record (customer_id, record_date, " \ |
|---|
| 110 | "description, amount, quantity, record_type) VALUES ((select customer_id from customers where login_id=%s), %s, " \ |
|---|
| 111 | "%s, %s, 1, 'Plan');" |
|---|
| 112 | desc = "%s: %s" % (datestr, ro["description"]) |
|---|
| 113 | try: |
|---|
| 114 | session.execute(sql, (ro["login_id"], db_time, desc, ro["price"])) |
|---|
| 115 | except: |
|---|
| 116 | log_error("Failed to generate billing record for contact #%s" % \ |
|---|
| 117 | ro["login_id"], sys.exc_info()) |
|---|
| 118 | |
|---|
| 119 | def doRollover(type): |
|---|
| 120 | """Handles resetting customer caps when their usage rolls over""" |
|---|
| 121 | session = getSession(ADMIN_SESSION_ID) |
|---|
| 122 | |
|---|
| 123 | sql = "SELECT * FROM rollover WHERE cap_period=%s" |
|---|
| 124 | res = session.query(sql, (type)) |
|---|
| 125 | for ro in res: |
|---|
| 126 | try: |
|---|
| 127 | # Update the broadband_account table to reset the cap |
|---|
| 128 | sql = "UPDATE broadband_account SET plan_start=CURRENT_TIMESTAMP, "\ |
|---|
| 129 | "cap_at_mb=%s, used_mb=0, radius_plan=%s WHERE login_id=%s" |
|---|
| 130 | session.execute(sql, (ro["included_mb"], ro["radius_plan"], \ |
|---|
| 131 | ro["login_id"])) |
|---|
| 132 | except: |
|---|
| 133 | log_error("Failed to reset cap and state for contact #%s" % \ |
|---|
| 134 | ro["login_id"], sys.exc_info()) |
|---|
| 135 | |
|---|
| 136 | #Reset the warning sent flags |
|---|
| 137 | try: |
|---|
| 138 | sql = "UPDATE broadband_account SET warning_sent=false where exists " \ |
|---|
| 139 | "(select billing_plan.plan_id, billing_plan.cap_period from " \ |
|---|
| 140 | "billing_plan where broadband_account.plan_id = " \ |
|---|
| 141 | "billing_plan.plan_id AND billing_plan.cap_period=%s)" |
|---|
| 142 | session.execute(sql, (type)) |
|---|
| 143 | except: |
|---|
| 144 | log_error("Failed to reset warning_sent field") |
|---|
| 145 | |
|---|
| 146 | def doCapWarn(customer): |
|---|
| 147 | session = getSession(ADMIN_SESSION_ID) |
|---|
| 148 | |
|---|
| 149 | sql = "SELECT cap_action, cap_param, cap_price, description FROM " \ |
|---|
| 150 | "billing_plan WHERE plan_id=%s" |
|---|
| 151 | res = session.query(sql, (customer["plan_id"])) |
|---|
| 152 | |
|---|
| 153 | sql = "UPDATE broadband_account SET warning_sent='t' WHERE " \ |
|---|
| 154 | "login_id=%s" |
|---|
| 155 | session.execute(sql,(customer["login_id"])) |
|---|
| 156 | |
|---|
| 157 | subject = "Data Cap Notice: 80% used" |
|---|
| 158 | if res[0]["cap_action"] == "throttle": |
|---|
| 159 | sendCapEmail(customer, subject, "throttle-warn") |
|---|
| 160 | elif res[0]["cap_action"] == "purchase": |
|---|
| 161 | customer["purchase_mb"] = res[0]["cap_param"] |
|---|
| 162 | customer["purchase_price"] = "$%.2f" % (res[0]["cap_price"]*1.125) |
|---|
| 163 | sendCapEmail(customer, subject, "purchase-warn") |
|---|
| 164 | else: |
|---|
| 165 | log_error("Unknown cap action '%s' for contact '%s'" % \ |
|---|
| 166 | (res[0]["cap_action"], customer['username'])) |
|---|
| 167 | |
|---|
| 168 | def doCapAction(customer): |
|---|
| 169 | """Called when a customer has exceeded their cap. |
|---|
| 170 | |
|---|
| 171 | Rate limits or purchases more data for the customer, based on their chosen |
|---|
| 172 | plan. |
|---|
| 173 | """ |
|---|
| 174 | session = getSession(ADMIN_SESSION_ID) |
|---|
| 175 | |
|---|
| 176 | sql = "SELECT cap_action, cap_param, cap_price, description FROM " \ |
|---|
| 177 | "billing_plan WHERE plan_id=%s" |
|---|
| 178 | res = session.query(sql, (customer["plan_id"])) |
|---|
| 179 | |
|---|
| 180 | used_mb = int(customer["used_mb"]) |
|---|
| 181 | subject = "Data Cap Notice: %.1fGB used." % (used_mb/1024.0) |
|---|
| 182 | |
|---|
| 183 | if res[0]["cap_action"] == "throttle": |
|---|
| 184 | # Customer has chosen to be throttled |
|---|
| 185 | current_plan = customer["radius_plan"] |
|---|
| 186 | if current_plan == res[0]["cap_param"]: |
|---|
| 187 | # Already throttled |
|---|
| 188 | return |
|---|
| 189 | else: |
|---|
| 190 | # Throttle and notify |
|---|
| 191 | sql = "UPDATE broadband_account SET radius_plan=%s WHERE " \ |
|---|
| 192 | "login_id=%s" |
|---|
| 193 | session.execute(sql,(res[0]["cap_param"], customer["login_id"])) |
|---|
| 194 | log_info("Moved '%s' to plan %s (throttled)" % \ |
|---|
| 195 | (customer["username"], res[0]["description"])) |
|---|
| 196 | # Tell the customer |
|---|
| 197 | sendCapEmail(customer, subject, "throttle") |
|---|
| 198 | elif res[0]["cap_action"] == "purchase": |
|---|
| 199 | # Customer has chosen to purchase more data |
|---|
| 200 | cap_at_mb = customer["cap_at_mb"] |
|---|
| 201 | used_mb = customer["used_mb"] |
|---|
| 202 | |
|---|
| 203 | # If the customer is leeching at full rate, it's possible that we may |
|---|
| 204 | # have to purchase multiple extra blocks of usage to get their cap |
|---|
| 205 | # back above their current data usage. |
|---|
| 206 | extra_mb = 0 |
|---|
| 207 | extra_price = 0 |
|---|
| 208 | while cap_at_mb < used_mb: |
|---|
| 209 | try: |
|---|
| 210 | sql = "UPDATE broadband_account SET cap_at_mb=cap_at_mb+%s " \ |
|---|
| 211 | "WHERE login_id=%s" |
|---|
| 212 | session.execute(sql,(res[0]["cap_param"], |
|---|
| 213 | customer["login_id"])) |
|---|
| 214 | |
|---|
| 215 | sql = "INSERT INTO billing_record (customer_id, record_date, " \ |
|---|
| 216 | "description, amount, quantity,record_type) VALUES (%%s, " \ |
|---|
| 217 | "CURRENT_TIMESTAMP, '%sMB data pack', %%s, 1, 'Data')" % \ |
|---|
| 218 | (res[0]["cap_param"]) |
|---|
| 219 | session.execute(sql, (customer["customer_id"], |
|---|
| 220 | res[0]["cap_price"])) |
|---|
| 221 | cap_at_mb += res[0]["cap_param"] |
|---|
| 222 | log_info("Purchased %sMB block for '%s'. New cap: %sMB." % \ |
|---|
| 223 | (res[0]["cap_param"], customer["username"], cap_at_mb)) |
|---|
| 224 | extra_mb += res[0]["cap_param"] |
|---|
| 225 | extra_price += res[0]["cap_price"] |
|---|
| 226 | except: |
|---|
| 227 | log_error("Failed to purchase new block for '%s'!" % \ |
|---|
| 228 | customer["username"], sys.exc_info()) |
|---|
| 229 | break |
|---|
| 230 | # Tell the customer |
|---|
| 231 | customer["purchased_mb"] = extra_mb |
|---|
| 232 | customer["purchased_price"] = "$%.2f" % (extra_price*1.125) |
|---|
| 233 | sendCapEmail(customer, subject, "purchase") |
|---|
| 234 | else: |
|---|
| 235 | log_error("Unknown cap action '%s' for contact '%s'" % \ |
|---|
| 236 | (res[0]["cap_action"], customer['username'])) |
|---|
| 237 | |
|---|
| 238 | def sendCapEmail(customer, subject, action): |
|---|
| 239 | """Sends the user an email regarding their cap |
|---|
| 240 | |
|---|
| 241 | The second parameter is used to choose a template which decides the actual |
|---|
| 242 | message that the user will receive. |
|---|
| 243 | Valid values are typically the same as the cap actions. |
|---|
| 244 | Eg (throttle, purchase), but can be any value that will find a valid file in |
|---|
| 245 | the billing template directory. |
|---|
| 246 | """ |
|---|
| 247 | from crcnetd.modules.ccs_billing import billing_template_dir |
|---|
| 248 | |
|---|
| 249 | # Can't send email if no template available |
|---|
| 250 | if billing_template_dir is None: |
|---|
| 251 | log_error("Failed to load cap email template; No template specified") |
|---|
| 252 | return False |
|---|
| 253 | template = "%s/cap-%s-email.tmpl" % (billing_template_dir, action) |
|---|
| 254 | try: |
|---|
| 255 | email_template = Template(file=template) |
|---|
| 256 | except: |
|---|
| 257 | log_error("Failed to load cap email template: %s" % template, |
|---|
| 258 | sys.exc_info()) |
|---|
| 259 | return "" |
|---|
| 260 | |
|---|
| 261 | # Setup data for the body of the email |
|---|
| 262 | info = {} |
|---|
| 263 | info["customer"] = customer |
|---|
| 264 | email_template._searchList = [info] |
|---|
| 265 | email_template._CHEETAH__searchList += email_template._searchList |
|---|
| 266 | |
|---|
| 267 | # Send the email |
|---|
| 268 | rv = billing_email(customer["email"], subject, |
|---|
| 269 | str(email_template).strip(), []) |
|---|
| 270 | if rv: |
|---|
| 271 | log_info("Emailed Cap Notice to '%s'" % customer["email"]) |
|---|
| 272 | return True |
|---|
| 273 | else: |
|---|
| 274 | log_error("Failed to email Cap Notice to '%s'" % customer["email"]) |
|---|
| 275 | return False |
|---|