# Kuppa-Main: Business Domain Reference

> Deep reference for porting the Business/Location/Staff system into Cuppa (Next.js/Prisma).
> Source: `/Users/mac/www/cuppa/Kuppa-main` (Laravel 11 + Inertia/Vue)

---

## Domain Hierarchy

```
User (owner / staff)
 └── BusinessGroup          ← brand, Stripe, billing, verification, entitlements
      └── Business[]        ← physical locations (soft-deletable)
           ├── BusinessOpeningHour[]
           ├── BusinessLocation[]   ← legacy mini-table (rarely used)
           ├── VenueImage[]
           └── staff via business_user pivot (M2M with User)

Organization (guild / association)
 └── OrganizationMember
      ├── business_id  → business_groups.id  ← NOTE: points to GROUP not location
      └── user_id      → users.id
```

**Active context:** `users.selected_business_id` → which `Business` (location) the current session operates on.

**Key rule:** "Locations" in the API/MCP always means `Business` rows, NOT `business_locations` rows.
There is **no Franchise model** — multi-location brands are simply one `BusinessGroup` with many `Business` rows.

---

## Models

### BusinessGroup

> Brand/account layer. Owns Stripe, billing, verification, logo, name.

**Table:** `business_groups`

| Field | Type | Notes |
|-------|------|-------|
| `id` | bigint | PK |
| `user_id` | bigint | FK → users (owner) |
| `account_type` | string | `null` or `event_organizer` |
| `logo` | string | S3 path |
| `name` | string | brand name |
| `description` | text | brand description |
| `claim_code` | string | for claiming ownership |
| `balance` | decimal | wallet |
| `is_verified` | boolean | group verification |
| `stripe_account_id` | string | Stripe Connect |
| `brand_colors` | JSON | array of hex colors |
| `claimed_at` | timestamp | null = placeholder/unclaimed |
| `has_claimed_free_menu_group` | boolean | |
| `onboarding_source` | enum | `claim_registration`, `self_registration`, `admin`, `automated_import` |
| `deleted_at` | timestamp | SoftDeletes |

**Key relations:**
- `owner()` → User
- `businesses()` → HasMany Business
- `mainBusiness()` → HasOne Business where `is_main = true`
- `products()`, `menuBoardGroups()` → HasMany
- `organizationMemberships()` → HasMany OrganizationMember

**Claim logic:** `claimed_at` is non-null only when `user_id` is set AND owner email does not contain `@owner.placeholder`. Placeholder owners are AI/import created entries.

**Stripe helpers:** `generateConnectLink()`, `generateContinueOnboardingLink()`, `getStripeAccountDetails()` (cached 600s), `disconnectStripeAccount()`.

---

### Business (= "Location")

> Physical site. The API/MCP/UI call these "locations."

**Table:** `businesses`

**Traits:** `SoftDeletes`, `OrderableByBoosts`

**Always eager-loaded:** `businessGroup`

| Group | Fields |
|-------|--------|
| Ownership | `user_id`, `business_group_id`, `is_main` |
| Identity | `name`, `alias_names[]`, `location_name`, `logo`, `description` |
| Contact | `phone`, `country_code`, `email`, `owner_name`, `owner_email`, `contact_email`, `contact_phone` |
| Address | `location`, `street_address`, `city`, `state`, `county`, `country`, `zip_code`, `lat`, `lng`, `timezone` |
| Commerce | `stripe_account_id`, `wallet_balance`, `tax_rate`, `allow_gifting`, `rating` |
| Social | `website`, `facebook_url`, `twitter_url`, `instagram_url`, `youtube_url`, `linkedin_url`, `tiktok_url`, `yelp_url`, `tripadvisor_url`, `google_maps_url` |
| Brewery meta | `founded_year`, `brewery_type`, `venue_type` (enum: `brewery`/`taproom`), `annual_production`, `beer_count`, `tags[]`, `beer_styles[]`, `awards[]` |
| External | `untappd_brewery_id`, `untappd_brewery_slug`, `is_ai_added`, `ai_verified` |
| Status | `is_permanently_closed`, `is_archived`, `deleted_at` |

**Key accessors/logic:**
- `name` / `description` / `logo_url` — prefer `businessGroup` values
- `is_verified` — from `businessGroup.is_verified`
- `open_status` — timezone-aware from `openingHours` (supports overnight hours)
- `distance` — Haversine miles from request `lat`/`lng`
- `allow_gifting` — DB flag AND Stripe Connect fully onboarded on group
- `setLocationAttribute` — auto-parses city/state/country from Google Maps string

**Key relations:**
- `businessGroup()` → BelongsTo BusinessGroup
- `owner()` → BelongsTo User (via `user_id`)
- `staff()` → BelongsToMany User via `business_user`
- `locations()` → HasMany BusinessLocation (legacy)
- `openingHours()`, `products()`, `events()`, `menuBoards()`, `venueImages()` → HasMany

