Saltar al contenido principal

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

ConceptDescription
LocationPhysical site belonging to a business
AddressLinked postal/civic address (1-to-1 relationship)
Freeze / UnfreezeLifecycle states that block a location from transactional activity
TimezoneIANA timezone identifier for the location (default: America/Guatemala)
Google PlaceOptional Google Places ID + geographic point for map integration
Tax NumberVAT / tax registration number for the site (also used as establishment code on FEL invoices)
Legal AddressPer-branch legal/tax address printed on receipts and PDFs; overrides the business-level address
Legal NamePer-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 DataImmutable 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 token
  • RolesGuard (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):

  1. document_data.location.legalAddress / .legalName — immutable snapshot captured at document creation
  2. Live location.legalAddress / .legalName from 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.
  3. business.legalAddress / .legalName — business-level fallback
  4. null — 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 fetchLocationSnapshotbuildDocumentData 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 — timestamp
  • frozenBy — UUID of the user who froze it
  • frozenReason — mandatory reason string
  • frozenSessionId — 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 businessId or a 404 Not Found is returned.

API Endpoints

Base path: /locations

MethodPathDescription
POST/locationsCreate a location (with address)
GET/locationsList locations (paginated, filterable by businessId)
GET/locations/:idGet a location by ID (includes address)
PATCH/locations/:idUpdate a location (address upserted automatically)
PATCH/locations/:id/freezeFreeze a location
PATCH/locations/:id/unfreezeUnfreeze a location
DELETE/locations/:idDelete 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)

ParameterTypeRequiredDescription
businessIdUUIDNoFilter by business
searchstringNoSearches name, googlePlaceId, taxNumber, address.lineOne, address.lineTwo
pagenumberNoPage number (default: 1)
sizenumberNoPage size (0 = all)
orderBystringNoColumn: name, googlePlaceId, taxNumber
orderasc|descNoSort 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 + nested CreateAddressDTO
  • UpdateLocationDTO — extends PartialType(CreateLocationDTO) (Swagger-aware)
  • FreezeLocationDTO — reason + frozenBy + optional sessionId
  • UnfreezeLocationDTO — 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

  1. Address created atomically with the locationPOST /locations accepts an inline address object; the service creates the address first, then links it. If address creation fails, the location is not created. No two-step API is exposed.

  2. Address upserted on updatePATCH /locations/:id will create a new address or update the existing one depending on whether addressId is provided. Both fields are optional — omitting address leaves the existing address untouched.

  3. Hard deleteDELETE /locations/:id performs a hard delete. Soft-delete is handled at the business logic level (setting isActive = false) rather than row-level deletion for most use cases.

  4. sortableLocationKeys in the domain layer — The sortable column set is defined in domain/locations-repository.domain.ts so 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/

FileRequest
list-locations.ymlList Locations
create-location.ymlCreate Location
create-location-2.ymlCreate Location (ZACAPA)
create-location-3.ymlCreate Location (TOTONICAPAN)
get-location-by-id.ymlGet Location by ID
update-location.ymlUpdate Location
freeze-location.ymlFreeze Location
unfreeze-location.ymlUnfreeze Location
delete-location.ymlDelete Location

Known Follow-ups

  • unfrozenBy audit field — The unfreeze operation does not record who unfroze the location. Adding a unfrozen_by + unfrozen_at column to the schema would complete the audit trail symmetrically.
  • Kysely in domain portfindFirst and findMany accept ExpressionBuilder<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.