# MerchantService - Service Documentation

> Auto-generated from source code audit. Last updated: 2026-03-13.

---

## Overview

| Property | Value |
|----------|-------|
| **Service Name** | MerchantService |
| **Framework** | .NET 10.0 / C# 14 |
| **Architecture** | Clean Architecture + CQRS (MediatR 12.4) |
| **Database** | PostgreSQL (Neon) via EF Core 10 + Npgsql 10 |
| **Auth** | JWT Bearer (Duende IdentityServer OIDC) |
| **Port** | 5005 (development) |
| **Database Name** | `merchant_service` |
| **Health Checks** | `/health`, `/health/live`, `/health/ready` |

**Aggregates**: Merchant, Shop (+ ShopBranch), MerchantStaff (+ DeviceToken, ShopMember), AttendanceRecord, LeaveRequest

**MediatR Pipeline**: `LoggingBehavior` -> `ValidatorBehavior` -> `TransactionBehavior` -> Handler

---

## API Endpoints

### MerchantsController
**Route**: `api/v1/merchants` | **Auth**: `[Authorize]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/me` | Get current merchant profile | JWT |
| POST | `/register` | Register a new merchant | JWT |
| PUT | `/me` | Update current merchant | JWT |
| POST | `/me/verify` | Submit merchant verification | JWT |
| GET | `/{merchantId}/shops` | Get merchant's shops (paginated) | JWT |
| PUT | `/{merchantId}/shops/{shopId}/set-default` | Set default shop | JWT |
| POST | `/{merchantId}/shops/{shopId}/transfer` | Transfer shop ownership | JWT |

### ShopsController
**Route**: `api/v1/shops` | **Auth**: `[Authorize]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/stats` | Get shop statistics | JWT |
| GET | `/{shopId}/settings` | Get shop settings | JWT |
| PUT | `/{shopId}/settings` | Update shop settings | JWT |
| GET | `/` | Get my shops | JWT |
| GET | `/{shopId}` | Get shop by ID | JWT |
| GET | `/slug/{slug}` | Get shop by slug | Anonymous |
| POST | `/` | Create a new shop | JWT |
| PUT | `/{shopId}` | Update shop | JWT |
| POST | `/{shopId}/publish` | Publish shop (make visible) | JWT |
| POST | `/{shopId}/deactivate` | Deactivate shop | JWT |
| POST | `/{shopId}/close` | Close shop permanently | JWT |
| POST | `/{shopId}/branches` | Add branch to shop | JWT |
| GET | `/{shopId}/branches` | Get shop branches | Anonymous |
| PUT | `/{shopId}/branches/{branchId}` | Update branch | JWT |
| DELETE | `/{shopId}/branches/{branchId}` | Delete branch | JWT |
| GET | `/nearby` | Get nearby shops (Haversine) | Anonymous |

### StaffController
**Route**: `api/v1/merchants/me/staff` | **Auth**: `[Authorize]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/` | Get merchant's staff list | JWT |
| POST | `/create-active` | Create staff directly as Active | JWT |
| POST | `/invite` | Invite a new staff member | JWT |
| PUT | `/{staffId}` | Update staff member | JWT |
| DELETE | `/{staffId}` | Delete/terminate staff member | JWT |

### StaffPublicController
**Route**: `api/v1/staff` | **Auth**: `[Authorize]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/roles` | Get available staff roles | Anonymous |
| POST | `/accept-invite` | Accept staff invitation | JWT |
| GET | `/debug/all` | Debug: list all staff | Anonymous |
| POST | `/debug/seed` | Debug: seed test staff data | Anonymous |
| POST | `/debug/update-userid` | Debug: update staff userId | Anonymous |
| POST | `/debug/update-merchant` | Debug: update merchant userId | Anonymous |
| GET | `/lookup` | Lookup staff by email | Anonymous |

### AttendanceController
**Route**: `api/v1/attendance`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/staff/{staffId}` | Get attendance by staff (month/year) | - |
| GET | `/shop/{shopId}` | Get attendance by shop (month/year) | - |
| POST | `/check-in` | Staff check-in | - |
| POST | `/check-out` | Staff check-out | - |

### LeaveRequestsController
**Route**: `api/v1/leave-requests`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/staff/{staffId}` | Get leave requests by staff | - |
| GET | `/shop/{shopId}` | Get leave requests by shop | - |
| POST | `/` | Create leave request | - |
| POST | `/{id}/approve` | Approve leave request | - |
| POST | `/{id}/reject` | Reject leave request | - |

### PosController
**Route**: `api/v1/pos`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/auth/pin` | PIN-based POS authentication | Anonymous |
| POST | `/devices/register` | Register POS device | JWT |
| GET | `/me` | Get current POS staff profile | JWT |

### DevicesController
**Route**: `api/v1/devices` | **Auth**: `[Authorize]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/` | List registered devices | JWT |

### SubscriptionsController
**Route**: `api/v1/subscriptions` | **Auth**: `[Authorize]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/me` | Get current subscription | JWT |
| GET | `/plans` | Get available plans | Anonymous |
| POST | `/subscribe` | Subscribe to a plan | JWT |
| GET | `/usage` | Get subscription usage | JWT |

### AdminMerchantsController
**Route**: `api/v1/admin/merchants` | **Auth**: `[Authorize(Roles="Admin,SuperAdmin")]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/` | List all merchants (paginated) | Admin |
| GET | `/{merchantId}` | Get merchant detail | Admin |
| GET | `/statistics` | Get platform statistics | Admin |
| POST | `/{merchantId}/approve` | Approve merchant | Admin |
| POST | `/{merchantId}/reject` | Reject merchant | Admin |
| POST | `/{merchantId}/suspend` | Suspend merchant | Admin |
| POST | `/{merchantId}/reactivate` | Reactivate merchant | Admin |
| POST | `/{merchantId}/ban` | Ban merchant | Admin |

### AdminShopsController
**Route**: `api/v1/admin/shops` | **Auth**: `[Authorize(Roles="Admin,SuperAdmin")]`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/` | List all shops (paginated) | Admin |
| GET | `/{shopId}` | Get shop detail | Admin |
| POST | `/{shopId}/suspend` | Suspend shop | Admin |
| POST | `/{shopId}/reactivate` | Reactivate shop | Admin |
| POST | `/{shopId}/close` | Close shop | Admin |

