UNPKG

@invoicing-sdk/domain

Version:

Core domain logic for the invoicing system including entities, value objects, services, and events

1,530 lines (1,508 loc) 60.8 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/value-objects/Money.ts var Money; var init_Money = __esm({ "src/value-objects/Money.ts"() { "use strict"; Money = class _Money { constructor(amount, currency) { this.amount = amount; this.currency = currency; if (amount < 0) { throw new Error("Money amount cannot be negative"); } if (!currency || currency.length !== 3) { throw new Error("Currency must be a valid 3-letter code"); } if (!Number.isInteger(amount)) { throw new Error("Money amount must be an integer (cents)"); } } static EUR_RATES = { EUR: 1, USD: 0.92 }; /** * Constructs a new Money value with 0 amount. * @param currency The currency to create the menu in. */ static zero(currency) { return new _Money(0, currency); } /** * Convert this money amount to EUR */ toEUR() { const rate = _Money.EUR_RATES[this.currency]; if (!rate) { throw new Error(`Unsupported currency: ${this.currency}`); } return Math.round(this.amount * rate); } /** * Create Money from EUR amount */ static fromEUR(amount, targetCurrency) { const rate = _Money.EUR_RATES[targetCurrency]; if (!rate) { throw new Error(`Unsupported currency: ${targetCurrency}`); } const convertedAmount = Math.round(amount / rate); return new _Money(convertedAmount, targetCurrency); } /** * Add two money amounts (must be same currency) */ add(...others) { const currency = this.currency; let total = this.amount; for (const other of others) { if (this.currency !== other.currency) { throw new Error("Cannot add money with different currencies"); } total += other.amount; } return new _Money(total, currency); } /** * Subtract two money amounts (must be same currency) */ subtract(other) { if (this.currency !== other.currency) { throw new Error("Cannot subtract money with different currencies"); } const result = this.amount - other.amount; if (result < 0) { throw new Error("Subtraction would result in negative amount"); } return new _Money(result, this.currency); } /** * Multiply money by a factor */ multiply(factor) { if (factor < 0) { throw new Error("Cannot multiply money by negative factor"); } return new _Money(Math.round(this.amount * factor), this.currency); } /** * Check if two money amounts are equal */ equals(other) { return this.amount === other.amount && this.currency === other.currency; } /** * Get the amount in the major currency unit (e.g., euros instead of cents) */ toMajorUnit() { return this.amount / 100; } /** * Create Money from major currency unit */ static fromMajorUnit(amount, currency) { return new _Money(Math.round(amount * 100), currency); } /** * String representation */ toString() { return `${this.toMajorUnit().toFixed(2)} ${this.currency}`; } /** * Convert to plain object for serialization */ toPlainObject() { return { amount: this.amount, currency: this.currency }; } }; } }); // src/value-objects/CustomerInfo.ts var CustomerInfo; var init_CustomerInfo = __esm({ "src/value-objects/CustomerInfo.ts"() { "use strict"; CustomerInfo = class _CustomerInfo { constructor(firstName, lastName, street, zip, city, country, email, phone) { this.firstName = firstName; this.lastName = lastName; this.street = street; this.zip = zip; this.city = city; this.country = country; this.email = email; this.phone = phone; this.validate(); } validate() { if (!this.firstName?.trim()) { throw new Error("First name is required"); } if (!this.lastName?.trim()) { throw new Error("Last name is required"); } if (!this.street?.trim()) { throw new Error("Street address is required"); } if (!this.zip?.trim()) { throw new Error("ZIP code is required"); } if (!this.city?.trim()) { throw new Error("City is required"); } if (!this.country?.trim()) { throw new Error("Country is required"); } if (!this.email?.trim()) { throw new Error("Email is required"); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(this.email)) { throw new Error("Invalid email format"); } if (this.phone && this.phone.trim()) { const phoneRegex = /^[\+]?[0-9\s\-\(\)]{7,20}$/; if (!phoneRegex.test(this.phone.trim())) { throw new Error("Invalid phone format"); } } if (this.zip.length < 3 || this.zip.length > 10) { throw new Error("ZIP code must be between 3 and 10 characters"); } if (this.country.length < 2) { throw new Error("Country must be at least 2 characters"); } } /** * Get full name */ getFullName() { return `${this.firstName} ${this.lastName}`; } /** * Get formatted address */ getFormattedAddress() { return `${this.street} ${this.zip} ${this.city} ${this.country}`; } /** * Check if two customer info objects are equal */ equals(other) { return this.firstName === other.firstName && this.lastName === other.lastName && this.street === other.street && this.zip === other.zip && this.city === other.city && this.country === other.country && this.email === other.email && this.phone === other.phone; } /** * Create a copy with updated fields */ update(updates) { return new _CustomerInfo( updates.firstName ?? this.firstName, updates.lastName ?? this.lastName, updates.street ?? this.street, updates.zip ?? this.zip, updates.city ?? this.city, updates.country ?? this.country, updates.email ?? this.email, updates.phone ?? this.phone ); } /** * Convert to plain object for serialization */ toPlainObject() { const result = { firstName: this.firstName, lastName: this.lastName, street: this.street, zip: this.zip, city: this.city, country: this.country, email: this.email }; if (this.phone !== void 0) { result.phone = this.phone; } return result; } /** * Create from plain object */ static fromPlainObject(data) { return new _CustomerInfo( data.firstName, data.lastName, data.street, data.zip, data.city, data.country, data.email, data.phone ); } }; } }); // src/value-objects/OrderItem.ts var OrderItem; var init_OrderItem = __esm({ "src/value-objects/OrderItem.ts"() { "use strict"; init_Money(); OrderItem = class _OrderItem { constructor(unitPrice, quantity, name, variant, productNumber) { this.unitPrice = unitPrice; this.quantity = quantity; this.name = name; this.variant = variant; this.productNumber = productNumber; this.validate(); this.linePrice = this.calculateLinePrice(); } linePrice; validate() { if (!this.name?.trim()) { throw new Error("Item name is required"); } if (!Number.isInteger(this.quantity) || this.quantity <= 0) { throw new Error("Quantity must be a positive integer"); } if (this.unitPrice.amount <= 0) { throw new Error("Unit price must be positive"); } if (this.productNumber !== void 0 && !this.productNumber.trim()) { throw new Error("Product number cannot be empty string"); } if (this.variant !== void 0 && !this.variant.trim()) { throw new Error("Variant cannot be empty string"); } } calculateLinePrice() { return this.unitPrice.multiply(this.quantity); } /** * Get the total price for this line item */ getTotalPrice() { return this.linePrice; } /** * Get display name including variant if present */ getDisplayName() { if (this.variant) { return `${this.name} (${this.variant})`; } return this.name; } /** * Get full item description including product number */ getFullDescription() { let description = this.getDisplayName(); if (this.productNumber) { description = `${this.productNumber} - ${description}`; } return description; } /** * Check if two order items are equal */ equals(other) { return this.unitPrice.equals(other.unitPrice) && this.quantity === other.quantity && this.name === other.name && this.variant === other.variant && this.productNumber === other.productNumber; } /** * Create a copy with updated quantity */ withQuantity(newQuantity) { return new _OrderItem( this.unitPrice, newQuantity, this.name, this.variant, this.productNumber ); } /** * Create a copy with updated unit price */ withUnitPrice(newUnitPrice) { return new _OrderItem( newUnitPrice, this.quantity, this.name, this.variant, this.productNumber ); } /** * Convert to plain object for serialization */ toPlainObject() { const result = { unitPrice: { amount: this.unitPrice.amount, currency: this.unitPrice.currency }, quantity: this.quantity, linePrice: { amount: this.linePrice.amount, currency: this.linePrice.currency }, name: this.name }; if (this.variant !== void 0) { result.variant = this.variant; } if (this.productNumber !== void 0) { result.productNumber = this.productNumber; } return result; } /** * Create from plain object */ static fromPlainObject(data) { const unitPrice = new Money( data.unitPrice.amount, data.unitPrice.currency ); return new _OrderItem( unitPrice, data.quantity, data.name, data.variant, data.productNumber ); } }; } }); // src/types/errors.ts var DomainError, InvalidInvoiceNumberError, DuplicateInvoiceNumberError, InvoiceTotalMismatchError, InvoiceDraftNotFoundError, InvoiceAlreadyExistsError, InvoiceIntegrityCheckError, InvoiceStateViolationError, InvoiceTransitionFailedError, OrderNotFoundError, ErrorCode; var init_errors = __esm({ "src/types/errors.ts"() { "use strict"; DomainError = class extends Error { constructor(message, code, details) { super(message); this.code = code; this.details = details; this.name = this.constructor.name; } }; InvalidInvoiceNumberError = class extends DomainError { constructor(invoiceNumber, reason) { super( `Invalid invoice number: ${invoiceNumber}${reason ? ` - ${reason}` : ""}`, "INVALID_INVOICE_NUMBER" /* INVALID_INVOICE_NUMBER */, { invoiceNumber, reason } ); } }; DuplicateInvoiceNumberError = class extends DomainError { constructor(invoiceNumber) { super( `Invoice number already exists: ${invoiceNumber}`, "DUPLICATE_INVOICE_NUMBER" /* DUPLICATE_INVOICE_NUMBER */, { invoiceNumber } ); } }; InvoiceTotalMismatchError = class extends DomainError { constructor(expected, actual, because) { super( `Invoice totals do not match: expected ${expected.toString()}, got ${actual.toString()}`, "TOTAL_MISMATCH" /* TOTAL_MISMATCH */, { expected: expected.toString(), actual: actual.toString(), because: because ? JSON.stringify( { subtotal: because.subtotal.toString(), taxes: because.taxes.toString(), shippingCost: because.shippingCost.toString(), discount: because.discount.toString() }, null, 2 ) : void 0 } ); } }; InvoiceDraftNotFoundError = class extends DomainError { constructor(orderId) { super( `Invoice draft not found for order: ${orderId}`, "INVOICE_DRAFT_NOT_FOUND" /* INVOICE_DRAFT_NOT_FOUND */, { orderId } ); } }; InvoiceAlreadyExistsError = class extends DomainError { constructor(orderId) { super( `Invoice already exists for order: ${orderId}`, "INVOICE_ALREADY_EXISTS" /* INVOICE_ALREADY_EXISTS */, { orderId } ); } }; InvoiceIntegrityCheckError = class extends DomainError { constructor(invoiceId, reason, reasonDetails) { super( `Invoice ${invoiceId} is not in a valid state! ${reason}${reasonDetails ? ` - ${JSON.stringify(reasonDetails)}` : ""}`, "INVOICE_STATE_VIOLATION" /* INVOICE_STATE_VIOLATION */, { invoiceId, reason, reasonDetails } ); this.invoiceId = invoiceId; this.reason = reason; this.reasonDetails = reasonDetails; } }; InvoiceStateViolationError = class extends DomainError { constructor(invoiceId, expected, received) { super( `Invoice ${invoiceId} has unexpected state: Expected ${expected} but received ${received}`, "INVOICE_STATE_VIOLATION" /* INVOICE_STATE_VIOLATION */, { invoiceId, expected, received } ); } }; InvoiceTransitionFailedError = class extends DomainError { constructor(invoiceId, fromStatus, toStatus) { super( `Invoice ${invoiceId} cannot transition from ${fromStatus} to ${toStatus}`, "TRANSITION_VIOLATION" /* TRANSITION_VIOLATION */, { invoiceId, fromStatus, toStatus } ); } }; OrderNotFoundError = class extends DomainError { constructor(orderId) { super(`Order not found: ${orderId}`, "ORDER_NOT_FOUND" /* ORDER_NOT_FOUND */, { orderId }); } }; ErrorCode = /* @__PURE__ */ ((ErrorCode2) => { ErrorCode2["INVALID_INVOICE_NUMBER"] = "INVALID_INVOICE_NUMBER"; ErrorCode2["DUPLICATE_INVOICE_NUMBER"] = "DUPLICATE_INVOICE_NUMBER"; ErrorCode2["TOTAL_MISMATCH"] = "TOTAL_MISMATCH"; ErrorCode2["ORDER_NOT_FOUND"] = "ORDER_NOT_FOUND"; ErrorCode2["INVOICE_ALREADY_EXISTS"] = "INVOICE_ALREADY_EXISTS"; ErrorCode2["INVALID_CUSTOMER_DATA"] = "INVALID_CUSTOMER_DATA"; ErrorCode2["INVALID_ORDER_ITEMS"] = "INVALID_ORDER_ITEMS"; ErrorCode2["PDF_GENERATION_FAILED"] = "PDF_GENERATION_FAILED"; ErrorCode2["SHIPPING_COST_NEGATIVE"] = "SHIPPING_COST_NEGATIVE"; ErrorCode2["TRANSITION_VIOLATION"] = "TRANSITION_VIOLATION"; ErrorCode2["INVOICE_STATE_VIOLATION"] = "INVOICE_STATE_VIOLATION"; ErrorCode2["INVOICE_DRAFT_NOT_FOUND"] = "INVOICE_DRAFT_NOT_FOUND"; return ErrorCode2; })(ErrorCode || {}); } }); // src/types/shared.ts var OrderStatus, InvoiceStatus; var init_shared = __esm({ "src/types/shared.ts"() { "use strict"; OrderStatus = /* @__PURE__ */ ((OrderStatus2) => { OrderStatus2["PENDING"] = "pending"; OrderStatus2["CONFIRMED"] = "confirmed"; OrderStatus2["SHIPPED"] = "shipped"; OrderStatus2["DELIVERED"] = "delivered"; OrderStatus2["CANCELLED"] = "cancelled"; return OrderStatus2; })(OrderStatus || {}); InvoiceStatus = /* @__PURE__ */ ((InvoiceStatus2) => { InvoiceStatus2["DRAFT"] = "draft"; InvoiceStatus2["FINALIZED"] = "finalized"; return InvoiceStatus2; })(InvoiceStatus || {}); } }); // src/types/index.ts var init_types = __esm({ "src/types/index.ts"() { "use strict"; init_errors(); init_shared(); } }); // src/value-objects/ShippingCost.ts var ShippingCostValidationError, ShippingCost; var init_ShippingCost = __esm({ "src/value-objects/ShippingCost.ts"() { "use strict"; init_types(); init_Money(); ShippingCostValidationError = class extends DomainError { constructor(rule) { let message = "Validation failed"; switch (rule) { case "NON_NEGATIVE": message = "Shipping cost must be non-negative"; break; } super( `Shipping costs are not valid: ${message}`, "SHIPPING_COST_NEGATIVE" /* SHIPPING_COST_NEGATIVE */ ); } }; ShippingCost = class _ShippingCost extends Money { constructor(cost) { super(cost.amount, cost.currency); this.cost = cost; if (!this.#validate()) { throw new ShippingCostValidationError("NON_NEGATIVE"); } } /** * Determines the shipping cost based on an order's grand total value. * Due to business rules, shipping is applied (or not applied) based on the grand total. * @param grandTotal The order's grand total value * @param shippingRates Shipping rates provider that provides the costs * @param shippingPolicy Policy that influences the way costs are calculated * @returns Shipping costs based on business rules, policies and rates. */ static determine(grandTotal, shippingRates, shippingPolicy) { if (shippingPolicy.isFreeShippingAllowed(grandTotal)) { return new _ShippingCost(Money.zero("EUR")); } return new _ShippingCost(shippingRates.getStandardShippingRate()); } /** * Validates the shipping cost. It doesn't allow the amount to be less than 0. */ #validate() { return this.cost.amount >= 0; } }; } }); // src/events/InvoiceEvents.ts function generateEventId() { return (0, import_uuid.v4)(); } var import_uuid, InvoiceEvent, InvoiceIssuedEvent, InvoiceCreatedEvent, InvoiceRequested, InvoicePdfGeneratedEvent, InvoiceValidationFailedEvent; var init_InvoiceEvents = __esm({ "src/events/InvoiceEvents.ts"() { "use strict"; import_uuid = require("uuid"); InvoiceEvent = class { constructor(eventId, eventType, aggregateId, occurredOn, version, invoiceId) { this.eventId = eventId; this.eventType = eventType; this.aggregateId = aggregateId; this.occurredOn = occurredOn; this.version = version; this.invoiceId = invoiceId; } }; InvoiceIssuedEvent = class extends InvoiceEvent { constructor(eventId, aggregateId, occurredOn, version, invoiceId, orderId, invoiceNumber, grandTotal, pdfBytes, issuedBy) { super( eventId, "InvoiceIssued", aggregateId, occurredOn, version, invoiceId ); this.invoiceId = invoiceId; this.orderId = orderId; this.invoiceNumber = invoiceNumber; this.grandTotal = grandTotal; this.pdfBytes = pdfBytes; this.issuedBy = issuedBy; } eventType = "InvoiceIssued"; /** * Convert to plain object for serialization */ toPlainObject() { return { eventId: this.eventId, eventType: this.eventType, aggregateId: this.aggregateId, occurredOn: this.occurredOn.toISOString(), version: this.version, invoiceId: this.invoiceId, orderId: this.orderId, invoiceNumber: this.invoiceNumber, grandTotal: this.grandTotal.toPlainObject(), issuedBy: this.issuedBy }; } }; InvoiceCreatedEvent = class extends InvoiceEvent { constructor(eventId, aggregateId, occurredOn, version, invoiceId, orderId, invoiceNumber, grandTotal, pdfBytes, createdBy) { super( eventId, "InvoiceCreated", aggregateId, occurredOn, version, invoiceId ); this.invoiceId = invoiceId; this.orderId = orderId; this.invoiceNumber = invoiceNumber; this.grandTotal = grandTotal; this.pdfBytes = pdfBytes; this.createdBy = createdBy; } eventType = "InvoiceCreated"; /** * Convert to plain object for serialization */ toPlainObject() { return { eventId: this.eventId, eventType: this.eventType, aggregateId: this.aggregateId, occurredOn: this.occurredOn.toISOString(), version: this.version, invoiceId: this.invoiceId, orderId: this.orderId, invoiceNumber: this.invoiceNumber, grandTotal: this.grandTotal.toPlainObject(), createdBy: this.createdBy }; } }; InvoiceRequested = class { constructor(eventId, aggregateId, occurredOn, version, orderId, requestedBy, invoiceNumber) { this.eventId = eventId; this.aggregateId = aggregateId; this.occurredOn = occurredOn; this.version = version; this.orderId = orderId; this.requestedBy = requestedBy; this.invoiceNumber = invoiceNumber; } eventType = "InvoiceRequested"; /** * Convert to plain object for serialization */ toPlainObject() { return { eventId: this.eventId, eventType: this.eventType, aggregateId: this.aggregateId, occurredOn: this.occurredOn.toISOString(), version: this.version, orderId: this.orderId, requestedBy: this.requestedBy, invoiceNumber: this.invoiceNumber }; } }; InvoicePdfGeneratedEvent = class extends InvoiceEvent { constructor(eventId, aggregateId, occurredOn, version, invoiceId, invoiceNumber, pdfSize, pdfBytes) { super( eventId, "InvoicePdfGenerated", aggregateId, occurredOn, version, invoiceId ); this.invoiceId = invoiceId; this.invoiceNumber = invoiceNumber; this.pdfSize = pdfSize; this.pdfBytes = pdfBytes; } eventType = "InvoicePdfGenerated"; /** * Convert to plain object for serialization */ toPlainObject() { return { eventId: this.eventId, eventType: this.eventType, aggregateId: this.aggregateId, occurredOn: this.occurredOn.toISOString(), version: this.version, invoiceId: this.invoiceId, invoiceNumber: this.invoiceNumber, pdfSize: this.pdfSize }; } }; InvoiceValidationFailedEvent = class extends InvoiceEvent { constructor(eventId, aggregateId, occurredOn, version, invoiceId, orderId, invoiceNumber, validationErrors, attemptedBy) { super( eventId, "InvoiceValidationFailed", aggregateId, occurredOn, version, invoiceId ); this.invoiceId = invoiceId; this.orderId = orderId; this.invoiceNumber = invoiceNumber; this.validationErrors = validationErrors; this.attemptedBy = attemptedBy; } eventType = "InvoiceValidationFailed"; /** * Convert to plain object for serialization */ toPlainObject() { return { eventId: this.eventId, eventType: this.eventType, aggregateId: this.aggregateId, occurredOn: this.occurredOn.toISOString(), version: this.version, invoiceId: this.invoiceId, orderId: this.orderId, invoiceNumber: this.invoiceNumber, validationErrors: [...this.validationErrors], attemptedBy: this.attemptedBy }; } }; } }); // src/events/index.ts var init_events = __esm({ "src/events/index.ts"() { "use strict"; init_InvoiceEvents(); } }); // src/services/InvoiceIdGenerator.default.ts var import_uuid2, InvoiceIdGeneratorDefault; var init_InvoiceIdGenerator_default = __esm({ "src/services/InvoiceIdGenerator.default.ts"() { "use strict"; import_uuid2 = require("uuid"); InvoiceIdGeneratorDefault = class { generate() { return Promise.resolve((0, import_uuid2.v4)()); } }; } }); // src/entities/aggregate-root.ts var AggregateRoot; var init_aggregate_root = __esm({ "src/entities/aggregate-root.ts"() { "use strict"; AggregateRoot = class { _domainEvents = []; /** * Adds a domain event to the aggregate's list of domain events. * * @typeParam T - The type of the domain event, extending `DomainEvent`. * @param event - The domain event to add. Must include an `eventType` property. */ addDomainEvent(event) { this._domainEvents.push(event); } /** * Get all unpublished domain events */ getUncommittedEvents() { return [...this._domainEvents]; } /** * Clear all domain events (called after publishing) */ markEventsAsCommitted() { this._domainEvents = []; } /** * Check if there are unpublished events */ hasUncommittedEvents() { return this._domainEvents.length > 0; } /** * Publishes all uncommitted events using the provided publisher, * and marks all domains as commited. * * It is a convenience wrapper around: * * ```typescript * const publisher = //...; * const uncommitted = this.getUncommittedEvents(); * publisher.publishAll(uncommitted); * this.markEventsAsCommitted(); * ``` */ publishAllUncommitted(publisher) { const uncommitted = this.getUncommittedEvents(); publisher.publishAll(uncommitted); this.markEventsAsCommitted(); } }; } }); // src/entities/InvoiceDraft.ts var InvoiceDraft_exports = {}; __export(InvoiceDraft_exports, { InvoiceDraft: () => InvoiceDraft }); var InvoiceDraft; var init_InvoiceDraft = __esm({ "src/entities/InvoiceDraft.ts"() { "use strict"; init_types(); init_Invoice(); InvoiceDraft = class extends Invoice { constructor(invoiceId, orderId, invoiceNumber, customerSnapshot, itemsSnapshot, financials, createdBy) { super( invoiceId, orderId, invoiceNumber, customerSnapshot, itemsSnapshot, financials, /* @__PURE__ */ new Date(), createdBy, new Uint8Array(), "draft" /* DRAFT */ ); } }; } }); // src/entities/Invoice.ts var Invoice; var init_Invoice = __esm({ "src/entities/Invoice.ts"() { "use strict"; init_events(); init_InvoiceIdGenerator_default(); init_types(); init_CustomerInfo(); init_Money(); init_OrderItem(); init_ShippingCost(); init_aggregate_root(); Invoice = class _Invoice extends AggregateRoot { constructor(invoiceId, orderId, invoiceNumber, customerSnapshot, itemsSnapshot, financials, createdOn, createdBy, pdfBytes, status) { super(); this.invoiceId = invoiceId; this.orderId = orderId; this.invoiceNumber = invoiceNumber; this.customerSnapshot = customerSnapshot; this.itemsSnapshot = itemsSnapshot; this.financials = financials; this.createdOn = createdOn; this.createdBy = createdBy; this.pdfBytes = pdfBytes; this.status = status; this.validateTotals(); } /** * Validates invoice number uniqueness */ static async #validateInvoiceNumber(invoiceNumber, validator) { await validator.validateUniqueness(invoiceNumber); } /** * Factory method to create a new invoice. * * Validates business rules and calculates totals. * * **NOTE**: The invoice information you pass must be in `DRAFT` status. * If that's not the case, a `InvoiceTransitionViolationError` will be thrown. * * Requires dependencies, such as the invoice number validator, * because it's a core business rule that they are unique. * * @deprecated Use {@link issue} instead. * It provides a more aligned naming convention and, * under the hood, this method work the same way. * Will be removed in the next major version. * * @see https://github.com/benjamin-kraatz/invoicing-sdk/issues/14 */ static async create(data, pdfBytes, deps) { return _Invoice.issue(data, pdfBytes, deps); } /** * Factory method to issue a new invoice. * * Validates business rules and calculates totals. * * **NOTE**: The invoice information you pass must be in `DRAFT` status. * If that's not the case, a `InvoiceTransitionViolationError` will be thrown. * * Requires dependencies, such as the invoice number validator, * because it's a core business rule that they are unique. * * This is the preferred method for issuing invoices as it aligns with real-world * accounting terminology where invoices are "issued" to customers. * * @see {@link InvoiceNumberValidator} * @see {@link InvoiceStateViolationError} * @see {@link InvoiceAggregateDependencies} */ static async issue(data, pdfBytes, deps) { if (data.status !== "draft" /* DRAFT */) { throw new InvoiceStateViolationError( data.invoiceNumber, "draft" /* DRAFT */, data.status ); } await _Invoice.#validateInvoiceNumber( data.invoiceNumber, deps.invoiceNumberValidator ); const issuedOn = /* @__PURE__ */ new Date(); const customerSnapshot = new CustomerInfo( data.customerSnapshot.firstName, data.customerSnapshot.lastName, data.customerSnapshot.street, data.customerSnapshot.zip, data.customerSnapshot.city, data.customerSnapshot.country, data.customerSnapshot.email, data.customerSnapshot.phone ); const itemsSnapshot = data.itemsSnapshot.map( (item) => new OrderItem( item.unitPrice, item.quantity, item.name, item.variant, item.productNumber ) ); const invoiceId = await _Invoice.generateId(deps.invoiceIdGenerator); const invoice = new _Invoice( invoiceId, data.orderId, data.invoiceNumber, customerSnapshot, itemsSnapshot, data.financials, issuedOn, data.createdBy, pdfBytes, "finalized" /* FINALIZED */ ); const issuedEvent = new InvoiceIssuedEvent( generateEventId(), invoiceId, issuedOn, 1, invoiceId, data.orderId, data.invoiceNumber, data.financials.grandTotal, pdfBytes, data.createdBy ); invoice.addDomainEvent(issuedEvent); return invoice; } /** * Creates a new draft invoice and emits an `InvoiceRequested` domain event. * * It creates a new invoice number (without reservation). * The returned invoice can be used for actual issuing. * * A draft is required before issuing an invoice. * @param data - The data required to create the invoice draft. * @param deps - The dependencies required for invoice creation, including optional `invoiceIdGenerator` and required `invoiceNumberGenerator`. * @returns A promise that resolves to the invoice draft when the draft invoice is created and the domain event is added. * * @see {@link InvoiceIdGenerator} * @see {@link InvoiceNumberGenerator} */ static async createDraft(data, deps) { const { InvoiceDraft: InvoiceDraft2 } = await Promise.resolve().then(() => (init_InvoiceDraft(), InvoiceDraft_exports)); const id = await _Invoice.generateId(deps.invoiceIdGenerator); const invoiceNumber = await deps.invoiceNumberGenerator.generate(); const invoiceDraft = new InvoiceDraft2( id, data.orderId, invoiceNumber, data.customerSnapshot, data.itemsSnapshot, data.financials, data.createdBy ); const aggregateId = id; const requestedEvent = new InvoiceRequested( generateEventId(), aggregateId, /* @__PURE__ */ new Date(), 1, data.orderId, data.createdBy, invoiceNumber ); invoiceDraft.addDomainEvent(requestedEvent); return invoiceDraft; } static async generateId(invoiceIdGenerator) { return (invoiceIdGenerator ?? new InvoiceIdGeneratorDefault()).generate(); } /** * Transitions the invoice to the given status in a type-safe way. * * - To transition to FINALIZED, you must provide PDF bytes. * - To transition to DRAFT, you must not provide PDF bytes. * * @param args - Discriminated union for status and pdfBytes. * @returns A new invoice instance with the updated status. * @throws InvoiceTransitionFailedError when the transition is not allowed * * @deprecated In preparation to an upcoming issue fix, the {@link Invoice.create} * method will be renamed and makes the `transitionTo` function obsolete. * Consider calling {@link Invoice.create} directly. * This function will be removed in the next major release. */ transitionTo(args) { const current = this.status; const { status } = args; if (current === "finalized" /* FINALIZED */ && status === "draft" /* DRAFT */) { throw new InvoiceTransitionFailedError(this.invoiceId, current, status); } else if (current === status) { throw new InvoiceTransitionFailedError(this.invoiceId, current, status); } if (status === "finalized" /* FINALIZED */) { return new _Invoice( this.invoiceId, this.orderId, this.invoiceNumber, this.customerSnapshot, this.itemsSnapshot, this.financials, this.createdOn, this.createdBy, args.pdfBytes, status ); } else if (status === "draft" /* DRAFT */) { return new _Invoice( this.invoiceId, this.orderId, this.invoiceNumber, this.customerSnapshot, this.itemsSnapshot, this.financials, this.createdOn, this.createdBy, this.pdfBytes, status ); } else { throw new InvoiceTransitionFailedError(this.invoiceId, current, status); } } /** * Checks if the current invoice status matches the specified status. * * @param status - The status to compare with the invoice's current status. * @returns `true` if the invoice's status matches the specified status, otherwise `false`. */ requireStatus(status) { return this.status === status; } /** * Returns the current status of the invoice. * * @returns {InvoiceStatus} The status of the invoice. */ getStatus() { return this.status; } /** * Validates that invoice totals are mathematically correct * Business rule: grandTotal = subtotal + taxes + shippingCost - discount */ validateTotals() { const { subtotal, taxes, shippingCost, discount, grandTotal } = this.financials; const currency = subtotal.currency; if (taxes.currency !== currency || shippingCost.currency !== currency || discount.currency !== currency || grandTotal.currency !== currency) { throw new Error("All financial amounts must be in the same currency"); } const calculatedTotal = subtotal.add(taxes).add(shippingCost).subtract(discount); if (!calculatedTotal.equals(grandTotal)) { throw new InvoiceTotalMismatchError(calculatedTotal, grandTotal, { subtotal, taxes, shippingCost, discount }); } const itemsTotal = this.itemsSnapshot.reduce( (total, item) => total.add(item.linePrice), new Money(0, currency) ); if (!itemsTotal.equals(subtotal)) { throw new InvoiceTotalMismatchError(itemsTotal, subtotal); } } /** * Creates the InvoiceIssuedEvent domain event */ toDomainEvent() { return new InvoiceIssuedEvent( generateEventId(), this.invoiceId, /* @__PURE__ */ new Date(), 1, this.invoiceId, this.orderId, this.invoiceNumber, this.financials.grandTotal, this.pdfBytes, this.createdBy ); } /** * Get the total number of items in the invoice */ getTotalItemCount() { return this.itemsSnapshot.reduce((total, item) => total + item.quantity, 0); } /** * Get the currency used in this invoice */ getCurrency() { return this.financials.grandTotal.currency; } /** * Check if the invoice contains a specific product */ containsProduct(productNumber) { return this.itemsSnapshot.some( (item) => item.productNumber === productNumber ); } /** * Get formatted invoice summary for display */ getSummary() { const itemCount = this.getTotalItemCount(); const total = this.financials.grandTotal.toString(); return `Invoice ${this.invoiceNumber}: ${itemCount} items, Total: ${total}`; } /** * Convert to plain object for serialization */ toPlainObject() { return { invoiceId: this.invoiceId, orderId: this.orderId, invoiceNumber: this.invoiceNumber, customerSnapshot: this.customerSnapshot.toPlainObject(), itemsSnapshot: this.itemsSnapshot.map((item) => item.toPlainObject()), financials: { subtotal: this.financials.subtotal.toPlainObject(), taxes: this.financials.taxes.toPlainObject(), shippingCost: this.financials.shippingCost.toPlainObject(), discount: this.financials.discount.toPlainObject(), grandTotal: this.financials.grandTotal.toPlainObject() }, createdOn: this.createdOn.toISOString(), createdBy: this.createdBy }; } /** * Reconstruct Invoice from plain object (for repository loading) */ static fromPlainObject(data) { const customerSnapshot = CustomerInfo.fromPlainObject( data.customerSnapshot ); const itemsSnapshot = data.itemsSnapshot.map( (item) => OrderItem.fromPlainObject(item) ); const financials = { subtotal: new Money( data.financials.subtotal.amount, data.financials.subtotal.currency ), taxes: new Money( data.financials.taxes.amount, data.financials.taxes.currency ), shippingCost: new ShippingCost( new Money( data.financials.shippingCost.amount, data.financials.shippingCost.currency ) ), discount: new Money( data.financials.discount.amount, data.financials.discount.currency ), grandTotal: new Money( data.financials.grandTotal.amount, data.financials.grandTotal.currency ) }; const invoice = new _Invoice( data.invoiceId, data.orderId, data.invoiceNumber, customerSnapshot, itemsSnapshot, financials, new Date(data.createdOn), data.createdBy, data.pdfBytes, data.status ); return invoice; } }; } }); // src/index.ts var index_exports = {}; __export(index_exports, { AggregateRoot: () => AggregateRoot, BasePDFRenderer: () => BasePDFRenderer, CustomerInfo: () => CustomerInfo, DEFAULT_GENERATOR_CONFIG: () => DEFAULT_GENERATOR_CONFIG, DEFAULT_INVOICE_NUMBER_CONFIG: () => DEFAULT_INVOICE_NUMBER_CONFIG, DEFAULT_PDF_OPTIONS: () => DEFAULT_PDF_OPTIONS, DomainError: () => DomainError, DuplicateInvoiceNumberError: () => DuplicateInvoiceNumberError, ErrorCode: () => ErrorCode, GENERATOR_PRESETS: () => GENERATOR_PRESETS, InvalidInvoiceNumberError: () => InvalidInvoiceNumberError, InvalidLegalInfoError: () => InvalidLegalInfoError, Invoice: () => Invoice, InvoiceAlreadyExistsError: () => InvoiceAlreadyExistsError, InvoiceCreatedEvent: () => InvoiceCreatedEvent, InvoiceDraft: () => InvoiceDraft, InvoiceDraftNotFoundError: () => InvoiceDraftNotFoundError, InvoiceFinancials: () => InvoiceFinancials, InvoiceIntegrityCheckError: () => InvoiceIntegrityCheckError, InvoiceIssuedEvent: () => InvoiceIssuedEvent, InvoiceNumberGeneratorImpl: () => InvoiceNumberGeneratorImpl, InvoiceNumberValidatorImpl: () => InvoiceNumberValidatorImpl, InvoicePdfGeneratedEvent: () => InvoicePdfGeneratedEvent, InvoiceRequested: () => InvoiceRequested, InvoiceStateViolationError: () => InvoiceStateViolationError, InvoiceStatus: () => InvoiceStatus, InvoiceTotalMismatchError: () => InvoiceTotalMismatchError, InvoiceTransitionFailedError: () => InvoiceTransitionFailedError, InvoiceValidationFailedEvent: () => InvoiceValidationFailedEvent, Money: () => Money, OrderItem: () => OrderItem, OrderNotFoundError: () => OrderNotFoundError, OrderStatus: () => OrderStatus, PDFGenerationError: () => PDFGenerationError, ShippingCost: () => ShippingCost, createInvoiceNumberGenerator: () => createInvoiceNumberGenerator, generateEventId: () => generateEventId, validateGeneratedNumber: () => validateGeneratedNumber }); module.exports = __toCommonJS(index_exports); // src/value-objects/index.ts init_Money(); init_CustomerInfo(); init_OrderItem(); // src/value-objects/Financials.ts init_types(); var InvoiceFinancials = class { constructor(subtotal, taxes, shippingCost, discount, grandTotal) { this.subtotal = subtotal; this.taxes = taxes; this.shippingCost = shippingCost; this.discount = discount; this.grandTotal = grandTotal; const calculated = subtotal.add(taxes).add(shippingCost).subtract(discount); if (!calculated.equals(grandTotal)) { throw new InvoiceTotalMismatchError(calculated, grandTotal); } } }; // src/value-objects/index.ts init_ShippingCost(); // src/index.ts init_types(); // src/entities/index.ts init_Invoice(); init_InvoiceDraft(); init_aggregate_root(); // src/index.ts init_events(); // src/services/InvoiceNumberValidator.ts init_types(); var DEFAULT_INVOICE_NUMBER_CONFIG = { pattern: /^\d{4}-\d{4}$/, minLength: 9, maxLength: 9, requireSequential: true }; var InvoiceNumberValidatorImpl = class { constructor(repository, config = DEFAULT_INVOICE_NUMBER_CONFIG) { this.repository = repository; this.config = config; } async validate(invoiceNumber) { this.validateFormat(invoiceNumber); await this.validateUniqueness(invoiceNumber); if (this.config.requireSequential) { await this.validateSequential(invoiceNumber); } } validateFormat(invoiceNumber) { if (!invoiceNumber || typeof invoiceNumber !== "string") { throw new InvalidInvoiceNumberError( invoiceNumber, "Invoice number must be a non-empty string" ); } if (invoiceNumber.length < this.config.minLength || invoiceNumber.length > this.config.maxLength) { throw new InvalidInvoiceNumberError( invoiceNumber, `Length must be between ${this.config.minLength} and ${this.config.maxLength} characters` ); } if (!this.config.pattern.test(invoiceNumber)) { throw new InvalidInvoiceNumberError( invoiceNumber, `Must match pattern: ${this.config.pattern.source}` ); } if (this.config.prefix && !invoiceNumber.startsWith(this.config.prefix)) { throw new InvalidInvoiceNumberError( invoiceNumber, `Must start with prefix: ${this.config.prefix}` ); } if (this.config.suffix && !invoiceNumber.endsWith(this.config.suffix)) { throw new InvalidInvoiceNumberError( invoiceNumber, `Must end with suffix: ${this.config.suffix}` ); } } async validateUniqueness(invoiceNumber) { const exists = await this.repository.exists(invoiceNumber); if (exists) { throw new DuplicateInvoiceNumberError(invoiceNumber); } } /** * Validates that the invoice number follows sequential numbering * For format YYYY-NNNN, ensures NNNN is the next expected sequence number */ async validateSequential(invoiceNumber) { const match = invoiceNumber.match(/^(\d{4})-(\d{4})$/); if (!match) { return; } const [, year, sequenceStr] = match; if (!year || !sequenceStr) { return; } const sequence = parseInt(sequenceStr, 10); const currentYear = (/* @__PURE__ */ new Date()).getFullYear().toString(); if (year === currentYear) { const lastSequence = await this.repository.getLastSequenceNumber(year); const expectedSequence = lastSequence + 1; if (sequence !== expectedSequence) { throw new InvalidInvoiceNumberError( invoiceNumber, `Expected sequence number ${expectedSequence.toString().padStart(4, "0")}, got ${sequenceStr}` ); } } else if (parseInt(year, 10) > parseInt(currentYear, 10)) { throw new InvalidInvoiceNumberError( invoiceNumber, `Cannot create invoice numbers for future year: ${year}` ); } } }; // src/services/PDFRenderer.ts var DEFAULT_PDF_OPTIONS = { format: "A4", language: "de", includeZUGfERD: true }; var PDFGenerationError = class extends Error { constructor(message, cause) { super(message); this.cause = cause; this.name = "PDFGenerationError"; } }; var InvalidLegalInfoError = class extends Error { constructor(message, missingFields) { super(message); this.missingFields = missingFields; this.name = "InvalidLegalInfoError"; } }; var BasePDFRenderer = class { validateLegalInfo(legalInfo) { const missingFields = []; if (!legalInfo.companyName?.trim()) { missingFields.push("companyName"); } if (!legalInfo.companyAddress?.street?.trim()) { missingFields.push("companyAddress.street"); } if (!legalInfo.companyAddress?.zip?.trim()) { missingFields.push("companyAddress.zip"); } if (!legalInfo.companyAddress?.city?.trim()) { missingFields.push("companyAddress.city"); } if (!legalInfo.contactInfo?.phone?.trim()) { missingFields.push("contactInfo.phone"); } if (!legalInfo.contactInfo?.email?.trim()) { missingFields.push("contactInfo.email"); } if (!legalInfo.legalInfo?.vatNumber?.trim()) { missingFields.push("legalInfo.vatNumber"); } if (!legalInfo.legalInfo?.taxNumber?.trim()) { missingFields.push("legalInfo.taxNumber"); } if (!legalInfo.bankInfo?.bankName?.trim()) { missingFields.push("bankInfo.bankName"); } if (!legalInfo.bankInfo?.iban?.trim()) { missingFields.push("bankInfo.iban"); } if (