---

### BusinessLocation (legacy)

> Small secondary table. Rarely used. Prefer `Business` as "location."

**Table:** `business_locations`

| Field | Type |
|-------|------|
| `id` | bigint |
| `business_id` | bigint FK → businesses |
| `name` | string |
| `lat` | decimal |
| `lng` | decimal |

Relations: `business()` → BelongsTo Business, `menuBoardDesigns()`

---

### User (business-relevant parts)

**Business relations:**
- `businessGroup()` → HasOne BusinessGroup (owner: `business_groups.user_id`)
- `business()` → BelongsTo Business via `selected_business_id`
- `businesses()` → BelongsToMany Business via `business_user` pivot
- `effective_business_group` accessor: owned group → selected business's group → first staffed business's group

**User roles (Spatie):**
`business_owner`, `business_manager`, `product_manager`, `menu_manager`, `content_manager`, `business_staff`, `organizer`

**`getApiRole()` mapping:**
| Permissions | API role |
|-------------|----------|
| `manage business` | `business_manager` |
| `manage menu boards` only | `menu_manager` |
| `manage products` + menus | `product_manager` |
| `manage content` only | `content_manager` |
| any | `business_staff` |

---

### Organization / OrganizationMember

**Organization fields:** `user_id`, `badge`, `name`, `type`, `scope`, `website_url`, `membership_type`, `description`

| Field | Enum values |
|-------|------------|
| `type` | `guild`, `association` |
| `scope` | `local`, `regional`, `province`, `national`, `international` |
| `membership_type` | `breweries`, `taprooms`, `both`, `users`, `all` |

**OrganizationMember fields:** `organization_id`, `business_id` (→ **business_groups.id**), `user_id`, `status`

**Status enum:** `pending`, `approved`, `rejected`, `invited`

---

### BusinessOpeningHour

**Fields:** `business_id`, `day`, `is_open` (bool), `open` (H:i), `close` (H:i)
**Accessors:** `formatted_open` / `formatted_close` in 12h business timezone

---

## Pivot Tables

### `business_user` (staff assignments)

| Field | Notes |
|-------|-------|
| `user_id` | FK → users |
| `business_id` | FK → businesses |

A user can be staff at multiple `Business` (location) rows within or across groups.

---

## Controllers

### `Business\Api\LocationController` (primary locations API)

| Method | Route | Behavior |
|--------|-------|----------|
| `index` | GET locations | All locations (withTrashed). Owner/taproom → group; organizer → effective group; staff → their businesses |
| `active` | GET locations/active | Same, without trashed |
| `store` | POST locations | Owner/taproom only. Validates + geocodes + creates Business under group |
| `show` | GET locations/{id} | Access-checked detail |
| `update` | PUT locations/{id} | Re-geocodes if address changed; handles venue images |
| `destroy` | DELETE locations/{id} | Soft delete; re-selects another if current was selected |
| `restore` | POST locations/{id}/restore | Restore within group |
| `select` | POST locations/{id}/select | Sets `selected_business_id` |

**Store/update validation:**
```
email: required|email
phone: required
location_name: required
location: required (address string for geocoding)
tax_rate: nullable|numeric|min:1|max:100
opening_hours: required|array
opening_hours.*.is_open: nullable|boolean
opening_hours.*.open/close: required_if is_open, H:i format
allow_gifting: nullable|boolean
venue_images.*: image|mimes|max (update only)
```

---

### `Business\ProfileController` (web group + location lifecycle)

| Action | Notes |
|--------|-------|
| `storeProfileComplete` | Creates group + first location (`is_main=true`); logo required |
| `storeNewLocation` | Extra location; sets `is_main` if none exist; auto-selects new location |
| `update` | Group name/description/logo |
| `updateBusiness` | Selected location profile + hours + images; gifting gated by Stripe |
| `selectBusiness` | Sets `selected_business_id` |
| `deleteLocation` | Soft delete; promotes another to `is_main` if needed |
| `restoreLocation` | Restore soft-deleted location |
| `setMainBranch` | Exactly one `is_main` per group; also auto-selects it |

---

### Staff Controllers

**`Business\StaffController`:**
- Creates `business_staff` users, attaches via `business_user` pivot
- Permissions stored as Spatie permission arrays
- Detach removes pivot; deletes user if they have no remaining locations

**Permission presets UI:**
| Label | Spatie permissions |
|-------|-------------------|
| Content Manager | `manage content` |
| Menu Manager | `manage menu boards` |
| Product Manager | `manage menu boards`, `manage products` |
| Business Manager | above + `manage business` |

---

## Services

| Service | Purpose |
|---------|---------|
| `BusinessGroupLocationsService` | Read-only list of locations in a group |
| `BusinessLocationAdminService` | Validates PATCH location is inside group |
| `BusinessUserAccess` | Owner of group OR staff pivot → can manage |
| `BusinessOwnerService` | Create/link owner users; placeholder emails `@owner.placeholder` |
| `BusinessAddressService` | Google + OSM fallback geocoding with address simplification |
| `BusinessGroupMergeService` | Merge duplicate groups (businesses, products, orgs, menus, etc.) |