---

## Commands

| # | Command | Handler | Location |
|---|---------|---------|----------|
| 1 | `RegisterMerchantCommand` | `RegisterMerchantCommandHandler` | Commands/Merchants/ |
| 2 | `UpdateMerchantCommand` | `UpdateMerchantCommandHandler` | Commands/Merchants/ |
| 3 | `SubmitMerchantVerificationCommand` | `SubmitMerchantVerificationCommandHandler` | Commands/Merchants/ |
| 4 | `CreateShopCommand` | `CreateShopCommandHandler` | Commands/Shops/ |
| 5 | `UpdateShopCommand` | `UpdateShopCommandHandler` | Commands/Shops/ |
| 6 | `UpdateShopSettingsCommand` | `UpdateShopSettingsCommandHandler` | Commands/Shops/ |
| 7 | `SetDefaultShopCommand` | `SetDefaultShopCommandHandler` | Commands/Shops/ |
| 8 | `TransferShopCommand` | `TransferShopCommandHandler` | Commands/Shops/ |
| 9 | `PublishShopCommand` | `PublishShopCommandHandler` | Commands/Shops/ |
| 10 | `SetShopInactiveCommand` | `SetShopInactiveCommandHandler` | Commands/Shops/ |
| 11 | `CloseShopCommand` | `CloseShopCommandHandler` | Commands/Shops/ |
| 12 | `AddShopBranchCommand` | `AddShopBranchCommandHandler` | Commands/Shops/ |
| 13 | `UpdateBranchCommand` | `UpdateBranchCommandHandler` | Commands/Shops/ |
| 14 | `DeleteBranchCommand` | `DeleteBranchCommandHandler` | Commands/Shops/ |
| 15 | `InviteStaffCommand` | `InviteStaffCommandHandler` | Commands/Staff/ |
| 16 | `CreateActiveStaffCommand` | `CreateActiveStaffCommandHandler` | Commands/Staff/ |
| 17 | `UpdateStaffCommand` | `UpdateStaffCommandHandler` | Commands/Staff/ |
| 18 | `DeleteStaffCommand` | `DeleteStaffCommandHandler` | Commands/Staff/ |
| 19 | `AcceptInviteCommand` | `AcceptInviteCommandHandler` | Commands/Staff/ |
| 20 | `PinAuthCommand` | `PinAuthCommandHandler` | Commands/Pos/ |
| 21 | `RegisterDeviceCommand` | `RegisterDeviceCommandHandler` | Commands/Pos/ |
| 22 | `CheckInCommand` | `CheckInCommandHandler` | Commands/Attendance/ |
| 23 | `CheckOutCommand` | `CheckOutCommandHandler` | Commands/Attendance/ |
| 24 | `CreateLeaveRequestCommand` | `CreateLeaveRequestCommandHandler` | Commands/LeaveRequests/ |
| 25 | `ApproveLeaveRequestCommand` | `ApproveLeaveRequestCommandHandler` | Commands/LeaveRequests/ |
| 26 | `RejectLeaveRequestCommand` | `RejectLeaveRequestCommandHandler` | Commands/LeaveRequests/ |
| 27 | `ApproveMerchantCommand` | `ApproveMerchantCommandHandler` | Commands/Admin/ |
| 28 | `RejectMerchantCommand` | `RejectMerchantCommandHandler` | Commands/Admin/ |
| 29 | `SuspendMerchantCommand` | `SuspendMerchantCommandHandler` | Commands/Admin/ |
| 30 | `ReactivateMerchantCommand` | `ReactivateMerchantCommandHandler` | Commands/Admin/ |
| 31 | `BanMerchantCommand` | `BanMerchantCommandHandler` | Commands/Admin/ |
| 32 | `SubscribeCommand` | `SubscribeCommandHandler` | Commands/Subscriptions/ |

---

## Queries

