diff --git a/src/main/java/io/autoinvestor/application/HoldingDeleteUseCase/HoldingDeleteCommand.java b/src/main/java/io/autoinvestor/application/HoldingDeleteUseCase/HoldingDeleteCommand.java new file mode 100644 index 0000000..f193208 --- /dev/null +++ b/src/main/java/io/autoinvestor/application/HoldingDeleteUseCase/HoldingDeleteCommand.java @@ -0,0 +1,7 @@ +package io.autoinvestor.application.HoldingDeleteUseCase; + +public record HoldingDeleteCommand( + String userId, + String assetId +) { +} diff --git a/src/main/java/io/autoinvestor/application/HoldingDeleteUseCase/HoldingDeleteCommandHandler.java b/src/main/java/io/autoinvestor/application/HoldingDeleteUseCase/HoldingDeleteCommandHandler.java new file mode 100644 index 0000000..395b111 --- /dev/null +++ b/src/main/java/io/autoinvestor/application/HoldingDeleteUseCase/HoldingDeleteCommandHandler.java @@ -0,0 +1,44 @@ +package io.autoinvestor.application.HoldingDeleteUseCase; + +import io.autoinvestor.application.HoldingsReadModel; +import io.autoinvestor.application.UsersWalletReadModel; +import io.autoinvestor.domain.events.Event; +import io.autoinvestor.domain.events.EventPublisher; +import io.autoinvestor.domain.events.WalletEventStoreRepository; +import io.autoinvestor.domain.model.Wallet; +import io.autoinvestor.domain.model.WalletId; +import io.autoinvestor.exceptions.UserWithoutPortfolio; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class HoldingDeleteCommandHandler { + + private final UsersWalletReadModel usersWalletReadModel; + private final WalletEventStoreRepository eventStore; + private final HoldingsReadModel holdingsReadModel; + private final EventPublisher eventPublisher; + + public void handle (HoldingDeleteCommand command) { + String walletId = this.usersWalletReadModel.getWalletId(command.userId()); + if (walletId == null) { + throw UserWithoutPortfolio.with(command.userId()); + } + Wallet wallet = this.eventStore.get(WalletId.of(walletId)) + .orElseThrow(() -> UserWithoutPortfolio.with(command.userId())); + wallet.deleteHolding(command.userId(), command.assetId()); + List> events = wallet.getUncommittedEvents(); + + this.eventStore.save(wallet); + + boolean removed = this.holdingsReadModel.delete(command.userId(), command.assetId()); + if (!removed) { + return; + } + this.eventPublisher.publish(events); + wallet.markEventsAsCommitted(); + } +} diff --git a/src/main/java/io/autoinvestor/application/HoldingsReadModel.java b/src/main/java/io/autoinvestor/application/HoldingsReadModel.java index 3de436a..cc7cf0b 100644 --- a/src/main/java/io/autoinvestor/application/HoldingsReadModel.java +++ b/src/main/java/io/autoinvestor/application/HoldingsReadModel.java @@ -5,5 +5,7 @@ public interface HoldingsReadModel { void add(HoldingsReadModelDTO dto); void update(HoldingsReadModelDTO dto); + boolean delete(String userId, String assetId); List getHoldings(String userId); + boolean assetAlreadyExists(String userIs, String assetId); } diff --git a/src/main/java/io/autoinvestor/application/NewHoldingUseCase/NewHoldingCommandHandler.java b/src/main/java/io/autoinvestor/application/NewHoldingUseCase/NewHoldingCommandHandler.java index 0845184..9123bc9 100644 --- a/src/main/java/io/autoinvestor/application/NewHoldingUseCase/NewHoldingCommandHandler.java +++ b/src/main/java/io/autoinvestor/application/NewHoldingUseCase/NewHoldingCommandHandler.java @@ -9,6 +9,7 @@ import io.autoinvestor.domain.model.AssetId; import io.autoinvestor.domain.model.Wallet; import io.autoinvestor.domain.model.WalletId; +import io.autoinvestor.exceptions.AssetAlreadyExists; import io.autoinvestor.exceptions.UserWithoutPortfolio; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -30,6 +31,9 @@ public void handle(NewHoldingCommand command) { throw UserWithoutPortfolio.with(command.userId()); } + if (this.holdingsReadModel.assetAlreadyExists(command.userId(), command.assetId())) { + throw AssetAlreadyExists.with(command.userId(), command.assetId()); + } Wallet wallet = this.eventStore.get(WalletId.of(walletId)) .orElseThrow(() -> UserWithoutPortfolio.with(command.userId())); diff --git a/src/main/java/io/autoinvestor/domain/events/HoldingWasDeletedEvent.java b/src/main/java/io/autoinvestor/domain/events/HoldingWasDeletedEvent.java new file mode 100644 index 0000000..df8313d --- /dev/null +++ b/src/main/java/io/autoinvestor/domain/events/HoldingWasDeletedEvent.java @@ -0,0 +1,44 @@ +package io.autoinvestor.domain.events; + +import io.autoinvestor.domain.Id; +import io.autoinvestor.domain.model.Amount; +import io.autoinvestor.domain.model.AssetId; +import io.autoinvestor.domain.model.UserId; +import io.autoinvestor.domain.model.WalletId; + +import java.util.Date; + +public class HoldingWasDeletedEvent extends Event { + public static final String TYPE = "PORTFOLIO_ASSET_REMOVED"; + + private HoldingWasDeletedEvent(Id aggregateId, HoldingWasDeletedEventPayload payload) { + super (aggregateId, TYPE, payload); + } + + protected HoldingWasDeletedEvent( + EventId id, + Id aggregateId, + HoldingWasDeletedEventPayload payload, + Date occurredAt, + int version) { + super(id, aggregateId, TYPE, payload, occurredAt, version); + } + + public static HoldingWasDeletedEvent with(WalletId walletId, + UserId userId, + AssetId assetId) { + HoldingWasDeletedEventPayload payload = new HoldingWasDeletedEventPayload( + userId.value(), assetId.value() + ); + return new HoldingWasDeletedEvent(walletId, payload); + } + + public static HoldingWasDeletedEvent hydrate (EventId id, + Id aggregateId, + HoldingWasDeletedEventPayload payload, + Date occurredAt, + int version) { + return new HoldingWasDeletedEvent(id, aggregateId, payload, occurredAt, version); + } + +} diff --git a/src/main/java/io/autoinvestor/domain/events/HoldingWasDeletedEventPayload.java b/src/main/java/io/autoinvestor/domain/events/HoldingWasDeletedEventPayload.java new file mode 100644 index 0000000..5f200b4 --- /dev/null +++ b/src/main/java/io/autoinvestor/domain/events/HoldingWasDeletedEventPayload.java @@ -0,0 +1,16 @@ +package io.autoinvestor.domain.events; + +import java.util.Map; + +public record HoldingWasDeletedEventPayload( + String userId, + String assetId +) implements EventPayload { + @Override + public Map asMap() { + return Map.of( + "userId" , userId, + "assetId", assetId + ); + } +} diff --git a/src/main/java/io/autoinvestor/domain/model/Wallet.java b/src/main/java/io/autoinvestor/domain/model/Wallet.java index f342f9d..02268f8 100644 --- a/src/main/java/io/autoinvestor/domain/model/Wallet.java +++ b/src/main/java/io/autoinvestor/domain/model/Wallet.java @@ -57,6 +57,14 @@ public void updateHolding(String userId, String assetId, Integer amount, Integer )); } + public void deleteHolding(String userId, String assetId) { + this.apply(HoldingWasDeletedEvent.with( + this.state.getWalletId(), + UserId.of(userId), + AssetId.of(assetId) + )); + } + @Override protected void when(Event e) { switch (e.getType()) { @@ -69,6 +77,8 @@ protected void when(Event e) { case HoldingWasUpdatedEvent.TYPE: whenHoldingUpdated((HoldingWasUpdatedEvent) e); break; + case HoldingWasDeletedEvent.TYPE: + default: throw new IllegalArgumentException("Unknown event type"); } @@ -88,4 +98,8 @@ private void whenHoldingCreated(HoldingWasCreatedEvent event) { private void whenHoldingUpdated(HoldingWasUpdatedEvent event) { this.state = this.state.withHoldingUpdated(event); } + + private void whenHoldingDeleted(HoldingWasDeletedEvent event) { + this.state = this.state.withHoldingDeleted(event); + } } diff --git a/src/main/java/io/autoinvestor/domain/model/WalletState.java b/src/main/java/io/autoinvestor/domain/model/WalletState.java index 9e1c503..66e9d38 100644 --- a/src/main/java/io/autoinvestor/domain/model/WalletState.java +++ b/src/main/java/io/autoinvestor/domain/model/WalletState.java @@ -56,4 +56,14 @@ public WalletState withHoldingUpdated(HoldingWasUpdatedEvent event) { this.holdings ); } + + public WalletState withHoldingDeleted(HoldingWasDeletedEvent event) { + HoldingWasDeletedEventPayload payload = event.getPayload(); + holdings.remove(AssetId.of(payload.assetId())); + return new WalletState( + this.walletId, + this.userId, + this.holdings + ); + } } diff --git a/src/main/java/io/autoinvestor/exceptions/AssetAlreadyExists.java b/src/main/java/io/autoinvestor/exceptions/AssetAlreadyExists.java new file mode 100644 index 0000000..91021d8 --- /dev/null +++ b/src/main/java/io/autoinvestor/exceptions/AssetAlreadyExists.java @@ -0,0 +1,12 @@ +package io.autoinvestor.exceptions; + +public class AssetAlreadyExists extends RuntimeException { + private AssetAlreadyExists(String message) { + super(message); + } + + public static AssetAlreadyExists with (String userId, String assetId) { + String message = "Duplicate asset " + assetId + " for user " + userId; + return new AssetAlreadyExists(message); + } +} diff --git a/src/main/java/io/autoinvestor/infrastructure/read_models/InMemoryHoldingsReadModel.java b/src/main/java/io/autoinvestor/infrastructure/read_models/InMemoryHoldingsReadModel.java index 7d3f345..7f871f8 100644 --- a/src/main/java/io/autoinvestor/infrastructure/read_models/InMemoryHoldingsReadModel.java +++ b/src/main/java/io/autoinvestor/infrastructure/read_models/InMemoryHoldingsReadModel.java @@ -20,8 +20,18 @@ public void update(HoldingsReadModelDTO dto) { } + @Override + public boolean delete(String userId, String assetId) { + return false; + } + @Override public List getHoldings(String userId) { return List.of(); } + + @Override + public boolean assetAlreadyExists(String userIs, String assetId) { + return false; + } } diff --git a/src/main/java/io/autoinvestor/infrastructure/read_models/MongoHoldingsReadModel.java b/src/main/java/io/autoinvestor/infrastructure/read_models/MongoHoldingsReadModel.java index 4be19b2..e6e8c00 100644 --- a/src/main/java/io/autoinvestor/infrastructure/read_models/MongoHoldingsReadModel.java +++ b/src/main/java/io/autoinvestor/infrastructure/read_models/MongoHoldingsReadModel.java @@ -1,5 +1,6 @@ package io.autoinvestor.infrastructure.read_models; +import com.mongodb.client.result.DeleteResult; import io.autoinvestor.application.HoldingsReadModel; import io.autoinvestor.application.HoldingsReadModelDTO; import org.springframework.context.annotation.Profile; @@ -39,10 +40,26 @@ public void update(HoldingsReadModelDTO dto) { this.template.updateFirst(query, update, MongoHoldingsReadModelDocument.class); } + @Override + public boolean delete(String userId, String assetId) { + Query query = new Query(Criteria.where("userId").is(userId) + .and("assetId").is(assetId)); + DeleteResult result = this.template.remove(query, MongoHoldingsReadModelDocument.class); + return result.getDeletedCount() > 0; + } + @Override public List getHoldings(String userId) { Query query = new Query(Criteria.where("userId").is(userId)); return this.template.find(query, MongoHoldingsReadModelDocument.class) .stream().map(mapper::toDTO).toList(); } + + @Override + public boolean assetAlreadyExists(String userId, String assetId) { + Query query = new Query(Criteria.where("userId").is(userId) + .and("assetId").is(assetId)); + return this.template.exists(query, MongoHoldingsReadModelDocument.class); + } + } diff --git a/src/main/java/io/autoinvestor/ui/PortfolioController.java b/src/main/java/io/autoinvestor/ui/PortfolioController.java index b01bc72..b28164f 100644 --- a/src/main/java/io/autoinvestor/ui/PortfolioController.java +++ b/src/main/java/io/autoinvestor/ui/PortfolioController.java @@ -1,5 +1,7 @@ package io.autoinvestor.ui; +import io.autoinvestor.application.HoldingDeleteUseCase.HoldingDeleteCommand; +import io.autoinvestor.application.HoldingDeleteUseCase.HoldingDeleteCommandHandler; import io.autoinvestor.application.HoldingsReadModelDTO; import io.autoinvestor.application.NewHoldingUseCase.NewHoldingCommand; import io.autoinvestor.application.NewHoldingUseCase.NewHoldingCommandHandler; @@ -27,6 +29,7 @@ public class PortfolioController { private final WalletCreatedHandler walletCreatedHandler; private final GetHoldingResponseDocumentMapper mapperGetHoldingResponse; private final UpdateHoldingCommandHandler updateHoldingCommandHandler; + private final HoldingDeleteCommandHandler holdingDeleteCommandHandler; @PostMapping public ResponseEntity addHolding( @RequestHeader(value = "X-User-Id", required = true) String userId, @@ -67,6 +70,13 @@ public ResponseEntity putHolding ( return ResponseEntity.status(HttpStatus.CREATED).build(); } + public ResponseEntity deleteHolding ( + @RequestHeader (value = "X-User-Id", required = true) String userId, + @RequestParam(value = "assetId", required = true) String assetId + ) { + holdingDeleteCommandHandler.handle(new HoldingDeleteCommand(userId, assetId)); + return ResponseEntity.status(HttpStatus.OK).build(); + } @PostMapping("/user") public ResponseEntity simulateIncomingUserCreatedMessage (