Customers Module
Overview
The customers module manages customer records used across the FlowPOS platform for:
- FEL (Electronic Invoicing) — tax attribution (taxId, taxName, taxAddress) required by Latin American fiscal authorities
- Retail POS — attaching a customer to a
salefor tracking and loyalty - Restaurant — attaching a customer to an
orderor reservation
Customers are business-scoped: every customer belongs to a specific business and is isolated from other businesses.
Architecture
The module follows Hexagonal Architecture with strict layer boundaries.
customers/
├── customers.module.ts # NestJS module wiring + Redis provider
├── domain/
│ └── customers-repository.domain.ts # ICustomersRepository port + sortableCustomerKeys
├── application/
│ └── customers.service.ts # Use cases (CRUD + purchase history + Redis cache)
├── infrastructure/
│ └── customers.repository.ts # Kysely DB adapter (implements port)
└── interfaces/
├── customers.controller.ts # HTTP adapter
├── dtos/
│ ├── create-customer.dto.ts
│ ├── update-customer.dto.ts
│ ├── update-customer-status.dto.ts
│ └── purchase-summary.dto.ts
└── query/
└── paginate-customers.query.ts
Layer responsibilities
| Layer | Responsibility |
|---|---|
domain/ | Repository port (ICustomersRepository) and sortableCustomerKeys constant. Zero framework dependencies. |
application/ | CustomersService — orchestrates use cases, translates DB constraint violations into HTTP-level errors, manages Redis cache for purchase summaries. |
infrastructure/ | CustomersRepository — Kysely queries against customer, sale, and order tables. Implements ICustomersRepository. |
interfaces/ | CustomersController — maps HTTP requests to service calls, extracts auth context, enforces RBAC via @PermissionResource / @PermissionAction. |
Dependency rule
interfaces → application → domain ← infrastructure
Infrastructure never imports from interfaces or application. Domain has no outward dependencies.
Domain Concepts
Customer
A customer row is primarily a tax entity with optional personal contact details.
| Field | Description |
|---|---|
taxId | Tax ID number (NIT in Guatemala, RUT in Chile, etc.) |
taxName | Legal name registered with tax authority |
taxAddress | Legal address for invoicing |
firstName / lastName | Optional personal name |
email | Optional email — validated with RFC-compliant format check |
phone | Optional phone number (unique per business via DB constraint) |
address / city / postalCode | Optional delivery address |
customerCode | Optional internal POS code for fast lookup |
countryId / stateName / departmentName / municipalityName | Optional geographic hierarchy |
taxpayerType | Tax category (e.g. PEQUEÑO_CONTRIBUYENTE, GENERAL) |
status | Lifecycle status: active, inactive, or blocked |
tags | Free-form segmentation tags (array) |
notes | Staff-facing notes (max 2000 chars) |
documentNumber | Government-issued ID |
businessId | Owning business — all queries must scope by this |
isActive | Soft-enable/disable without deletion |
createdBy / updatedBy | Audit trail UUIDs (set from Firebase token server-side) |
Use Cases
| Use case | Method | HTTP |
|---|---|---|
| Create customer | createCustomer | POST /customers |
| List customers (paginated) | getAllCustomers | GET /customers |
| Get single customer | getCustomerById | GET /customers/:id |
| Update customer | updateCustomer | PATCH /customers/:id |
| Update customer status | updateCustomerStatus | PATCH /customers/:id/status |
| Delete customer | deleteCustomer | DELETE /customers/:id |
| Get transaction history | getCustomerTransactionHistory | GET /customers/:id/transactions |
| Get purchase summary | getPurchaseSummary | GET /customers/:id/purchase-summary |
| Get purchase history | getCustomerPurchases | GET /customers/:id/purchases |
| Get purchase detail | getPurchaseDetail | GET /customers/:id/purchases/:purchaseId |
| Reattribute sales (merge) | reattributeSalesToCustomer | (internal — no HTTP endpoint yet) |
API Endpoints
POST /customers
Creates a new customer record.
Request body (see CreateCustomerDTO for full schema):
{
"taxId": "17195594",
"taxName": "LUIS RANGEL CASTRO",
"taxAddress": "5TA. CALLE 2-78 ZONA 2 TECPAN GUATEMALA, CHIMALTENANGO",
"firstName": "LUIS",
"lastName": "RANGEL",
"email": "luis@example.com",
"phone": "56917111",
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"isActive": true,
"createdBy": "550e8400-e29b-41d4-a716-446655440000"
}
createdByis overridden server-side from the Firebase ID token (db_user_idclaim). The body field is a fallback only.
Responses: 201 Created | 400 Bad Request | 409 Conflict (duplicate phone)
GET /customers
Returns a paginated list. Supports full-text search and filtering.
Query parameters:
| Param | Required | Description |
|---|---|---|
businessId | No | Filter by business UUID |
search | No | Searches taxId, taxName, taxAddress, firstName, lastName, email, phone, address, city, postalCode |
phone | No | Partial phone search (ILIKE). Overrides search when provided |
name | No | Partial name search. Secondary to phone |
status | No | Filter by active, inactive, or blocked |
page | No | Page number (default: 1) |
size | No | Page size (default: 10; 0 = no limit) |
orderBy | No | Sort field (see sortable fields below) |
order | No | asc or desc |
Sortable fields: taxId, taxName, taxAddress, firstName, lastName, email, phone, address, city, postalCode
Response: 200 OK — IOffsetPagination<Customer> with data, total, page, size, totalPages.
GET /customers/:id
Returns a single customer by UUID.
Response: 200 OK or 404 Not Found.
PATCH /customers/:id
Partially updates a customer. Only supplied fields are changed.
Request body — any subset of CreateCustomerDTO fields plus required updatedBy.
updatedByis overridden server-side from the Firebase token.
Responses: 200 OK | 400 Bad Request | 404 Not Found | 409 Conflict (duplicate phone)
PATCH /customers/:id/status
Updates the lifecycle status of a customer.
Query parameters:
| Param | Required | Description |
|---|---|---|
businessId | No* | Business UUID. Falls back to token claim. |
Request body:
{
"status": "inactive"
}
Valid values: active, inactive, blocked.
Responses: 200 OK | 400 Bad Request | 404 Not Found
DELETE /customers/:id
Hard-deletes a customer record. All linked sale.customer_id values are set to NULL via ON DELETE SET NULL FK constraint — purchase history records remain but are no longer attributed to any customer.
Response: 200 OK or 404 Not Found.
GET /customers/:id/transactions
Returns a paginated list of all sales and restaurant orders linked to this customer.
Query parameters:
| Param | Required | Description |
|---|---|---|
businessId | No* | Business UUID |
limit | No | Page size (default: 20, max: 100) |
offset | No | Pagination offset (default: 0) |
Response: 200 OK
{
"data": [...],
"total": 42,
"limit": 20,
"offset": 0
}
GET /customers/:id/purchase-summary
Returns computed lifetime metrics for a customer. Results are cached in Redis for 60 seconds.
Query parameters:
| Param | Required | Description |
|---|---|---|
businessId | No* | Business UUID. Falls back to current_business_id claim in token. |
Response: 200 OK
{
"customerId": "uuid",
"businessId": "uuid",
"lifetimeValue": "1250.00",
"totalTransactions": 12,
"averageTransactionValue": "104.17",
"lastPurchaseDate": "2026-03-01T10:00:00.000Z",
"cachedAt": "2026-03-16T12:00:00.000Z"
}
lifetimeValue:SUM(sale + exchange amounts) − SUM(return amounts)forstatus = 'completed'sales.cachedAt: ISO timestamp when the metrics were computed.
GET /customers/:id/purchases
Returns a paginated, reverse-chronological list of retail sales and restaurant orders linked to a customer.
Query parameters:
| Param | Required | Description |
|---|---|---|
businessId | No* | Business UUID |
limit | No | Page size (default: 20, max: 100) |
offset | No | Pagination offset (default: 0) |
fromDate | No | ISO 8601 start timestamp filter (inclusive) |
toDate | No | ISO 8601 end timestamp filter (inclusive) |
type | No | sale (retail only) or order (restaurant only) |
saleType | No | sale, exchange, or return — only meaningful when type=sale |
locationId | No | UUID — filter to a specific location |
Response: 200 OK
{
"data": [
{
"id": "uuid",
"transactionType": "sale",
"saleType": "return",
"date": "2026-03-01T10:00:00Z",
"locationId": "uuid",
"locationName": "Main Store",
"totalAmount": "50.00",
"currencyCode": "USD",
"status": "completed",
"itemCount": 2,
"parentId": "uuid-of-original-sale"
}
],
"total": 42,
"limit": 20,
"offset": 0
}
GET /customers/:id/purchases/:purchaseId
Returns full receipt detail for a single sale or restaurant order.
Query parameters:
| Param | Required | Description |
|---|---|---|
businessId | No* | Business UUID |
Response: 200 OK — full PurchaseDetail object including line items, payment methods, discounts, and parent link.
Response: 404 Not Found — if the purchase does not exist or does not belong to the specified customer+business.
Access Control
- All endpoints require a valid Firebase ID token (via
AuthGuardapplied globally). - RBAC is enforced via
RolesGuardwith@PermissionResource(PolicyResource.Customer). - Each endpoint declares its required permission action (
PolicyAction.Create,.Read,.Update,.Delete).
Design Decisions
createdBy / updatedBy from token, not body
The controller extracts db_user_id from the Firebase token and uses it as the audit field. The DTO still accepts createdBy/updatedBy as a fallback for migration scenarios where the token claim is not yet set. Once all users are onboarded, the body fields can be removed.
Phone uniqueness enforcement
The customer_business_phone_unique DB constraint prevents duplicate phone numbers within the same business. The service translates PostgreSQL error code 23505 into a 409 Conflict HTTP response.
Redis-cached purchase summary
getPurchaseSummary results are cached in Redis with a 60-second TTL. If Redis is unavailable, the query falls back to the database — cache failures are non-blocking.
sortableCustomerKeys lives in the domain layer
The constant defines which columns are safe to sort by. It belongs to the domain because it is a business rule (not an HTTP or DB concern). The paginate-customers.query.ts re-exports it for the mixin composition without redefining it.
Hard delete
Customers currently use hard deletion (DELETE FROM customer). If audit or referential-integrity requirements change, this should be converted to a soft-delete (isActive = false / deletedAt timestamp).
Secondary sort by taxName
The repository always appends .orderBy("taxName", "desc") as a tiebreaker after the user's chosen sort column. This produces stable pagination when rows share the same primary sort value.
Transaction history UNION
Retail sale and restaurant order are separate document types. The transaction history endpoint uses a SQL UNION to provide a unified customer view across both commerce contexts.
Customer merge (internal)
reattributeSalesToCustomer re-assigns all sale and order records from a source customer to a target customer within a transaction. This is used by the (future) merge endpoint to consolidate duplicate customers without data loss.