| # | Query | Handler | Location |
|---|-------|---------|----------|
| 1 | `GetMerchantProfileQuery` | `GetMerchantProfileQueryHandler` | Queries/Merchants/ |
| 2 | `GetMerchantByIdQuery` | `GetMerchantByIdQueryHandler` | Queries/Merchants/ |
| 3 | `GetMyShopsQuery` | `GetMyShopsQueryHandler` | Queries/Shops/ |
| 4 | `GetShopByIdQuery` | `GetShopByIdQueryHandler` | Queries/Shops/ |
| 5 | `GetShopBySlugQuery` | `GetShopBySlugQueryHandler` | Queries/Shops/ |
| 6 | `GetMerchantShopsQuery` | `GetMerchantShopsQueryHandler` | Queries/Shops/ (paginated) |
| 7 | `GetShopSettingsQuery` | `GetShopSettingsQueryHandler` | Queries/Shops/ |
| 8 | `GetShopStatsQuery` | `GetShopStatsQueryHandler` | Queries/Shops/ |
| 9 | `GetBranchesQuery` | `GetBranchesQueryHandler` | Queries/Shops/ |
| 10 | `GetNearbyShopsQuery` | `GetNearbyShopsQueryHandler` | Queries/Shops/ (Haversine formula) |
| 11 | `GetMyStaffQuery` | `GetMyStaffQueryHandler` | Queries/Staff/ |
| 12 | `GetStaffRolesQuery` | `GetStaffRolesQueryHandler` | Queries/Staff/ |
| 13 | `GetPosStaffQuery` | `GetPosStaffQueryHandler` | Queries/Pos/ |
| 14 | `GetDevicesQuery` | `GetDevicesQueryHandler` | Queries/Pos/ |
| 15 | `GetAllMerchantsQuery` | `GetAllMerchantsQueryHandler` | Queries/Admin/ (paginated) |
| 16 | `GetAllShopsQuery` | `GetAllShopsQueryHandler` | Queries/Admin/ (paginated) |
| 17 | `GetMerchantDetailQuery` | `GetMerchantDetailQueryHandler` | Queries/Admin/ |
| 18 | `GetMerchantStatisticsQuery` | `GetMerchantStatisticsQueryHandler` | Queries/Admin/ |
| 19 | `GetAttendanceByStaffQuery` | `GetAttendanceByStaffQueryHandler` | Queries/Attendance/ |
| 20 | `GetAttendanceByShopQuery` | `GetAttendanceByShopQueryHandler` | Queries/Attendance/ |
| 21 | `GetLeaveRequestsByStaffQuery` | `GetLeaveRequestsByStaffQueryHandler` | Queries/LeaveRequests/ |
| 22 | `GetLeaveRequestsByShopQuery` | `GetLeaveRequestsByShopQueryHandler` | Queries/LeaveRequests/ |
| 23 | `GetSubscriptionQuery` | `GetSubscriptionQueryHandler` | Queries/Subscriptions/ |
| 24 | `GetSubscriptionPlansQuery` | `GetSubscriptionPlansQueryHandler` | Queries/Subscriptions/ |
| 25 | `GetSubscriptionUsageQuery` | `GetSubscriptionUsageQueryHandler` | Queries/Subscriptions/ |

---

## Validators

| # | Validator | Target Command |
|---|-----------|---------------|
| 1 | `RegisterMerchantCommandValidator` | RegisterMerchantCommand |
| 2 | `UpdateMerchantCommandValidator` | UpdateMerchantCommand |
| 3 | `CreateShopCommandValidator` | CreateShopCommand |
| 4 | `UpdateShopCommandValidator` | UpdateShopCommand |
| 5 | `SetDefaultShopCommandValidator` | SetDefaultShopCommand |
| 6 | `TransferShopCommandValidator` | TransferShopCommand |
| 7 | `AddShopBranchCommandValidator` | AddShopBranchCommand |
| 8 | `ApproveMerchantCommandValidator` | ApproveMerchantCommand |
| 9 | `RejectMerchantCommandValidator` | RejectMerchantCommand |
| 10 | `SuspendMerchantCommandValidator` | SuspendMerchantCommand |
| 11 | `ReactivateMerchantCommandValidator` | ReactivateMerchantCommand |
| 12 | `BanMerchantCommandValidator` | BanMerchantCommand |
| 13 | `CheckInCommandValidator` | CheckInCommand |
| 14 | `CheckOutCommandValidator` | CheckOutCommand |
| 15 | `CreateLeaveRequestCommandValidator` | CreateLeaveRequestCommand |
| 16 | `ApproveLeaveRequestCommandValidator` | ApproveLeaveRequestCommand |
| 17 | `RejectLeaveRequestCommandValidator` | RejectLeaveRequestCommand |
| 18 | `SubscribeCommandValidator` | SubscribeCommand |

---

## Domain Model

### Aggregate: Merchant
**Entity**: `Merchant` (Aggregate Root) | **Table**: `merchants`

**Fields**:
| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| _userId | Guid | `user_id` | Yes |
| _businessName | string | `business_name` (max 200) | Yes |
| TypeId | int | `type_id` | Yes |
| StatusId | int | `status_id` | Yes |
| VerificationStatusId | int | `verification_status_id` | Yes |
| _businessInfo | BusinessInfo (owned) | tax_id, business_license_number, company_registration_number, established_date | No |
| _settlementConfig | SettlementConfig (owned) | commission_rate, settlement_cycle_id, auto_settlement, bank_* | No |
| VerifiedAt | DateTime? | `verified_at` | No |
| VerifiedBy | Guid? | `verified_by` | No |
| _subscriptionPlanId | int | `subscription_plan_id` (default 0) | Yes |
| _createdAt | DateTime | `created_at` | Yes |
| _updatedAt | DateTime? | `updated_at` | No |
| _isDeleted | bool | `is_deleted` (default false) | Yes |

**Behavior Methods**: `Register` (static factory), `UpdateBusinessName`, `UpdateBusinessInfo`, `UpdateSettlementConfig`, `SubmitForVerification`, `Approve`, `Suspend`, `Reactivate`, `Ban`, `UpdateSubscriptionPlan`, `Delete`

**Domain Events**: `MerchantRegisteredDomainEvent`, `MerchantVerificationSubmittedDomainEvent`, `MerchantApprovedDomainEvent`, `MerchantSuspendedDomainEvent`, `MerchantBannedDomainEvent`

**Enumerations**:
- `MerchantType`: Individual(1), Company(2) | Table: `merchant_types`
- `MerchantStatus`: PendingApproval(1), Active(2), Suspended(3), Banned(4) | Table: `merchant_statuses`
- `VerificationStatus`: Unverified(1), Pending(2), Verified(3), Rejected(4) | Table: `verification_statuses`
- `SettlementCycle`: Daily(1), Weekly(2), Monthly(3) | Table: `settlement_cycles`

**Value Objects**:
- `BusinessInfo`: TaxId, BusinessLicenseNumber, CompanyRegistrationNumber, EstablishedDate
- `SettlementConfig`: CommissionRate, SettlementCycleId, AutoSettlement, BankAccount
- `BankAccount`: BankCode, BankName, AccountNumber, AccountHolderName

