Locations Module
Overview
A Location represents a physical business site (store, warehouse, branch office). Every location belongs to a Business and has one linked Address. Most transactional data in the system (sales, orders, cash register sessions, inventory) is scoped to a specific location.
Domain Concepts
| Concept | Description |
|---|---|
| Location | Physical site belonging to a business |
| Address | Linked postal/civic address (1-to-1 relationship) |
| Freeze / Unfreeze | Lifecycle states that block a location from transactional activity |
| Timezone | IANA timezone identifier for the location (default: America/Guatemala) |
| Google Place | Optional Google Places ID + geographic point for map integration |
| Tax Number | VAT / tax registration number for the site (also used as establishment code on FEL invoices) |
| Legal Address | Per-branch legal/tax address printed on receipts and PDFs; overrides the business-level address |
| Legal Name | Per-branch legal name (razón social) printed on receipts and PDFs; overrides the business-level legal name on the receipt header display line only (see FEL note below) |
| Document Data | Immutable jsonb snapshot ({ location: { name, taxNumber, legalAddress, legalName } }) captured at document creation for audit and historical reprint accuracy |
Architecture
The module follows Hexagonal Architecture (Ports & Adapters):
interfaces/ ← HTTP layer (controller, DTOs, query objects)
application/ ← Use cases (LocationsService)
domain/ ← Port interface (ILocationsRepository) + constants
infrastructure/ ← Kysely adapter (LocationsRepository)
Dependency flow: interfaces → application → domain ← infrastructure
Authorization
The controller is protected by:
AuthGuard(global) — validates Firebase ID tokenRolesGuard(class-level) — enforces RBAC/ABAC via CASL@PermissionResource(PolicyResource.Location)(class-level) — declares the resource@PermissionAction(PolicyAction.*)(method-level) — declares the required action per endpoint
Dependency Injection
The service depends on the ILocationsRepository port (symbol: LOCATIONS_REPOSITORY) rather than the concrete LocationsRepository. The module wires the implementation via useExisting:
{
provide: LOCATIONS_REPOSITORY,
useExisting: LocationsRepository,
}
This makes the service testable by swapping in a mock without touching NestJS module wiring.
Database Schema
location (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id UUID NOT NULL REFERENCES business(id),
address_id UUID REFERENCES address(id),
name TEXT,
tax_number TEXT,
legal_address VARCHAR, -- Branch legal/tax address (receipts/PDFs). See below.
legal_name VARCHAR, -- Branch legal name / razón social (receipts/PDFs). See below.
google_place_id TEXT,
google_place_point POINT,
contact JSONB,
timezone TEXT NOT NULL DEFAULT 'America/Guatemala',
available_to_sell BOOLEAN NOT NULL DEFAULT TRUE,
available_to_buy BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
frozen_at TIMESTAMPTZ,
frozen_by TEXT,
frozen_reason TEXT,
frozen_session_id UUID,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT,
updated_at TIMESTAMPTZ
)
Kysely uses camelCase column access (businessId, frozenAt, legalAddress, etc.) due to the --camel-case codegen flag.
legalAddress / legalName — per-branch issuer identity
location.legalAddress and location.legalName store the legal/tax address and legal name (razón social) for this branch, printed on receipts and PDFs in place of the business-level values. When set, they override business.legalAddress / business.legalName for documents issued by that branch.
Resolution order for the businessAddress and businessLegalName template fields (implemented in apps/backend/src/common/services/document-data.service.ts → resolveIssuerIdentity, applied independently per field):
document_data.location.legalAddress/.legalName— immutable snapshot captured at document creation- Live
location.legalAddress/.legalNamefrom DB — only for legacy documents created before the migration. Both columns are fetched in a single query (not one per field) when either is still unresolved after step 1. business.legalAddress/.legalName— business-level fallbacknull— nothing configured
resolveBusinessAddress still exists as a deprecated thin wrapper around resolveIssuerIdentity for callers that only need the address.
FEL compliance — legal name is scoped to display only. Unlike the address, location.legalName does not override the SAT/FEL emisor "Razón Social" field (issuer.legalName in sale.template.html's formal tax block). That field stays sourced from business.legalName only, because it identifies the taxpayer registered against issuer.taxId (the NIT) — a legal name that does not vary per branch, unlike the address. location.legalName only drives the cosmetic receipt header line (businessLegalName) on all receipt/PDF templates, including that same header on the A4 SAT invoice. The branch/location address, by contrast, is the correct issuer address on certified SAT invoices and does override issuer.address in the formal emisor block — the certified XML payload is built separately and remains unchanged regardless of either override.
document_data — Extensible Document Snapshot
Every document table (21 in total: sale, order, order_bill, quote, purchase, purchase_order, goods_received_note, inventory_adjustment, inventory_transfer, transfer_request, transfer_dispatch_note, transfer_goods_receipt, cash_register_session, contractor_assignment, material_consumption, production_run, credit_note, debit_note, stock_count_session, stock_count_scope, stock_count_task) has a nullable document_data jsonb column.
Shape
{
"location": {
"name": "MIXCO",
"taxNumber": "5",
"legalAddress": "5ta calle 2-78 zona 2, Guatemala City",
"legalName": "Comercial MIXCO, S.A."
}
}
location sub-key is snapshotted at document creation via fetchLocationSnapshot → buildDocumentData from @/common/services/document-data.service. It is immutable after creation — reprinting a historical document always shows the address as it was at issuance.
Extensibility: The location key is the first sub-key of a generic envelope. Future per-document snapshots (cashier identity, terminal info, etc.) can add sibling keys under document_data without a new migration.
No backfill: Documents created before this migration have document_data = null. The resolveIssuerIdentity resolver falls back to the live location DB query for those reprints.
Insertion: Services pass the plain object directly to Kysely (no JSON.stringify needed — Kysely handles JSONB serialization). The JsonValue cast (documentData as JsonValue) satisfies the generated type.
Freeze / Unfreeze Lifecycle
A location can be frozen to prevent it from being used for transactions (e.g., during an audit, at end of day, or following an incident).
ACTIVE ──freeze──▶ FROZEN
FROZEN ─unfreeze─▶ ACTIVE
Freeze records:
frozenAt— timestampfrozenBy— UUID of the user who froze itfrozenReason— mandatory reason stringfrozenSessionId— optional cash register session UUID
Unfreeze clears all four fields. At this time, no separate unfrozenBy audit field exists on the schema (tracked as a follow-up).
Business rules
- Freezing an already-frozen location returns
409 Conflict. - Unfreezing a non-frozen location returns
409 Conflict. - Location must exist and belong to the given
businessIdor a404 Not Foundis returned.
API Endpoints
Base path: /locations
| Method | Path | Description |
|---|---|---|
POST | /locations | Create a location (with address) |
GET | /locations | List locations (paginated, filterable by businessId) |
GET | /locations/:id | Get a location by ID (includes address) |
PATCH | /locations/:id | Update a location (address upserted automatically) |
PATCH | /locations/:id/freeze | Freeze a location |
PATCH | /locations/:id/unfreeze | Unfreeze a location |
DELETE | /locations/:id | Delete a location |
All endpoints require Bearer authentication (flowpos-id-token cookie or Authorization header).
All endpoints are guarded by RolesGuard with PermissionResource: Location.
Query parameters (GET /locations)
| Parameter | Type | Required | Description |
|---|---|---|---|
businessId | UUID | No | Filter by business |
search | string | No | Searches name, googlePlaceId, taxNumber, address.lineOne, address.lineTwo |
page | number | No | Page number (default: 1) |
size | number | No | Page size (0 = all) |
orderBy | string | No | Column: name, googlePlaceId, taxNumber |
order | asc|desc | No | Sort direction |
Request / Response Examples
Create a location
POST /locations
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "ANTIGUA STORE",
"businessId": "a1b2c3d4-...",
"createdBy": "user-uuid",
"taxNumber": "17195594",
"legalAddress": "5ta Calle 2-78 Zona 2, Antigua Guatemala",
"legalName": "Comercial Antigua, S.A.",
"timezone": "America/Guatemala",
"availableToSell": true,
"availableToBuy": true,
"isActive": true,
"contact": { "phone": "+50212345678" },
"address": {
"lineOne": "5ta Calle",
"municipality": "Antigua Guatemala",
"department": "Sacatepéquez",
"countryId": "GT",
"businessId": "a1b2c3d4-...",
"createdBy": "user-uuid"
}
}
Freeze a location
PATCH /locations/{id}/freeze?businessId={businessId}
Authorization: Bearer <token>
Content-Type: application/json
{
"reason": "End of day closure",
"frozenBy": "user-uuid",
"sessionId": "session-uuid"
}
Unfreeze a location
PATCH /locations/{id}/unfreeze?businessId={businessId}
Authorization: Bearer <token>
Content-Type: application/json
{
"reason": "Reopening for business"
}
Swagger / OpenAPI
All DTOs include @ApiProperty / @ApiPropertyOptional decorators with examples and descriptions. The Swagger UI at /api-docs shows full request/response schemas for this module.
Key DTO classes:
CreateLocationDTO— full location + nestedCreateAddressDTOUpdateLocationDTO— extendsPartialType(CreateLocationDTO)(Swagger-aware)FreezeLocationDTO— reason + frozenBy + optional sessionIdUnfreezeLocationDTO— optional reason
Timezone Validation
Timezone values must be valid IANA identifiers (e.g., America/Guatemala, Europe/London). The shared validator lives in:
- Global regex validator:
packages/global/validators/timezone.validator.ts - DTO decorator:
apps/backend/src/locations/interfaces/dtos/validators/timezone.validator.ts(@IsIANATimezone())
The default timezone is America/Guatemala (applied in the service if not provided).
Design Decisions
-
Address created atomically with the location —
POST /locationsaccepts an inlineaddressobject; the service creates the address first, then links it. If address creation fails, the location is not created. No two-step API is exposed. -
Address upserted on update —
PATCH /locations/:idwill create a new address or update the existing one depending on whetheraddressIdis provided. Both fields are optional — omittingaddressleaves the existing address untouched. -
Hard delete —
DELETE /locations/:idperforms a hard delete. Soft-delete is handled at the business logic level (settingisActive = false) rather than row-level deletion for most use cases. -
sortableLocationKeysin the domain layer — The sortable column set is defined indomain/locations-repository.domain.tsso that both the repository (infrastructure) and the query object (interfaces) can reference it without creating an upward dependency from infrastructure → interfaces.
Bruno API Collection
Located at: api-client/flowpos/collections/locations/
| File | Request |
|---|---|
list-locations.yml | List Locations |
create-location.yml | Create Location |
create-location-2.yml | Create Location (ZACAPA) |
create-location-3.yml | Create Location (TOTONICAPAN) |
get-location-by-id.yml | Get Location by ID |
update-location.yml | Update Location |
freeze-location.yml | Freeze Location |
unfreeze-location.yml | Unfreeze Location |
delete-location.yml | Delete Location |
Known Follow-ups
unfrozenByaudit field — The unfreeze operation does not record who unfroze the location. Adding aunfrozen_by+unfrozen_atcolumn to the schema would complete the audit trail symmetrically.- Kysely in domain port —
findFirstandfindManyacceptExpressionBuilder<DB, "location">lambdas, which couples the domain port to Kysely. A future improvement would use a typed predicate type to decouple the domain from the query builder.