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
3 changes: 3 additions & 0 deletions src/Apps/HTTP/BillingAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export default class BillingAPI extends HTTPServer {
case "get":
this.http.get(route.path, route.options || {}, route.action);
break;
case "patch":
this.http.patch(route.path, route.options || {}, route.action);
break;
case "post":
this.http.post(route.path, route.options || {}, route.action);
break;
Expand Down
77 changes: 77 additions & 0 deletions src/Apps/HTTP/Routing/Transaction/Patch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import ConfirmCommand from "@Transaction/Application/Confirm/Command";
import FailCommand from "@Transaction/Application/Fail/Command";
import RefundCommand from "@Transaction/Application/Refund/Command";
import type {FastifyReply, FastifyRequest} from "fastify";
import { Application } from "hollywood-js";
import { IRoute } from "../index";

export function confirm(app: Application.App): IRoute {
return {
action: async (req: FastifyRequest, res: FastifyReply) => {
const { uuid } = req.params as any;
await app.handle(new ConfirmCommand(uuid));
res.status(204).send();
},
method: "patch",
options: {
schema: {
params: {
properties: { uuid: { type: "string" } },
type: "object",
},
response: { 204: {}, 404: {}, 409: {} },
tags: ["Transactions"],
},
},
path: "/transaction/:uuid/confirm",
};
}

export function fail(app: Application.App): IRoute {
return {
action: async (req: FastifyRequest, res: FastifyReply) => {
const { uuid } = req.params as any;
const { reason } = req.body as any;
await app.handle(new FailCommand(uuid, reason ?? ""));
res.status(204).send();
},
method: "patch",
options: {
schema: {
body: {
properties: { reason: { type: "string" } },
type: "object",
},
params: {
properties: { uuid: { type: "string" } },
type: "object",
},
response: { 204: {}, 404: {}, 409: {} },
tags: ["Transactions"],
},
},
path: "/transaction/:uuid/fail",
};
}

export function refund(app: Application.App): IRoute {
return {
action: async (req: FastifyRequest, res: FastifyReply) => {
const { uuid } = req.params as any;
await app.handle(new RefundCommand(uuid));
res.status(204).send();
},
method: "patch",
options: {
schema: {
params: {
properties: { uuid: { type: "string" } },
type: "object",
},
response: { 204: {}, 404: {}, 409: {} },
tags: ["Transactions"],
},
},
path: "/transaction/:uuid/refund",
};
}
6 changes: 5 additions & 1 deletion src/Apps/HTTP/Routing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {FastifyReply, FastifyRequest} from "fastify";
import type { Application } from "hollywood-js";
import health from "./Monitor/Health";
import get from "./Transaction/Get";
import { confirm, fail, refund } from "./Transaction/Patch";
import create from "./Transaction/Post";

type Context = (app: Application.App) => IRoute;
Expand All @@ -14,7 +15,10 @@ export interface IRoute {
}

export const routes: Context[] = [
get,
confirm,
create,
fail,
get,
health,
refund,
];
13 changes: 13 additions & 0 deletions src/Billing/Shared/Domain/Exceptions/InvalidStateException.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Thrown when a domain command is applied to an aggregate in the wrong state.
*
* Source: Vernon (IDDD), ch. 7 — "Domain events model what happened. A full
* lifecycle requires events for each significant state transition, and each
* transition must be guarded by invariants that prevent invalid moves."
*/
export default class InvalidStateException extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidStateException";
}
}
10 changes: 10 additions & 0 deletions src/Billing/Transaction/Application/Confirm/Command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ICommand } from "hollywood-js/src/Application";
import TransactionId from "../../Domain/ValueObject/TransactionId";

export default class ConfirmCommand implements ICommand {
public readonly uuid: TransactionId;

constructor(uuid: string) {
this.uuid = new TransactionId(uuid);
}
}
28 changes: 28 additions & 0 deletions src/Billing/Transaction/Application/Confirm/Handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Application, EventSourcing } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import NotFoundException from "../../../Shared/Domain/Exceptions/NotFoundException";
import Transaction from "../../Domain/Transaction";
import ConfirmCommand from "./Command";