---

### Aggregate: Shop
**Entity**: `Shop` (Aggregate Root) | **Table**: `shops`

**Fields**:
| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| _merchantId | Guid | `merchant_id` | Yes |
| _name | string | `name` (max 100) | Yes |
| _slug | string | `slug` (max 100, unique) | Yes |
| TypeId | int | `type_id` | Yes |
| CategoryId | int | `category_id` | Yes |
| StatusId | int | `status_id` | Yes |
| _description | string? | `description` (max 2000) | No |
| _logoUrl | string? | `logo_url` (max 500) | No |
| _coverImageUrl | string? | `cover_image_url` (max 500) | No |
| _contactInfo | ContactInfo (owned) | phone, email, website | No |
| _operatingHours | OperatingHours (owned) | open_time, close_time, open_days | No |
| _features | ShopFeatures (owned) | `features_config` (JSONB) | No |
| _isDefault | bool | `is_default` (default false) | Yes |
| _createdAt | DateTime | `created_at` | Yes |
| _updatedAt | DateTime? | `updated_at` | No |
| _isDeleted | bool | `is_deleted` (default false) | Yes |
| Branches | List\<ShopBranch\> | FK: shop_id -> shop_branches | Navigation |

**Behavior Methods**: `UpdateInfo`, `UpdateSlug`, `UpdateContactInfo`, `UpdateOperatingHours`, `UpdateImages`, `UpdateFeatures`, `Publish`, `SetInactive`, `Close`, `AddBranch`, `RemoveBranch`, `SetAsDefault`, `ClearDefault`, `TransferOwnership`, `Delete`

**Domain Events**: `ShopCreatedDomainEvent`, `ShopPublishedDomainEvent`, `ShopClosedDomainEvent`, `ShopBranchAddedDomainEvent`, `ShopSetAsDefaultDomainEvent`, `ShopTransferredDomainEvent`

**Enumerations**:
- `ShopType`: OnlineOnly(1), PhysicalOnly(2), Hybrid(3) | Table: `shop_types`
- `ShopStatus`: Draft(1), Active(2), Inactive(3), Closed(4) | Table: `shop_statuses`
- `BusinessCategory`: FoodBeverage(1), Fashion(2), Electronics(3), Healthcare(4), Beauty(5), Education(6), Entertainment(7), Services(8), Grocery(9), HomeFurniture(10), Other(11), Cafe(12), Restaurant(13), Karaoke(14), Spa(15) | Table: `business_categories`

**Value Objects**:
- `ContactInfo`: Phone, Email, Website
- `OperatingHours`: OpenTime, CloseTime, OpenDays (DayOfWeek list, stored as comma-separated ints)
- `ShopFeatures`: Stored as JSONB in `features_config` column

**Child Entity: ShopBranch** | **Table**: `shop_branches`

| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| ShopId | Guid | `shop_id` (FK) | Yes |
| _name | string | `name` (max 100) | Yes |
| _code | string? | `code` (max 20) | No |
| _phone | string? | `phone` (max 20) | No |
| _isActive | bool | `is_active` (default true) | Yes |
| _address | Address (owned) | street, ward, district, city, province, postal_code, country_code | Yes |
| _location | GeoLocation (owned) | latitude, longitude | No |
| _operatingHours | OperatingHours (owned) | open_time, close_time, open_days | No |
| _createdAt | DateTime | `created_at` | Yes |
| _updatedAt | DateTime? | `updated_at` | No |

**Value Objects (Branch)**:
- `Address`: Street, Ward, District, City, Province, PostalCode, CountryCode (default "VN")
- `GeoLocation`: Latitude, Longitude

---

### Aggregate: MerchantStaff
**Entity**: `MerchantStaff` (Aggregate Root) | **Table**: `merchant_staff`

**Fields**:
| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| UserId | Guid? | `user_id` | No |
| MerchantId | Guid | `merchant_id` | Yes |
| EmployeeCode | string? | `employee_code` (max 20) | No |
| RoleId | int | `role_id` | Yes |
| StatusId | int | `status_id` | Yes |
| Permissions | StaffPermissions | `permissions` (int) | No |
| Phone | string? | `phone` (max 20) | No |
| Email | string? | `email` (max 100) | No |
| PinCodeHash | string? | `pin_code_hash` (max 100) | No |
| FirstName | string? | `first_name` (max 100) | No |
| LastName | string? | `last_name` (max 100) | No |
| Address | string? | `address` (max 500) | No |
| ProfilePhotoUrl | string? | `profile_photo_url` (max 500) | No |
| DocumentFrontUrl | string? | `document_front_url` (max 500) | No |
| DocumentBackUrl | string? | `document_back_url` (max 500) | No |
| JoinedAt | DateTime? | `joined_at` | No |
| TerminatedAt | DateTime? | `terminated_at` | No |
| CreatedAt | DateTime | `created_at` | Yes |
| UpdatedAt | DateTime? | `updated_at` | No |
| DeviceTokens | List\<DeviceToken\> | FK: staff_id -> device_tokens | Navigation |
| ShopAssignments | List\<ShopMember\> | FK: staff_id -> shop_members | Navigation |

**Factory Methods**: `Invite` (status=Invited), `CreateActive` (status=Active, joinedAt=now)

**Behavior Methods**: `AcceptInvitation`, `Update`, `UpdateRole`, `UpdatePermissions`, `SetPinCode`, `VerifyPinCode`, `RegisterDevice`, `RemoveDevice`, `AssignToShop`, `RemoveFromShop`, `Deactivate`, `Reactivate`, `Terminate`, `HasPermission`

