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:
- Creates a DRAFT sale via
SalesService.createSale() - Carries over items, discounts, customer/tax data, and currency
- Resolves currency from stored data or falls back to business default
- Marks the quote as CONVERTED (preventing further edits or deletion)
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 |
PdfModule | PDF generation, print preview, template data |
SalesModule | Quote-to-sale conversion |