Skip to content

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.

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.

scoopjoy/scoopjoy/setup/create_auto_repeats.py
import frappe
from 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.

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.

scoopjoy/scoopjoy/overrides/sales_invoice.py
import frappe
from 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:

scoopjoy/hooks.py
doc_events = {
"Sales Invoice": {
"on_recurring": "scoopjoy.scoopjoy.overrides.sales_invoice.on_recurring",
},
}

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.

scoopjoy/scoopjoy/utils/date_utils.py
import frappe
from 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 # Fallback

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.

scoopjoy/scoopjoy/report/auto_repeat_monitor/auto_repeat_monitor.py
import frappe
from 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