**Domain Events**: `StaffInvitedDomainEvent`, `StaffJoinedDomainEvent`, `StaffPinCodeSetDomainEvent`, `StaffDeviceRegisteredDomainEvent`, `StaffAssignedToShopDomainEvent`, `StaffTerminatedDomainEvent`

**Enumerations**:
- `StaffRole`: Cashier(1), Waiter(2), Manager(3), Admin(4), Kitchen(5), Barista(6) | Table: `staff_roles` (seeded: Cashier, Waiter, Manager, Admin)
- `StaffStatus`: Invited(1), Active(2), Inactive(3), Terminated(4) | Table: `staff_statuses`
- `ShopRole`: Cashier(1), Waiter(2), Manager(3), Owner(4), Kitchen(5), Barista(6) | Table: `shop_roles` (seeded: Cashier, Waiter, Manager, Owner)
- `StaffPermissions` (Flags): None(0), ViewSales(1), ProcessPayment(2), RefundOrder(4), ManageInventory(8), ViewReports(16), ManageStaff(32), ManageSettings(64), All(MaxValue)

**Child Entity: DeviceToken** | **Table**: `device_tokens`

| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| StaffId | Guid | `staff_id` (FK) | Yes |
| DeviceId | string | `device_id` (max 100) | Yes |
| DeviceName | string? | `device_name` (max 100) | No |
| FcmToken | string? | `fcm_token` (max 500) | No |
| Platform | string | `platform` (max 20) | Yes |
| LastUsedAt | DateTime? | `last_used_at` | No |
| CreatedAt | DateTime | `created_at` | Yes |

**Child Entity: ShopMember** | **Table**: `shop_members`

| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| StaffId | Guid | `staff_id` (FK) | Yes |
| ShopId | Guid | `shop_id` | Yes |
| BranchId | Guid? | `branch_id` | No |
| RoleId | int | `role_id` | Yes |
| CustomPermissions | StaffPermissions? | `custom_permissions` (int?) | No |
| IsPrimary | bool | `is_primary` (default false) | Yes |
| AssignedAt | DateTime | `assigned_at` | Yes |

---

### Aggregate: AttendanceRecord
**Entity**: `AttendanceRecord` (Aggregate Root) | **Table**: `attendance_records`

**Fields**:
| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| StaffId | Guid | `staff_id` | Yes |
| ShopId | Guid | `shop_id` | Yes |
| Date | DateTime | `date` | Yes |
| CheckIn | DateTime? | `check_in` | No |
| CheckOut | DateTime? | `check_out` | No |
| HoursWorked | decimal? | `hours_worked` (precision 5,2) | No |
| Status | string | `status` (max 20): Working/Completed/Late/Absent | Yes |
| Notes | string? | `notes` (max 500) | No |
| CreatedAt | DateTime | `created_at` | Yes |
| _updatedAt | DateTime? | `updated_at` | No |

**Factory**: `CheckInNow(staffId, shopId)` — creates with status "Working"

**Behavior Methods**: `DoCheckOut` (calculates HoursWorked, sets status "Completed"), `MarkAbsent`, `MarkLate`

**Indexes**: `ix_attendance_staff_date` (StaffId + Date, UNIQUE), `ix_attendance_shop_date` (ShopId + Date)

---

### Aggregate: LeaveRequest
**Entity**: `LeaveRequest` (Aggregate Root) | **Table**: `leave_requests`

**Fields**:
| Field | Type | Column | Required |
|-------|------|--------|----------|
| Id | Guid | `id` | Yes |
| StaffId | Guid | `staff_id` | Yes |
| ShopId | Guid | `shop_id` | Yes |
| LeaveType | string | `leave_type` (max 20): Annual/Sick/Personal/Maternity/Other | Yes |
| StartDate | DateTime | `start_date` | Yes |
| EndDate | DateTime | `end_date` | Yes |
| Reason | string? | `reason` (max 500) | No |
| Status | string | `status` (max 20): Pending/Approved/Rejected | Yes |
| ApprovedBy | Guid? | `approved_by` | No |
| ApprovedAt | DateTime? | `approved_at` | No |
| RejectionReason | string? | `rejection_reason` (max 500) | No |
| CreatedAt | DateTime | `created_at` | Yes |

**Computed**: `Days` = (EndDate - StartDate).Days + 1

**Factory**: `Create(staffId, shopId, leaveType, startDate, endDate, reason)` — validates endDate >= startDate, sets UTC dates

**Behavior Methods**: `Approve(approvedBy)`, `Reject(rejectedBy, reason?)` — both require status == "Pending"

**Indexes**: `ix_leave_requests_staff_id`, `ix_leave_requests_shop_id`, `ix_leave_requests_status`

---

## Database Schema

### Tables (14 total)

**Main Aggregate Tables**:
| Table | Aggregate | Type |
|-------|-----------|------|
| `merchants` | Merchant | Aggregate Root |
| `shops` | Shop | Aggregate Root |
| `shop_branches` | Shop | Child entity (FK: shop_id) |
| `merchant_staff` | MerchantStaff | Aggregate Root |
| `device_tokens` | MerchantStaff | Child entity (FK: staff_id) |
| `shop_members` | MerchantStaff | Child entity (FK: staff_id) |
| `attendance_records` | AttendanceRecord | Aggregate Root |
| `leave_requests` | LeaveRequest | Aggregate Root |

**Enumeration Lookup Tables** (seeded via migrations):
| Table | Values |
|-------|--------|
| `merchant_types` | Individual(1), Company(2) |
| `merchant_statuses` | PendingApproval(1), Active(2), Suspended(3), Banned(4) |
| `verification_statuses` | Unverified(1), Pending(2), Verified(3), Rejected(4) |
| `settlement_cycles` | Daily(1), Weekly(2), Monthly(3) |
| `shop_types` | OnlineOnly(1), PhysicalOnly(2), Hybrid(3) |
| `shop_statuses` | Draft(1), Active(2), Inactive(3), Closed(4) |
| `business_categories` | FoodBeverage(1)...Spa(15) (15 values) |
| `staff_roles` | Cashier(1), Waiter(2), Manager(3), Admin(4) |
| `staff_statuses` | Invited(1), Active(2), Inactive(3), Terminated(4) |
| `shop_roles` | Cashier(1), Waiter(2), Manager(3), Owner(4) |

