Quotes Module
Overview
The Quotes module manages price quotations for retail businesses. It supports creating quotes for customers (both staff-initiated and kiosk-originated), managing their lifecycle through various statuses, applying discounts and bundles, generating PDFs, and converting accepted quotes into sales.
Architecture
quotes/
├── quotes.module.ts # NestJS module (DI wiring)
├── domain/
│ └── quotes-repository.domain.ts # Repository port, types, injection token
├── application/
│ └── quotes.service.ts # Business logic & use cases
├── infrastructure/
│ └── quotes.repository.ts # Kysely DB adapter
└── interfaces/
├── quotes.controller.ts # HTTP routes
├── dtos/
│ ├── create-quote-staff.dto.ts # Create quote request
│ ├── update-quote.dto.ts # Update quote request
│ ├── apply-quote-discount.dto.ts # Discount apply/remove DTOs
│ └── quote-bundle.dto.ts # Bundle apply/remove/combo DTOs
└── query/
└── paginate-quotes.query.ts # Pagination & filter query
Dependency flow: interfaces → application → domain ← infrastructure
The repository is bound via a QUOTES_REPOSITORY Symbol token in the module, and the service depends on the IQuotesRepository interface (not the concrete class).
Domain Concepts
Quote
A price quotation document with:
| Field | Description |
|---|---|
businessId | Owning business (multi-tenancy scope) |
locationId | Originating location |
origin | STAFF (authenticated) or KIOSK (public) |
status | draft, sent, accepted, rejected, expired, requested, converted |
documentNumber | Auto-generated sequential number (staff quotes only) |
quoteDetail | JSONB containing { items: QuoteDetailItem[] } |
costDetail | JSONB with inventory cost snapshot |
cartDiscountDetail | JSONB with cart-level discount info |
totalAmount / totalBaseAmount | Computed totals in transaction and base currency |
expiresAt | Optional expiration date |
| Customer fields | customerName, customerId, customerEmail, customerPhone |
| Tax fields | taxId, taxName, taxAddress, taxpayerType |
QuoteDetailItem
Each line item in quoteDetail.items:
interface QuoteDetailItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
amount: number;
baseAmount: number;
taxes: TaxBreakdown[];
discountDetail?: LineDiscountDetail;
bundleApplicationId?: string;
bundleDetail?: LineBundleDetail;
bundle?: number; // bundle discount amount
baseBundle?: number; // bundle discount in base currency
}
Quote Status Lifecycle
DRAFT → SENT → ACCEPTED → CONVERTED (to sale)
↘ REJECTED
↘ EXPIRED
REQUESTED (kiosk) → DRAFT (staff picks up)
CONVERTEDis set automatically byconvertQuoteToSale()— clients cannot set this status directly.
Use Cases
| Use Case | Method | Endpoint |
|---|---|---|
| Create staff quote | POST /quotes | Full tax enrichment, document numbering, cost snapshot |
| Create kiosk quote | Internal | Minimal data, status REQUESTED, no doc number |
| List quotes | GET /quotes | Paginated with search, filter, sort |
| Get quote | GET /quotes/:id | Enriches bundle fields on read |
| Update quote | PATCH /quotes/:id | Re-enriches items, recalculates totals, cascade-cleans bundles |
| Delete quote | DELETE /quotes/:id | Blocks deletion of converted quotes |
| Apply line discount | POST /quotes/:id/items/discount | Stacked discount on item by index |
| Remove line discount | DELETE /quotes/:id/items/discount | Remove specific or all line discounts |
| Apply cart discount | POST /quotes/:id/discount | Cart-level discount |
| Remove cart discount | DELETE /quotes/:id/discount | Remove specific or all cart discounts |
| Generate PDF | GET /quotes/:id/pdf | Download as attachment |
| PDF as base64 | GET /quotes/:id/pdf/content | For embedding/email |
| Print preview | GET /quotes/:id/print | HTML for browser print |
| Public link | POST /quotes/:id/public-link | Signed URL for sharing |
| Template data | GET /quotes/:id/template-data | Merge fields for email templates |
| Convert to sale | POST /quotes/:id/convert-to-sale | Creates draft sale from accepted quote |
| Evaluate bundles | GET /quotes/:id/bundles/evaluate | Check eligible bundles |
| Apply bundle | POST /quotes/:id/bundles/apply | Apply bundle discount to items |
| Remove bundle | DELETE /quotes/:id/bundles/remove | Remove bundle, restore prices |
| Add combo | POST /quotes/:id/bundles/add-combo | Add combo items with bundle |
Key Design Decisions
Tax Enrichment on Create/Update
When items are provided, the service fetches product tax configurations from the database and recomputes amounts using LineItemTaxService. This ensures tax-inclusive pricing is always correct regardless of what the client sends.
Cost Snapshot
Every create/update with items computes a cost detail snapshot using current inventory WAC (weighted average cost). This is stored in costDetail for margin analysis.
Bundle Cascade Cleanup
When items are removed during an update, the service detects if any bundle's member items were removed and automatically:
- Calls
bundlesService.removeBundle()to clean up the application record - Restores original prices on remaining items from that bundle
Quote-to-Sale Conversion
convertQuoteToSale maps quote data to a sale structure:
- Stores lot intent on quote lines (
inventoryDetails+variantId) without reserving or decrementing stock - Re-checks those lots at convert time against tenant, location, product, variant, and
AVAILABLEstatus - If a quoted lot is missing, sold, at another location, or short on qty, convert fills the remainder from the next
AVAILABLElots at the line location (FEFO). Convert still fails if nothing can cover the line - Serial-tracked products require selected lot ids; batch lines may omit them and FEFO-pick at sale submit
- Creates a DRAFT sale via
SalesService.createSale()with the resolved lots copied frominventory_detail - Carries over items, discounts, customer/tax data, and currency
- Re-enriches each line's tax breakdown from current product tax config so FEL certification has
items[].taxes - Resolves currency from stored data or falls back to business default
- Marks the quote as CONVERTED (preventing further edits or deletion)
- The PWA then opens
/forms/sale/:idwithout hydrating from the convert POST body. SalePage GETs the sale (same path as Search → select), marks it as an existing document (isUpdate), and lands on Line items so Save and the quoted cart are available immediately. - Sale submit is what consumes stock (existing FEFO / explicit-lot path). Reservation is not in this pass.
Multi-Currency Support
Quotes store exchangeRate, currencyId, currencyCode, and a currency JSONB object. All amounts are tracked in both transaction currency (totalAmount) and base currency (totalBaseAmount).
Dependencies
| Module | Purpose |
|---|---|
BundlesModule | Bundle evaluation, application, removal |
DiscountsModule | Line and cart discount building |
DocumentCalculationsModule | Tax computation, total validation |
InventoriesModule | Cost data for cost snapshot |
InventoryDetailsModule | Lot re-check on quote-to-sale convert |
PdfModule | PDF generation, print preview, template data |
SalesModule | Quote-to-sale conversion |