├── .gitignore ├── MANIFEST.in ├── README.md ├── iff ├── __init__.py ├── api.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── hooks.py ├── iff │ ├── __init__.py │ └── install.py ├── jobs │ └── daily.py ├── modules.txt ├── patches.txt └── templates │ ├── __init__.py │ ├── emails │ ├── capture-failed.html │ └── emandate.html │ └── pages │ └── __init__.py ├── license.txt ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | iff/docs/current -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include iff *.css 8 | recursive-include iff *.csv 9 | recursive-include iff *.html 10 | recursive-include iff *.ico 11 | recursive-include iff *.js 12 | recursive-include iff *.json 13 | recursive-include iff *.md 14 | recursive-include iff *.png 15 | recursive-include iff *.py 16 | recursive-include iff *.svg 17 | recursive-include iff *.txt 18 | recursive-exclude iff *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## IFF ERPNext 2 | 3 | Membership automation for Internet Freedom Foundation. 4 | Contains a daily job to trigger payments using Razorpay E-Mandate for IFF Members, and send subsequent reports. 5 | 6 | #### License 7 | 8 | MIT 9 | -------------------------------------------------------------------------------- /iff/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | __version__ = '0.0.1' 5 | 6 | -------------------------------------------------------------------------------- /iff/api.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | import erpnext 3 | import razorpay 4 | import six 5 | import json 6 | from frappe.integrations.utils import get_payment_gateway_controller, make_post_request 7 | from frappe.utils import getdate, add_months, add_years, flt 8 | 9 | def get_client(): 10 | controller = get_payment_gateway_controller("Razorpay") 11 | controller.init_client() 12 | return controller.client 13 | 14 | @frappe.whitelist() 15 | def create_member(customer_id, plan, pan=None, address_dict=None): 16 | """ 17 | :param customer_id: Razorpay Customer ID 18 | :param plan: Razorpay Plan ID 19 | :param pan: Member's PAN Number 20 | :param address_dict: Member's address details 21 | { 22 | "address_line1": "", 23 | "address_line2: "", 24 | "state": "", 25 | "city": "", 26 | "country": "", 27 | "pincode": "" 28 | } 29 | :return: Member ID of the created member 30 | """ 31 | client = get_client() 32 | customer = frappe._dict(client.customer.fetch(customer_id)) 33 | 34 | # defaults 35 | today = getdate() 36 | plan = frappe.db.exists("Membership Type", { "razorpay_plan_id": plan }) 37 | member_name = None 38 | 39 | try: 40 | member = frappe.new_doc("Member") 41 | member.update({ 42 | "member_name": customer.name, 43 | "membership_type": plan, 44 | "pan_number": pan, 45 | "email_id": customer.email, 46 | "contact": customer.contact, 47 | "customer_id": customer_id, 48 | "subscription_status": "Active", 49 | "token_status": "Initiated", 50 | "subscription_start": today, 51 | "subscription_end": add_years(today, 2), 52 | }) 53 | member.insert(ignore_permissions=True) 54 | 55 | if address_dict: 56 | create_address(address_dict, doctype="Member", doc=member) 57 | 58 | member_name = member.name 59 | except Exception as e: 60 | title = "E Mandate Member creation failed for Customer {0}:{1}".format(customer_id, customer.email) 61 | log = frappe.log_error(e, title) 62 | 63 | return member_name 64 | 65 | @frappe.whitelist() 66 | def create_donor(name, email, contact, pan=None, address_dict=None): 67 | """ 68 | :param name: Razorpay Customer ID 69 | :param plan: Razorpay Plan ID 70 | :param pan: Member's PAN Number 71 | :param address_dict: Member's address details 72 | { 73 | "address_line1": "", 74 | "address_line2: "", 75 | "state": "", 76 | "city": "", 77 | "country": "", 78 | "pincode": "" 79 | } 80 | :return: Donor ID of the created donor 81 | """ 82 | donor_type = frappe.db.get_single_value("Non Profit Settings", "default_donor_type") 83 | 84 | donor = frappe.get_doc({ 85 | "doctype": "Donor", 86 | "donor_name": name, 87 | "donor_type": donor_type, 88 | "email": email, 89 | "contact": contact, 90 | "pan_number": pan 91 | }).insert(ignore_permissions=True) 92 | 93 | if address_dict: 94 | create_address(address_dict, doctype="Donor", doc=donor) 95 | 96 | return donor.name 97 | 98 | 99 | def create_address(address_dict, doctype, doc): 100 | party_name = doc.get('member_name') if doctype == 'Member' else doc.get('donor_name') 101 | if isinstance(address_dict, six.string_types): 102 | address_dict = json.loads(address_dict) 103 | 104 | address = frappe.get_doc({ 105 | "doctype": "Address", 106 | "address_type": "Billing", 107 | "address_title": party_name, 108 | "address_line1": address_dict.get("address_line1"), 109 | "address_line2": address_dict.get("address_line2"), 110 | "city": address_dict.get("city"), 111 | "state": address_dict.get("state"), 112 | "country": address_dict.get("country"), 113 | "pincode": address_dict.get("pincode"), 114 | "links": [{ 115 | "link_doctype": doctype, 116 | "link_name": doc.name, 117 | "link_title": party_name 118 | }] 119 | }).insert(ignore_permissions=True) 120 | 121 | return address 122 | 123 | 124 | def verify_signature(data): 125 | signature = frappe.request.headers.get("X-Razorpay-Signature") 126 | settings = frappe.get_doc("Non Profit Settings") 127 | key = settings.get_webhook_secret() 128 | controller = frappe.get_doc("Razorpay Settings") 129 | 130 | controller.verify_signature(data, signature, key) 131 | frappe.set_user(settings.creation_user) 132 | 133 | @frappe.whitelist(allow_guest=True) 134 | def payment_authorized(): 135 | # https://razorpay.com/docs/api/recurring-payments/webhooks/#payment-authorized 136 | data = frappe.request.get_data(as_text=True) 137 | 138 | try: 139 | verify_signature(data) 140 | except Exception as e: 141 | title = "E Mandate Webhook Verification Error during payment authorization" 142 | frappe.log_error(data, title) 143 | return { "status": "Failed", "reason": e} 144 | 145 | if isinstance(data, six.string_types): 146 | data = json.loads(data) 147 | data = frappe._dict(data) 148 | 149 | payment = data.payload.get("payment", {}).get("entity", {}) 150 | payment = frappe._dict(payment) 151 | 152 | controller = frappe.get_doc("Razorpay Settings") 153 | controller.init_client() 154 | client = controller.client 155 | 156 | member = frappe.db.exists("Member", {"customer_id": payment.customer_id}) 157 | token_data = client.token.fetch(payment.customer_id, payment.token_id) 158 | 159 | if not member: 160 | max_amount = token_data.get("max_amount") / 100 161 | plan = frappe.db.exists("Membership Type", { "amount": max_amount}) 162 | if plan: 163 | plan_id = frappe.db.get_value("Membership Type", plan, "razorpay_plan_id") 164 | member = create_member(payment.customer_id, plan_id) 165 | 166 | if member: 167 | frappe.db.set_value("Member", member, "razorpay_token", payment.token_id) 168 | status = token_data.get("recurring_details").get("status") 169 | if status == "confirmed": 170 | frappe.db.set_value("Member", member, "token_status", "Confirmed") 171 | if status == "rejected": 172 | frappe.db.set_value("Member", member, "token_status", "Rejected") 173 | return member 174 | 175 | @frappe.whitelist(allow_guest=True) 176 | def token_update(): 177 | # https://razorpay.com/docs/api/recurring-payments/webhooks/#token-confirmed 178 | data = frappe.request.get_data(as_text=True) 179 | 180 | try: 181 | verify_signature(data) 182 | except Exception as e: 183 | title = "E Mandate Webhook Verification Error during Token Update" 184 | frappe.log_error(data, title) 185 | return { "status": "Failed", "reason": e} 186 | 187 | if isinstance(data, six.string_types): 188 | data = json.loads(data) 189 | data = frappe._dict(data) 190 | 191 | controller = frappe.get_doc("Razorpay Settings") 192 | controller.init_client() 193 | client = controller.client 194 | 195 | token = frappe._dict(data.payload.get("token", {}).get("entity", {})) 196 | 197 | member = frappe.db.exists("Member", {"razorpay_token": token.id}) 198 | if member: 199 | token_status = "Initiated" 200 | if data.event in ["token.confirmed", "token.resumed"]: 201 | token_status = "Confirmed" 202 | if data.event == "token.rejected": 203 | token_status = "Rejected" 204 | if data.event == "token.cancelled": 205 | token_status = "Cancelled" 206 | 207 | frappe.db.set_value("Member", member, "token_status", token_status) 208 | return member 209 | 210 | @frappe.whitelist(allow_guest=True) 211 | def invoice_paid(): 212 | # https://razorpay.com/docs/api/recurring-payments/webhooks/ 213 | data = frappe.request.get_data(as_text=True) 214 | 215 | try: 216 | verify_signature(data) 217 | except Exception as e: 218 | log = frappe.log_error(data, "E Mandate Webhook Verification Error during payment update") 219 | return {"status": "Failed", "reason": e} 220 | 221 | if isinstance(data, six.string_types): 222 | data = json.loads(data) 223 | data = frappe._dict(data) 224 | 225 | payment = frappe._dict(data.payload.get("payment", {}).get("entity", {})) 226 | 227 | if not payment.method == "emandate": 228 | return 229 | 230 | controller = frappe.get_doc("Razorpay Settings") 231 | controller.init_client() 232 | client = controller.client 233 | 234 | today = getdate() 235 | 236 | member = frappe.db.exists("Member", {"customer_id": payment.customer_id}) 237 | token_data = client.token.fetch(payment.customer_id, payment.token_id) 238 | 239 | if not member: 240 | max_amount = token_data.get("max_amount") / 100 241 | plan = frappe.db.exists("Membership Type", { "amount": max_amount}) 242 | if plan: 243 | plan_id = frappe.db.get_value("Membership Type", plan, "razorpay_plan_id") 244 | member = create_member(payment.customer_id, plan_id) 245 | 246 | if member: 247 | member = frappe.get_doc("Member", member) 248 | 249 | membership = frappe.new_doc("Membership") 250 | membership.update({ 251 | "member": member.name, 252 | "membership_status": "New", 253 | "membership_type": member.membership_type, 254 | "currency": "INR", 255 | "paid": 1, 256 | "payment_id": payment.id, 257 | "from_date": today, 258 | "to_date": add_months(today, 1), 259 | "amount": frappe.db.get_value("Membership Type", member.membership_type, "amount") 260 | }) 261 | membership.insert(ignore_permissions=True) 262 | 263 | # Update membership values 264 | member.subscription_status = "Active" 265 | member.e_mandate = 1 266 | member.razorpay_token = payment.token_id 267 | status = token_data.get("recurring_details").get("status") 268 | if status == "confirmed": 269 | member.token_status = "Confirmed" 270 | if status == "rejected": 271 | member.token_status = "Rejected" 272 | 273 | member.membership_expiry_date = add_months(today, 1) 274 | member.save(ignore_permissions=True) 275 | 276 | settings = frappe.get_doc("Non Profit Settings") 277 | if settings.allow_invoicing and settings.automate_membership_invoicing: 278 | membership.generate_invoice(with_payment_entry=settings.automate_membership_payment_entries, save=True) 279 | 280 | @frappe.whitelist(allow_guest=True) 281 | def ping(): 282 | return "pong" 283 | 284 | @frappe.whitelist(allow_guest=True) 285 | def notify_donation_payment_failures(*args, **kwargs): 286 | """ 287 | Notifies donor with the payment failure 288 | """ 289 | from erpnext.non_profit.doctype.membership.membership import verify_signature as verify_donation_signature 290 | 291 | if not frappe.db.get_single_value('Non Profit Settings', 'notify_donation_payment_failures'): 292 | return 293 | 294 | data = frappe.request.get_data(as_text=True) 295 | 296 | try: 297 | verify_donation_signature(data, endpoint='Donation') 298 | except Exception as e: 299 | frappe.log_error(data, 'Donation Webhook Verification Error') 300 | return { 'status': 'Failed', 'reason': e } 301 | 302 | if isinstance(data, six.string_types): 303 | data = json.loads(data) 304 | 305 | data = frappe._dict(data) 306 | payment = data.payload.get('payment', {}).get('entity', {}) 307 | payment = frappe._dict(payment) 308 | 309 | if not data.event == 'payment.failed': 310 | return 311 | 312 | # to avoid capturing subscription payments as donations 313 | if payment.get('description') and 'subscription' in str(payment.description).lower(): 314 | return 315 | 316 | context = { 317 | 'email': payment.email, 318 | 'amount': flt(payment.amount) / 100, # Convert to rupees from paise 319 | } 320 | 321 | if type(payment.notes) == dict: 322 | for k, v in payment.notes.items(): 323 | # extract donor name from notes 324 | if 'name' in k.lower(): 325 | context['donor_name'] = v 326 | 327 | if not context.get('donor_name'): 328 | context['donor_name'] = context['email'] 329 | 330 | template = frappe.db.get_single_value('Non Profit Settings', 'email_template_for_failure') 331 | template = frappe.get_doc('Email Template', template) 332 | 333 | content = template.response_html if template.use_html else template.response 334 | 335 | frappe.sendmail(recipients=payment.email, 336 | subject=template.subject, 337 | message=frappe.render_template(content, context) 338 | ) 339 | 340 | return { 'status': 'Success' } -------------------------------------------------------------------------------- /iff/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/iff/c206b587a90c5e7380fd882b44b3ec75e8c022b7/iff/config/__init__.py -------------------------------------------------------------------------------- /iff/config/desktop.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from frappe import _ 4 | 5 | def get_data(): 6 | return [ 7 | { 8 | "module_name": "IFF", 9 | "color": "grey", 10 | "icon": "octicon octicon-file-directory", 11 | "type": "module", 12 | "label": _("IFF") 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /iff/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/iff" 6 | # docs_base_url = "https://[org_name].github.io/iff" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | def get_context(context): 11 | context.brand_html = "IFF" 12 | -------------------------------------------------------------------------------- /iff/hooks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from . import __version__ as app_version 4 | 5 | app_name = "iff" 6 | app_title = "IFF" 7 | app_publisher = "Frappe Technologies Pvt. Ltd." 8 | app_description = "Membership automation for IFF" 9 | app_icon = "octicon octicon-file-directory" 10 | app_color = "grey" 11 | app_email = "developers@frappe.io" 12 | app_license = "MIT" 13 | 14 | # Includes in
15 | # ------------------ 16 | 17 | # include js, css files in header of desk.html 18 | # app_include_css = "/assets/iff/css/iff.css" 19 | # app_include_js = "/assets/iff/js/iff.js" 20 | 21 | # include js, css files in header of web template 22 | # web_include_css = "/assets/iff/css/iff.css" 23 | # web_include_js = "/assets/iff/js/iff.js" 24 | 25 | # include js, css files in header of web form 26 | # webform_include_js = {"doctype": "public/js/doctype.js"} 27 | # webform_include_css = {"doctype": "public/css/doctype.css"} 28 | 29 | # include js in page 30 | # page_js = {"page" : "public/js/file.js"} 31 | 32 | # include js in doctype views 33 | # doctype_js = {"doctype" : "public/js/doctype.js"} 34 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 35 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 36 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 37 | 38 | # Home Pages 39 | # ---------- 40 | 41 | # application home page (will override Website Settings) 42 | # home_page = "login" 43 | 44 | # website user home page (by Role) 45 | # role_home_page = { 46 | # "Role": "home_page" 47 | # } 48 | 49 | # Generators 50 | # ---------- 51 | 52 | # automatically create page for each record of this doctype 53 | # website_generators = ["Web Page"] 54 | 55 | # Installation 56 | # ------------ 57 | 58 | # before_install = "iff.install.before_install" 59 | after_install = "iff.iff.install.after_install" 60 | 61 | # Desk Notifications 62 | # ------------------ 63 | # See frappe.core.notifications.get_notification_config 64 | 65 | # notification_config = "iff.notifications.get_notification_config" 66 | 67 | # Permissions 68 | # ----------- 69 | # Permissions evaluated in scripted ways 70 | 71 | # permission_query_conditions = { 72 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 73 | # } 74 | # 75 | # has_permission = { 76 | # "Event": "frappe.desk.doctype.event.event.has_permission", 77 | # } 78 | 79 | # Document Events 80 | # --------------- 81 | # Hook on document methods and events 82 | 83 | # doc_events = { 84 | # "*": { 85 | # "on_update": "method", 86 | # "on_cancel": "method", 87 | # "on_trash": "method" 88 | # } 89 | # } 90 | 91 | # Scheduled Tasks 92 | # --------------- 93 | 94 | scheduler_events = { 95 | # "all": [ 96 | # "iff.tasks.all" 97 | # ], 98 | "daily": [ 99 | "iff.jobs.daily.execute" 100 | ], 101 | # "hourly": [ 102 | # "iff.tasks.hourly" 103 | # ], 104 | # "weekly": [ 105 | # "iff.tasks.weekly" 106 | # ] 107 | # "monthly": [ 108 | # "iff.tasks.monthly" 109 | # ] 110 | } 111 | 112 | # Testing 113 | # ------- 114 | 115 | # before_tests = "iff.install.before_tests" 116 | 117 | # Overriding Methods 118 | # ------------------------------ 119 | # 120 | # override_whitelisted_methods = { 121 | # "frappe.desk.doctype.event.event.get_events": "iff.event.get_events" 122 | # } 123 | # 124 | # each overriding function accepts a `data` argument; 125 | # generated from the base implementation of the doctype dashboard, 126 | # along with any modifications made in other Frappe apps 127 | # override_doctype_dashboards = { 128 | # "Task": "iff.task.get_dashboard_data" 129 | # } 130 | 131 | # exempt linked doctypes from being automatically cancelled 132 | # 133 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 134 | 135 | -------------------------------------------------------------------------------- /iff/iff/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/iff/c206b587a90c5e7380fd882b44b3ec75e8c022b7/iff/iff/__init__.py -------------------------------------------------------------------------------- /iff/iff/install.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.custom.doctype.custom_field.custom_field import create_custom_field 4 | 5 | 6 | def after_install(): 7 | create_e_mandate_custom_fields() 8 | 9 | create_custom_field('Non Profit Settings', { 10 | 'label': _('Notify Payment Failures'), 11 | 'fieldname': 'notify_donation_payment_failures', 12 | 'fieldtype': 'Check', 13 | 'insert_after': 'donation_payment_account' 14 | }) 15 | create_custom_field('Non Profit Settings', { 16 | 'label': _('Email Template'), 17 | 'fieldname': 'email_template_for_failure', 18 | 'fieldtype': 'Link', 19 | 'options': 'Email Template', 20 | 'depends_on': 'notify_donation_payment_failures', 21 | 'mandatory_depends_on': 'notify_donation_payment_failures', 22 | 'insert_after': 'notify_donation_payment_failures' 23 | }) 24 | 25 | frappe.db.commit() 26 | 27 | def create_e_mandate_custom_fields(): 28 | create_custom_field('Member', { 29 | 'label': _('E Mandate Details'), 30 | 'fieldname': 'e_mandate_section', 31 | 'fieldtype': 'Section Break', 32 | 'insert_after': 'subscription_end' 33 | }) 34 | 35 | create_custom_field('Member', { 36 | 'label': _('Payment Via E Mandate?'), 37 | 'fieldname': 'e_mandate', 38 | 'fieldtype': 'Check', 39 | 'insert_after': 'e_mandate_section' 40 | }) 41 | 42 | create_custom_field('Member', { 43 | 'label': _('PAN Details'), 44 | 'fieldname': 'pan_number', 45 | 'fieldtype': 'Data', 46 | 'insert_after': 'email' 47 | }) 48 | 49 | create_custom_field('Member', { 50 | 'label': _('Contact Number'), 51 | 'fieldname': 'contact', 52 | 'fieldtype': 'Data', 53 | 'insert_after': 'pan_number' 54 | }) 55 | 56 | create_custom_field('Member', { 57 | 'label': _('Razorpay Token'), 58 | 'fieldname': 'razorpay_token', 59 | 'fieldtype': 'Data', 60 | 'insert_after': 'e_mandate' 61 | }) 62 | 63 | create_custom_field('Member', { 64 | 'label': _('Token Status'), 65 | 'fieldname': 'token_status', 66 | 'fieldtype': 'Select', 67 | 'options': '\nInitiated\nConfirmed\nRejected\nCancelled', 68 | 'insert_after': 'razorpay_token' 69 | }) 70 | 71 | create_custom_field('Non Profit Settings', { 72 | 'label': _('Enable E Mandate Daily Trigger'), 73 | 'fieldname': 'enable_e_mandate_payments', 74 | 'fieldtype': 'Check', 75 | 'insert_after': 'enable_razorpay_for_memberships' 76 | }) 77 | 78 | -------------------------------------------------------------------------------- /iff/jobs/daily.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import json 4 | 5 | import frappe 6 | from frappe.utils import get_url_to_form, getdate, add_months 7 | from frappe.utils.user import get_system_managers 8 | from frappe.integrations.utils import get_payment_gateway_controller, make_post_request 9 | from frappe.contacts.doctype.contact.contact import get_default_contact 10 | 11 | from razorpay.constants.url import URL 12 | 13 | class EMandatePayment(): 14 | def __init__(self): 15 | self.successful_transaction = [] 16 | self.failed_transaction = [] 17 | self.plans = get_all_plans() 18 | self.today = getdate() 19 | self.next = add_months(getdate(), 1) 20 | self.enabled = frappe.db.get_single_value("Non Profit Settings", "enable_e_mandate_payments") 21 | self.controller = get_payment_gateway_controller("Razorpay") 22 | self.controller.init_client() 23 | if self.controller.client: 24 | self.client = self.controller.client 25 | else: 26 | frappe.throw("Razorpay Not Setup") 27 | 28 | def trigger_payments(self): 29 | """Payment Workflow Utility 30 | 1. Get all members due for payment 31 | 1. Trigger Payment 32 | 1. Update Membership 33 | 1. Log success and failed payments 34 | """ 35 | if not self.enabled: 36 | frappe.throw("Please Enable E Mandate Payments in Non Profit Settings") 37 | members = self.get_members_due_for_payment() 38 | 39 | if not members: 40 | return 41 | 42 | for member in members: 43 | try: 44 | payment = self.trigger_payment_for_member(member) 45 | membership = self.update_membership_details(member, payment) 46 | self.successful_transaction.append(membership) 47 | except Exception as e: 48 | title = "E Mandate Payment Error for {0}".format(member.name) 49 | log = frappe.log_error(e, title) 50 | self.failed_transaction.append([member.name,get_url_to_form("Error Log", log.name), e]) 51 | 52 | send_update_email(self.successful_transaction, self.failed_transaction) 53 | 54 | def get_members_due_for_payment(self): 55 | """Compare expiry of all members and return list of members whose payment is due 56 | 57 | Returns: 58 | list: List of Member docs for whom payment is due 59 | """ 60 | all_members = [] 61 | # Get all members for e-mandate processing 62 | for member_name in frappe.get_all("Member", filters={ "e_mandate": 1, "token_status": "Confirmed" }, as_list=1): 63 | member = frappe.get_doc("Member", member_name[0]) 64 | 65 | expiry = None 66 | if member.membership_expiry_date: 67 | expiry = member.membership_expiry_date 68 | else: 69 | last_membership = get_last_membership(member.name) 70 | if last_membership: 71 | expiry = last_membership["to_date"] 72 | 73 | if ( 74 | member.subscription_end \ 75 | and expiry \ 76 | and expiry <= self.today \ 77 | and self.today < member.subscription_end 78 | ): 79 | all_members.append(member) 80 | 81 | return all_members 82 | 83 | 84 | def trigger_payment_for_member(self, member): 85 | """Trigger Razorpay payment and return payment ID 86 | 87 | Args: 88 | member (object): Member doctype object 89 | 90 | Returns: 91 | string: Razorpay payment ID 92 | """ 93 | # https://razorpay.com/docs/api/recurring-payments/emandate/subsequent-payments/ 94 | amount = self.plans[member.membership_type] * 100 # convert rupee to paise 95 | 96 | order = self.client.order.create(data = { 97 | "amount": amount, 98 | "currency": "INR", 99 | "payment_capture": 1 100 | }) 101 | 102 | order_id = order.get("id") 103 | if not order_id: 104 | frappe.throw("Could not create order") 105 | 106 | if not member.contact: 107 | frappe.throw("Member contact details missing") 108 | 109 | if not member.customer_id: 110 | frappe.throw("Member customer is missing") 111 | 112 | if not member.razorpay_token: 113 | frappe.throw("Razorpay token is missing") 114 | 115 | # Razorpay python does not have recurrig payments yet 116 | # use razorpay client to make requests 117 | url = "{}/payments/create/recurring".format(URL.BASE_URL) 118 | 119 | data = { 120 | "email": member.email_id or member.email, 121 | "contact": member.contact, 122 | "amount": amount, 123 | "currency": "INR", 124 | "order_id": order_id, 125 | "customer_id": member.customer_id, 126 | "token": member.get_password(fieldname="razorpay_token"), 127 | "recurring": 1, 128 | "notes": { 129 | "erpnext-name": member.name 130 | } 131 | } 132 | 133 | try: 134 | payment = make_post_request( 135 | url, 136 | auth=(self.controller.api_key, self.controller.get_password(fieldname="api_secret", raise_exception=False)), 137 | data=json.dumps(data), 138 | headers={ 139 | "content-type": "application/json" 140 | } 141 | ) 142 | except Exception as e: 143 | title = "Post request failure for IFF daily: {0}".format(member.name) 144 | frappe.log_error(json.dumps(data, indent=4), title) 145 | raise e 146 | 147 | return payment.get("razorpay_payment_id") 148 | 149 | 150 | def update_membership_details(self, member, payment): 151 | membership = frappe.new_doc("Membership") 152 | 153 | # Take date explicitly from members, since 154 | # payment attempts can fail and be retried the next day 155 | from_date = member.membership_expiry_date 156 | to_date = add_months(from_date, 1) 157 | 158 | membership.update({ 159 | "member": member.name, 160 | "membership_status": "Current", 161 | "membership_type": member.membership_type, 162 | "currency": "INR", 163 | "paid": 1, 164 | "payment_id": payment, 165 | "from_date": from_date, 166 | "to_date": to_date, 167 | "amount": self.plans[member.membership_type] 168 | }) 169 | membership.insert(ignore_permissions=True) 170 | 171 | member.membership_expiry_date = to_date 172 | member.save(ignore_permissions=True) 173 | 174 | settings = frappe.get_doc("Non Profit Settings") 175 | if settings.allow_invoicing and settings.automate_membership_invoicing: 176 | membership.generate_invoice(with_payment_entry=settings.automate_membership_payment_entries, save=True) 177 | 178 | return membership 179 | 180 | def send_update_email(successful, failed): 181 | if len(successful) and len(failed): 182 | frappe.sendmail( 183 | subject="E Mandate Payments Summary", 184 | recipients=get_system_managers(), 185 | template="emandate", 186 | args={ 187 | "successful": successful, 188 | "failed": failed 189 | } 190 | ) 191 | 192 | def get_last_membership(member): 193 | """Returns last membership if exists""" 194 | last_membership = frappe.get_all("Membership", "name,to_date,membership_type", 195 | dict(member=member, paid=1), order_by="to_date desc", limit=1) 196 | 197 | return last_membership and last_membership[0] 198 | 199 | def get_all_plans(): 200 | all_plans = {} 201 | plans = frappe.get_all("Membership Type", fields=["name", "amount"]) 202 | for plan in plans: 203 | all_plans[plan["name"]] = plan["amount"] 204 | 205 | return all_plans 206 | 207 | def execute(): 208 | if not frappe.db.get_single_value("Non Profit Settings", "enable_e_mandate_payments"): 209 | print("E Mandate Payment is Disabled") 210 | return 211 | em = EMandatePayment() 212 | em.trigger_payments() -------------------------------------------------------------------------------- /iff/modules.txt: -------------------------------------------------------------------------------- 1 | IFF -------------------------------------------------------------------------------- /iff/patches.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/iff/c206b587a90c5e7380fd882b44b3ec75e8c022b7/iff/patches.txt -------------------------------------------------------------------------------- /iff/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/iff/c206b587a90c5e7380fd882b44b3ec75e8c022b7/iff/templates/__init__.py -------------------------------------------------------------------------------- /iff/templates/emails/capture-failed.html: -------------------------------------------------------------------------------- 1 |5 | Contact: {{ contact }} 6 |
7 |8 | Email: {{ email_id }} 9 |
10 |11 | Customer ID: {{ customer_id }} 12 |
13 | 14 |You might have to create the entry manually. Follow these steps to do so!
17 |Member Name | 9 |Plan | 10 |Membership From | 11 |Membership To | 12 |
{{ item.member }} | 16 |{{ item.membership_type }} ({{ item.amount }}) | 17 |{{ item.from_date }} | 18 |{{ item.to_date }} | 19 |
The following transactions have failed, payment for these members will be tried again.
27 |If you are making another payment manually, please make sure to create the subsequent membership in ERPNext.
28 |Member Name | 32 |Reason | 33 |Error Log | 34 |
{{ item[0] }} | 38 |Error Log | 39 |{{ item[2] or "Not Specified" }} | 40 |