### Indexes

| Index Name | Table | Columns | Unique |
|------------|-------|---------|--------|
| `ix_merchants_user_id` | merchants | user_id | No |
| `ix_merchants_status` | merchants | status_id | No |
| `ix_shops_merchant_id` | shops | merchant_id | No |
| `ix_shops_slug` | shops | slug | Yes |
| `ix_shops_status` | shops | status_id | No |
| `ix_shops_category` | shops | category_id | No |
| `ix_shop_branches_shop_id` | shop_branches | shop_id | No |
| `ix_merchant_staff_user_id` | merchant_staff | user_id | No |
| `ix_merchant_staff_merchant_id` | merchant_staff | merchant_id | No |
| `ix_merchant_staff_email` | merchant_staff | email | No |
| `ix_device_tokens_staff_id` | device_tokens | staff_id | No |
| `ix_device_tokens_device_id` | device_tokens | device_id | No |
| `ix_shop_members_staff_id` | shop_members | staff_id | No |
| `ix_shop_members_shop_id` | shop_members | shop_id | No |
| `ix_attendance_staff_date` | attendance_records | staff_id, date | Yes |
| `ix_attendance_shop_date` | attendance_records | shop_id, date | No |
| `ix_leave_requests_staff_id` | leave_requests | staff_id | No |
| `ix_leave_requests_shop_id` | leave_requests | shop_id | No |
| `ix_leave_requests_status` | leave_requests | status | No |

### Owned Entities (flattened into parent table columns)

**merchants** table owns:
- `BusinessInfo` -> tax_id, business_license_number, company_registration_number, established_date
- `SettlementConfig` -> commission_rate, settlement_cycle_id, auto_settlement
  - `BankAccount` -> bank_code, bank_name, bank_account_number, bank_account_holder_name

**shops** table owns:
- `ContactInfo` -> phone, email, website
- `OperatingHours` -> open_time, close_time, open_days
- `ShopFeatures` -> features_config (JSONB)

**shop_branches** table owns:
- `Address` -> street, ward, district, city, province, postal_code, country_code
- `GeoLocation` -> latitude, longitude
- `OperatingHours` -> open_time, close_time, open_days

---

## Domain Events

### Merchant Events
| Event | Trigger |
|-------|---------|
| `MerchantRegisteredDomainEvent(Merchant)` | Merchant.Register() |
| `MerchantVerificationSubmittedDomainEvent(Merchant)` | Merchant.SubmitForVerification() |
| `MerchantApprovedDomainEvent(Merchant, ApprovedBy)` | Merchant.Approve() |
| `MerchantSuspendedDomainEvent(Merchant, Reason)` | Merchant.Suspend() |
| `MerchantBannedDomainEvent(Merchant, Reason)` | Merchant.Ban() |

### Shop Events
| Event | Trigger |
|-------|---------|
| `ShopCreatedDomainEvent(Shop)` | Shop constructor |
| `ShopPublishedDomainEvent(Shop)` | Shop.Publish() |
| `ShopClosedDomainEvent(Shop)` | Shop.Close() |
| `ShopBranchAddedDomainEvent(Shop, Branch)` | Shop.AddBranch() |
| `ShopSetAsDefaultDomainEvent(Shop)` | Shop.SetAsDefault() |
| `ShopTransferredDomainEvent(Shop, PreviousMerchantId, NewMerchantId)` | Shop.TransferOwnership() |

### Staff Events
| Event | Trigger |
|-------|---------|
| `StaffInvitedDomainEvent(Staff)` | MerchantStaff.Invite() |
| `StaffJoinedDomainEvent(Staff)` | MerchantStaff.AcceptInvitation() |
| `StaffPinCodeSetDomainEvent(Staff)` | MerchantStaff.SetPinCode() |
| `StaffDeviceRegisteredDomainEvent(Staff, Device)` | MerchantStaff.RegisterDevice() |
| `StaffAssignedToShopDomainEvent(Staff, Assignment)` | MerchantStaff.AssignToShop() |
| `StaffTerminatedDomainEvent(Staff)` | MerchantStaff.Terminate() |

---

## DbContext

**Class**: `MerchantServiceContext` (implements `DbContext` + `IUnitOfWork`)

**DbSets**:
- `Merchants` -> Merchant
- `Shops` -> Shop
- `ShopBranches` -> ShopBranch
- `MerchantStaff` -> MerchantStaff
- `ShopMembers` -> ShopMember
- `DeviceTokens` -> DeviceToken
- `AttendanceRecords` -> AttendanceRecord
- `LeaveRequests` -> LeaveRequest

**Key Behaviors**:
- `SaveEntitiesAsync()`: Dispatches domain events via MediatR before SaveChangesAsync
- `BeginTransactionAsync()`: Starts ReadCommitted isolation transaction
- `CommitTransactionAsync()`: Commits + disposes transaction
- `RollbackTransaction()`: Rollback + disposes transaction
- `OnModelCreating()`: ApplyConfigurationsFromAssembly + Ignores StaffRole/StaffStatus/ShopRole enumerations

**Ignored Enumerations in ModelBuilder** (resolved in-memory, not FK relationships):
- StaffRole, StaffStatus, ShopRole

---

## Repositories