@injectable()
export default class Confirm implements Application.ICommandHandler {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly writeModel: EventSourcing.EventStore<Transaction>,
) {}

@Application.autowiring
public async handle(command: ConfirmCommand): Promise<void | IAppError> {
try {
const transaction = await this.writeModel.load(command.uuid.toIdentity()) as Transaction;
transaction.confirm();
await this.writeModel.save(transaction);
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
throw new NotFoundException("Transaction not found");
}
throw err;
}
}
}
13 changes: 13 additions & 0 deletions src/Billing/Transaction/Application/Fail/Command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ICommand } from "hollywood-js/src/Application";
import TransactionId from "../../Domain/ValueObject/TransactionId";

export default class FailCommand implements ICommand {
public readonly uuid: TransactionId;

constructor(
uuid: string,
public readonly reason: string,
) {
this.uuid = new TransactionId(uuid);
}
}
28 changes: 28 additions & 0 deletions src/Billing/Transaction/Application/Fail/Handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Application, EventSourcing } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import NotFoundException from "../../../Shared/Domain/Exceptions/NotFoundException";
import Transaction from "../../Domain/Transaction";
import FailCommand from "./Command";

@injectable()
export default class Fail implements Application.ICommandHandler {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly writeModel: EventSourcing.EventStore<Transaction>,
) {}

@Application.autowiring
public async handle(command: FailCommand): Promise<void | IAppError> {
try {
const transaction = await this.writeModel.load(command.uuid.toIdentity()) as Transaction;
transaction.fail(command.reason);
await this.writeModel.save(transaction);
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
throw new NotFoundException("Transaction not found");
}
throw err;
}
}
}
10 changes: 10 additions & 0 deletions src/Billing/Transaction/Application/Refund/Command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ICommand } from "hollywood-js/src/Application";
import TransactionId from "../../Domain/ValueObject/TransactionId";

export default class RefundCommand implements ICommand {
public readonly uuid: TransactionId;

constructor(uuid: string) {
this.uuid = new TransactionId(uuid);
}
}
28 changes: 28 additions & 0 deletions src/Billing/Transaction/Application/Refund/Handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Application, EventSourcing } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import NotFoundException from "../../../Shared/Domain/Exceptions/NotFoundException";
import Transaction from "../../Domain/Transaction";
import RefundCommand from "./Command";

@injectable()
export default class Refund implements Application.ICommandHandler {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly writeModel: EventSourcing.EventStore<Transaction>,
) {}

@Application.autowiring
public async handle(command: RefundCommand): Promise<void | IAppError> {
try {
const transaction = await this.writeModel.load(command.uuid.toIdentity()) as Transaction;
transaction.refund();
await this.writeModel.save(transaction);
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
throw new NotFoundException("Transaction not found");
}
throw err;
}
}
}
13 changes: 13 additions & 0 deletions src/Billing/Transaction/Domain/Events/TransactionFailed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Domain } from "hollywood-js";

export default class TransactionFailed implements Domain.DomainEvent {
public readonly occurredAt: Date;

constructor(
public readonly aggregateId: string,
public readonly reason: string,
occurredAt?: Date,
) {
this.occurredAt = occurredAt ?? new Date();
}
}
12 changes: 12 additions & 0 deletions src/Billing/Transaction/Domain/Events/TransactionWasConfirmed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Domain } from "hollywood-js";

export default class TransactionWasConfirmed implements Domain.DomainEvent {
public readonly occurredAt: Date;

constructor(
public readonly aggregateId: string,
occurredAt?: Date,
) {
this.occurredAt = occurredAt ?? new Date();
}
}
12 changes: 12 additions & 0 deletions src/Billing/Transaction/Domain/Events/TransactionWasRefunded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Domain } from "hollywood-js";

