Skip to main content

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 sale for tracking and loyalty
  • Restaurant — attaching a customer to an order or 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

LayerResponsibility
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.

FieldDescription
taxIdTax ID number (NIT in Guatemala, RUT in Chile, etc.)
taxNameLegal name registered with tax authority
taxAddressLegal address for invoicing
firstName / lastNameOptional personal name
emailOptional email — validated with RFC-compliant format check
phoneOptional phone number (unique per business via DB constraint)
address / city / postalCodeOptional delivery address
customerCodeOptional internal POS code for fast lookup
countryId / stateName / departmentName / municipalityNameOptional geographic hierarchy
taxpayerTypeTax category (e.g. PEQUEÑO_CONTRIBUYENTE, GENERAL)
statusLifecycle status: active, inactive, or blocked
tagsFree-form segmentation tags (array)
notesStaff-facing notes (max 2000 chars)
documentNumberGovernment-issued ID
businessIdOwning business — all queries must scope by this
isActiveSoft-enable/disable without deletion
createdBy / updatedByAudit trail UUIDs (set from Firebase token server-side)

Use Cases

Use caseMethodHTTP
Create customercreateCustomerPOST /customers
List customers (paginated)getAllCustomersGET /customers
Get single customergetCustomerByIdGET /customers/:id
Update customerupdateCustomerPATCH /customers/:id
Update customer statusupdateCustomerStatusPATCH /customers/:id/status
Delete customerdeleteCustomerDELETE /customers/:id
Get transaction historygetCustomerTransactionHistoryGET /customers/:id/transactions
Get purchase summarygetPurchaseSummaryGET /customers/:id/purchase-summary
Get purchase historygetCustomerPurchasesGET /customers/:id/purchases
Get purchase detailgetPurchaseDetailGET /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"
}

createdBy is overridden server-side from the Firebase ID token (db_user_id claim). 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:

ParamRequiredDescription
businessIdNoFilter by business UUID
searchNoSearches taxId, taxName, taxAddress, firstName, lastName, email, phone, address, city, postalCode
phoneNoPartial phone search (ILIKE). Overrides search when provided
nameNoPartial name search. Secondary to phone
statusNoFilter by active, inactive, or blocked
pageNoPage number (default: 1)
sizeNoPage size (default: 10; 0 = no limit)
orderByNoSort field (see sortable fields below)
orderNoasc or desc

Sortable fields: taxId, taxName, taxAddress, firstName, lastName, email, phone, address, city, postalCode

Response: 200 OKIOffsetPagination<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.

updatedBy is 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:

ParamRequiredDescription
businessIdNo*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:

ParamRequiredDescription
businessIdNo*Business UUID
limitNoPage size (default: 20, max: 100)
offsetNoPagination 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:

ParamRequiredDescription
businessIdNo*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) for status = '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:

ParamRequiredDescription
businessIdNo*Business UUID
limitNoPage size (default: 20, max: 100)
offsetNoPagination offset (default: 0)
fromDateNoISO 8601 start timestamp filter (inclusive)
toDateNoISO 8601 end timestamp filter (inclusive)
typeNosale (retail only) or order (restaurant only)
saleTypeNosale, exchange, or return — only meaningful when type=sale
locationIdNoUUID — 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:

ParamRequiredDescription
businessIdNo*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 AuthGuard applied globally).
  • RBAC is enforced via RolesGuard with @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.