### IMerchantRepository / MerchantRepository
| Method | Return | Description |
|--------|--------|-------------|
| `GetByIdAsync(id)` | `Merchant?` | Filters by _isDeleted=false |
| `GetByUserIdAsync(userId)` | `Merchant?` | Filters by _isDeleted=false |
| `ExistsByUserIdAsync(userId)` | `bool` | Check existence |
| `GetAllAsync(pageNumber, pageSize)` | `IReadOnlyList<Merchant>` | Paginated, ordered by createdAt desc |
| `GetByStatusAsync(status)` | `IReadOnlyList<Merchant>` | Filter by MerchantStatus |
| `Add(merchant)` | `Merchant` | Add new |
| `Update(merchant)` | `void` | Mark as Modified |

### IShopRepository / ShopRepository
| Method | Return | Description |
|--------|--------|-------------|
| `GetByIdAsync(id)` | `Shop?` | Without branches |
| `GetByIdWithBranchesAsync(id)` | `Shop?` | Include branches |
| `GetBySlugAsync(slug)` | `Shop?` | Lowercase match |
| `SlugExistsAsync(slug)` | `bool` | Check slug uniqueness |
| `GetByMerchantIdAsync(merchantId)` | `IReadOnlyList<Shop>` | All shops for merchant |
| `GetByCategoryAsync(category, page, size)` | `IReadOnlyList<Shop>` | Active shops by category, paginated |
| `GetActiveShopsWithBranchesAsync()` | `IReadOnlyList<Shop>` | All active shops with branches |
| `GetByMerchantIdPagedAsync(merchantId, statusId?, page, size)` | `(Items, TotalCount)` | Paginated with optional status filter |
| `GetDefaultByMerchantIdAsync(merchantId)` | `Shop?` | Get default shop |
| `Add(shop)` | `Shop` | Add new |
| `Update(shop)` | `void` | Mark as Modified |

### IMerchantStaffRepository / MerchantStaffRepository
| Method | Return | Description |
|--------|--------|-------------|
| `GetByIdAsync(id)` | `MerchantStaff?` | Include ShopAssignments + DeviceTokens |
| `GetByUserIdAsync(userId)` | `MerchantStaff?` | Include ShopAssignments, exclude Terminated |
| `GetByUserIdAndMerchantIdAsync(userId, merchantId)` | `MerchantStaff?` | Include ShopAssignments, exclude Terminated |
| `GetByMerchantIdAsync(merchantId)` | `IReadOnlyList<MerchantStaff>` | Include ShopAssignments, exclude Terminated |
| `GetByShopIdAsync(shopId)` | `IReadOnlyList<MerchantStaff>` | Via ShopAssignments join |
| `ExistsByUserIdAndMerchantIdAsync(userId, merchantId)` | `bool` | Exclude Terminated |
| `GetByEmailAsync(email)` | `MerchantStaff?` | Include ShopAssignments, exclude Terminated |
| `GetByIdsAsync(ids)` | `IReadOnlyList<MerchantStaff>` | Batch lookup by ID list |
| `Add(staff)` | `MerchantStaff` | Add new |
| `Update(staff)` | `void` | Mark as Modified |

### IAttendanceRepository / AttendanceRepository
| Method | Return | Description |
|--------|--------|-------------|
| `GetByIdAsync(id)` | `AttendanceRecord?` | Simple lookup |
| `GetTodayRecordAsync(staffId)` | `AttendanceRecord?` | Today's record for staff |
| `GetByStaffAndMonthAsync(staffId, month, year)` | `List<AttendanceRecord>` | Monthly records, desc by date |
| `GetByShopAndMonthAsync(shopId, month, year)` | `List<AttendanceRecord>` | Monthly records for shop |
| `Add(record)` | `AttendanceRecord` | Add new |
| `Update(record)` | `void` | Mark as Modified |

### ILeaveRequestRepository / LeaveRequestRepository
| Method | Return | Description |
|--------|--------|-------------|
| `GetByIdAsync(id)` | `LeaveRequest?` | Simple lookup |
| `GetByStaffAsync(staffId)` | `List<LeaveRequest>` | All for staff, desc by createdAt |
| `GetByShopAsync(shopId)` | `List<LeaveRequest>` | All for shop, desc by createdAt |
| `Add(request)` | `LeaveRequest` | Add new |
| `Update(request)` | `void` | Mark as Modified |

---

## Dependencies

### NuGet Packages (from .csproj)
- **MediatR** 12.4.1 — CQRS command/query dispatch
- **FluentValidation** 11.11 — Input validation in pipeline
- **EF Core 10** (Microsoft.EntityFrameworkCore) — ORM
- **Npgsql.EntityFrameworkCore.PostgreSQL** 10 — PostgreSQL provider
- **Serilog** 8 — Structured logging
- **Hellang.Middleware.ProblemDetails** — RFC 7807 error responses
- **Asp.Versioning** 8.1 — API versioning
- **Swashbuckle** 7.2 — Swagger/OpenAPI

### Infrastructure Dependencies
- **PostgreSQL** (Neon cloud) — Primary database
- **Redis** 7 — Cache (configured but not actively used in this service)
- **IAM Service** (port 5001) — JWT token authority

### DI Registration (`DependencyInjection.cs`)
```
services.AddDbContext<MerchantServiceContext>(PostgreSQL + Npgsql retry 5x/30s)
services.AddScoped<IMerchantRepository, MerchantRepository>()
services.AddScoped<IShopRepository, ShopRepository>()
services.AddScoped<IMerchantStaffRepository, MerchantStaffRepository>()
services.AddScoped<IAttendanceRepository, AttendanceRepository>()
services.AddScoped<ILeaveRequestRepository, LeaveRequestRepository>()
services.AddScoped<IRequestManager, RequestManager>()
```

### MediatR Pipeline Registration (`Program.cs`)
```
cfg.RegisterServicesFromAssemblyContaining<Program>()
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>))
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>))
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>))
```

