Auto Repeat & Recurring Documents
Problem: Auto-generate ScoopJoy’s monthly franchise royalty invoices, quarterly audit checklists, and annual agreement renewals — on a fixed schedule, without anyone remembering to click “create”.
Solution: Lean on Frappe’s built-in Auto Repeat DocType. It clones a
reference document on a frequency you choose (Monthly, Quarterly, Yearly).
For anything more than a plain clone — like computing each franchise’s royalty
from last month’s sales — wire up an on_recurring hook that fires after the copy
is made.
Step 1: Template documents setup
Section titled “Step 1: Template documents setup”Each Auto Repeat needs a reference document to clone. This one-off setup script
creates the three template docs and their matching Auto Repeat records. Run it
once with bench execute scoopjoy.scoopjoy.setup.create_auto_repeats.setup_auto_repeats.
import frappefrom frappe.utils import today, add_months
def setup_auto_repeats(): """Create Auto Repeat configurations for franchise operations. Run once during setup: bench execute scoopjoy.scoopjoy.setup.create_auto_repeats.setup_auto_repeats """ _setup_monthly_royalty_invoice() _setup_quarterly_audit_checklist() _setup_annual_agreement_renewal() frappe.db.commit()
def _setup_monthly_royalty_invoice(): """Monthly royalty invoice template - repeats on 1st of each month.""" # Create template Sales Invoice if frappe.db.exists("Sales Invoice", {"custom_is_template": 1, "custom_template_type": "Royalty"}): return
template_inv = frappe.get_doc({ "doctype": "Sales Invoice", "customer": "ScoopJoy Franchise Template", # Placeholder; customized on_recurring "posting_date": today(), "due_date": add_months(today(), 1), "custom_is_template": 1, "custom_template_type": "Royalty", "items": [ { "item_code": "FRANCHISE-ROYALTY", "item_name": "Monthly Franchise Royalty", "qty": 1, "rate": 0, # Calculated dynamically in on_recurring "description": "Monthly franchise royalty based on sales", } ], }) template_inv.insert(ignore_permissions=True)
# Create Auto Repeat frappe.get_doc({ "doctype": "Auto Repeat", "reference_doctype": "Sales Invoice", "reference_document": template_inv.name, "frequency": "Monthly", "start_date": frappe.utils.get_first_day(today()), "repeat_on_day": 1, "notify_by_email": 1, "recipients": "accounts@scoopjoy.com", "subject": "Monthly Royalty Invoice Generated - {{ doc.customer_name }}", "message": "A new royalty invoice has been generated. Please review and submit.", "disabled": 0, }).insert(ignore_permissions=True)
def _setup_quarterly_audit_checklist(): """Quarterly franchise audit checklist.""" if frappe.db.exists("ToDo", {"description": ("like", "%Quarterly Audit Template%")}): return
template_todo = frappe.get_doc({ "doctype": "ToDo", "description": ( "Quarterly Audit Template - Franchise Standards Review\n\n" "Checklist:\n" "- [ ] Product quality inspection\n" "- [ ] Hygiene and cleanliness audit\n" "- [ ] Brand compliance check\n" "- [ ] Equipment maintenance review\n" "- [ ] Staff training compliance\n" "- [ ] Financial records review\n" "- [ ] Customer complaint analysis\n" "- [ ] Inventory accuracy check" ), "priority": "High", "allocated_to": "admin@scoopjoy.com", }) template_todo.insert(ignore_permissions=True)
frappe.get_doc({ "doctype": "Auto Repeat", "reference_doctype": "ToDo", "reference_document": template_todo.name, "frequency": "Quarterly", "start_date": frappe.utils.get_first_day(today()), "notify_by_email": 1, "recipients": "operations@scoopjoy.com", "subject": "Quarterly Franchise Audit Due", "message": "The quarterly franchise audit checklist has been generated.", "disabled": 0, }).insert(ignore_permissions=True)
def _setup_annual_agreement_renewal(): """Annual franchise agreement renewal reminders.""" if frappe.db.exists("ToDo", {"description": ("like", "%Annual Agreement Renewal Template%")}): return
template_todo = frappe.get_doc({ "doctype": "ToDo", "description": ( "Annual Agreement Renewal Template\n\n" "Tasks:\n" "- [ ] Review franchise performance metrics\n" "- [ ] Update royalty rates if applicable\n" "- [ ] Review territory agreements\n" "- [ ] Renew insurance certificates\n" "- [ ] Update franchise agreement documents\n" "- [ ] Schedule renewal meeting with franchise owner" ), "priority": "Urgent", "allocated_to": "legal@scoopjoy.com", }) template_todo.insert(ignore_permissions=True)
frappe.get_doc({ "doctype": "Auto Repeat", "reference_doctype": "ToDo", "reference_document": template_todo.name, "frequency": "Yearly", "start_date": frappe.utils.get_first_day(today()), "notify_by_email": 1, "recipients": "legal@scoopjoy.com", "subject": "Annual Franchise Agreement Renewals Due", "message": "Annual agreement renewal tasks have been generated.", "disabled": 0, }).insert(ignore_permissions=True)Note the subject field uses a Jinja expression — {{ doc.customer_name }} — so
the notification email interpolates the cloned document’s fields at send time.
Step 2: Post-creation hook (on_recurring)
Section titled “Step 2: Post-creation hook (on_recurring)”A plain clone isn’t enough for royalties: each franchise owes a different amount
based on its own sales. The on_recurring hook fires after Auto Repeat creates
the copy, so we use the template as a trigger, fan out one real invoice per active
franchise, then delete the template copy.
import frappefrom frappe.utils import get_first_day, get_last_day, add_months, today
def on_recurring(doc, auto_repeat_doc): """Hook called after Auto Repeat creates a new Sales Invoice.
Customize the generated royalty invoice with actual franchise data. This is triggered by the Auto Repeat framework after document creation. """ if not doc.custom_is_template and doc.custom_template_type != "Royalty": return
# Get all active franchises franchises = frappe.get_all( "Customer", filters={"customer_group": "Franchise", "disabled": 0}, fields=["name", "customer_name", "custom_royalty_percentage"], )
# Calculate the previous month's date range last_month_start = get_first_day(add_months(today(), -1)) last_month_end = get_last_day(add_months(today(), -1))
for franchise in franchises: # Calculate last month's sales total_sales = frappe.db.sql( """ SELECT COALESCE(SUM(grand_total), 0) FROM `tabSales Invoice` WHERE customer = %s AND posting_date BETWEEN %s AND %s AND docstatus = 1 AND custom_template_type IS NULL """, (franchise.name, last_month_start, last_month_end), )[0][0]
if total_sales <= 0: continue
royalty_pct = franchise.custom_royalty_percentage or 5 royalty_amount = total_sales * (royalty_pct / 100)
# Create individual royalty invoice for this franchise royalty_inv = frappe.get_doc({ "doctype": "Sales Invoice", "customer": franchise.name, "posting_date": today(), "due_date": add_months(today(), 1), "items": [ { "item_code": "FRANCHISE-ROYALTY", "item_name": f"Monthly Royalty - {franchise.customer_name}", "qty": 1, "rate": royalty_amount, "description": ( f"Royalty at {royalty_pct}% on sales of " f"{frappe.format_value(total_sales, {'fieldtype': 'Currency'})} " f"for {frappe.utils.formatdate(last_month_start)} to " f"{frappe.utils.formatdate(last_month_end)}" ), } ], }) royalty_inv.insert(ignore_permissions=True)
# Delete the template copy (we created individual invoices instead) frappe.delete_doc("Sales Invoice", doc.name, ignore_permissions=True, force=True)The hook receives both the freshly-cloned doc and the auto_repeat_doc that
spawned it. The signature line if not doc.custom_is_template and doc.custom_template_type != "Royalty" bails early on anything that isn’t our royalty
template, so the hook stays inert for unrelated Sales Invoices.
Register the hook in doc_events — the on_recurring event is what Auto Repeat
calls into:
doc_events = { "Sales Invoice": { "on_recurring": "scoopjoy.scoopjoy.overrides.sales_invoice.on_recurring", },}Step 3: Handling holidays and weekends
Section titled “Step 3: Handling holidays and weekends”A royalty run that lands on a Sunday or a national holiday should slide to the next working day. This helper walks forward from a target date, skipping weekends and any dates in the configured Holiday List.
import frappefrom frappe.utils import getdate, add_days, get_first_day
def get_next_business_day(date, holiday_list=None): """Get the next business day, skipping weekends and holidays.
Args: date: The target date holiday_list: Name of the Holiday List to check against
Returns: The next business day as a date object """ date = getdate(date)
if not holiday_list: holiday_list = frappe.db.get_single_value("ScoopJoy Settings", "default_holiday_list")
holidays = set() if holiday_list: holidays = set( frappe.get_all( "Holiday", filters={"parent": holiday_list}, pluck="holiday_date", ) )
max_attempts = 30 # Safety valve for _ in range(max_attempts): # Skip Saturday (5) and Sunday (6) if date.weekday() in (5, 6): date = add_days(date, 1) continue # Skip holidays if date in holidays: date = add_days(date, 1) continue return date
return date # FallbackStep 4: Auto Repeat monitoring dashboard
Section titled “Step 4: Auto Repeat monitoring dashboard”A Script Report that surfaces every active Auto Repeat, when it next fires, and whether it’s overdue — so a missed schedule never slips by unnoticed.
import frappefrom frappe.utils import today, getdate
def execute(filters=None): columns = [ {"fieldname": "name", "label": "Auto Repeat", "fieldtype": "Link", "options": "Auto Repeat", "width": 200}, {"fieldname": "reference_doctype", "label": "DocType", "fieldtype": "Data", "width": 150}, {"fieldname": "frequency", "label": "Frequency", "fieldtype": "Data", "width": 100}, {"fieldname": "next_schedule_date", "label": "Next Run", "fieldtype": "Date", "width": 120}, {"fieldname": "status", "label": "Status", "fieldtype": "Data", "width": 100}, {"fieldname": "days_until_next", "label": "Days Until Next", "fieldtype": "Int", "width": 120}, {"fieldname": "last_created", "label": "Last Created Doc", "fieldtype": "Data", "width": 200}, ]
data = frappe.db.sql( """ SELECT ar.name, ar.reference_doctype, ar.reference_document, ar.frequency, ar.next_schedule_date, ar.disabled, ar.status FROM `tabAuto Repeat` ar WHERE ar.disabled = 0 ORDER BY ar.next_schedule_date ASC """, as_dict=True, )
for row in data: if row.next_schedule_date: row.days_until_next = (getdate(row.next_schedule_date) - getdate(today())).days else: row.days_until_next = -1
# Status indicator if row.disabled: row.status = "Disabled" elif row.days_until_next < 0: row.status = "Overdue" elif row.days_until_next == 0: row.status = "Due Today" else: row.status = "Scheduled"
# Find last document created by this auto repeat last_doc = frappe.get_all( row.reference_doctype, filters={"auto_repeat": row.name}, order_by="creation desc", limit=1, pluck="name", ) row.last_created = last_doc[0] if last_doc else "None"
return columns, data