Skip to content

Commit eb366c6

Browse files
authored
Feat: 3.2 Update monthly budget (#17)
* Feat: 3.2 Update monthly budget
1 parent 0f68916 commit eb366c6

12 files changed

Lines changed: 808 additions & 16 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.upkeep.application.port.in.budget;
2+
3+
import com.upkeep.domain.model.budget.Currency;
4+
5+
public interface UpdateCompanyBudgetUseCase {
6+
7+
UpdateBudgetResult execute(UpdateBudgetCommand command);
8+
9+
record UpdateBudgetCommand(
10+
String companyId,
11+
String actorUserId,
12+
long newAmountCents,
13+
Currency currency
14+
) {}
15+
16+
record UpdateBudgetResult(
17+
String budgetId,
18+
long amountCents,
19+
String currency,
20+
boolean isLowerThanAllocations,
21+
long currentAllocationsCents
22+
) {}
23+
}
24+
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.upkeep.application.usecase;
2+
3+
import com.upkeep.application.port.in.budget.UpdateCompanyBudgetUseCase;
4+
import com.upkeep.application.port.out.audit.AuditEventRepository;
5+
import com.upkeep.application.port.out.budget.BudgetRepository;
6+
import com.upkeep.application.port.out.membership.MembershipRepository;
7+
import com.upkeep.domain.exception.BudgetNotFoundException;
8+
import com.upkeep.domain.exception.MembershipNotFoundException;
9+
import com.upkeep.domain.exception.UnauthorizedOperationException;
10+
import com.upkeep.domain.model.audit.AuditEvent;
11+
import com.upkeep.domain.model.budget.Budget;
12+
import com.upkeep.domain.model.budget.Money;
13+
import com.upkeep.domain.model.company.CompanyId;
14+
import com.upkeep.domain.model.customer.CustomerId;
15+
import com.upkeep.domain.model.membership.Membership;
16+
import jakarta.enterprise.context.ApplicationScoped;
17+
import jakarta.inject.Inject;
18+
import jakarta.transaction.Transactional;
19+
20+
@ApplicationScoped
21+
public class UpdateCompanyBudgetUseCaseImpl implements UpdateCompanyBudgetUseCase {
22+
23+
private final BudgetRepository budgetRepository;
24+
private final AuditEventRepository auditEventRepository;
25+
private final MembershipRepository membershipRepository;
26+
27+
@Inject
28+
public UpdateCompanyBudgetUseCaseImpl(BudgetRepository budgetRepository,
29+
AuditEventRepository auditEventRepository,
30+
MembershipRepository membershipRepository) {
31+
this.budgetRepository = budgetRepository;
32+
this.auditEventRepository = auditEventRepository;
33+
this.membershipRepository = membershipRepository;
34+
}
35+
36+
@Override
37+
@Transactional
38+
public UpdateBudgetResult execute(UpdateBudgetCommand command) {
39+
CompanyId companyId = CompanyId.from(command.companyId());
40+
CustomerId actorId = CustomerId.from(command.actorUserId());
41+
42+
Membership membership = membershipRepository.findByCustomerIdAndCompanyId(actorId, companyId)
43+
.orElseThrow(() -> new MembershipNotFoundException(command.actorUserId(), command.companyId()));
44+
45+
if (!membership.isOwner()) {
46+
throw new UnauthorizedOperationException("Only owners can update the company budget");
47+
}
48+
49+
Budget budget = budgetRepository.findByCompanyId(companyId)
50+
.orElseThrow(() -> new BudgetNotFoundException(command.companyId()));
51+
52+
long previousAmountCents = budget.getAmount().amountCents();
53+
long currentAllocationsCents = calculateCurrentAllocations(companyId);
54+
55+
Money newAmount = new Money(command.newAmountCents(), command.currency());
56+
budget.updateAmount(newAmount);
57+
budgetRepository.save(budget);
58+
59+
AuditEvent event = AuditEvent.budgetUpdated(companyId, actorId, budget, previousAmountCents);
60+
auditEventRepository.save(event);
61+
62+
boolean isLowerThanAllocations = command.newAmountCents() < currentAllocationsCents;
63+
64+
return new UpdateBudgetResult(
65+
budget.getId().toString(),
66+
newAmount.amountCents(),
67+
newAmount.currency().name(),
68+
isLowerThanAllocations,
69+
currentAllocationsCents
70+
);
71+
}
72+
73+
private long calculateCurrentAllocations(CompanyId companyId) {
74+
// TODO: Implement when allocations are available (Story 4.x)
75+
// For now, return 0 as allocations are not yet implemented
76+
return 0L;
77+
}
78+
}
79+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.upkeep.domain.exception;
2+
3+
/**
4+
* Thrown when attempting to update a budget that does not exist.
5+
*/
6+
public class BudgetNotFoundException extends DomainException {
7+
8+
private final String companyId;
9+
10+
public BudgetNotFoundException(String companyId) {
11+
super("No budget found for this company");
12+
this.companyId = companyId;
13+
}
14+
15+
public String getCompanyId() {
16+
return companyId;
17+
}
18+
}
19+

apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/budget/BudgetResource.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import com.upkeep.application.port.in.budget.SetCompanyBudgetUseCase;
66
import com.upkeep.application.port.in.budget.SetCompanyBudgetUseCase.SetBudgetCommand;
77
import com.upkeep.application.port.in.budget.SetCompanyBudgetUseCase.SetBudgetResult;
8+
import com.upkeep.application.port.in.budget.UpdateCompanyBudgetUseCase;
9+
import com.upkeep.application.port.in.budget.UpdateCompanyBudgetUseCase.UpdateBudgetCommand;
10+
import com.upkeep.application.port.in.budget.UpdateCompanyBudgetUseCase.UpdateBudgetResult;
811
import com.upkeep.application.port.out.auth.TokenService;
912
import com.upkeep.application.port.out.auth.TokenService.TokenClaims;
1013
import com.upkeep.domain.model.budget.Currency;
@@ -14,6 +17,7 @@
1417
import jakarta.ws.rs.Consumes;
1518
import jakarta.ws.rs.CookieParam;
1619
import jakarta.ws.rs.GET;
20+
import jakarta.ws.rs.PATCH;
1721
import jakarta.ws.rs.POST;
1822
import jakarta.ws.rs.Path;
1923
import jakarta.ws.rs.PathParam;
@@ -29,13 +33,16 @@ public class BudgetResource {
2933
private static final String ACCESS_TOKEN_COOKIE = "access_token";
3034

3135
private final SetCompanyBudgetUseCase setCompanyBudgetUseCase;
36+
private final UpdateCompanyBudgetUseCase updateCompanyBudgetUseCase;
3237
private final GetBudgetSummaryUseCase getBudgetSummaryUseCase;
3338
private final TokenService tokenService;
3439

3540
public BudgetResource(SetCompanyBudgetUseCase setCompanyBudgetUseCase,
41+
UpdateCompanyBudgetUseCase updateCompanyBudgetUseCase,
3642
GetBudgetSummaryUseCase getBudgetSummaryUseCase,
3743
TokenService tokenService) {
3844
this.setCompanyBudgetUseCase = setCompanyBudgetUseCase;
45+
this.updateCompanyBudgetUseCase = updateCompanyBudgetUseCase;
3946
this.getBudgetSummaryUseCase = getBudgetSummaryUseCase;
4047
this.tokenService = tokenService;
4148
}
@@ -91,6 +98,38 @@ public Response setBudget(@CookieParam(ACCESS_TOKEN_COOKIE) String accessToken,
9198
.build();
9299
}
93100

101+
@PATCH
102+
public Response updateBudget(@CookieParam(ACCESS_TOKEN_COOKIE) String accessToken,
103+
@PathParam("companyId") String companyId,
104+
@Valid UpdateBudgetRequest request) {
105+
TokenClaims claims = validateToken(accessToken);
106+
107+
if (claims == null) {
108+
return unauthorizedResponse();
109+
}
110+
111+
Currency currency = Currency.valueOf(request.currency());
112+
113+
UpdateBudgetResult result = updateCompanyBudgetUseCase.execute(
114+
new UpdateBudgetCommand(
115+
companyId,
116+
claims.userId(),
117+
request.amountCents(),
118+
currency
119+
)
120+
);
121+
122+
UpdateBudgetResponse response = new UpdateBudgetResponse(
123+
result.budgetId(),
124+
result.amountCents(),
125+
result.currency(),
126+
result.isLowerThanAllocations(),
127+
result.currentAllocationsCents()
128+
);
129+
130+
return Response.ok(ApiResponse.success(response)).build();
131+
}
132+
94133
private TokenClaims validateToken(String accessToken) {
95134
if (accessToken == null || accessToken.isBlank()) {
96135
return null;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.upkeep.infrastructure.adapter.in.rest.budget;
2+
3+
import jakarta.validation.constraints.Min;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
6+
7+
public record UpdateBudgetRequest(
8+
@Min(value = 100, message = "Budget must be at least 100 cents (1.00)")
9+
long amountCents,
10+
11+
@NotBlank(message = "Currency is required")
12+
@Pattern(regexp = "EUR|USD|GBP", message = "Currency must be EUR, USD, or GBP")
13+
String currency
14+
) {
15+
}
16+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.upkeep.infrastructure.adapter.in.rest.budget;
2+
3+
public record UpdateBudgetResponse(
4+
String budgetId,
5+
long amountCents,
6+
String currency,
7+
boolean isLowerThanAllocations,
8+
long currentAllocationsCents
9+
) {
10+
}
11+

apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/budget/BudgetJpaRepository.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ public class BudgetJpaRepository implements BudgetRepository, PanacheRepositoryB
1717
@Override
1818
public void save(Budget budget) {
1919
BudgetEntity entity = BudgetMapper.toEntity(budget);
20-
persist(entity);
20+
BudgetEntity existingEntity = findById(budget.getId().value());
21+
22+
if (existingEntity != null) {
23+
existingEntity.amountCents = entity.amountCents;
24+
existingEntity.currency = entity.currency;
25+
existingEntity.updatedAt = entity.updatedAt;
26+
} else {
27+
persist(entity);
28+
}
2129
}
2230

2331
@Override

0 commit comments

Comments
 (0)