---

## Configuration

### appsettings.json
```json
{
    "ConnectionStrings": {
        "DefaultConnection": "Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=merchant_service;..."
    },
    "Redis": { "ConnectionString": "localhost:6379" },
    "Jwt": {
        "Authority": "http://localhost:5001",
        "Audience": "goodgo-api",
        "RequireHttpsMetadata": false
    }
}
```

### appsettings.Development.json
- Logging: Debug level
- EF Core SQL logging enabled

### appsettings.Production.json
- Logging: Warning level only
- ConnectionString: via environment variable (DATABASE_URL)

### Environment Variables
| Variable | Purpose |
|----------|---------|
| `DATABASE_URL` | Fallback connection string |
| `ASPNETCORE_ENVIRONMENT` | Development enables sensitive data logging |
| `Jwt:Authority` | JWT issuer authority URL |

### JWT Configuration
- Authority: IAM IdentityServer (default `http://localhost:5001`)
- Development mode: Skips signature validation (accepts any valid JWT structure)
- Production mode: Full signature validation enabled

### Health Checks
| Endpoint | Purpose |
|----------|---------|
| `/health` | Full health (includes PostgreSQL probe) |
| `/health/live` | Liveness probe (app running, no DB check) |
| `/health/ready` | Readiness probe (includes PostgreSQL probe) |

### Subscription Plans
| Plan | ID | Price |
|------|----|-------|
| Starter | 0 | Free |
| Growth | 1 | 299,000 VND/month |
| Pro | 2 | 799,000 VND/month |
| Enterprise | 3 | 1,999,000 VND/month |

---

## Idempotency

**IRequestManager / RequestManager**: Used for duplicate command detection via `ClientRequest` entity. Registered as scoped service.

---

## File Structure

```
services/merchant-service-net/
  src/
    MerchantService.API/
      Application/
        Behaviors/
          LoggingBehavior.cs
          ValidatorBehavior.cs
          TransactionBehavior.cs
        Commands/
          Admin/           (Approve/Reject/Suspend/Ban/Reactivate Merchant)
          Attendance/       (CheckIn, CheckOut)
          LeaveRequests/    (Create/Approve/Reject LeaveRequest)
          Merchants/        (Register/Update/Verify Merchant)
          Pos/              (PinAuth, RegisterDevice)
          Shops/            (Create/Update/Publish/Close/Branch/Transfer Shop)
          Staff/            (Invite/CreateActive/Update/Delete/AcceptInvite Staff)
          Subscriptions/    (Subscribe)
        Queries/
          Admin/            (GetAllMerchants/Shops, GetMerchantDetail/Statistics)
          Attendance/       (GetAttendanceByStaff/Shop)
          LeaveRequests/    (GetLeaveRequestsByStaff/Shop)
          Merchants/        (GetMerchantProfile/ById)
          Pos/              (GetPosStaff, GetDevices)
          Shops/            (GetMyShops/ById/BySlug/Settings/Stats/Branches/Nearby)
          Staff/            (GetMyStaff, GetStaffRoles)
          Subscriptions/    (GetSubscription/Plans/Usage)
        Validations/        (18 validators)
      Controllers/
        MerchantsController.cs
        ShopsController.cs
        StaffController.cs        (StaffController + StaffPublicController)
        AttendanceController.cs
        LeaveRequestsController.cs
        PosController.cs
        DevicesController.cs
        SubscriptionsController.cs
        AdminMerchantsController.cs
        AdminShopsController.cs
      Program.cs
      appsettings.json / .Development.json / .Production.json

    MerchantService.Domain/
      AggregatesModel/
        MerchantAggregate/    (Merchant, IMerchantRepository, MerchantStatus, MerchantType, VerificationStatus, SettlementCycle, BusinessInfo, SettlementConfig, BankAccount)
        ShopAggregate/        (Shop, IShopRepository, ShopBranch, ShopStatus, ShopType, BusinessCategory, ContactInfo, OperatingHours, ShopFeatures, Address, GeoLocation)
        MerchantStaffAggregate/ (MerchantStaff, IMerchantStaffRepository, StaffRole, StaffStatus, ShopRole, StaffPermissions, DeviceToken, ShopMember)
        AttendanceAggregate/  (AttendanceRecord, IAttendanceRepository)
        LeaveRequestAggregate/ (LeaveRequest, ILeaveRequestRepository)
      Events/
        MerchantDomainEvents.cs  (5 events)
        ShopDomainEvents.cs      (6 events)
        StaffDomainEvents.cs     (6 events)
      SeedWork/               (Entity, IAggregateRoot, IRepository, IUnitOfWork, ValueObject, Enumeration)
      Exceptions/             (DomainException)

    MerchantService.Infrastructure/
      MerchantServiceContext.cs   (DbContext + IUnitOfWork)
      DependencyInjection.cs      (AddInfrastructure extension)
      EntityConfigurations/
        MerchantEntityTypeConfiguration.cs        (+ MerchantType, MerchantStatus, VerificationStatus, SettlementCycle configs)
        ShopEntityTypeConfiguration.cs            (+ ShopBranch, ShopType, ShopStatus, BusinessCategory configs)
        MerchantStaffEntityTypeConfiguration.cs   (+ DeviceToken, ShopMember, StaffRole, StaffStatus, ShopRole configs)
        AttendanceRecordEntityTypeConfiguration.cs
        LeaveRequestEntityTypeConfiguration.cs
      Repositories/
        MerchantRepository.cs
        ShopRepository.cs
        MerchantStaffRepository.cs
        AttendanceRepository.cs
        LeaveRequestRepository.cs
      Idempotency/
        IRequestManager.cs
        RequestManager.cs
        ClientRequest.cs
      Migrations/               (EF Core migrations)
```
