# Buy Local Lowveld — Mailchimp Integration Architecture **System:** PHP membership platform + Mailchimp Marketing API **Scope:** Audience sync, dynamic tagging, customer journeys, newsletters, backend email control **Last updated:** Phase 2c --- ## Table of contents 1. [System architecture overview](#1-system-architecture-overview) 2. [Mailchimp audience structure](#2-mailchimp-audience-structure) 3. [Tagging strategy and logic](#3-tagging-strategy-and-logic) 4. [Customer journey design](#4-customer-journey-design) 5. [PHP API integration](#5-php-api-integration) 6. [Sync strategy](#6-sync-strategy) 7. [Newsletter setup and segmentation](#7-newsletter-setup-and-segmentation) 8. [Backend-triggered email strategy](#8-backend-triggered-email-strategy) 9. [Step-by-step Mailchimp setup guide](#9-step-by-step-mailchimp-setup-guide) 10. [Common pitfalls and how to avoid them](#10-common-pitfalls-and-how-to-avoid-them) --- ## 1. System architecture overview ``` ┌─────────────────────────────────────────┐ │ Buy Local Website (PHP) │ │ │ │ Public pages Member portal │ │ signup-submit edit-business │ │ contact-submit cancel-membership │ │ newsletter-sub payfast-itn │ │ admin panel cron scripts │ └──────────────┬──────────────────────────┘ │ │ HTTPS (Mailchimp Marketing API v3) │ includes/mailchimp.php │ ┌──────────────▼──────────────────────────┐ │ Mailchimp │ │ │ │ Audience (one list, tag-segmented) │ │ Merge fields (7 custom fields) │ │ Tags (lifecycle + tier + industry) │ │ Customer Journeys (8 automations) │ │ Campaigns (4× monthly newsletter) │ └─────────────────────────────────────────┘ ``` ### Design decisions **Single audience, not multiple.** All contacts — members, leads, newsletter subscribers — live in one Mailchimp audience. Segments and tags differentiate them. This avoids duplicate contacts across lists and simplifies billing (Mailchimp charges per contact, not per list). **API-first, not form embeds.** We never use Mailchimp's hosted signup forms. All upserts happen server-side via the API. This gives us control over exactly what data gets pushed and when. **Upsert, not insert.** Every Mailchimp write uses the `PUT /lists/{id}/members/{hash}` endpoint, not `POST`. If the contact already exists, it's updated. If not, it's created. No duplicates possible at the API level. **Tags over segments for automation triggers.** Customer Journeys in Mailchimp trigger on "contact is tagged" or via the API Journey endpoint. Both work. We support both. --- ## 2. Mailchimp audience structure ### Merge fields (custom fields) These are the PHP-to-Mailchimp field mappings. Create them in Mailchimp once; they persist on every contact. | Mailchimp tag | Type | Description | Source DB column | |---------------|--------|---------------------------|---------------------| | `FNAME` | Text | First name | `members.first_name` | | `LNAME` | Text | Last name | `members.last_name` | | `PHONE` | Phone | Contact number | `members.phone` | | `BUSINESS` | Text | Business name | `members.business_name` | | `INDUSTRY` | Text | Directory category slug | `members.industry` | | `TIER` | Text | Membership tier | `members.tier` | | `JOINDATE` | Date | Date they joined | `members.join_date` | | `RENEWAL` | Date | Next renewal date | `members.renewal_date` | > **Note:** Mailchimp requires `FNAME` and `LNAME` to exist by default. The other six need to be created manually once — see [Section 9](#9-step-by-step-mailchimp-setup-guide). ### Audience segmentation model ``` All contacts in the audience │ ├── Lifecycle tag (exactly one per contact at a time) │ New Member / Payment Received / Renewal Reminder / │ Payment Overdue / Cancelled Member / Lead │ ├── Tier tag (exactly one per member) │ Bronze / Silver / Gold / Platinum / Diamond │ ├── Source tag (how they entered the system) │ Newsletter Signup / Contact Form / Become a Member │ └── Industry tag (their directory category, additive) Plumbing / Hospitality / Retail / Agriculture / etc. ``` Tags are the segmentation engine. A Mailchimp segment like "Tier=Gold AND Lifecycle=Renewal Reminder" gives you exactly the right audience for a renewal campaign without creating duplicate lists. --- ## 3. Tagging strategy and logic ### Lifecycle tags — mutually exclusive Only one lifecycle tag applies at any time. When a new lifecycle event fires, the old tag is removed and the new one added. This is handled by `mc_set_exclusive_tag()`. ```php // includes/mailchimp.php — applies one tag, removes the others in the family mc_set_exclusive_tag($email, MC_LIFECYCLE_TAGS, 'Payment Received'); ``` | Tag | Set when | Set by | |-----|----------|--------| | `New Member` | Signup form submitted | `signup-submit.php` | | `Payment Received` | PayFast ITN confirmed | `payfast-itn.php`, `admin/invoices.php` | | `Renewal Reminder` | 30 days before `renewal_date` | `cron/renewal-reminder.php` | | `Payment Overdue` | Invoice past `due_at` | `cron/overdue.php` | | `Cancelled Member` | Member cancels OR admin sets status=cancelled | `member/cancel-membership.php`, `admin/member-edit.php` | | `Lead` | Contact form submitted (not a paying member) | `contact-submit.php` | ### Tier tags — mutually exclusive Set from `members.tier` during any sync. When a member upgrades from Silver to Gold, Silver is removed and Gold is added. ```php // Runs on every mc_sync_member_from_db() call mc_set_exclusive_tag($email, MC_TIER_TAGS, $member['tier']); // e.g. removes 'Silver', applies 'Gold' ``` ### Industry tags — additive Industry tags are **not** mutually exclusive. A member can belong to multiple categories. Tags are added, never automatically removed (a member who was in Retail and moves to Hospitality keeps the Retail tag — remove it manually if needed). ```php mc_upsert_member($email, [], [$member['industry']], 'subscribed'); ``` ### Tag update triggers Tags update automatically whenever any of these happen: | Event | Trigger | What updates | |-------|---------|--------------| | New signup | `signup-submit.php` | Sets: tier, `New Member`, source, industry | | Profile edit (member) | `member/edit-business.php` | Updates all merge fields, tier, industry | | Profile edit (admin) | `admin/member-edit.php` | Same as above | | Payment confirmed | `payfast-itn.php` | Lifecycle → `Payment Received` | | Admin marks paid | `admin/invoices.php` | Lifecycle → `Payment Received` | | 30-day reminder | `cron/renewal-reminder.php` (daily) | Lifecycle → `Renewal Reminder` | | Invoice overdue | `cron/overdue.php` (daily) | Lifecycle → `Payment Overdue` | | Member cancels | `member/cancel-membership.php` | Lifecycle → `Cancelled Member` | | Admin cancels | `admin/member-edit.php` (status=cancelled) | Lifecycle → `Cancelled Member` | | Admin re-sync | "Re-sync now" button | All merge fields + tier + industry | --- ## 4. Customer journey design Eight customer journeys are wired in code. All are triggered via `mc_trigger_journey()` from PHP. You build the email content and sequence in the Mailchimp dashboard; the code fires them at the right moment. ### Journey overview | Constant | Trigger event | Trigger point in code | |----------|--------------|----------------------| | `MC_JOURNEY_NEW_MEMBER` | Member signs up | `signup-submit.php` | | `MC_JOURNEY_NEWSLETTER` | Newsletter signup | `newsletter-submit.php` | | `MC_JOURNEY_CONTACT_LEAD` | Contact form submitted | `contact-submit.php` | | `MC_JOURNEY_PROFILE_UPDATE` | Member updates their details | `member/edit-business.php` | | `MC_JOURNEY_PAYMENT_RECEIVED` | Payment confirmed | `payfast-itn.php`, `admin/invoices.php` | | `MC_JOURNEY_RENEWAL_REMINDER` | 30 days before renewal | `cron/renewal-reminder.php` | | `MC_JOURNEY_PAYMENT_OVERDUE` | Invoice past due date | `cron/overdue.php` | | `MC_JOURNEY_CANCELLATION` | Membership cancelled | `member/cancel-membership.php`, `admin/member-edit.php` | ### Recommended journey sequences #### A. New Member Journey (12-month onboarding) | Delay | Email | Purpose | |-------|-------|---------| | Immediate | Welcome + login link | Confirm membership, get them into the portal | | Day 3 | How to edit your listing | Drive listing completion | | Day 7 | Introduce the directory | Show what other members look like | | Day 14 | Member benefits reminder | Reinforce value | | Day 30 | Check-in | Personal touch, ask for feedback | | Month 3 | "You've been a member 3 months" | Social proof / engagement | | Month 6 | Mid-year update | Industry news + Buy Local events | | Month 11 | Renewal approaching | Soft reminder, 30 days before cron fires | #### B. Newsletter Welcome Journey | Delay | Email | Purpose | |-------|-------|---------| | Immediate | Welcome + what to expect | Confirm subscription | | Day 2 | Introduce the directory | Browse members CTA | | Day 5 | Become a member CTA | Convert subscriber to paying member | #### C. Payment Received Journey | Delay | Email | Purpose | |-------|-------|---------| | Immediate | Receipt / confirmation | Invoice summary, next steps | | Day 1 | "You're all set" | How to make the most of membership | #### D. Renewal Reminder Journey (fires 30 days out) | Delay | Email | Purpose | |-------|-------|---------| | Immediate | "Your membership renews in 30 days" | Early notice, no action needed | | Day 7 | "3 weeks to renewal" | Reminder with payment link | | Day 21 | "Renew now" | Direct CTA | #### E. Payment Overdue Journey | Delay | Email | Purpose | |-------|-------|---------| | Immediate | "Invoice overdue" | Clear, no pressure | | Day 3 | "Still outstanding" | Gentle follow-up | | Day 7 | "Final notice before suspension" | Clear consequence | #### F. Cancellation Journey (retention) | Delay | Email | Purpose | |-------|-------|---------| | Immediate | "We're sorry to see you go" | Acknowledge, no hard sell | | Day 3 | "Was it something we could fix?" | Open door for feedback | | Day 14 | "You're always welcome back" | Re-engagement, soft | | Day 90 | "Things have changed at Buy Local" | Win-back attempt | --- ## 5. PHP API integration All Mailchimp calls go through `includes/mailchimp.php`. You should rarely need to call the API directly — use the wrapper functions. ### Core wrapper: mc_upsert_member The single most important function. Creates or updates a contact. Safe to call any number of times. ```php mc_upsert_member( string $email, array $merge_fields, // ['FNAME' => 'Jane', 'BUSINESS' => 'Acme'] array $tags, // ['Bronze', 'New Member'] — ADDED to existing string $status // 'subscribed' | 'pending' | 'unsubscribed' ): bool ``` ### Sync a full member record Call this whenever anything about a member changes. Handles merge fields + tier tag + industry tag in one call. ```php $member = db_row('SELECT * FROM members WHERE id = :id', ['id' => $id]); mc_sync_member_from_db($member, 'profile_update'); ``` ### Trigger a customer journey ```php mc_trigger_journey(MC_JOURNEY_PAYMENT_RECEIVED, $member['email']); ``` Returns `true` if triggered, `false` if the journey IDs aren't configured yet (silently skips — doesn't throw). ### Lifecycle event convenience wrappers Each of these sets the right tag AND triggers the right journey in one call: ```php mc_event_payment_received($member); // Flow E mc_event_renewal_reminder($member); // Flow F mc_event_payment_overdue($member); // Flow G mc_event_cancelled($member); // Flow H ``` ### Bulk sync (existing members) There's no bulk-sync script in the codebase yet because every page-load does real-time sync. If you ever need to push all existing members to a fresh Mailchimp audience, run this from the CLI: ```php // Save as db/bulk-sync.php — run once from CLI only require_once __DIR__ . '/../includes/db.php'; require_once __DIR__ . '/../includes/mailchimp.php'; if (PHP_SAPI !== 'cli') exit('CLI only'); $members = db_all("SELECT * FROM members WHERE status IN ('active','pending')"); $done = 0; $failed = 0; foreach ($members as $m) { $ok = mc_sync_member_from_db($m, 'bulk_sync'); $ok ? $done++ : $failed++; // Respect Mailchimp's rate limit: ~10 req/sec max usleep(150000); // 150ms between calls echo ($ok ? '.' : 'F'); } echo "\nDone: $done synced, $failed failed\n"; ``` --- ## 6. Sync strategy ### Real-time sync (primary) Every action that changes member data triggers an immediate Mailchimp sync. No delays, no queues. | Action | Sync happens at | Latency | |--------|----------------|---------| | New signup | End of `signup-submit.php` | < 1 second | | Profile edit | On form save in `member/edit-business.php` | < 1 second | | Admin edit | On form save in `admin/member-edit.php` | < 1 second | | Payment confirmed | In `payfast-itn.php` after PayFast callback | 2–10 seconds | | Cancel membership | In `member/cancel-membership.php` | < 1 second | ### Scheduled sync (secondary) Cron scripts run daily to handle events that can't be triggered in real-time. ``` 0 2 * * * php /path/to/buylocal/cron/renewal-reminder.php 30 2 * * * php /path/to/buylocal/cron/overdue.php ``` Or trigger via HTTP with the cron secret: ``` https://yoursite.co.za/buylocal/cron/renewal-reminder.php?secret=YOUR_CRON_SECRET https://yoursite.co.za/buylocal/cron/overdue.php?secret=YOUR_CRON_SECRET ``` ### Duplicate prevention Three layers: 1. **API level:** every write uses `PUT` (upsert). Mailchimp hashes the email address as the unique key. Two `PUT` calls for the same email = one contact, updated. 2. **Tag level:** `mc_set_exclusive_tag()` removes the old tag before applying the new one. A member can never have both "Bronze" and "Gold" at the same time. 3. **DB level:** `members.email` has a `UNIQUE KEY`. The signup form checks for existing email before inserting. ### Conflict resolution When DB and Mailchimp disagree (e.g. someone edited a merge field directly in Mailchimp), the DB always wins on next sync. The sync is always DB→Mailchimp, never the other direction. --- ## 7. Newsletter setup and segmentation ### Sending cadence 4 newsletters per month. Recommended schedule: | Send | Suggested day | Content focus | |------|---------------|---------------| | Week 1 | Tuesday | New member spotlights | | Week 2 | Tuesday | Events + community news | | Week 3 | Tuesday | Member deals and offers | | Week 4 | Tuesday | Industry insights | ### Segment options Because every member has tier, lifecycle, industry, and source tags, you can send a newsletter to precisely the right audience without touching the codebase. **In Mailchimp → Campaigns → Create → To → Use segment:** | Segment name | Condition | Use for | |---|---|---| | All active members | Tag = `Payment Received` OR Tag = `New Member` | General membership news | | Gold and above | Tag = `Gold` OR `Platinum` OR `Diamond` | Premium-tier content | | Industry: Hospitality | Tag = `Hospitality` | Sector-specific news | | Renewal due soon | Tag = `Renewal Reminder` | Renewal-specific campaign | | Newsletter subscribers | Tag = `Newsletter Signup` | Non-member newsletter | | All of the above | No filter | Maximum reach | ### Segments don't need code Tags are already being set correctly by the PHP backend. Creating a segment in Mailchimp is a UI operation — no code changes required. --- ## 8. Backend-triggered email strategy ### How it works The PHP backend triggers Mailchimp journeys via the API. Mailchimp then sends the emails from its own infrastructure (authenticated, tracked, unsubscribe-managed). This means: - **Deliverability** is Mailchimp's problem, not yours - **Email design** lives in Mailchimp, edited without code deploys - **Open/click tracking** is automatic - **Unsubscribes** are handled by Mailchimp's one-click unsubscribe ### What you can trigger from the backend | From where | What gets triggered | |---|---| | Admin panel → mark invoice paid | `Payment Received` lifecycle event + journey | | Admin panel → edit member → status=cancelled | `Cancelled Member` event + retention journey | | Admin panel → "Re-sync now" button | Full merge-field + tag update (no journey) | | Cron scripts | Renewal reminder + overdue events | | Member edits their profile | Profile update journey | ### Sending a one-off email to a specific member (manual) The cleanest approach is via Mailchimp's **Campaigns → Plain text campaign → Send to segment → [filter by email]**. No code needed. For a programmatic one-off, you'd use Mailchimp Transactional (Mandrill), which the `/test/` demo already explored. That's a separate add-on — not yet wired into the main system. ### Sending a bulk email from the admin panel Not yet built. When needed, add an `admin/send-campaign.php` page that: 1. Lets admin pick a segment (tier, industry, lifecycle) 2. Lets admin write or pick a template 3. Calls `POST /campaigns` + `POST /campaigns/{id}/actions/send` The API functions to do this already exist — `mc_call()` in `includes/mailchimp.php` accepts any endpoint. --- ## 9. Step-by-step Mailchimp setup guide Do this once, before going live. ### Step 1 — Create the audience Mailchimp → Audience → Add audience. - **Audience name:** Buy Local Lowveld Members - **From name:** Buy Local Lowveld - **From email:** info@buylocallowveld.co.za - **Remind subscribers how they signed up:** "You're receiving this because you signed up at buylocallowveld.co.za" ### Step 2 — Add custom merge fields Audience → Settings → Audience fields and *|MERGE|* tags → Add a field. Create these, in order: | Field name | Tag | Type | Required? | |---|---|---|---| | First Name | `FNAME` | Text | Yes (already exists) | | Last Name | `LNAME` | Text | Yes (already exists) | | Phone | `PHONE` | Phone | No | | Business Name | `BUSINESS` | Text | No | | Industry | `INDUSTRY` | Text | No | | Tier | `TIER` | Text | No | | Join Date | `JOINDATE` | Date | No | | Renewal Date | `RENEWAL` | Date | No | ### Step 3 — Get your credentials - **API key:** Profile icon → Account → Extras → API keys → Create a key Format: `a1b2c3d4e5f6...xxxx-us21` - **Audience ID:** Audience → Settings → Audience name and defaults → Audience ID (right side) - **Server prefix:** the bit after the `-` in your API key (e.g. `us21`) Edit `includes/config.php`: ```php define('MC_API_KEY', 'your-key-here-us21'); define('MC_SERVER_PREFIX', ''); // leave blank — auto-derived define('MC_AUDIENCE_ID', 'abc1234567'); ``` ### Step 4 — Build the customer journeys Mailchimp → Automations → Customer Journeys → Create journey. Do this for each of the 8 journeys in [Section 4](#4-customer-journey-design). For each: 1. Name the journey (e.g. "BLL — New Member") 2. Set the **starting point trigger** to "API called" (this is the most flexible) 3. Add the email steps with the delays from the table 4. Click **Turn on** 5. From the journey's URL or settings, copy the **journey_id** and **step_id** 6. Paste them into `includes/config.php`: ```php define('MC_JOURNEY_NEW_MEMBER', [ 'journey_id' => 1234567, 'step_id' => 9876543, ]); ``` > **Shortcut for the newsletter journey:** use "Contact is tagged → Newsletter Signup" as the trigger instead of API-called. Then you don't need to paste IDs — Mailchimp fires it automatically when the tag is applied. ### Step 5 — Test the sync 1. Use a test email address you control 2. Go to `/buylocal/become-member.php` on the dev site 3. Fill in the form and submit 4. Check Mailchimp audience — your test email should appear within 5 seconds with: - Merge fields populated (first name, business name, tier) - Tags: `New Member`, `Bronze` (or whichever tier), `Become a Member`, and the industry slug 5. If it doesn't appear, check `buylocal/mailchimp.log` for the error ### Step 6 — Set up cron Option A — Server crontab (SSH required): ```cron 0 2 * * * php /home/elegaysv/dev.systems.elegantwork.co.za/buylocal/cron/renewal-reminder.php 30 2 * * * php /home/elegaysv/dev.systems.elegantwork.co.za/buylocal/cron/overdue.php ``` Option B — External URL pinger (no SSH required): Set up a free job at [cron-job.org](https://cron-job.org) or [uptimerobot.com](https://uptimerobot.com) to hit daily: ``` https://dev.systems.elegantwork.co.za/buylocal/cron/renewal-reminder.php?secret=YOUR_CRON_SECRET https://dev.systems.elegantwork.co.za/buylocal/cron/overdue.php?secret=YOUR_CRON_SECRET ``` Set `CRON_SECRET` in `includes/config.php` to a long random string. Generate one: ```bash php -r "echo bin2hex(random_bytes(24));" ``` --- ## 10. Common pitfalls and how to avoid them ### 1. Merge fields don't exist in Mailchimp yet **Symptom:** Sync appears to succeed but merge fields are blank in Mailchimp. **Cause:** You called `PUT /members` with a `BUSINESS` merge tag that doesn't exist in the audience. **Fix:** Create all merge fields in the Mailchimp dashboard first (Step 2 above). Mailchimp silently ignores unknown merge tags — it doesn't error. ### 2. Wrong audience ID **Symptom:** API returns 404 or contacts appear in the wrong audience. **Cause:** Copy-paste error, or you have multiple audiences. **Fix:** Double-check the Audience ID in Mailchimp → Audience → Settings → Audience name and defaults. It's a 10-character alphanumeric string, not the audience name. ### 3. API key permissions **Symptom:** API returns 401 Unauthorized. **Cause:** The API key was deleted or expired. **Fix:** Generate a new one in Mailchimp. Each key is permanent until you delete it. ### 4. Tier or lifecycle tag not updating (old tag stays) **Symptom:** A Gold member gets upgraded to Platinum but still shows Gold in Mailchimp. **Cause:** The sync ran but the tag-family array in `MC_TIER_TAGS` doesn't match what's in Mailchimp (capitalisation, spelling). **Fix:** Tag names in `config.php` must be an exact character-for-character match of what's in your Mailchimp audience. Check Mailchimp → Audience → Tags for the canonical names. ### 5. Journey doesn't fire **Symptom:** Payment confirmed in DB, `mc_event_payment_received()` called, but no email arrives. **Cause A:** Journey IDs are `0` in `config.php` — `mc_trigger_journey()` silently skips when IDs are zero. **Cause B:** Journey is built but not turned on. **Cause C:** Journey exists but the contact is already past that step. **Fix:** Confirm IDs are correct in config, confirm the journey is "on" in Mailchimp, check `mailchimp_sync_log` in the DB for success/fail rows. ### 6. Duplicate contacts **Symptom:** Same person appears twice in Mailchimp. **Cause:** They signed up with two different email addresses, or someone added them manually in the Mailchimp UI with a different capitalisation. **Fix:** Mailchimp treats `Jane@Example.com` and `jane@example.com` as the same contact — the API hashes to lowercase. Check for actual different-email duplicates and merge them in the Mailchimp UI. Our code always `strtolower(trim($email))` before calling the API. ### 7. Cron fires tags but no emails **Symptom:** `cron_runs` table shows successful runs, `mailchimp_sync_log` shows tags applied, but members don't receive reminder emails. **Cause:** The journey isn't built, or the trigger condition doesn't match the tag name exactly. **Fix:** Build the journey in Mailchimp. The cron only sets the tag; Mailchimp is responsible for detecting that tag change and sending the email. ### 8. Rate limits **Symptom:** 429 errors in `mailchimp.log` during bulk sync. **Cause:** Mailchimp's Marketing API allows ~10 concurrent connections and has a daily send limit tied to your plan. **Fix:** The bulk-sync script in Section 5 already includes `usleep(150000)` (150ms between calls) to stay under the limit. If you have more than ~500 members to sync at once, spread it over multiple runs. ### 9. Contact becomes "unsubscribed" unexpectedly **Symptom:** A member is in the DB as active but their Mailchimp status is "unsubscribed" and they stop receiving emails. **Cause:** They clicked unsubscribe on a Mailchimp email. Once unsubscribed, the API cannot re-subscribe them — Mailchimp's compliance rules prevent this. **Fix:** They need to re-subscribe themselves. Do not try to set their status back to "subscribed" via the API — Mailchimp will reject it, and doing so in some regions violates CAN-SPAM / POPIA. ### 10. Industry tags accumulate over time **Symptom:** A member has 5 industry tags despite only ever picking one. **Cause:** Industry tags are additive — we add new ones but don't remove old ones when a member changes category. **Fix:** This is by design for most cases (a restaurant that adds "Events" to their offering). If you want strict one-industry-only behaviour, update `mc_sync_member_from_db()` to explicitly remove all industry tags before adding the current one. That would require a `DELETE /tags` call alongside the `PUT /members` call. --- ## Quick-reference: all integration points | File | What it does to Mailchimp | |------|--------------------------| | `signup-submit.php` | Upsert + tier tag + `New Member` + source tag + journey | | `member/edit-business.php` | Full merge-field sync + tier + industry + journey | | `admin/member-edit.php` | Full sync on save; fires `Cancelled` event if status flipped | | `member/cancel-membership.php` | `Cancelled Member` tag + cancellation journey | | `contact-submit.php` | Upsert + `Contact Form` tag + `Lead` lifecycle + journey | | `newsletter-submit.php` | Upsert + `Newsletter Signup` tag + welcome journey | | `payfast-itn.php` | `Payment Received` lifecycle + journey (on confirmed payment) | | `admin/invoices.php` | Same as above (for EFT payments marked manually) | | `cron/renewal-reminder.php` | `Renewal Reminder` tag + journey for members 30 days out | | `cron/overdue.php` | `Payment Overdue` tag + journey for overdue invoices | --- *Document covers Phase 2c of the Buy Local Lowveld codebase. All code referenced is in `includes/mailchimp.php` and `includes/config.php`.*