Skip to content

DanielFerguson/flc-crm

Repository files navigation

Ferguson Livestock CRM

A custom CRM and order management system for Ferguson Livestock, a family-run pasture-fed Murray Grey beef operation in Snake Valley, Victoria. Built specifically for managing direct-to-consumer beef box sales through processing cycles (drops), SMS-based customer communication, and Stripe payment integration.

This is a single-user admin application used by Dan and Tahlia Ferguson to manage their customer base, process orders, track inventory per processing cycle, and communicate with customers via SMS.

Tech Stack

  • Laravel (latest stable) — PHP framework
  • Filament PHP 5 — admin panel framework (provides all UI, no custom frontend)
  • SQLite (development) — database
  • Twilio SDK — SMS sending, receiving, and delivery tracking
  • Stripe PHP SDK — payment link generation, webhook handling, order sync
  • Laravel Queues (database driver) — background job processing for SMS and Stripe sync
  • Spatie Laravel Activity Log — audit trail on all models

No custom JavaScript or frontend framework is required. Filament handles all UI components including forms, tables, actions, widgets, and relation managers.

Domain Concepts

Processing Cycles (Drops)

Ferguson Livestock does not have continuous supply. Cattle are processed in batches, roughly every 7–8 weeks. Each batch is referred to as a "drop" (e.g., February 2026 Drop, April 2026 Drop).

Each drop has:

  • A name/label (e.g., "February 2026")
  • A status: planning → active → sold_out → complete
  • A defined inventory of products with per-cycle pricing
  • Associated orders
  • Associated expenses for profitability tracking

Products & Inventory

There are three products:

  1. 5kg Beef Box — the core unit of inventory
  2. 500g Beef Mince — optional extra, purchased alongside a box
  3. 2kg Soup & Stock Bones — optional extra, purchased alongside a box

Mince and bones are add-ons. They are never sold independently — they are only available as extras when a customer purchases a box.

Larger box options (10kg, 20kg) are purchase bundles made up of 5kg box units:

  • 5kg = 1× 5kg box unit
  • 10kg = 2× 5kg box units
  • 20kg = 4× 5kg box units

Inventory per cycle is tracked as three simple numbers: total 5kg box units, total mince packs, total bones packs. When a 10kg order is placed and paid, 5kg box inventory decrements by 2.

Per-Cycle Pricing with Stripe Price IDs

Pricing varies between drops for A/B testing purposes. Each cycle defines its own prices and corresponding Stripe Price IDs:

Product Example (Feb 2026) Example (Apr 2026)
5kg box $150 (price_abc) $170 (price_def)
10kg box (2×5kg) $260 (price_ghi) $270 (price_jkl)
20kg box (4×5kg) TBD TBD
500g mince TBD TBD
2kg bones TBD TBD

Each product variation within a cycle stores its own Stripe Price ID. When the CRM generates a payment link, it uses the Price ID from the active cycle.

Contacts

A contact is a customer or prospective customer. Contacts can originate from:

  • Website form submission (fergusonlivestock.com.au)
  • Stripe purchase (synced via webhook)
  • CSV import (one-time Klaviyo migration)
  • Manual creation in the CRM

Contact lifecycle status: lead → customer → inactive

  • Lead — has registered interest (form submission) but has never purchased
  • Customer — has at least one completed (paid) order
  • Inactive — has not ordered in a configurable number of cycles (used for re-engagement targeting)

Contacts can be tagged for segmentation (e.g., "Ballarat", "5kg preference", "10kg preference", "VIP", "April 2026 interested").

Contact Deduplication

Contacts may arrive from multiple sources with slight variations. The CRM needs a ContactMerger service:

  • Automatic matching: match on phone number first (most reliable), then email
  • Manual merge UI: Filament action to merge two contacts, preserving all order history, SMS history, and activity from both records
  • On inbound webhook (Stripe or form), check for existing contact before creating a new one

Orders

Orders are synced from Stripe via webhooks. An order belongs to a contact and a processing cycle.

Order statuses mirror Stripe's native payment statuses (e.g., pending, paid, expired, refunded). No custom status workflow — keep it simple.

An order contains:

  • Link to contact
  • Link to processing cycle
  • Stripe Payment Intent ID / Checkout Session ID
  • Line items (which products and quantities)
  • Total amount
  • Status (from Stripe)
  • Timestamps