export default class TransactionWasRefunded implements Domain.DomainEvent {
public readonly occurredAt: Date;

constructor(
public readonly aggregateId: string,
occurredAt?: Date,
) {
this.occurredAt = occurredAt ?? new Date();
}
}
3 changes: 3 additions & 0 deletions src/Billing/Transaction/Domain/Repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type TransactionId from "./ValueObject/TransactionId";
import type { TransactionStatus } from "./ValueObject/TransactionStatus";

/**
* Read model projection for a Transaction.
Expand All @@ -11,10 +12,12 @@ export interface ITransactionReadDTO {
product: string;
priceAmount: number;
priceCurrency: string;
status: TransactionStatus;
createdAt: Date;
}

export default interface IRepository {
get(id: TransactionId): Promise<ITransactionReadDTO | null>;
save(dto: ITransactionReadDTO): Promise<void>;
updateStatus(uuid: string, status: TransactionStatus): Promise<void>;
}
67 changes: 66 additions & 1 deletion src/Billing/Transaction/Domain/Transaction.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Domain } from "hollywood-js";
import InvalidStateException from "../../Shared/Domain/Exceptions/InvalidStateException";
import TransactionFailed from "./Events/TransactionFailed";
import TransactionWasConfirmed from "./Events/TransactionWasConfirmed";
import TransactionWasCreated from "./Events/TransactionWasCreated";
import TransactionWasRefunded from "./Events/TransactionWasRefunded";
import Price from "./ValueObject/Price";
import type TransactionId from "./ValueObject/TransactionId";
import { TransactionStatus } from "./ValueObject/TransactionStatus";

export default class Transaction extends Domain.EventSourcedAggregateRoot {

Expand All @@ -20,15 +25,71 @@ export default class Transaction extends Domain.EventSourcedAggregateRoot {

private price?: Price;
private product: string = "";
private status: TransactionStatus = TransactionStatus.PENDING;

constructor(uuid: Domain.Identity) {
super(uuid);
this.registerHandler(TransactionWasCreated, (event) => this.onTransactionWasCreated(event));
this.registerHandler(TransactionWasCreated, (event) => this.onTransactionWasCreated(event));
this.registerHandler(TransactionWasConfirmed, (event) => this.onTransactionWasConfirmed(event));
this.registerHandler(TransactionFailed, (event) => this.onTransactionFailed(event));
this.registerHandler(TransactionWasRefunded, (event) => this.onTransactionWasRefunded(event));
}

/**
* Confirm a pending transaction.
* Guard: only PENDING transactions can be confirmed.
*/
public confirm(): void {
if (this.status !== TransactionStatus.PENDING) {
throw new InvalidStateException(
`Cannot confirm a transaction in state ${this.status}`,
);
}
this.raise(new TransactionWasConfirmed(this.getAggregateRootId().toString()));
}

/**
* Mark a pending transaction as failed.
* Guard: only PENDING transactions can fail.
*/
public fail(reason: string): void {
if (this.status !== TransactionStatus.PENDING) {
throw new InvalidStateException(
`Cannot fail a transaction in state ${this.status}`,
);
}
this.raise(new TransactionFailed(this.getAggregateRootId().toString(), reason));
}

/**
* Refund a confirmed transaction.
* Guard: only CONFIRMED transactions can be refunded.
*/
public refund(): void {
if (this.status !== TransactionStatus.CONFIRMED) {
throw new InvalidStateException(
`Cannot refund a transaction in state ${this.status}`,
);
}
this.raise(new TransactionWasRefunded(this.getAggregateRootId().toString()));
}

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

private onTransactionWasConfirmed(event: TransactionWasConfirmed): void {
this.status = TransactionStatus.CONFIRMED;
}

private onTransactionFailed(event: TransactionFailed): void {
this.status = TransactionStatus.FAILED;
}

private onTransactionWasRefunded(event: TransactionWasRefunded): void {
this.status = TransactionStatus.REFUNDED;
}

get pricing(): Price {
Expand All @@ -38,4 +99,8 @@ export default class Transaction extends Domain.EventSourcedAggregateRoot {
get productName(): string {
return this.product;
}

get currentStatus(): TransactionStatus {
return this.status;
}
}
Loading