--- title: Loyal Thread — Feature Reference (AI) description: Compressed feature map of the app for LLM/agent ingestion. Not rendered in the merchant Help panel. updated: 2026-09-01 --- # Purpose This file is a dense, machine-readable map of the app's features for AI agents and tools (context loading, RAG, support-bot grounding, onboarding a new agent session). It is not user-facing prose — see `resources/docs/{getting-started,core-concepts,guides,reference}/` (served by `App\Services\DocumentationService`) for the human Help panel content. App type: multi-tenant SaaS loyalty/rewards platform for cannabis dispensaries and retail merchants (`Program` = a merchant's loyalty program, tenant-scoped via `stancl/tenancy`). Two customer-facing SPAs (Vue + Inertia) per program: the **customer portal** and the **budtender/employee portal**. Merchant/admin side is a Filament v4 panel. # Points Customers earn points from receipt uploads, purchases, and configured bonuses; redeem points for catalog rewards. - Models: `PointsConfiguration` (earn rules per program), `PointsMultiplier` (time/product-based multipliers), `PointsTransaction` (ledger entries). - Routes: `Api\Customer\PointsHistoryController`. - Related: Tiers, Receipts, Rewards Catalog. # Tiers Loyalty tier levels (e.g. bronze/silver/gold) computed from a customer's points, unlocking perks/multipliers. - Model: `Tier`. # Receipts Customers upload a receipt photo; the app matches line items to catalog products and awards points automatically. - Model: `Receipt`. - Routes: `Api\Customer\ReceiptsController`. # Rewards Catalog Merchants define redeemable rewards (with images and optional size/color variants); customers browse, select a variant, cart, and check out using points. Variant-level inventory is tracked independently and rolled up to the parent reward; `Order` records the chosen variant via `reward_variant_id`. Merchants with multiple programs can bulk-copy selected catalog items to another active program (images and variants included) via the "Copy to Program" bulk action; `RewardProgramCopyService` handles atomic per-reward copy with file duplication and rollback on failure. - Models: `Reward`, `RewardImage`, `RewardVariant`. - Services: `RewardProgramCopyService` (bulk cross-program copy). - Routes: `Api\Customer\RewardsController`, `Api\Customer\CartController`, `Api\Customer\CheckoutController`. # Product Bonus Points (Budtender Program) Per-employee/budtender bonus point incentives tied to specific products, used to drive budtender-recommended sales. Bonuses support optional date windows (`starts_at`/`ends_at`) and location-string filtering; managed via a Filament merchant resource; applied at receipt processing time by `EmployeeReceiptCreditService`. Bonus lookups are scoped by `tenant_id` (not the employee's `program_id`) because a bonused product may belong to a different program than the employee. Flat receipt credit and product bonus are awarded as two separate `PointsTransaction` entries so employees see distinct line items. When a bonus goes live, `app:announce-employee-product-bonuses` sends a push + database notification to all active employees tenant-wide via `EmployeeProductBonusAnnouncementNotification`; `announced_at` on the bonus row tracks whether the announcement has been sent. - Models: `EmployeeProductBonus` (`announced_at`, scopes: `dueForAnnouncement`, `currentlyActive`), `Product`, `ProductImage`, `CustomerProductNote`. - Admin: `Filament\Merchant\Resources\EmployeeProductBonusResource`. - Notifications: `EmployeeProductBonusAnnouncementNotification` (push + database). - Command: `AnnounceEmployeeProductBonuses` (`app:announce-employee-product-bonuses`). - Related: Product Quizzes, Product Reviews, Push Notifications. # Product Quizzes Templated quizzes tied to products (e.g. staff/customer product knowledge). - Model: `ProductQuizTemplate`. # Product Reviews Customers leave reviews on products. - Model: `ProductReview`. - Routes: `Api\Customer\ProductReviewsController`. # Customer Identity Unification A single `CustomerIdentity` record unifies login credentials (email, password, Google ID, email verification) across multiple per-program `Customer` membership rows. Resetting a password or verifying email on any one membership applies to all. Authenticated customers can list their other program memberships and switch to one via a short-lived, single-use, cross-domain token. Google OAuth supports a "join" confirmation flow when an existing identity is detected during login on a new program; `RegistrationSource::LinkedAccount` tags these enrollments. - Models: `CustomerIdentity` (shared credentials), `Customer.customer_identity_id` FK. - Services: `CustomerCrossDomainLogin` (mints `oauth_login_tokens` and `oauth_join_tokens`), `CustomerEnrollmentService` (enrollment with referral/bonus/attribution handling). - Routes: `Api\Customer\ProgramSwitchController` (`GET /v1/customer/programs`, `POST /v1/customer/programs/switch`), `LoginController::join` (`POST /login/join`), `LoginController::joinViaGoogle` (`POST /login/join-google`). - DB tables: `customer_identities`, `oauth_join_tokens`. # Customer Portal Program-scoped Vue SPA at `/{program_slug}/customer/*`: dashboard, orders, points/journal history, profile, notifications, PWA install, web push, event-form submissions, legal pages. The SPA includes a `ForgotPassword.vue` page at `/forgot-password` (sends a reset link via `ForgotPasswordController`). Password reset resolves the token to the correct program membership before logging in; if no membership exists for the reset-requesting program, the response returns `can_join: true` and the customer must sign in to complete enrollment. - Models: `Customer`, `Order`. - Routes: `Api\Customer\DashboardController`, `OrdersController`, `JournalController`, `ProfileController`, `Customer\NotificationController`, `Customer\PushSubscriptionController`, `Customer\PwaController`, `Customer\LegalController`. - Auth: `Api\Customer\LoginController`, `RegisterController`, `ForgotPasswordController`, `SpaResetPasswordController`, `EmailVerificationController`, `Customer\Auth\GoogleAuthController`, `Customer\Auth\AcceptInviteController` (employee invite acceptance). - Related: Customer Identity Unification (cross-program login/switching). # Budtender/Employee Portal A parallel Vue SPA at `/{program_slug}/budtender/*` for program employees — same auth/notification/push infrastructure as the customer portal, distinct routes/namespace. The employee dashboard (`Api\Customer\DashboardController`) returns `active_product_bonuses` (currently active `EmployeeProductBonus` records for the tenant) alongside the standard dashboard payload so employees can see which product bonuses are running. The budtender registration page supports Google sign-up (redirects to the shared `/customer/auth/google` endpoint); the SPA also exposes a `/forgot-password` route (reuses `customer/pages/ForgotPassword.vue`). ## Budtender Attribution (two-field model) Customer attribution is split across two fields on `Customer`: - `referred_by_employee_id` — **one-time acquisition record**; stamped at registration or first login with an employee code and never overwritten. Drives the referral bonus. - `current_employee_id` — **mutable current budtender**; updated every time the customer scans a new employee QR code (at registration, at login, or mid-session via `CaptureEmployeeCode` middleware for already-authenticated customers). Drives the "Your Budtender" display in the customer portal and is the default fallback in `EmployeeAttributionService` when crediting a receipt with no scan-time code. - Relationship: `Customer::currentEmployee()` (`BelongsTo` self via `current_employee_id`). - Validation helper: `Customer::employeeBelongsToProgram(int $id, Program $program)` — enforces program membership only when the program is employee-type; consumer programs accept any tenant-wide employee. # Budtender Leaderboard Contests Time-windowed competitive scoring periods for employee programs; merchants create contests with `starts_at`/`ends_at` windows and a `freeze_at` cutoff; `ContestLeaderboardService` ranks employees by referral signups and attributed receipt sales during the window. The leaderboard is toggled per `Program` via `leaderboard_enabled`; expired contests are frozen by the `FreezeExpiredContests` console command. Budtender SPA exposes a **Leaderboard** page (current contest + rankings + past contests) and a **Stats** page (personal signups, sales total, points balance, recent activity). - Model: `Contest` (`starts_at`, `ends_at`, `freeze_at`, `frozen_at`, `results`). - Service: `ContestLeaderboardService` (ranking computation). - Admin: `Filament\Merchant\Resources\Contests\ContestResource` (CRUD), `Program.leaderboard_enabled` toggle. - Command: `FreezeExpiredContests` (`app:freeze-expired-contests`). - Routes: `GET /api/v1/customer/leaderboard` (`Api\Customer\LeaderboardController`), `GET /api/v1/customer/my-stats` (`Api\Customer\BudtenderStatsController`). - SPA pages: `budtender/pages/Leaderboard.vue`, `budtender/pages/Stats.vue`. # Promotions / Event Forms Merchants run promotional forms/events; customers submit responses. - Models: `EventForm`, `EventFormSubmission`. - Routes: `Api\Customer\EventFormController`. # Merchant Announcements Merchants broadcast messages to their program's customers. - Model: `MerchantAnnouncement`. # Webhooks Outbound event delivery to merchant-configured endpoints, plus an inbound webhook for GoHighLevel points sync. - Models: `WebhookEndpoint`, `WebhookDelivery`, `WebhookDeliveryAttempt`. - Routes: `Api\Webhooks\GhlPointsWebhookController` (inbound). # Push Notifications Web push subscribe/unsubscribe/test, plus an in-app notification bell — shared by both SPAs. - Routes: `Customer\PushSubscriptionController`, `Customer\NotificationController`. # GoHighLevel (GHL) Integration OAuth install flow from the GHL Marketplace, followed by a manual token-exchange/connect step, then ongoing points sync via inbound webhook. - Routes: `Ghl\GhlOAuthCallbackController` (`/oauth/callback`), `Ghl\GhlConnectController` (`/ghl/connect`), `Api\Webhooks\GhlPointsWebhookController`. # Admin API Read-only JSON API for AI agents and external tools to query business data. All endpoints require `X-Api-Key: ` (configured via `ADMIN_API_KEY` env var). No session or OAuth needed. All responses wrap data in `{ "success": true, "data": ... }`. List endpoints are paginated: `{ "data": [...], "meta": { "current_page", "last_page", "per_page", "total" } }`. Base path: `/api/v1/admin` ## Authentication Header: `X-Api-Key: ` Missing key → 401 `{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Missing API key." } }` Wrong key → 401 `{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Invalid API key." } }` ## Business Metrics `GET /api/v1/admin/metrics` Returns a full business snapshot — no params. Response fields: - `data.mrr.total` — current MRR in dollars (integer) - `data.mrr.arr` — ARR (mrr × 12) - `data.mrr.prev_month` — prior month MRR - `data.mrr.mom_change_percent` — month-over-month % change - `data.mrr.by_tier[]` — `{ tier, count, mrr }` for starter / pro / enterprise - `data.merchants.total / paying / trialing / onboarding` — merchant segment counts - `data.health_summary` — `{ healthy, at_risk, churning }` counts - `data.monthly_trends[]` — last 6 months: `{ month (YYYY-MM), month_label, new_merchants, new_paying, new_customers, mrr }` - `data.failed_payments[]` — `{ id, business_name, payment_failed_at }` ## Merchant Health Overview `GET /api/v1/admin/health` Lists all merchants ranked by health (churning → at_risk → healthy → new). Query params: `?status=healthy|at_risk|churning|new` Response fields per item: `id, business_name, health_status, last_activity_at, health_checked_at, payment_failed (bool), payment_failed_at, subscription_status, tier, onboarded (bool)` Top-level `summary`: `{ healthy, at_risk, churning, new }` — counts regardless of filter. ## Merchant List `GET /api/v1/admin/tenants` Query params: `?search=`, `?tier=starter|pro|enterprise`, `?status=active|inactive`, `?sort=id|business_name|created_at|tier`, `?direction=asc|desc` Response fields per item: `id, business_name, contact_email, admin_email, is_active, tier, subscription_status, created_at` ## Merchant Detail `GET /api/v1/admin/tenants/{id}` Response fields: `id, business_name, contact_email, contact_phone, admin_email, admin_name, is_active, tier, subscription_status, program_name, ghl_location_id, created_at` ## Merchant Deep Health `GET /api/v1/admin/tenants/{id}/health` Initializes tenant context to query engagement data. Slower than the list endpoint. Response fields: - `data.health_status` — healthy | at_risk | churning | new - `data.metrics.signups_30d` — new customers in last 30 days - `data.metrics.total_customers` - `data.metrics.receipts_30d` - `data.metrics.redemptions_30d` - `data.metrics.event_submissions_30d` - `data.metrics.active_customers_30d` — unique customers with a points transaction in 30d - `data.metrics.avg_points_balance` - `data.metrics.has_ever_redeemed` — bool - `data.metrics.last_activity_at` — ISO 8601 timestamp of most recent points transaction - `data.metrics.payment_failed_at` ## Merchant Customers `GET /api/v1/admin/tenants/{id}/customers` Query params: `?search=` (name/email), `?sort=name|email|points_balance|created_at`, `?direction=asc|desc` Response fields per item: `id, name, email, phone, points_balance, lifetime_points_earned, is_verified, enrolled_at, created_at` ## Merchant Programs `GET /api/v1/admin/tenants/{id}/programs` No params. Returns all programs for the tenant ordered default-first. Response fields per item: `id, name, slug, type (consumer|employee), is_active, is_default, total_customers, total_orders, total_receipts, created_at` ## Merchant Products `GET /api/v1/admin/tenants/{id}/products` Products are SKUs used for receipt matching and bonus point multipliers. Query params: `?program_id=`, `?status=active|inactive`, `?search=` (name/brand/sku), `?sort=name|brand_name|sku|created_at`, `?direction=asc|desc` Response fields per item: `id, program_id, program_name, name, brand_name, sku, is_active, created_at` ## Merchant Rewards `GET /api/v1/admin/tenants/{id}/rewards` Rewards catalog — items customers redeem points for. Query params: `?program_id=`, `?status=active|inactive`, `?sort=title|point_value|sort_order|created_at`, `?direction=asc|desc` Response fields per item: `id, program_id, program_name, title, type, point_value, is_active, is_unlimited, stock_quantity, sort_order` (stock_quantity is the rollup of variant quantities when variants exist) ## Merchant Orders `GET /api/v1/admin/tenants/{id}/orders` Redemption orders placed by customers. Query params: `?program_id=`, `?status=pending|shipped|cancelled`, `?sort=points_spent|created_at|status`, `?direction=asc|desc` Response fields per item: `id, program_id, program_name, customer_id, customer_name, customer_email, reward_id, reward_title, points_spent, quantity, status, fulfilled_at, created_at` ## Merchant Event Forms `GET /api/v1/admin/tenants/{id}/event-forms` Promotional sign-up forms; submissions earn points. Query params: `?program_id=`, `?status=active|inactive` Response fields per item: `id, program_id, program_name, title, slug, is_active, points_awarded, expires_at, total_submissions, created_at` ## Merchant Receipts `GET /api/v1/admin/tenants/{id}/receipts` Customer-uploaded receipts — matched to products and awarded points. Query params: `?program_id=`, `?status=pending|processing|approved|rejected`, `?sort=total_amount|points_awarded|purchase_date|created_at`, `?direction=asc|desc` Response fields per item: `id, program_id, program_name, customer_id, customer_name, customer_email, status, points_awarded, total_amount, merchant_name, purchase_date, rejection_reason, processed_at, created_at` ## Merchant Settings `GET /api/v1/admin/tenants/{id}/settings` Points earn configuration per program (one record per program). Response fields per item: `program_id, program_name, program_slug, signup_bonus_points, email_verification_points, profile_completion_points, receipt_upload_points, points_per_dollar, auto_approval_threshold, multiplier_stacking_mode (highest_wins|stack_multiply|stack_add), referral_signup_points` # Merchant Legal Acceptance Merchants must confirm platform terms of service and privacy policy before operating a program. Versioned history is stored for both documents; confirmation is recorded on the tenant via `legal_confirmed_at`. When new terms/privacy are saved they are synced to all the tenant's programs (`terms_and_conditions` / `privacy_policy` on `Program`). Newly created programs are seeded from the tenant's active legal versions (or `config/legal.php` defaults). A `LegalConfirmationModal` Livewire component gates the Merchant Filament panel until acceptance is recorded. - Models: `TenantTermsVersion` (`tenant_terms_history`), `TenantPrivacyVersion` (`tenant_privacy_history`), `Tenant.legal_confirmed_at`. - Admin: `Filament\Merchant\Livewire\LegalConfirmationModal`, Merchant Settings page legal section, legal history drawer. - Config: `config/legal.php` (default terms, indemnification clause, default privacy policy). - Seeder: `BackfillTenantTermsSeeder`. # Merchant / Tenant / Program Admin Multi-tenant org structure: a `Tenant` owns one or more `Program`s; Filament admin panel for managing programs, branding, team invitations, invite codes, and revenue history. - Models: `Tenant`, `Program`, `ProgramInviteCode`, `TeamInvitation`, `TenantRevenueHistory`, `User`. - Routes: `Merchant\BrandingController`, `Merchant\SwitchProgramAndRedirect`, `Api\Admin\TenantsController`, `Api\Tenants\CreateTenantController`. # Checkout / Billing Merchant-side self-serve signup and subscription billing via Laravel Cashier (Stripe): checkout session, billing portal, webhook-driven subscription state. - Routes: `Checkout\CheckoutController`, `Checkout\StripeWebhookController`. # Impersonation Support/admin users can impersonate another account and return via a "leave impersonation" route. - Routes: `ImpersonationController`.