@invoicing-sdk/domain
Version:
Core domain logic for the invoicing system including entities, value objects, services, and events
1 lines • 108 kB
Source Map (JSON)
{"version":3,"sources":["../src/value-objects/Money.ts","../src/value-objects/CustomerInfo.ts","../src/value-objects/OrderItem.ts","../src/types/errors.ts","../src/types/shared.ts","../src/types/index.ts","../src/value-objects/ShippingCost.ts","../src/events/InvoiceEvents.ts","../src/events/index.ts","../src/services/InvoiceIdGenerator.default.ts","../src/entities/aggregate-root.ts","../src/entities/InvoiceDraft.ts","../src/entities/Invoice.ts","../src/value-objects/index.ts","../src/value-objects/Financials.ts","../src/index.ts","../src/entities/index.ts","../src/services/InvoiceNumberValidator.ts","../src/services/PDFRenderer.ts","../src/services/InvoiceNumberGenerator.ts"],"sourcesContent":["import type { Currency } from \"./Currency\";\n\nexport type MoneyPlainObject = { amount: number; currency: string };\n\n/**\n * Money value object representing monetary amounts with currency\n * Stores amounts in cents to avoid floating point precision issues\n */\nexport class Money {\n private static readonly EUR_RATES: Record<Currency, number> = {\n EUR: 1,\n USD: 0.92,\n };\n\n /**\n * Constructs a new Money value with 0 amount.\n * @param currency The currency to create the menu in.\n */\n static zero(currency: Currency): Money {\n return new Money(0, currency);\n }\n\n constructor(\n public readonly amount: number, // in cents\n public readonly currency: Currency\n ) {\n if (amount < 0) {\n throw new Error(\"Money amount cannot be negative\");\n }\n if (!currency || currency.length !== 3) {\n throw new Error(\"Currency must be a valid 3-letter code\");\n }\n if (!Number.isInteger(amount)) {\n throw new Error(\"Money amount must be an integer (cents)\");\n }\n }\n\n /**\n * Convert this money amount to EUR\n */\n toEUR(): number {\n const rate = Money.EUR_RATES[this.currency];\n if (!rate) {\n throw new Error(`Unsupported currency: ${this.currency}`);\n }\n return Math.round(this.amount * rate);\n }\n\n /**\n * Create Money from EUR amount\n */\n static fromEUR(amount: number, targetCurrency: Currency): Money {\n const rate = Money.EUR_RATES[targetCurrency];\n if (!rate) {\n throw new Error(`Unsupported currency: ${targetCurrency}`);\n }\n const convertedAmount = Math.round(amount / rate);\n return new Money(convertedAmount, targetCurrency);\n }\n\n /**\n * Add two money amounts (must be same currency)\n */\n add(...others: Money[]): Money {\n const currency = this.currency;\n let total: number = this.amount;\n for (const other of others) {\n if (this.currency !== other.currency) {\n throw new Error(\"Cannot add money with different currencies\");\n }\n total += other.amount;\n }\n return new Money(total, currency);\n }\n\n /**\n * Subtract two money amounts (must be same currency)\n */\n subtract(other: Money): Money {\n if (this.currency !== other.currency) {\n throw new Error(\"Cannot subtract money with different currencies\");\n }\n const result = this.amount - other.amount;\n if (result < 0) {\n throw new Error(\"Subtraction would result in negative amount\");\n }\n return new Money(result, this.currency);\n }\n\n /**\n * Multiply money by a factor\n */\n multiply(factor: number): Money {\n if (factor < 0) {\n throw new Error(\"Cannot multiply money by negative factor\");\n }\n return new Money(Math.round(this.amount * factor), this.currency);\n }\n\n /**\n * Check if two money amounts are equal\n */\n equals(other: Money): boolean {\n return this.amount === other.amount && this.currency === other.currency;\n }\n\n /**\n * Get the amount in the major currency unit (e.g., euros instead of cents)\n */\n toMajorUnit(): number {\n return this.amount / 100;\n }\n\n /**\n * Create Money from major currency unit\n */\n static fromMajorUnit(amount: number, currency: Currency): Money {\n return new Money(Math.round(amount * 100), currency);\n }\n\n /**\n * String representation\n */\n toString(): string {\n return `${this.toMajorUnit().toFixed(2)} ${this.currency}`;\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): { amount: number; currency: string } {\n return {\n amount: this.amount,\n currency: this.currency,\n };\n }\n}\n","/**\n * CustomerInfo value object representing customer data snapshot\n * Immutable snapshot of customer information at invoice creation time\n */\nexport class CustomerInfo {\n constructor(\n public readonly firstName: string,\n public readonly lastName: string,\n public readonly street: string,\n public readonly zip: string,\n public readonly city: string,\n public readonly country: string,\n public readonly email: string,\n public readonly phone?: string\n ) {\n this.validate();\n }\n\n private validate(): void {\n if (!this.firstName?.trim()) {\n throw new Error('First name is required');\n }\n if (!this.lastName?.trim()) {\n throw new Error('Last name is required');\n }\n if (!this.street?.trim()) {\n throw new Error('Street address is required');\n }\n if (!this.zip?.trim()) {\n throw new Error('ZIP code is required');\n }\n if (!this.city?.trim()) {\n throw new Error('City is required');\n }\n if (!this.country?.trim()) {\n throw new Error('Country is required');\n }\n if (!this.email?.trim()) {\n throw new Error('Email is required');\n }\n\n // Validate email format\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n if (!emailRegex.test(this.email)) {\n throw new Error('Invalid email format');\n }\n\n // Validate phone format if provided\n if (this.phone && this.phone.trim()) {\n const phoneRegex = /^[\\+]?[0-9\\s\\-\\(\\)]{7,20}$/;\n if (!phoneRegex.test(this.phone.trim())) {\n throw new Error('Invalid phone format');\n }\n }\n\n // Validate ZIP code format (basic validation)\n if (this.zip.length < 3 || this.zip.length > 10) {\n throw new Error('ZIP code must be between 3 and 10 characters');\n }\n\n // Validate country code (ISO 3166-1 alpha-2 or full name)\n if (this.country.length < 2) {\n throw new Error('Country must be at least 2 characters');\n }\n }\n\n /**\n * Get full name\n */\n getFullName(): string {\n return `${this.firstName} ${this.lastName}`;\n }\n\n /**\n * Get formatted address\n */\n getFormattedAddress(): string {\n return `${this.street}\\n${this.zip} ${this.city}\\n${this.country}`;\n }\n\n /**\n * Check if two customer info objects are equal\n */\n equals(other: CustomerInfo): boolean {\n return (\n this.firstName === other.firstName &&\n this.lastName === other.lastName &&\n this.street === other.street &&\n this.zip === other.zip &&\n this.city === other.city &&\n this.country === other.country &&\n this.email === other.email &&\n this.phone === other.phone\n );\n }\n\n /**\n * Create a copy with updated fields\n */\n update(updates: Partial<Omit<CustomerInfo, 'validate' | 'getFullName' | 'getFormattedAddress' | 'equals' | 'update'>>): CustomerInfo {\n return new CustomerInfo(\n updates.firstName ?? this.firstName,\n updates.lastName ?? this.lastName,\n updates.street ?? this.street,\n updates.zip ?? this.zip,\n updates.city ?? this.city,\n updates.country ?? this.country,\n updates.email ?? this.email,\n updates.phone ?? this.phone\n );\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): {\n firstName: string;\n lastName: string;\n street: string;\n zip: string;\n city: string;\n country: string;\n email: string;\n phone?: string;\n } {\n const result: {\n firstName: string;\n lastName: string;\n street: string;\n zip: string;\n city: string;\n country: string;\n email: string;\n phone?: string;\n } = {\n firstName: this.firstName,\n lastName: this.lastName,\n street: this.street,\n zip: this.zip,\n city: this.city,\n country: this.country,\n email: this.email,\n };\n \n if (this.phone !== undefined) {\n result.phone = this.phone;\n }\n \n return result;\n }\n\n /**\n * Create from plain object\n */\n static fromPlainObject(data: {\n firstName: string;\n lastName: string;\n street: string;\n zip: string;\n city: string;\n country: string;\n email: string;\n phone?: string;\n }): CustomerInfo {\n return new CustomerInfo(\n data.firstName,\n data.lastName,\n data.street,\n data.zip,\n data.city,\n data.country,\n data.email,\n data.phone\n );\n }\n}","import type { Currency } from \"./Currency.js\";\nimport { Money, type MoneyPlainObject } from \"./Money.js\";\n\n/**\n * OrderItem value object representing a line item in an order/invoice\n * Immutable snapshot of order item data with price calculations\n */\nexport class OrderItem {\n public readonly linePrice: Money;\n\n constructor(\n public readonly unitPrice: Money,\n public readonly quantity: number,\n public readonly name: string,\n public readonly variant?: string | undefined,\n public readonly productNumber?: string | undefined\n ) {\n this.validate();\n this.linePrice = this.calculateLinePrice();\n }\n\n private validate(): void {\n if (!this.name?.trim()) {\n throw new Error(\"Item name is required\");\n }\n if (!Number.isInteger(this.quantity) || this.quantity <= 0) {\n throw new Error(\"Quantity must be a positive integer\");\n }\n if (this.unitPrice.amount <= 0) {\n throw new Error(\"Unit price must be positive\");\n }\n if (this.productNumber !== undefined && !this.productNumber.trim()) {\n throw new Error(\"Product number cannot be empty string\");\n }\n if (this.variant !== undefined && !this.variant.trim()) {\n throw new Error(\"Variant cannot be empty string\");\n }\n }\n\n private calculateLinePrice(): Money {\n return this.unitPrice.multiply(this.quantity);\n }\n\n /**\n * Get the total price for this line item\n */\n getTotalPrice(): Money {\n return this.linePrice;\n }\n\n /**\n * Get display name including variant if present\n */\n getDisplayName(): string {\n if (this.variant) {\n return `${this.name} (${this.variant})`;\n }\n return this.name;\n }\n\n /**\n * Get full item description including product number\n */\n getFullDescription(): string {\n let description = this.getDisplayName();\n if (this.productNumber) {\n description = `${this.productNumber} - ${description}`;\n }\n return description;\n }\n\n /**\n * Check if two order items are equal\n */\n equals(other: OrderItem): boolean {\n return (\n this.unitPrice.equals(other.unitPrice) &&\n this.quantity === other.quantity &&\n this.name === other.name &&\n this.variant === other.variant &&\n this.productNumber === other.productNumber\n );\n }\n\n /**\n * Create a copy with updated quantity\n */\n withQuantity(newQuantity: number): OrderItem {\n return new OrderItem(\n this.unitPrice,\n newQuantity,\n this.name,\n this.variant,\n this.productNumber\n );\n }\n\n /**\n * Create a copy with updated unit price\n */\n withUnitPrice(newUnitPrice: Money): OrderItem {\n return new OrderItem(\n newUnitPrice,\n this.quantity,\n this.name,\n this.variant,\n this.productNumber\n );\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): {\n unitPrice: { amount: number; currency: string };\n quantity: number;\n linePrice: { amount: number; currency: string };\n name: string;\n variant?: string;\n productNumber?: string;\n } {\n const result: {\n unitPrice: { amount: number; currency: string };\n quantity: number;\n linePrice: { amount: number; currency: string };\n name: string;\n variant?: string;\n productNumber?: string;\n } = {\n unitPrice: {\n amount: this.unitPrice.amount,\n currency: this.unitPrice.currency,\n },\n quantity: this.quantity,\n linePrice: {\n amount: this.linePrice.amount,\n currency: this.linePrice.currency,\n },\n name: this.name,\n };\n\n if (this.variant !== undefined) {\n result.variant = this.variant;\n }\n\n if (this.productNumber !== undefined) {\n result.productNumber = this.productNumber;\n }\n\n return result;\n }\n\n /**\n * Create from plain object\n */\n static fromPlainObject(data: {\n unitPrice: MoneyPlainObject;\n quantity: number;\n name: string;\n variant?: string;\n productNumber?: string;\n }): OrderItem {\n const unitPrice = new Money(\n data.unitPrice.amount,\n data.unitPrice.currency as Currency\n );\n return new OrderItem(\n unitPrice,\n data.quantity,\n data.name,\n data.variant,\n data.productNumber\n );\n }\n}\n","import { InvoiceFinancials, Money } from \"../value-objects\";\nimport { InvoiceStatus } from \"./shared\";\n\n/**\n * Base domain error class\n */\nexport abstract class DomainError extends Error {\n constructor(\n message: string,\n public readonly code: ErrorCode,\n public readonly details?: Record<string, any>\n ) {\n super(message);\n this.name = this.constructor.name;\n }\n}\n\n/**\n * Invoice-specific domain errors\n */\nexport class InvalidInvoiceNumberError extends DomainError {\n constructor(invoiceNumber: string, reason?: string) {\n super(\n `Invalid invoice number: ${invoiceNumber}${reason ? ` - ${reason}` : \"\"}`,\n ErrorCode.INVALID_INVOICE_NUMBER,\n { invoiceNumber, reason }\n );\n }\n}\n\nexport class DuplicateInvoiceNumberError extends DomainError {\n constructor(invoiceNumber: string) {\n super(\n `Invoice number already exists: ${invoiceNumber}`,\n ErrorCode.DUPLICATE_INVOICE_NUMBER,\n { invoiceNumber }\n );\n }\n}\n\nexport class InvoiceTotalMismatchError extends DomainError {\n constructor(\n expected: Money,\n actual: Money,\n because?: Omit<InvoiceFinancials, \"grandTotal\">\n ) {\n super(\n `Invoice totals do not match: expected ${expected.toString()}, got ${actual.toString()}`,\n ErrorCode.TOTAL_MISMATCH,\n {\n expected: expected.toString(),\n actual: actual.toString(),\n because: because\n ? JSON.stringify(\n {\n subtotal: because.subtotal.toString(),\n taxes: because.taxes.toString(),\n shippingCost: because.shippingCost.toString(),\n discount: because.discount.toString(),\n },\n null,\n 2\n )\n : undefined,\n }\n );\n }\n}\n\n/**\n * Error thrown when an invoice draft cannot be found for a given order.\n *\n * @remarks\n * This error is typically used in scenarios where an operation expects an existing invoice draft\n * associated with a specific order, but none is found in the system, e.g. when issuing an invoice.\n *\n * @extends DomainError\n *\n * @param orderId - The identifier of the order for which the invoice draft was not found.\n *\n * @example\n * ```typescript\n * throw new InvoiceDraftNotFoundError('order-123');\n * ```\n */\nexport class InvoiceDraftNotFoundError extends DomainError {\n constructor(orderId: string) {\n super(\n `Invoice draft not found for order: ${orderId}`,\n ErrorCode.INVOICE_DRAFT_NOT_FOUND,\n { orderId }\n );\n }\n}\n\n/**\n * @deprecated An invoice that already exists is likely an invoice with the same invoice number.\n * Therefore, use {@link DuplicateInvoiceNumberError} instead.\n * This error will be removed in the next major version.\n */\nexport class InvoiceAlreadyExistsError extends DomainError {\n constructor(orderId: string) {\n super(\n `Invoice already exists for order: ${orderId}`,\n ErrorCode.INVOICE_ALREADY_EXISTS,\n { orderId }\n );\n }\n}\n\nexport class InvoiceIntegrityCheckError extends DomainError {\n constructor(\n readonly invoiceId: string,\n readonly reason: \"INVOICE_NUMBER_MISMATCH\",\n readonly reasonDetails?: string | Record<string, string | number>\n ) {\n super(\n `Invoice ${invoiceId} is not in a valid state! ${reason}${\n reasonDetails ? ` - ${JSON.stringify(reasonDetails)}` : \"\"\n }`,\n ErrorCode.INVOICE_STATE_VIOLATION,\n { invoiceId, reason, reasonDetails }\n );\n }\n}\n\nexport class InvoiceStateViolationError extends DomainError {\n constructor(\n invoiceId: string,\n expected: InvoiceStatus,\n received: InvoiceStatus\n ) {\n super(\n `Invoice ${invoiceId} has unexpected state: Expected ${expected} but received ${received}`,\n ErrorCode.INVOICE_STATE_VIOLATION,\n { invoiceId, expected, received }\n );\n }\n}\n\nexport class InvoiceTransitionFailedError extends DomainError {\n constructor(\n invoiceId: string,\n fromStatus: InvoiceStatus,\n toStatus: InvoiceStatus\n ) {\n super(\n `Invoice ${invoiceId} cannot transition from ${fromStatus} to ${toStatus}`,\n ErrorCode.TRANSITION_VIOLATION,\n { invoiceId, fromStatus, toStatus }\n );\n }\n}\n\nexport class OrderNotFoundError extends DomainError {\n constructor(orderId: string) {\n super(`Order not found: ${orderId}`, ErrorCode.ORDER_NOT_FOUND, {\n orderId,\n });\n }\n}\n\n/**\n * Error codes for domain errors\n */\nexport enum ErrorCode {\n INVALID_INVOICE_NUMBER = \"INVALID_INVOICE_NUMBER\",\n DUPLICATE_INVOICE_NUMBER = \"DUPLICATE_INVOICE_NUMBER\",\n TOTAL_MISMATCH = \"TOTAL_MISMATCH\",\n ORDER_NOT_FOUND = \"ORDER_NOT_FOUND\",\n INVOICE_ALREADY_EXISTS = \"INVOICE_ALREADY_EXISTS\",\n INVALID_CUSTOMER_DATA = \"INVALID_CUSTOMER_DATA\",\n INVALID_ORDER_ITEMS = \"INVALID_ORDER_ITEMS\",\n PDF_GENERATION_FAILED = \"PDF_GENERATION_FAILED\",\n SHIPPING_COST_NEGATIVE = \"SHIPPING_COST_NEGATIVE\",\n TRANSITION_VIOLATION = \"TRANSITION_VIOLATION\",\n INVOICE_STATE_VIOLATION = \"INVOICE_STATE_VIOLATION\",\n INVOICE_DRAFT_NOT_FOUND = \"INVOICE_DRAFT_NOT_FOUND\",\n}\n","import type { InvoiceDraft } from \"../entities/InvoiceDraft.js\";\nimport { CustomerInfo } from \"../value-objects/CustomerInfo.js\";\nimport { Money } from \"../value-objects/Money.js\";\nimport { OrderItem } from \"../value-objects/OrderItem.js\";\nimport type { ShippingCost } from \"../value-objects/ShippingCost.js\";\n\n/**\n * Unique identifier type for entities\n */\nexport type UUID = string;\n\n/**\n * Represents the data required to issue a new invoice.\n *\n * This type includes only the fields of {@link InvoiceDraft}, omitting any methods\n * and some fields that are not relevant for the issuance process.\n *\n * The alias exists just to provide clarity to the meaning in the domain model.\n */\nexport type IssueInvoiceData = Omit<\n {\n [K in keyof InvoiceDraft as InvoiceDraft[K] extends Function\n ? never\n : K]: InvoiceDraft[K];\n },\n \"invoiceId\" | \"createdOn\" | \"pdfBytes\"\n> & { status: InvoiceStatus.DRAFT };\n\n/**\n * Represents the data required to create a new invoice.\n *\n * @deprecated Use {@link IssueInvoiceData} instead. \"Issue Invoice\" is the preferred terminology\n * as it aligns with real-world accounting language where invoices are issued to customers.\n * This alias will be removed in the next major version.\n *\n * This type includes only the fields of {@link InvoiceDraft}, omitting any methods\n * and some fields that are not relevant for the creation process.\n *\n * The alias exists just to provide clarity to the meaning in the domain model.\n */\nexport type CreateInvoiceData = IssueInvoiceData;\n\n/**\n * Order entity interface (read-only reference)\n */\nexport interface Order {\n readonly orderId: string;\n readonly customer: CustomerInfo;\n readonly items: OrderItem[];\n readonly subtotal: Money;\n readonly taxes: Money;\n /**\n * @deprecated This value is ignored and exists for migration purposes only. The domain enforces its own Shipping policies. **Expected version of removal: 0.2.0**\n */\n readonly shippingCost: ShippingCost;\n readonly discount: Money;\n readonly status: OrderStatus;\n readonly createdAt: Date;\n}\n\n/**\n * Order status enumeration\n */\nexport enum OrderStatus {\n PENDING = \"pending\",\n CONFIRMED = \"confirmed\",\n SHIPPED = \"shipped\",\n DELIVERED = \"delivered\",\n CANCELLED = \"cancelled\",\n}\n\n/**\n * Invoice status enumeration\n */\nexport enum InvoiceStatus {\n DRAFT = \"draft\",\n FINALIZED = \"finalized\",\n}\n\n/**\n * Pagination result wrapper\n */\nexport interface PaginatedResult<T> {\n readonly items: T[];\n readonly totalCount: number;\n readonly page: number;\n readonly limit: number;\n readonly hasNextPage: boolean;\n readonly hasPreviousPage: boolean;\n}\n\n/**\n * Base domain event interface\n */\nexport interface DomainEvent {\n readonly eventId: UUID;\n readonly eventType: string;\n readonly aggregateId: UUID;\n readonly occurredOn: Date;\n readonly version: 1;\n}\n\n/**\n * Utility type for making properties optional\n */\nexport type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;\n\n/**\n * Utility type for making properties required\n */\nexport type Required<T, K extends keyof T> = T & { [P in K]-?: T[P] };\n\n/**\n * An interface that contains functions to make a {@link DomainEvent}\n * serializable.\n */\nexport interface SerializableEvent<T extends DomainEvent> {\n toPlainObject(): PlainEvent<T>;\n}\n\n/**\n * Utility type for serializing a DomainEvent to a plain object.\n *\n * For example, it requires `occurredOn` to be a string instead of a Date.\n */\nexport type PlainEvent<T extends DomainEvent> = Omit<\n T,\n \"occurredOn\" | \"toPlainObject\"\n> & {\n occurredOn: string;\n};\n","export * from \"./errors\";\nexport * from \"./shared.js\";\n","import type {\n ShippingCostPolicy,\n ShippingRateProvider,\n} from \"../policies/Shipping.js\";\nimport { DomainError, ErrorCode } from \"../types/index.js\";\nimport { Money, type MoneyPlainObject } from \"./Money.js\";\n\nexport type ShippingCostRule = \"NON_NEGATIVE\";\n\nexport class ShippingCostValidationError extends DomainError {\n constructor(rule: ShippingCostRule) {\n let message = \"Validation failed\";\n switch (rule) {\n case \"NON_NEGATIVE\":\n message = \"Shipping cost must be non-negative\";\n break;\n }\n\n super(\n `Shipping costs are not valid: ${message}`,\n ErrorCode.SHIPPING_COST_NEGATIVE\n );\n }\n}\n\n/**\n * Shipping cost extends Money and enforces special business rules,\n * namely custom shipping policies and dynamic shipping rates.\n *\n * It has to extend Money so that it can be used in combination\n * with the Money arithmetics, e.g. for grand total calculation.\n */\nexport class ShippingCost extends Money {\n /**\n * Determines the shipping cost based on an order's grand total value.\n * Due to business rules, shipping is applied (or not applied) based on the grand total.\n * @param grandTotal The order's grand total value\n * @param shippingRates Shipping rates provider that provides the costs\n * @param shippingPolicy Policy that influences the way costs are calculated\n * @returns Shipping costs based on business rules, policies and rates.\n */\n static determine(\n grandTotal: Money,\n shippingRates: ShippingRateProvider,\n shippingPolicy: ShippingCostPolicy\n ): ShippingCost {\n if (shippingPolicy.isFreeShippingAllowed(grandTotal)) {\n return new ShippingCost(Money.zero(\"EUR\"));\n }\n\n return new ShippingCost(shippingRates.getStandardShippingRate());\n }\n\n constructor(readonly cost: Money) {\n super(cost.amount, cost.currency);\n if (!this.#validate()) {\n throw new ShippingCostValidationError(\"NON_NEGATIVE\");\n }\n }\n\n /**\n * Validates the shipping cost. It doesn't allow the amount to be less than 0.\n */\n #validate(): boolean {\n return this.cost.amount >= 0;\n }\n}\n","import { v4 as uuidv4 } from \"uuid\";\nimport {\n PlainEvent,\n SerializableEvent,\n type DomainEvent,\n type UUID,\n} from \"../types/index.js\";\nimport { Money } from \"../value-objects/Money.js\";\n\n/**\n * Base class for invoice-related domain events\n */\nabstract class InvoiceEvent implements DomainEvent {\n constructor(\n public readonly eventId: UUID,\n public readonly eventType: string,\n public readonly aggregateId: UUID,\n public readonly occurredOn: Date,\n public readonly version: 1,\n public readonly invoiceId: UUID\n ) {}\n}\n\n/**\n * Domain event published when an invoice is issued\n * Contains essential information for downstream processing\n */\nexport class InvoiceIssuedEvent extends InvoiceEvent {\n public override readonly eventType = \"InvoiceIssued\" as const;\n\n constructor(\n eventId: UUID,\n aggregateId: UUID,\n occurredOn: Date,\n version: 1,\n public override readonly invoiceId: UUID,\n public readonly orderId: string,\n public readonly invoiceNumber: string,\n public readonly grandTotal: Money,\n public readonly pdfBytes: Uint8Array,\n public readonly issuedBy: string\n ) {\n super(\n eventId,\n \"InvoiceIssued\",\n aggregateId,\n occurredOn,\n version,\n invoiceId\n );\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): {\n eventId: string;\n eventType: \"InvoiceIssued\";\n aggregateId: string;\n occurredOn: string;\n version: 1;\n invoiceId: string;\n orderId: string;\n invoiceNumber: string;\n grandTotal: { amount: number; currency: string };\n issuedBy: string;\n // Note: pdfBytes excluded from serialization for size reasons\n } {\n return {\n eventId: this.eventId,\n eventType: this.eventType,\n aggregateId: this.aggregateId,\n occurredOn: this.occurredOn.toISOString(),\n version: this.version,\n invoiceId: this.invoiceId,\n orderId: this.orderId,\n invoiceNumber: this.invoiceNumber,\n grandTotal: this.grandTotal.toPlainObject(),\n issuedBy: this.issuedBy,\n };\n }\n}\n\n/**\n * Domain event published when an invoice is created\n * Contains essential information for downstream processing\n * \n * @deprecated Use {@link InvoiceIssuedEvent} instead. \"Issue Invoice\" is the preferred terminology\n * as it aligns with real-world accounting language where invoices are issued to customers.\n * This event will be removed in the next major version.\n */\nexport class InvoiceCreatedEvent extends InvoiceEvent {\n public override readonly eventType = \"InvoiceCreated\" as const;\n\n constructor(\n eventId: UUID,\n aggregateId: UUID,\n occurredOn: Date,\n version: 1,\n public override readonly invoiceId: UUID,\n public readonly orderId: string,\n public readonly invoiceNumber: string,\n public readonly grandTotal: Money,\n public readonly pdfBytes: Uint8Array,\n public readonly createdBy: string\n ) {\n super(\n eventId,\n \"InvoiceCreated\",\n aggregateId,\n occurredOn,\n version,\n invoiceId\n );\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): {\n eventId: string;\n eventType: \"InvoiceCreated\";\n aggregateId: string;\n occurredOn: string;\n version: 1;\n invoiceId: string;\n orderId: string;\n invoiceNumber: string;\n grandTotal: { amount: number; currency: string };\n createdBy: string;\n // Note: pdfBytes excluded from serialization for size reasons\n } {\n return {\n eventId: this.eventId,\n eventType: this.eventType,\n aggregateId: this.aggregateId,\n occurredOn: this.occurredOn.toISOString(),\n version: this.version,\n invoiceId: this.invoiceId,\n orderId: this.orderId,\n invoiceNumber: this.invoiceNumber,\n grandTotal: this.grandTotal.toPlainObject(),\n createdBy: this.createdBy,\n };\n }\n}\n\n/**\n * Domain event published when an invoice is requested.\n *\n * Contains essential information for downstream processing.\n */\nexport class InvoiceRequested\n implements DomainEvent, SerializableEvent<InvoiceRequested>\n{\n readonly eventType: string = \"InvoiceRequested\";\n constructor(\n public eventId: UUID,\n public aggregateId: UUID,\n public occurredOn: Date,\n public version: 1,\n public readonly orderId: string,\n public readonly requestedBy: string,\n public readonly invoiceNumber: string\n ) {}\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): PlainEvent<InvoiceRequested> {\n return {\n eventId: this.eventId,\n eventType: this.eventType,\n aggregateId: this.aggregateId,\n occurredOn: this.occurredOn.toISOString(),\n version: this.version,\n orderId: this.orderId,\n requestedBy: this.requestedBy,\n invoiceNumber: this.invoiceNumber,\n };\n }\n}\n\n/**\n * Domain event published when an invoice PDF is generated\n * Separate from creation to handle PDF generation failures\n */\nexport class InvoicePdfGeneratedEvent extends InvoiceEvent {\n public override readonly eventType = \"InvoicePdfGenerated\" as const;\n\n constructor(\n eventId: UUID,\n aggregateId: UUID,\n occurredOn: Date,\n version: 1,\n public override readonly invoiceId: UUID,\n public readonly invoiceNumber: string,\n public readonly pdfSize: number,\n public readonly pdfBytes: Uint8Array\n ) {\n super(\n eventId,\n \"InvoicePdfGenerated\",\n aggregateId,\n occurredOn,\n version,\n invoiceId\n );\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): {\n eventId: string;\n eventType: \"InvoicePdfGenerated\";\n aggregateId: string;\n occurredOn: string;\n version: 1;\n invoiceId: string;\n invoiceNumber: string;\n pdfSize: number;\n // Note: pdfBytes excluded from serialization for size reasons\n } {\n return {\n eventId: this.eventId,\n eventType: this.eventType,\n aggregateId: this.aggregateId,\n occurredOn: this.occurredOn.toISOString(),\n version: this.version,\n invoiceId: this.invoiceId,\n invoiceNumber: this.invoiceNumber,\n pdfSize: this.pdfSize,\n };\n }\n}\n\n/**\n * Domain event published when an invoice validation fails\n * Used for monitoring and alerting purposes\n */\nexport class InvoiceValidationFailedEvent extends InvoiceEvent {\n public override readonly eventType = \"InvoiceValidationFailed\" as const;\n\n constructor(\n eventId: UUID,\n aggregateId: UUID,\n occurredOn: Date,\n version: 1,\n public override readonly invoiceId: UUID,\n public readonly orderId: string,\n public readonly invoiceNumber: string,\n public readonly validationErrors: string[],\n public readonly attemptedBy: string\n ) {\n super(\n eventId,\n \"InvoiceValidationFailed\",\n aggregateId,\n occurredOn,\n version,\n invoiceId\n );\n }\n\n /**\n * Convert to plain object for serialization\n */\n toPlainObject(): {\n eventId: string;\n eventType: \"InvoiceValidationFailed\";\n aggregateId: string;\n occurredOn: string;\n version: 1;\n invoiceId: string;\n orderId: string;\n invoiceNumber: string;\n validationErrors: string[];\n attemptedBy: string;\n } {\n return {\n eventId: this.eventId,\n eventType: this.eventType,\n aggregateId: this.aggregateId,\n occurredOn: this.occurredOn.toISOString(),\n version: this.version,\n invoiceId: this.invoiceId,\n orderId: this.orderId,\n invoiceNumber: this.invoiceNumber,\n validationErrors: [...this.validationErrors],\n attemptedBy: this.attemptedBy,\n };\n }\n}\n\n/**\n * Generates a new event ID as a uuidv4 string.\n * @returns A UUID string\n */\nexport function generateEventId(): string {\n return uuidv4();\n}\n","export {\n InvoiceIssuedEvent,\n InvoiceCreatedEvent,\n InvoicePdfGeneratedEvent,\n InvoiceValidationFailedEvent,\n InvoiceRequested,\n generateEventId,\n} from \"./InvoiceEvents.js\";\nexport { DomainEventPublisher } from \"./DomainEventPublisher.js\";\n","// You could come up with \"But doesn't it break the rule that the domain should not depend on external packages?\"\n// And yes, true. As long as it doesn't meet strict business rule enforcement needs.\n// And one important business rule we define is: uuidv4 ids for invoices.\nimport { v4 as uuidv4 } from \"uuid\";\nimport { InvoiceIdGenerator } from \"./InvoiceIdGenerator\";\n\n/**\n * Default implementation of InvoiceIdGenerator that uses `uuid` with UUIDv4 IDs.\n *\n * @see {@link InvoiceIdGenerator}\n * @since 0.4.0\n */\nexport class InvoiceIdGeneratorDefault implements InvoiceIdGenerator {\n generate(): Promise<string> {\n return Promise.resolve(uuidv4());\n }\n}\n","import { DomainEventPublisher } from \"../events/DomainEventPublisher\";\nimport { DomainEvent } from \"../types\";\n\n/**\n * Aggregate root base class that supports domain event collection\n */\nexport abstract class AggregateRoot {\n private _domainEvents: DomainEvent[] = [];\n\n /**\n * Adds a domain event to the aggregate's list of domain events.\n *\n * @typeParam T - The type of the domain event, extending `DomainEvent`.\n * @param event - The domain event to add. Must include an `eventType` property.\n */\n protected addDomainEvent<T extends DomainEvent>(\n event: T & { eventType: T[\"eventType\"] }\n ): void {\n this._domainEvents.push(event);\n }\n\n /**\n * Get all unpublished domain events\n */\n getUncommittedEvents(): DomainEvent[] {\n return [...this._domainEvents];\n }\n\n /**\n * Clear all domain events (called after publishing)\n */\n markEventsAsCommitted(): void {\n this._domainEvents = [];\n }\n\n /**\n * Check if there are unpublished events\n */\n hasUncommittedEvents(): boolean {\n return this._domainEvents.length > 0;\n }\n\n /**\n * Publishes all uncommitted events using the provided publisher,\n * and marks all domains as commited.\n *\n * It is a convenience wrapper around:\n *\n * ```typescript\n * const publisher = //...;\n * const uncommitted = this.getUncommittedEvents();\n * publisher.publishAll(uncommitted);\n * this.markEventsAsCommitted();\n * ```\n */\n publishAllUncommitted(publisher: DomainEventPublisher) {\n const uncommitted = this.getUncommittedEvents();\n publisher.publishAll(uncommitted);\n this.markEventsAsCommitted();\n }\n}\n","import { InvoiceCreatedEvent, DomainEventPublisher } from \"../events\";\nimport { InvoiceStatus, DomainEvent } from \"../types\";\nimport {\n CustomerInfo,\n OrderItem,\n MoneyPlainObject,\n InvoiceFinancials,\n} from \"../value-objects\";\nimport { Invoice } from \"./Invoice.js\";\n\n/**\n * Represents a draft version of an invoice, extending the base `Invoice` class.\n *\n * The `InvoiceDraft` class is used to create invoices that are in the draft state,\n * allowing for further modifications before finalization. It initializes the invoice\n * with the provided details and sets its status to `DRAFT`.\n *\n * @remarks\n * This class is typically used when an invoice is being prepared but has not yet been issued.\n *\n * @param invoiceId - The unique identifier for the invoice.\n * @param orderId - The identifier of the associated order.\n * @param invoiceNumber - The invoice number assigned to this draft.\n * @param customerSnapshot - A snapshot of the customer information at the time of draft creation.\n * @param itemsSnapshot - A readonly array of order items included in the invoice.\n * @param financials - The financial details associated with the invoice.\n * @param createdBy - The identifier of the user who created the draft.\n */\nexport class InvoiceDraft extends Invoice {\n constructor(\n invoiceId: string,\n orderId: string,\n invoiceNumber: string,\n customerSnapshot: CustomerInfo,\n itemsSnapshot: readonly OrderItem[],\n financials: InvoiceFinancials,\n createdBy: string\n ) {\n super(\n invoiceId,\n orderId,\n invoiceNumber,\n customerSnapshot,\n itemsSnapshot,\n financials,\n new Date(),\n createdBy,\n new Uint8Array(),\n InvoiceStatus.DRAFT\n );\n }\n}\n","import {\n InvoiceCreatedEvent,\n InvoiceIssuedEvent,\n InvoiceRequested,\n generateEventId,\n} from \"../events/index.js\";\nimport { InvoiceIdGeneratorDefault } from \"../services/InvoiceIdGenerator.default.js\";\nimport { InvoiceIdGenerator } from \"../services/InvoiceIdGenerator.js\";\nimport { InvoiceNumberGenerator } from \"../services/InvoiceNumberGenerator.js\";\nimport type { InvoiceNumberValidator } from \"../services/InvoiceNumberValidator.js\";\nimport {\n InvoiceStateViolationError,\n InvoiceStatus,\n InvoiceTotalMismatchError,\n InvoiceTransitionFailedError,\n type CreateInvoiceData,\n type IssueInvoiceData,\n type UUID,\n} from \"../types/index.js\";\nimport type { Currency } from \"../value-objects/Currency.js\";\nimport { CustomerInfo } from \"../value-objects/CustomerInfo.js\";\nimport type {\n InvoiceFinancials,\n MoneyPlainObject,\n} from \"../value-objects/index.js\";\nimport { Money } from \"../value-objects/Money.js\";\nimport { OrderItem } from \"../value-objects/OrderItem.js\";\nimport { ShippingCost } from \"../value-objects/ShippingCost.js\";\nimport { AggregateRoot } from \"./aggregate-root.js\";\nimport type { InvoiceDraft } from \"./InvoiceDraft.js\";\n\nexport type InvoiceAggregateDependencies = {\n invoiceNumberValidator: InvoiceNumberValidator;\n /**\n * The invoice ID generator.\n * If not provided, it will use {@link InvoiceIdGeneratorDefault}.\n */\n invoiceIdGenerator?: InvoiceIdGenerator;\n};\n\n/**\n * Invoice aggregate root\n * Manages invoice lifecycle, validation, and business rules\n * Immutable once created to ensure data integrity\n */\nexport class Invoice extends AggregateRoot {\n constructor(\n public readonly invoiceId: UUID,\n public readonly orderId: string,\n public readonly invoiceNumber: string,\n public readonly customerSnapshot: CustomerInfo,\n public readonly itemsSnapshot: readonly OrderItem[],\n public readonly financials: InvoiceFinancials,\n public readonly createdOn: Date,\n public readonly createdBy: string,\n public readonly pdfBytes: Uint8Array,\n private readonly status: InvoiceStatus\n ) {\n super();\n // Validate totals on construction\n this.validateTotals();\n }\n\n /**\n * Validates invoice number uniqueness\n */\n static async #validateInvoiceNumber(\n invoiceNumber: string,\n validator: InvoiceNumberValidator\n ) {\n // it will throw if not unique. this may be not ideal, should return a bool instead.\n // But for now, OK.\n await validator.validateUniqueness(invoiceNumber);\n }\n\n /**\n * Factory method to create a new invoice.\n *\n * Validates business rules and calculates totals.\n *\n * **NOTE**: The invoice information you pass must be in `DRAFT` status.\n * If that's not the case, a `InvoiceTransitionViolationError` will be thrown.\n *\n * Requires dependencies, such as the invoice number validator,\n * because it's a core business rule that they are unique.\n *\n * @deprecated Use {@link issue} instead.\n * It provides a more aligned naming convention and,\n * under the hood, this method work the same way.\n * Will be removed in the next major version.\n *\n * @see https://github.com/benjamin-kraatz/invoicing-sdk/issues/14\n */\n static async create(\n data: CreateInvoiceData,\n pdfBytes: Uint8Array,\n deps: InvoiceAggregateDependencies\n ): Promise<Invoice> {\n // until removal, just call `issue`\n return Invoice.issue(data, pdfBytes, deps);\n }\n\n /**\n * Factory method to issue a new invoice.\n *\n * Validates business rules and calculates totals.\n *\n * **NOTE**: The invoice information you pass must be in `DRAFT` status.\n * If that's not the case, a `InvoiceTransitionViolationError` will be thrown.\n *\n * Requires dependencies, such as the invoice number validator,\n * because it's a core business rule that they are unique.\n *\n * This is the preferred method for issuing invoices as it aligns with real-world\n * accounting terminology where invoices are \"issued\" to customers.\n *\n * @see {@link InvoiceNumberValidator}\n * @see {@link InvoiceStateViolationError}\n * @see {@link InvoiceAggregateDependencies}\n */\n static async issue(\n data: IssueInvoiceData,\n pdfBytes: Uint8Array,\n deps: InvoiceAggregateDependencies\n ): Promise<Invoice> {\n // Validate the status. It must be DRAFT. Because it's all typed, this should never happen.\n if (data.status !== InvoiceStatus.DRAFT) {\n throw new InvoiceStateViolationError(\n data.invoiceNumber,\n InvoiceStatus.DRAFT,\n data.status\n );\n }\n\n // First, validate the invoice number. If that fails, we can just skip all the heavy logic.\n await Invoice.#validateInvoiceNumber(\n data.invoiceNumber,\n deps.invoiceNumberValidator\n );\n\n const issuedOn = new Date();\n\n // Create immutable snapshots\n const customerSnapshot = new CustomerInfo(\n data.customerSnapshot.firstName,\n data.customerSnapshot.lastName,\n data.customerSnapshot.street,\n data.customerSnapshot.zip,\n data.customerSnapshot.city,\n data.customerSnapshot.country,\n data.customerSnapshot.email,\n data.customerSnapshot.phone\n );\n\n const itemsSnapshot = data.itemsSnapshot.map(\n (item) =>\n new OrderItem(\n item.unitPrice,\n item.quantity,\n item.name,\n item.variant,\n item.productNumber\n )\n );\n\n const invoiceId = await Invoice.generateId(deps.invoiceIdGenerator);\n\n const invoice = new Invoice(\n invoiceId,\n data.orderId,\n data.invoiceNumber,\n customerSnapshot,\n itemsSnapshot,\n data.financials,\n issuedOn,\n data.createdBy,\n pdfBytes,\n InvoiceStatus.FINALIZED\n );\n\n // Add domain event for invoice issuing\n const issuedEvent = new InvoiceIssuedEvent(\n generateEventId(),\n invoiceId,\n issuedOn,\n 1,\n invoiceId,\n data.orderId,\n data.invoiceNumber,\n data.financials.grandTotal,\n pdfBytes,\n data.createdBy\n );\n invoice.addDomainEvent(issuedEvent);\n\n return invoice;\n }\n\n /**\n * Creates a new draft invoice and emits an `InvoiceRequested` domain event.\n *\n * It creates a new invoice number (without reservation).\n * The returned invoice can be used for actual issuing.\n *\n * A draft is required before issuing an invoice.\n * @param data - The data required to create the invoice draft.\n * @param deps - The dependencies required for invoice creation, including optional `invoiceIdGenerator` and required `invoiceNumberGenerator`.\n * @returns A promise that resolves to the invoice draft when the draft invoice is created and the domain event is added.\n *\n * @see {@link InvoiceIdGenerator}\n * @see {@link InvoiceNumberGenerator}\n */\n static async createDraft(\n // We don't need the status, as this is always DRAFT\n data: Omit<IssueInvoiceData, \"invoiceNumber\" | \"status\">,\n deps: {\n invoiceIdGenerator?: InvoiceIdGenerator;\n invoiceNumberGenerator: InvoiceNumberGenerator;\n }\n ): Promise<InvoiceDraft> {\n // Dynamic import to avoid circular dependency at module initialization time\n const { InvoiceDraft } = await import(\"./InvoiceDraft.js\");\n const id = await Invoice.generateId(deps.invoiceIdGenerator);\n // Don't generateAndReserve; just generate to not persist. A draft invoice nbr must not be persisted yet.\n const invoiceNumber = await deps.invoiceNumberGenerator.generate();\n const invoiceDraft = new InvoiceDraft(\n id,\n data.orderId,\n invoiceNumber,\n data.customerSnapshot,\n data.itemsSnapshot,\n data.financials,\n data.createdBy\n );\n\n const aggregateId = id;\n const requestedEvent = new InvoiceRequested(\n generateEventId(),\n aggregateId,\n new Date(),\n 1,\n data.orderId,\n data.createdBy,\n invoiceNumber\n );\n invoiceDraft.addDomainEvent(requestedEvent);\n\n return invoiceDraft;\n }\n\n private static async generateId(\n invoiceIdGenerator?: InvoiceIdGenerator\n ): Promise<string> {\n return (invoiceIdGenerator ?? new InvoiceIdGeneratorDefault()).generate();\n }\n\n /**\n * Transitions the invoice to the given status in a type-safe way.\n *\n * - To transition to FINALIZED, you must provide PDF bytes.\n * - To transition to DRAFT, you must not provide PDF bytes.\n *\n * @param args - Discriminated union for status and pdfBytes.\n * @returns A new invoice instance with the updated status.\n * @throws InvoiceTransitionFailedError when the transition is not allowed\n *\n * @deprecated In preparation to an upcoming issue fix, the {@link Invoice.create}\n * method will be renamed and makes the `transitionTo` function obsolete.\n * Consider calling {@link Invoice.create} directly.\n * This function will be removed in the next major release.\n */\n transitionTo(\n args:\n | { status: InvoiceStatus.FINALIZED; pdfBytes: Uint8Array }\n | { status: InvoiceStatus.DRAFT }\n ): Invoice {\n const current = this.status;\n const { status } = args;\n\n if (current === InvoiceStatus.FINALIZED && status === InvoiceStatus.DRAFT) {\n throw new InvoiceTransitionFailedError(this.invoiceId, current, status);\n } else if (current === status) {\n throw new InvoiceTransitionFailedError(this.invoiceId, current, status);\n }\n\n if (status === InvoiceStatus.FINALIZED) {\n // TypeScript ensures pdfBytes is present here\n return new Invoice(\n this.invoiceId,\n this.orderId,\n this.invoiceNumber,\n this.customerSnapshot,\n this.itemsSnapshot,\n this.financials,\n this.createdOn,\n this.createdBy,\n args.pdfBytes,\n status\n );\n } else if (status === InvoiceStatus.DRAFT) {\n // No pdfBytes allowed in this branch\n return new Invoice(\n this.invoiceId,\n this.orderId,\n this.invoiceNumber,\n this.customerSnapshot,\n this.itemsSnapshot,\n this.financials,\n this.createdOn,\n this.createdBy,\n this.pdfBytes,\n status\n );\n } else {\n throw new InvoiceTransitionFailedError(this.invoiceId, current, status);\n }\n }\n\n /**\n * Checks if the current invoice status matches the specified status.\n *\n * @param status - The status to compare with the invoice's current status.\n * @returns `true` if the invoice's status matches the specified status, otherwise `false`.\n */\n requireStatus(status: InvoiceStatus): boolean {\n return this.status === status;\n }\n\n /**\n * Returns the current status of the invoice.\n *\n * @returns {InvoiceStatus} The status of the invoice.\n */\n getStatus(): InvoiceStatus {\n return this.status;\n }\n\n /**\n * Validates that invoice totals are mathematically correct\n * Business rule: grandTotal = subtotal + taxes + shippingCost - discount\n */\n validateTotals(): void {\n const { subtotal, taxes, shippingCost, discount, grandTotal } =\n this.financials;\n\n // Ensure all amounts are in the same currency\n const currency = subtotal.currency;\n if (\n taxes.currency !== currency ||\n shippingCost.currency !== currency ||\n discount.currency !== currency ||\n grandTotal.currency !== currency\n ) {\n throw new Error(\"All financial amounts must be in the same currency\");\n }\n\n // Calculate expected grand total\n const calculatedTotal = subtotal\n .add(taxes)\n .add(shippingCost)\n .subtract(discount);\n\n if (!calculatedTotal.equals(grandTotal)) {\n throw new InvoiceTotalMismatchError(calculatedTotal, grandTotal, {\n subtotal,\n taxes,\n shippingCost,\n discount,\n });\n }\n\n // Validate that subtotal matches sum of line items\n const itemsTotal = this.itemsSnapshot.reduce(\n (total, item) => total.add(item.linePrice),\n new Money(0, currency)\n );\n\n if (!itemsTotal.equals(subtotal)) {\n throw new InvoiceTotalMismatchError(itemsTotal, subtotal);\n }\n }\n\n /**\n * Creates the InvoiceIssuedEvent domain event\n */\n toDomainEvent(): InvoiceIssuedEvent {\n return new InvoiceIssuedEvent(\n generateEventId(),\n this.invoiceId,\n new Date(),\n 1,\n this.invoiceId,\n this.orderId,\n this.invoiceNumber,\n this.financials.grandTotal,\n this.pdfBytes,\n this.createdBy\n );\n }\n\n /**\n * Get the total number of items in the invoice\n */\n getTotalItemCount(): number {\n return this.itemsSnapshot.reduce((total, item) => total + item.quantity, 0