diff --git a/CLAUDE.md b/CLAUDE.md
index 4960b61..b83885b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -731,8 +731,23 @@ WantedBy=multi-user.target
- [ ] Gift cards (issuance with unique code; POS redemption; balance tracking; expiry)
- [ ] End-of-day reconciliation (Close Day flow; cash count entry; variance calculation; `daily_reconciliations` record)
-### Phase 4 — Tenant Operations Modules
-- [ ] Staff management (profiles, job type, system role, location assignments, schedules)
+### Phase 4 — Tenant Operations Modules ✅ COMPLETE
+- [x] Pay structure per staff (hourly_rate, salary_amount, guarantee_amount, commission_rate, commission_enabled, pay_period — editable via staff form)
+- [x] Pay period calculation engine (app/tenant/pay_periods/routes.py): hourly × hours, salary fixed, guarantee = max(guarantee, commission); results written to staff_pay_periods
+- [x] Pay period approval workflow (draft → approved → paid; tenant_admin only)
+- [x] Inventory (CRUD, reorder alerts, manual adjustment log; tenant_feature_required("inventory"))
+- [x] Automatic inventory deduction on POS product sale (matched by SKU or name; InventoryLog entry created)
+- [x] Appointment reminder engine (APScheduler job every 5 min; sends 24h + 2h email reminders; status=pending|sent|failed|cancelled)
+- [x] Appointment reminders scheduled on appointment create (24h + 2h ahead)
+- [x] Customer review request engine (APScheduler job every 10 min; delay per tenant setting; smart routing ≥4 stars shows platform links; one send enforced by review_request_sent_at)
+- [x] app/scheduler_jobs.py — send_appointment_reminders(), send_review_requests()
+- [x] Scheduler init in create_tenant_app() with SCHEDULER_API_ENABLED=False
+- [x] pay_periods_bp + inventory_bp registered in tenant factory
+- [x] Nav links wired: inventory → inventory.index, pay_periods → pay_periods.index
+- [x] Both template trees in sync: 73 files each
+- [x] Full validation passed: 6 imports OK, 0 missing templates, 0 illegal Jinja2, 0 broken extends
+
+### Phase 4 — Staff management (profiles, job type, system role, location assignments, schedules) (profiles, job type, system role, location assignments, schedules)
- [ ] Pay structure setup per staff member (pay type, rates, pay period, commission toggle)
- [ ] Commission tracking (per transaction, period summary; respects `commission_enabled` flag per staff)
- [ ] Working hours / clockings (clock-in at login or manual; clock-out; total hours computed per period)
@@ -949,6 +964,13 @@ Every fix must be validated by a programmatic test (import test, render test, or
**Rule 10 — Pack files when more than 5 files are changed.**
When a fix touches more than 5 files, compress them into a ZIP for delivery. Always include only the changed files — not the entire project.
+**Rule 11 — Never call Python builtins or stdlib objects inside Jinja2 `{{ }}` expressions.**
+Jinja2 does not have access to Python's standard library or builtins such as `set()`, `dict()`, `list()`, `int()`, `float()`, `str()`, `len()`, `sorted()`, `enumerate()`, `zip()`, `timedelta`, `datetime`, `date`. Any computation involving these must be done in the route and passed as a named template variable. Violations produce `UndefinedError` in production. Examples of what NOT to do: `(assigned_ids or set())`, `(view_date - timedelta(days=1))`. Correct approach: compute `prev_date = view_date - timedelta(days=1)` in the route and pass `prev_date=prev_date` to `render_template`.
+
+**Rule 12 — Always pass every variable a template references, on every code path.**
+Every `render_template` call for a given template must supply the same set of variables — including early-return error paths. If the template uses `assigned_ids`, every `render_template("...form.html", ...)` call in that view must include `assigned_ids=...`. Missing variables on error-return paths produce `UndefinedError` only when that path is hit, making them hard to catch in testing.
+When a fix touches more than 5 files, compress them into a ZIP for delivery. Always include only the changed files — not the entire project.
+
---
## Phase 1 — Implementation Notes
diff --git a/app/scheduler_jobs.py b/app/scheduler_jobs.py
new file mode 100644
index 0000000..3979e37
--- /dev/null
+++ b/app/scheduler_jobs.py
@@ -0,0 +1,184 @@
+"""
+app/scheduler_jobs.py
+APScheduler job definitions for the tenant portal.
+Registered in create_tenant_app() after scheduler.init_app().
+
+Jobs:
+ - send_appointment_reminders every 5 minutes
+ - send_review_requests every 10 minutes
+"""
+import logging
+from datetime import datetime, timezone, timedelta
+
+logger = logging.getLogger(__name__)
+
+
+def send_appointment_reminders(app):
+ """
+ Scan appointment_reminders for pending reminders due to be sent.
+ Sends email via Flask-Mail and marks status = 'sent' or 'failed'.
+ Runs every 5 minutes.
+ """
+ with app.app_context():
+ from app.extensions import db, mail
+ from app.models.salon import AppointmentReminder, Appointment, Customer
+
+ now = datetime.now(timezone.utc)
+
+ due = AppointmentReminder.query.filter_by(status="pending").filter(
+ AppointmentReminder.scheduled_for <= now
+ ).limit(50).all()
+
+ if not due:
+ return
+
+ sent = failed = 0
+ for reminder in due:
+ appt = Appointment.query.get(reminder.appointment_id)
+ if not appt or appt.status in ("cancelled", "no_show"):
+ reminder.status = "cancelled"
+ continue
+
+ customer = Customer.query.get(appt.customer_id) if appt.customer_id else None
+ if not customer or not customer.email:
+ # No email — mark sent so we don't retry endlessly
+ reminder.status = "sent"
+ reminder.sent_at = now
+ sent += 1
+ continue
+
+ try:
+ from flask_mail import Message
+ from app.models.platform import Tenant
+ tenant = Tenant.query.get(appt.tenant_id)
+ tenant_name = tenant.name if tenant else "Your salon"
+
+ msg = Message(
+ subject=f"Appointment Reminder — {tenant_name}",
+ recipients=[customer.email],
+ body=(
+ f"Hi {customer.name},\n\n"
+ f"This is a reminder of your upcoming appointment:\n"
+ f"Date: {appt.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
+ f"Service: {appt.service.name if appt.service else 'N/A'}\n\n"
+ f"See you soon!\n{tenant_name}"
+ ),
+ )
+ mail.send(msg)
+ reminder.status = "sent"
+ reminder.sent_at = now
+ sent += 1
+ except Exception as exc:
+ logger.error("Reminder send failed id=%s: %s", reminder.id, exc)
+ reminder.status = "failed"
+ failed += 1
+
+ db.session.commit()
+ if sent or failed:
+ logger.info("Appointment reminders: sent=%d failed=%d", sent, failed)
+
+
+def send_review_requests(app):
+ """
+ Find completed transactions where review_request_sent_at IS NULL and
+ the transaction is old enough (per tenant setting review_request_delay_minutes).
+ Sends review email with smart routing:
+ - rating ≥ 4 → shows Google/Facebook/Yelp links
+ - rating < 4 → silent internal feedback only
+ One send per transaction enforced by review_request_sent_at.
+ Runs every 10 minutes.
+ """
+ with app.app_context():
+ from app.extensions import db, mail
+ from app.models.salon import Transaction, Customer, TenantSetting, CheckoutReview
+ from app.models.platform import Tenant
+
+ now = datetime.now(timezone.utc)
+
+ # Find eligible transactions: completed, not voided, no review sent yet
+ candidates = Transaction.query.filter(
+ Transaction.voided_at.is_(None),
+ Transaction.review_request_sent_at.is_(None),
+ Transaction.customer_id.isnot(None),
+ ).limit(100).all()
+
+ sent = 0
+ for txn in candidates:
+ # Get tenant-specific delay (default 60 minutes)
+ delay_setting = TenantSetting.query.filter_by(
+ tenant_id=txn.tenant_id,
+ setting_key="review_request_delay_minutes",
+ ).first()
+ delay_minutes = int(delay_setting.setting_value or 60) if delay_setting else 60
+ eligible_after = txn.created_at.replace(tzinfo=timezone.utc) + timedelta(minutes=delay_minutes)
+
+ if now < eligible_after:
+ continue
+
+ customer = Customer.query.get(txn.customer_id)
+ if not customer or not customer.email:
+ # Mark sent to avoid retrying
+ txn.review_request_sent_at = now
+ continue
+
+ tenant = Tenant.query.get(txn.tenant_id)
+ tenant_name = tenant.name if tenant else "Your salon"
+
+ # Fetch review settings
+ def _get_setting(key):
+ s = TenantSetting.query.filter_by(
+ tenant_id=txn.tenant_id, setting_key=key).first()
+ return s.setting_value if s else None
+
+ google_url = _get_setting("google_review_url")
+ yelp_url = _get_setting("yelp_review_url")
+ facebook_url = _get_setting("facebook_review_url")
+
+ # Check if a review already submitted
+ existing_review = CheckoutReview.query.filter_by(
+ transaction_id=txn.id
+ ).first()
+
+ try:
+ from flask_mail import Message
+
+ if existing_review and existing_review.rating >= 4:
+ # High rating — encourage public review
+ links = []
+ if google_url:
+ links.append(f"Google: {google_url}")
+ if yelp_url:
+ links.append(f"Yelp: {yelp_url}")
+ if facebook_url:
+ links.append(f"Facebook: {facebook_url}")
+
+ body = (
+ f"Hi {customer.name},\n\n"
+ f"Thank you for visiting {tenant_name}! We're so glad you enjoyed your visit.\n"
+ f"Would you mind sharing your experience online?\n\n"
+ + ("\n".join(links) if links else "(No review links configured)")
+ + f"\n\nThank you!\n{tenant_name}"
+ )
+ else:
+ # No review yet or low rating — generic thank you only
+ body = (
+ f"Hi {customer.name},\n\n"
+ f"Thank you for visiting {tenant_name}! We hope to see you again soon.\n\n"
+ f"{tenant_name}"
+ )
+
+ msg = Message(
+ subject=f"Thank you for visiting {tenant_name}!",
+ recipients=[customer.email],
+ body=body,
+ )
+ mail.send(msg)
+ txn.review_request_sent_at = now
+ sent += 1
+ except Exception as exc:
+ logger.error("Review request failed txn=%s: %s", txn.id, exc)
+ # Don't mark sent_at — will retry next cycle
+
+ db.session.commit()
+ if sent:
+ logger.info("Review requests sent: %d", sent)
diff --git a/app/templates/tenant/appointments/index.html b/app/templates/tenant/appointments/index.html
index b16a947..d98a586 100644
--- a/app/templates/tenant/appointments/index.html
+++ b/app/templates/tenant/appointments/index.html
@@ -5,11 +5,12 @@
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/tenant/inventory/log.html b/app/templates/tenant/inventory/log.html
new file mode 100644
index 0000000..5a2884a
--- /dev/null
+++ b/app/templates/tenant/inventory/log.html
@@ -0,0 +1,31 @@
+{% extends "tenant/layouts/base.html" %}
+{% block title %}Stock Log — {{ item.name }}{% endblock %}
+{% block content %}
+
+
Stock Log — {{ item.name }}
+ Current: {{ item.qty_on_hand }} units
+
+
+
+
+
Date
Change
Reason
+
+ {% for e in entries %}
+
+
{{ e.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+ {{ '+' if e.delta > 0 else '' }}{{ e.delta }}
+
+
{{ e.reason or '—' }}
+
+ {% else %}
+
No log entries.
+ {% endfor %}
+
+
+
+
+
+ Back to Inventory
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/tenant/layouts/base.html b/app/templates/tenant/layouts/base.html
index 7815faa..1437712 100644
--- a/app/templates/tenant/layouts/base.html
+++ b/app/templates/tenant/layouts/base.html
@@ -116,7 +116,7 @@
+{% endblock %}
\ No newline at end of file
diff --git a/templates/tenant/inventory/log.html b/templates/tenant/inventory/log.html
new file mode 100644
index 0000000..5a2884a
--- /dev/null
+++ b/templates/tenant/inventory/log.html
@@ -0,0 +1,31 @@
+{% extends "tenant/layouts/base.html" %}
+{% block title %}Stock Log — {{ item.name }}{% endblock %}
+{% block content %}
+
+
Stock Log — {{ item.name }}
+ Current: {{ item.qty_on_hand }} units
+
+
+
+
+
Date
Change
Reason
+
+ {% for e in entries %}
+
+
{{ e.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+ {{ '+' if e.delta > 0 else '' }}{{ e.delta }}
+
+
{{ e.reason or '—' }}
+
+ {% else %}
+
No log entries.
+ {% endfor %}
+
+
+
+
+
+ Back to Inventory
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/tenant/layouts/base.html b/templates/tenant/layouts/base.html
index 7815faa..1437712 100644
--- a/templates/tenant/layouts/base.html
+++ b/templates/tenant/layouts/base.html
@@ -116,7 +116,7 @@