@invoicing-sdk/domain
Version:
Core domain logic for the invoicing system including entities, value objects, services, and events
1,530 lines (1,509 loc) • 58.1 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
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 });
};
// 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
import { v4 as uuidv4 } from "uuid";
function generateEventId() {
return uuidv4();
}
var InvoiceEvent, InvoiceIssuedEvent, InvoiceCreatedEvent, InvoiceRequested, InvoicePdfGeneratedEvent, InvoiceValidationFailedEvent;
var init_InvoiceEvents = __esm({
"src/events/InvoiceEvents.ts"() {
"use strict";
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
import { v4 as uuidv42 } from "uuid";
var InvoiceIdGeneratorDefault;
var init_InvoiceIdGenerator_default = __esm({
"src/services/InvoiceIdGenerator.default.ts"() {
"use strict";
InvoiceIdGeneratorDefault = class {
generate() {
return Promise.resolve(uuidv42());
}
};
}
});
// 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/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 (!legalInfo.bankInfo?.bic?.trim()) {
missingFields.push("bankInfo.bic");
}
if (legalInfo.bankInfo?.iban && !this.isValidIBAN(legalInfo.bankInfo.iban)) {
missingFields.push("bankInfo.iban (invalid format)");
}
if (legalInfo.legalInfo?.vatNumber && !this.isValidGermanVATNumber(legalInfo.legalInfo.vatNumber)) {
missingFields.push("legalInfo.vatNumber (invalid format)");
}
if (missingFields.length > 0) {
throw new InvalidLegalInfoError(
`Missing or invalid required legal information: ${missingFields.join(
", "
)}`,
missingFields
);
}
}
generateZUGfERDXML(invoiceData) {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p0:basic</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>${invoiceData.invoiceNumber}</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">${this.formatDateForXML(
invoiceData.createdOn
)}</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:ApplicableHeaderTradeAgreement>
<ram:BuyerReference>${invoiceData.orderId}</ram:BuyerReference>
<ram:SellerTradeParty>
<ram:Name>${invoiceData.legalInfo.companyName}</ram:Name>
<ram:PostalTradeAddress>
<ram:LineOne>${invoiceData.legalInfo.companyAddress.street}</ram:LineOne>
<ram:CityName>${invoiceData.legalInfo.companyAddress.city}</ram:CityName>
<ram:PostcodeCode>${invoiceData.legalInfo.companyAddress.zip}</ram:PostcodeCode>
<ram:CountryID>${invoiceData.legalInfo.companyAddress.country}</ram:CountryID>
</ram:PostalTradeAddress>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">${invoiceData.legalInfo.legalInfo.vatNumber}</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>${invoiceData.customer.firstName} ${invoiceData.customer.lastName}</ram:Name>
<ram:PostalTradeAddress>
<ram:LineOne>${invoiceData.customer.street}</ram:LineOne>
<ram:CityName>${invoiceData.customer.city}</ram:CityName>
<ram:PostcodeCode>${invoiceData.customer.zip}</ram:PostcodeCode>
<ram:CountryID>${invoiceDat