---

## Routes Summary

### API routes (`routes/api.php`)

```
GET  /api/business-groups
GET  /api/business-groups/{group}/minimal

# Auth: Sanctum + role business_owner|business_staff|taproom|event_organizer
GET    /api/business/v1/locations
POST   /api/business/v1/locations
GET    /api/business/v1/locations/active
GET    /api/business/v1/locations/{business}
PUT    /api/business/v1/locations/{business}
DELETE /api/business/v1/locations/{business}
POST   /api/business/v1/locations/{business}/restore
POST   /api/business/v1/locations/{business}/select
```

### Web routes (`routes/web.php`) — `business.` prefix

```
group-profile          GET/POST  → BusinessGroup/Profile
complete-profile       GET/POST
create new location    GET/POST
delete-location        DELETE
restore-location       POST
set-main-branch        POST
select-business/{id}   POST
staff index/store/update/detach  (requires: manage business permission)
```

---

## Frontend Pages

### `BusinessGroup/Profile.vue`
- Edit group logo, name (max 50), description (max 1000)
- Grid of location cards with badges: Selected / Main Branch / Archived
- Radio-select active location
- Archive / Restore / Set as Main (with confirm modals)
- "Add New Location" button

### `BusinessGroup/Staff/Index.vue`
- Left panel: Add/edit staff (name, email, permission radio cards, multi-location checkboxes)
- Right panel: Staff cards with permission chips
- Detach with confirm dialog

---

## MCP Location Tools

### MCP v2 (`app/MCP2/Tools/Locations/`)

| Tool name | Read/Write | Notes |
|-----------|-----------|-------|
| `locations.list` | R | Paginated; returns `business_group` + meta |
| `locations.group_list` | R | Simple sibling list with `is_main` |
| `locations.active` | R | Non-deleted only |
| `locations.get` | R | Single location details |
| `locations.create` | W | Delegates to v1 |
| `locations.update` | W | Name + address fuzzy resolve |
| `locations.switch` | W | Sets `selected_business_id` |
| `locations.delete` | W | Soft delete; blocks deleting last active location |
| `locations.restore` | W | Delegates to v1 |

**ID semantics:** location `id` === `businesses.id` — use as `business_id` anywhere.

---

## Cross-Cutting Business Rules

1. **Brand vs site:** Group owns Stripe, verification, billing, brand logo/name. Business owns address, hours, tax, venue images, `location_name`.
2. **Main branch:** At most one `is_main` per group. Used for logo fallback and admin ordering.
3. **Staff vs owner:** Owners access via `businessGroup.businesses`; staff access via `business_user` pivot + Spatie permissions.
4. **Gifting:** Location `allow_gifting` flag AND group Stripe `charges_enabled` + `details_submitted`.
5. **Discovery:** Event organizer groups excluded from brewery discovery (`scopeVisibleInDiscovery`).
6. **Claiming:** Placeholder owners (email `@owner.placeholder`) are not "claimed"; `claimed_at` tracks real ownership.
7. **Geocoding:** Required on create. Update only re-geocodes if address string changed. Fallback: OSM. Reject lat/lng `0,0`.
8. **Merge:** Admin can merge duplicate groups via `BusinessGroupMergeService`.
9. **`business_locations` table:** Legacy secondary table. Not the primary "location" — that is `businesses`. MCP and APIs confirm `Business` = location.

---

## Mapping to Cuppa (Next.js/Prisma)

| Kuppa-main concept | Cuppa equivalent |
|-------------------|-----------------|
| `BusinessGroup` | `Business` model (brand/company) |
| `Business` (row) | `Location` model (physical site) |
| `business_user` pivot | `LocationStaff` model |
| Spatie role on user | `BusinessMemberRole` enum on membership |
| `selected_business_id` | Can add `activeLocationId` to session/JWT |
| `is_main` on Business | `isMain` on Location |
| `Organization` | Out of scope for now |
| `BusinessLocation` legacy | Not needed in Cuppa |
| Franchise (does NOT exist) | New `Franchise` model as middle tier in Cuppa |

### Notes for implementation:
- In Cuppa we add an explicit `Franchise` tier (which Kuppa-main did not have). This means: `Business → Franchise → Location` OR `Business → Location` (direct).
- Keep `BusinessMemberRole` enum: `OWNER`, `ADMIN`, `MANAGER`, `STAFF`
- User-to-business membership: `BusinessMember` table (replaces `business_groups.user_id` ownership)
- User-to-franchise membership: `FranchiseMember` table
- User-to-location staff: `LocationStaff` table
- Global roles (existing RBAC) remain for admin-level access; membership tables handle entity-scoped access