When an order is confirmed as paid via Stripe webhook, the CRM automatically decrements inventory on the associated cycle.

SMS Communication

SMS is the primary communication channel. Powered by Twilio with a dedicated Australian mobile number.

Capabilities:

  • Send individual SMS from a contact's detail page
  • Send bulk SMS to filtered/tagged contact segments
  • SMS templates with merge fields: {first_name}, {suburb}, {cycle_name}, {payment_link} etc.
  • Inbound reply handling via Twilio webhook (logged against the contact)
  • Automatic opt-out handling: if a contact replies STOP, mark them as opted out and exclude from all future sends
  • Delivery status tracking via Twilio status callbacks (queued, sent, delivered, failed)
  • Chat-style SMS thread view on each contact's detail page

Twilio forwarding setup: Inbound SMS to the Twilio number should be forwarded to Dan's personal mobile number so replies can be handled conversationally outside the CRM if needed. Replies are also logged in the CRM via webhook.

Spam Act 2003 compliance:

  • Store opt-in timestamp per contact (when they submitted the form or explicitly consented)
  • Honour STOP/unsubscribe replies automatically
  • Only send marketing SMS to contacts with valid opt-in

A2P registration: The Twilio number will need to be registered for Application-to-Person messaging with Australian carriers. This should be noted in deployment documentation.

Data Models

Contact

Field Type Notes
id ulid Primary key
first_name string
last_name string nullable
email string nullable, unique where not null
phone string nullable, unique where not null, E.164 format
suburb string nullable
postcode string nullable
status enum lead, customer, inactive
source enum website_form, stripe, csv_import, manual
opted_in_at timestamp nullable, when they consented to SMS
opted_out_at timestamp nullable, when they sent STOP
stripe_customer_id string nullable
notes text nullable, free-form notes
created_at timestamp
updated_at timestamp
deleted_at timestamp soft deletes

Has many: orders, sms_messages, form_submissions, tags (many-to-many)

Tag

Field Type Notes
id ulid Primary key
name string unique
created_at timestamp

Many-to-many with contacts via contact_tag pivot table.

ProcessingCycle

Field Type Notes
id ulid Primary key
name string e.g., "February 2026"
status enum planning, active, sold_out, complete
notes text nullable
created_at timestamp
updated_at timestamp

Has many: cycle_products, orders, expenses

CycleProduct

Defines what products are available in a cycle, their pricing, Stripe Price IDs, and inventory.

Field Type Notes
id ulid Primary key
processing_cycle_id foreign key
product_type enum 5kg_box, 10kg_box, 20kg_box, 500g_mince, 2kg_bones
price_cents integer price in cents
stripe_price_id string Stripe Price ID for this product in this cycle
initial_stock integer starting inventory (for boxes, in 5kg units; for extras, in individual packs)
reserved_stock integer default 0, decremented on paid orders
created_at timestamp

available_stock is a computed attribute: initial_stock - reserved_stock

For box products, initial_stock is always in 5kg box units. A 10kg product with initial_stock of 0 and no independent stock — it draws from the 5kg box pool. The CRM should track a single box_units inventory on the cycle level and decrement appropriately:

  • 5kg order → decrement 1
  • 10kg order → decrement 2
  • 20kg order → decrement 4

Alternatively, simplify the CycleProduct model so that only the 5kg_box, 500g_mince, and 2kg_bones entries carry initial_stock. The 10kg and 20kg entries only need price_cents and stripe_price_id since they consume 5kg box inventory. The CRM should enforce that a payment link cannot be generated if insufficient 5kg box units remain for the selected purchase option.

Order

Field Type Notes
id ulid Primary key
contact_id foreign key
processing_cycle_id foreign key
stripe_checkout_session_id string nullable
stripe_payment_intent_id string nullable
status string mirrors Stripe: pending, paid, expired, refunded
total_cents integer
paid_at timestamp nullable
created_at timestamp
updated_at timestamp

Has many: order_items

OrderItem

Field Type Notes
id ulid Primary key
order_id foreign key
cycle_product_id foreign key
quantity integer
unit_price_cents integer price at time of purchase
created_at timestamp

Expense

