Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Billing/Transaction/Application/Create/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ export default class CreateCommand implements ICommand {
price: { amount: number, currency: string },
) {
this.uuid = new TransactionId(uuid);
this.price = new Price(price.amount, price.currency);
this.price = new Price(String(price.amount), price.currency);
}
}
14 changes: 12 additions & 2 deletions src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { Domain } from "hollywood-js";
import type Price from "../ValueObject/Price";

/**
* Raised when a Transaction is created.
*
* Carries only primitive data so that the event is safe to serialize, store,
* and replay without coupling the event schema to the Price value object.
* To reconstruct a Price, call: new Price(event.amount, event.currency)
*
* Source: Young — "Events are the durable record. They must be self-describing
* with primitive data." CQRS Documents (2010).
*/
export default class TransactionWasCreated implements Domain.DomainEvent {
public readonly occurredAt: Date;

constructor(
public readonly aggregateId: string,
public readonly product: string,
public readonly price: Price,
public readonly amount: string,
public readonly currency: string,
occurredAt?: Date,
) {
this.occurredAt = occurredAt ?? new Date();
Expand Down
7 changes: 4 additions & 3 deletions src/Billing/Transaction/Domain/Transaction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Domain } from "hollywood-js";
import TransactionWasCreated from "./Events/TransactionWasCreated";
import type Price from "./ValueObject/Price";
import Price from "./ValueObject/Price";
import type TransactionId from "./ValueObject/TransactionId";

export default class Transaction extends Domain.EventSourcedAggregateRoot {
Expand All @@ -11,7 +11,8 @@ export default class Transaction extends Domain.EventSourcedAggregateRoot {
instance.raise(new TransactionWasCreated(
uuid.toString(),
product,
price,
price.amount,
price.currency,
));

return instance;
Expand All @@ -27,7 +28,7 @@ export default class Transaction extends Domain.EventSourcedAggregateRoot {

private onTransactionWasCreated(event: TransactionWasCreated): void {
this.product = event.product;
this.price = event.price;
this.price = new Price(event.amount, event.currency);
}

get pricing(): Price {
Expand Down
17 changes: 12 additions & 5 deletions src/Billing/Transaction/Domain/ValueObject/Price.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import InvalidArgumentException from "../../../Shared/Domain/Exceptions/InvalidArgumentException";
import { currencies } from "./CurrencyList";

/**
* Money value object. Stores the amount as a decimal string to avoid
* IEEE 754 floating-point precision loss. Use string arithmetic or a
* dedicated decimal library (e.g., decimal.js) for money calculations.
*/
export default class Price {

private static validateCurrency(currency: string): void {
Expand All @@ -9,21 +14,23 @@ export default class Price {
}
}

private static validateAmount(amount: number): void {
if (isNaN(amount)) {
private static validateAmount(amount: string): void {
const parsed = Number(amount);

if (isNaN(parsed)) {
throw new InvalidArgumentException("Amount is not a number");
}

if (Math.sign(amount) < 0) {
if (parsed < 0) {
throw new InvalidArgumentException("Negative prices not allowed");
}
}

public readonly amount: number;
public readonly amount: string;
public readonly currency: string;

constructor(
amount: number,
amount: string,
currency: string,
) {
Price.validateAmount(amount);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export class Transactions {

instance.uuid = event.aggregateId;
instance.product = event.product;
instance.priceAmount = event.price.amount;
instance.priceCurrency = event.price.currency;
instance.priceAmount = Number(event.amount);
instance.priceCurrency = event.currency;
instance.createdAt = new Date();

return instance;
Expand Down
2 changes: 1 addition & 1 deletion tests/Apps/HTTP/Transaction/GetOne.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("GET /transaction/:uuid", () => {

it("GetOne /ae081e7a-ec8c-4ff1-9de5-f70383fe03a7", async () => {
const txnuuid = "ae081e7a-ec8c-4ff1-9de5-f70383fe03a7";
await transactionRepository.save(Transaction.create(new TransactionId(txnuuid), "uuu", new Price(1, "EUR")));
await transactionRepository.save(Transaction.create(new TransactionId(txnuuid), "uuu", new Price("1", "EUR")));

const result: any = await api.http.inject({
method: "GET",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("GetOne Transaction", () => {
await repository.save(Transaction.create(
new TransactionId("ae081e7a-ec8c-4ff1-9de5-f70383fe03a7"),
"",
new Price(1, "EUR"),
new Price("1", "EUR"),
));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import TransactionWasCreated from "@Transaction/Domain/Events/TransactionWasCrea

describe("Transaction", () => {
test("Create transaction", () => {
const price = new Price(2, "EUR");
const price = new Price("2", "EUR");
const transactionID = new TransactionId("ae081e7a-ec8c-4ff1-9de5-f70383fe03a7");
const instance = Transaction.create(transactionID, "product", price);
const stream = instance.getUncommittedEvents();

const event = stream.events[0].event as TransactionWasCreated;

expect(instance.getAggregateRootId().toString()).toBe("ae081e7a-ec8c-4ff1-9de5-f70383fe03a7");
expect(stream.events[0]).toBeInstanceOf(Domain.DomainMessage);
expect((stream.events[0].event as TransactionWasCreated).product).toBe("product");
expect((stream.events[0].event as TransactionWasCreated).price).toBe(price);
expect(event.product).toBe("product");
expect(event.amount).toBe(price.amount);
expect(event.currency).toBe(price.currency);
expect(instance.version()).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import InvalidArgumentException from "@Shared/Domain/Exceptions/InvalidArgumentE

describe("Price", () => {
test("Create Price VO from Scalars", () => {
const instance = new Price(12.5, "EUR");
const instance = new Price("12.5", "EUR");

expect(instance.currency).toBe("EUR");
expect(instance.amount).toBe(12.5);
expect(instance.amount).toBe("12.5");
});

test("Invalid Currency", () => {
const todo = () => new Price(12.5, "GBP");
const todo = () => new Price("12.5", "GBP");
expect(todo).toThrow(InvalidArgumentException);
});
});