Field Type Notes
id ulid Primary key
processing_cycle_id foreign key
description string e.g., "Processing fees", "Packing materials", "Delivery fuel"
amount_cents integer
date date nullable
created_at timestamp
updated_at timestamp

SmsMessage

Field Type Notes
id ulid Primary key
contact_id foreign key
direction enum outbound, inbound
body text message content
twilio_sid string nullable, Twilio message SID
status enum queued, sent, delivered, failed, received
template_id foreign key nullable, if sent from a template
sent_at timestamp nullable
created_at timestamp

SmsTemplate

Field Type Notes
id ulid Primary key
name string e.g., "Pre-sale announcement"
body text template with merge fields: {first_name}, {suburb}, {cycle_name}, {payment_link}
created_at timestamp
updated_at timestamp

FormSubmission

Field Type Notes
id ulid Primary key
contact_id foreign key nullable, linked after matching/creation
payload json raw form data as submitted
processed_at timestamp nullable, when contact was created/linked
created_at timestamp

Filament Admin Panel Structure

Resources

ContactResource

  • Table: searchable/filterable list with columns for name, phone, email, suburb, status, source, tags, order count, last order date
  • Create/Edit form: all contact fields plus tag selector
  • View page with:
    • Contact details infolist
    • Activity timeline (all interactions in chronological order)
    • SMS thread (chat-style view using Filament infolist, with inline send form)
    • Relation manager: Orders
    • Relation manager: Form Submissions
  • Header actions: Send SMS, Generate Payment Link, Merge with Another Contact

ProcessingCycleResource

  • Table: list of all cycles with status, dates, stock remaining summary
  • Create/Edit form: name, status, notes
  • Relation manager: CycleProducts (define products, pricing, Stripe Price IDs, stock levels per cycle)
  • Relation manager: Orders (all orders for this cycle)
  • Relation manager: Expenses
  • View page with:
    • Inventory status panel (visual indicators for remaining stock — 5kg units, mince, bones)
    • Revenue summary (auto-calculated from paid orders)
    • Expenses summary
    • Profit/margin calculation
  • Header actions: Bulk SMS to Leads, Mark as Active, Mark as Sold Out, Mark as Complete

OrderResource

  • Table: all orders with contact name, cycle, status, total, date
  • Primarily read-only (synced from Stripe), but allow manual notes
  • View page with order items, Stripe links, contact details

SmsTemplateResource

  • CRUD for SMS templates
  • Preview with sample merge field values

FormSubmissionResource

  • Table: incoming form submissions, filterable by processed/unprocessed
  • Action: Create Contact from Submission (pre-fills contact form with submission data)

Dashboard

Filament dashboard with the following widgets:

  • Active Cycle Overview — current cycle name, status, inventory remaining (5kg units, mince, bones) as progress bars or stats
  • Recent Orders — last 10 orders with contact name, products, total, status
  • Revenue per Cycle — bar chart showing revenue across all completed and active cycles
  • Cycle Profitability — table showing each cycle's revenue, expenses, profit, margin percentage
  • Contacts Summary — total leads, total customers, new leads this week
  • SMS Stats — messages sent this cycle, delivery rate

Filament Actions

SendSmsAction — available on ContactResource view page. Opens a modal with a text field (or template selector + merge field preview). Dispatches SendSmsJob.

BulkSmsAction — available on ContactResource table as a bulk action. Select contacts (or use filters/tags), choose or write message, preview recipient count, dispatches BulkSmsBlastJob. Must exclude opted-out contacts. Should warn if recipient count exceeds available stock on the active cycle.

GeneratePaymentLinkAction — available on ContactResource view page. Modal to select active cycle, choose product (5kg/10kg/20kg), optionally add extras (mince, bones with quantities). Checks inventory availability. Creates a Stripe Checkout Session with the correct Price IDs from the cycle. Optionally sends the link via SMS immediately. Creates an order record with pending status.

MergeContactsAction — select a primary contact and a secondary contact. Moves all orders, SMS messages, form submissions, and activity log entries from secondary to primary. Soft-deletes secondary.

ImportCsvAction — one-time action (can be a Filament page or import action). Upload CSV exported from Klaviyo. Maps columns to contact fields. Creates contacts, skipping duplicates based on phone/email matching.

API Endpoints (Outside Filament)

These are standard Laravel routes, not part of the Filament panel.

POST /webhooks/stripe

Handles Stripe webhook events. Must verify Stripe webhook signature.

Events to handle:

  • checkout.session.completed — mark order as paid, decrement cycle inventory, update contact status to customer if currently lead, link to contact via Stripe customer email/metadata
  • checkout.session.expired — mark order as expired, release reserved inventory if applicable

POST /webhooks/twilio/inbound

Handles inbound SMS from Twilio. Must verify Twilio request signature.

Logic:

  • Look up contact by phone number (sender)
  • Create SmsMessage record with direction inbound
  • If message body is STOP/UNSUBSCRIBE/CANCEL/END/QUIT, set opted_out_at on contact
  • If message body is START/UNSTOP, clear opted_out_at
  • Forward the message to Dan's personal number via Twilio (configurable in .env)

POST /webhooks/twilio/status

Handles Twilio delivery status callbacks. Updates SmsMessage status field (queued → sent → delivered or failed).

POST /api/form-submission

Receives form data from the Vercel API route on fergusonlivestock.com.au. Should be authenticated with a simple API key (stored in .env, passed as a bearer token or header).

Logic:

  • Store raw payload as a FormSubmission
  • Attempt to match to existing contact by phone number, then email
  • If match found, link the submission to the contact
  • If no match, create a new contact with status lead and source website_form, set opted_in_at to now
  • Add relevant tags if present in form data

Background Jobs

All external API calls should be dispatched as queued jobs using Laravel's database queue driver.

  • SendSmsJob — sends a single SMS via Twilio, creates SmsMessage record
  • BulkSmsBlastJob — iterates through a collection of contacts, dispatches individual SendSmsJob per contact (allows for individual delivery tracking and failure handling)
  • SyncStripeOrderJob — processes a Stripe webhook payload, creates/updates order and contact records. Must be idempotent (check for existing order by stripe_checkout_session_id before creating)
  • ProcessFormSubmissionJob — handles contact matching/creation from a form submission

Configuration (.env)

STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

TWILIO_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=        # The Twilio Australian mobile number
TWILIO_FORWARD_TO_NUMBER=  # Dan's personal iPhone number for SMS forwarding

FORM_SUBMISSION_API_KEY=   # API key for the website form endpoint

CSV Import (Klaviyo Migration)

One-time import feature. Expected CSV columns (mapping should be configurable in the import UI):

  • First name
  • Last name
  • Email
  • Phone number
  • Any tags/segments from Klaviyo

Import logic:

  • Normalise phone numbers to E.164 format (+61...)
  • Skip duplicates based on phone/email matching
  • Set source as csv_import
  • Set opted_in_at based on Klaviyo subscription date if available, otherwise import timestamp
  • Report: X imported, X skipped (duplicate), X failed (invalid data)

Filament has a built-in import action (ImportAction) that supports CSV uploads with column mapping. Use this rather than building a custom import page.

Deployment Notes

  • Queue worker: must be running for SMS and Stripe webhook processing. Use php artisan queue:work with a process manager like Supervisor in production.
  • Scheduler: run php artisan schedule:run via cron for any scheduled tasks (optional, not critical for v1).
  • Twilio A2P registration: register the Twilio number for Application-to-Person messaging with Australian carriers before going live. Messages may be filtered/blocked without this.
  • Stripe webhook endpoint: register the /webhooks/stripe URL in the Stripe dashboard and subscribe to checkout.session.completed and checkout.session.expired events.
  • Twilio webhook URLs: configure the Twilio number's incoming message webhook to /webhooks/twilio/inbound and set the status callback URL on outbound messages to /webhooks/twilio/status.
  • SSL: required for all webhook endpoints.
  • Hosting: any standard Laravel-compatible host (Forge + DigitalOcean, Laravel Vapor, shared hosting with SSH access, etc.).

Out of Scope for V1

  • Email sending (SMS is the sole communication channel)
  • Public-facing customer portal or storefront
  • Multi-user roles/permissions (single admin user)
  • Automated drip sequences or scheduled campaign sends
  • Complex reporting beyond dashboard widgets
  • Integration with any service beyond Stripe, Twilio, and the website form

About

A custom Filament PHP-based CRM tool for the Ferguson Livestock Company.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages