WIP: Implement recurring transactions

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2021-04-03 17:16:25 -07:00
parent 36ee1bccfa
commit f9a1521d65
10 changed files with 625 additions and 16 deletions

View file

@ -2,7 +2,6 @@ package com.wbrawner.budgetserver.budget;
import com.wbrawner.budgetserver.permission.UserPermissionRequest; import com.wbrawner.budgetserver.permission.UserPermissionRequest;
import javax.validation.constraints.NotNull;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
@ -23,7 +22,6 @@ public class BudgetRequest {
this.users.addAll(users); this.users.addAll(users);
} }
@NotNull
public Set<UserPermissionRequest> getUsers() { public Set<UserPermissionRequest> getUsers() {
return Set.copyOf(users); return Set.copyOf(users);
} }

View file

@ -0,0 +1,184 @@
package com.wbrawner.budgetserver.recurrence;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.user.User;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import java.util.TimeZone;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity
public class RecurringTransaction {
@Id
private final String id = randomId();
@ManyToOne
@JoinColumn(nullable = false)
private final User createdBy;
private String title;
private String description;
private Long amount;
@ManyToOne
private Category category;
private Boolean expense;
@ManyToOne
@JoinColumn(nullable = false)
private Budget budget;
private String timeZone;
private int time;
private FrequencyUnit frequencyUnit;
private int frequencyValue;
public RecurringTransaction() {
this(
null,
null,
FrequencyUnit.DAILY,
0,
null,
0,
null,
null,
null,
null,
null
);
}
public RecurringTransaction(
String title,
String description,
FrequencyUnit frequencyUnit,
int frequencyValue,
String timeZone,
int time,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget
) {
this.title = title;
this.description = description;
this.frequencyUnit = frequencyUnit;
this.frequencyValue = frequencyValue;
setTimeZone(timeZone);
this.time = time;
this.amount = amount;
this.category = category;
this.expense = expense;
this.createdBy = createdBy;
this.budget = budget;
}
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public FrequencyUnit getFrequencyUnit() {
return frequencyUnit;
}
public int getFrequencyValue() {
return frequencyValue;
}
public void setFrequency(FrequencyUnit frequencyUnit, int frequencyValue) {
if (frequencyValue < 0) throw new IllegalArgumentException("frequencyValue must be at least 0");
if (frequencyValue > frequencyUnit.maxValue) {
throw new IllegalArgumentException(String.format(
"Invalid frequencyValue. Requested %d for %s but maxValue is %d",
frequencyValue,
frequencyUnit.name(),
frequencyUnit.maxValue
));
}
this.frequencyUnit = frequencyUnit;
this.frequencyValue = frequencyValue;
}
public String getTimeZone() {
return timeZone;
}
public void setTimeZone(String timeZone) {
this.timeZone = TimeZone.getTimeZone(timeZone).getID();
}
public int getTimeOfDayInSeconds() {
return time;
}
public void setTimeOfDayInSeconds(int time) {
this.time = time;
}
public Long getAmount() {
return amount;
}
public void setAmount(Long amount) {
this.amount = amount;
}
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public Boolean isExpense() {
return expense;
}
public void setExpense(Boolean expense) {
this.expense = expense;
}
public User getCreatedBy() {
return createdBy;
}
public Budget getBudget() {
return budget;
}
public void setBudget(Budget budget) {
this.budget = budget;
}
enum FrequencyUnit {
DAILY(0),
WEEKLY(7),
MONTHLY(30),
YEARLY(365);
int maxValue;
FrequencyUnit(int maxValue) {
this.maxValue = maxValue;
}
}
}

View file

@ -0,0 +1,168 @@
package com.wbrawner.budgetserver.recurrence;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.category.CategoryRepository;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
@RestController
@RequestMapping(path = "/recurrence")
@Transactional
public class RecurringTransactionController {
private final CategoryRepository categoryRepository;
private final RecurringTransactionRepository recurringTransactionRepository;
private final UserPermissionRepository userPermissionsRepository;
private final Logger logger = LoggerFactory.getLogger(RecurringTransactionController.class);
public RecurringTransactionController(
CategoryRepository categoryRepository,
RecurringTransactionRepository recurringTransactionRepository,
UserPermissionRepository userPermissionsRepository
) {
this.categoryRepository = categoryRepository;
this.recurringTransactionRepository = recurringTransactionRepository;
this.userPermissionsRepository = userPermissionsRepository;
}
@GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<List<RecurringTransactionResponse>> getRecurringTransactions(
@RequestParam("budgetId") String budgetId
) {
List<UserPermission> userPermissions = userPermissionsRepository.findAllByUserAndBudget_IdIn(
getCurrentUser(),
Collections.singletonList(budgetId),
null
);
if (userPermissions.isEmpty()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
var budget = userPermissions.get(0).getBudget();
var transactions = recurringTransactionRepository.findAllByBudget(budget)
.stream()
.map(RecurringTransactionResponse::new)
.collect(Collectors.toList());
return ResponseEntity.ok(transactions);
}
@GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<RecurringTransactionResponse> getRecurringTransaction(@PathVariable String id) {
var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var transaction = recurringTransactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(new RecurringTransactionResponse(transaction));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<Object> newTransaction(@RequestBody RecurringTransactionRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null);
if (userResponse == null) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid budget ID"));
}
if (userResponse.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
var budget = userResponse.getBudget();
Category category = null;
if (request.getCategoryId() != null) {
category = categoryRepository.findByBudgetAndId(budget, request.getCategoryId()).orElse(null);
}
return ResponseEntity.ok(new RecurringTransactionResponse(recurringTransactionRepository.save(new RecurringTransaction(
request.getTitle(),
request.getDescription(),
request.getFrequencyUnit(),
request.getFrequencyValue(),
request.getTimeZone(),
request.getTime(),
request.getAmount(),
category,
request.getExpense(),
getCurrentUser(),
budget
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<Object> updateTransaction(@PathVariable String id, @RequestBody RecurringTransactionRequest request) {
var transaction = recurringTransactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request.getTitle() != null) {
transaction.setTitle(request.getTitle());
}
if (request.getDescription() != null) {
transaction.setDescription(request.getDescription());
}
if (request.getTimeZone() != null) {
transaction.setTimeZone(request.getTimeZone());
}
if (request.getAmount() != null) {
transaction.setAmount(request.getAmount());
}
if (request.getExpense() != null) {
transaction.setExpense(request.getExpense());
}
if (request.getTime() != null) {
transaction.setTimeOfDayInSeconds(request.getTime());
}
if (request.getFrequencyUnit() != null && request.getFrequencyValue() != null) {
transaction.setFrequency(request.getFrequencyUnit(), request.getFrequencyValue());
}
if (request.getBudgetId() != null) {
var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()).orElse(null);
if (newUserPermission == null || newUserPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse("Invalid budget"));
}
transaction.setBudget(newUserPermission.getBudget());
}
if (request.getCategoryId() != null) {
var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId()).orElse(null);
if (category == null) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse("Invalid category"));
}
transaction.setCategory(category);
}
return ResponseEntity.ok(new RecurringTransactionResponse(recurringTransactionRepository.save(transaction)));
}
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<Void> deleteTransaction(@PathVariable String id) {
var transaction = recurringTransactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
// Check that the transaction belongs to an budget that the user has access to before deleting it
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
recurringTransactionRepository.delete(transaction);
return ResponseEntity.ok().build();
}
}

View file

@ -0,0 +1,13 @@
package com.wbrawner.budgetserver.recurrence;
import com.wbrawner.budgetserver.budget.Budget;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface RecurringTransactionRepository extends PagingAndSortingRepository<RecurringTransaction, String> {
Optional<RecurringTransaction> findByIdAndBudgetIn(String id, List<Budget> budgets);
List<RecurringTransaction> findAllByBudget(Budget budget);
}

View file

@ -0,0 +1,83 @@
package com.wbrawner.budgetserver.recurrence;
class RecurringTransactionRequest {
private final String title;
private final String description;
private final Long amount;
private final String categoryId;
private final Boolean expense;
private final String budgetId;
private final String timeZone;
private final Integer time;
private final RecurringTransaction.FrequencyUnit frequencyUnit;
private final Integer frequencyValue;
RecurringTransactionRequest() {
this(null, null, null, 0, null, 0, null, null, null, null);
}
RecurringTransactionRequest(
String title,
String description,
RecurringTransaction.FrequencyUnit frequencyUnit,
int frequencyValue,
String timeZone,
int time,
Long amount,
String categoryId,
Boolean expense,
String budgetId
) {
this.title = title;
this.description = description;
this.frequencyUnit = frequencyUnit;
this.frequencyValue = frequencyValue;
this.timeZone = timeZone;
this.time = time;
this.amount = amount;
this.categoryId = categoryId;
this.expense = expense;
this.budgetId = budgetId;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public String getTimeZone() {
return timeZone;
}
public Integer getTime() {
return time;
}
public RecurringTransaction.FrequencyUnit getFrequencyUnit() {
return frequencyUnit;
}
public Integer getFrequencyValue() {
return frequencyValue;
}
public Long getAmount() {
return amount;
}
public String getCategoryId() {
return categoryId;
}
public Boolean getExpense() {
return expense;
}
public String getBudgetId() {
return budgetId;
}
}

View file

@ -0,0 +1,62 @@
package com.wbrawner.budgetserver.recurrence;
class RecurringTransactionResponse {
public final String id;
public final String title;
public final String description;
public final long amount;
public final boolean expense;
public final String budgetId;
public final String categoryId;
public final String createdBy;
public final String timeZone;
public final int time;
public final RecurringTransaction.FrequencyUnit frequencyUnit;
public final int frequencyValue;
RecurringTransactionResponse(
String id,
String title,
String description,
long amount,
boolean expense,
String budgetId,
String categoryId,
String createdBy,
String timeZone,
int time,
RecurringTransaction.FrequencyUnit frequencyUnit,
int frequencyValue
) {
this.id = id;
this.title = title;
this.description = description;
this.amount = amount;
this.expense = expense;
this.budgetId = budgetId;
this.categoryId = categoryId;
this.createdBy = createdBy;
this.timeZone = timeZone;
this.time = time;
this.frequencyUnit = frequencyUnit;
this.frequencyValue = frequencyValue;
}
RecurringTransactionResponse(RecurringTransaction recurringTransaction) {
this(
recurringTransaction.getId(),
recurringTransaction.getTitle(),
recurringTransaction.getDescription(),
recurringTransaction.getAmount(),
recurringTransaction.isExpense(),
recurringTransaction.getBudget().getId(),
recurringTransaction.getCategory() != null ? recurringTransaction.getCategory().getId() : null,
recurringTransaction.getCreatedBy().getId(),
recurringTransaction.getTimeZone(),
recurringTransaction.getTimeOfDayInSeconds(),
recurringTransaction.getFrequencyUnit(),
recurringTransaction.getFrequencyValue()
);
}
}

View file

@ -0,0 +1,84 @@
package com.wbrawner.budgetserver.recurrence;
import com.wbrawner.budgetserver.transaction.Transaction;
import com.wbrawner.budgetserver.transaction.TransactionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.TimeZone;
@Component
public class RecurringTransactionTask {
private final RecurringTransactionRepository recurringTransactionRepository;
private final TransactionRepository transactionRepository;
@Autowired
public RecurringTransactionTask(
RecurringTransactionRepository recurringTransactionRepository,
TransactionRepository transactionRepository
) {
this.recurringTransactionRepository = recurringTransactionRepository;
this.transactionRepository = transactionRepository;
}
@Scheduled(cron = "0 0 * * * *")
public void createTransactions() {
recurringTransactionRepository.findAll().forEach(recurringTransaction -> {
GregorianCalendar today = new GregorianCalendar(TimeZone.getTimeZone(recurringTransaction.getTimeZone()));
// if recurrence matches today, create a new transaction
int adjustedFrequencyValue, calendarField;
switch (recurringTransaction.getFrequencyUnit()) {
case DAILY -> {
// Daily transactions should have the frequency value set to 0, so we just force it to be the same
// as the current date in order to force the transaction creation.
adjustedFrequencyValue = today.get(Calendar.DATE);
calendarField = Calendar.DATE;
}
case WEEKLY -> {
// No adjustments needed for day of week
adjustedFrequencyValue = today.get(Calendar.DAY_OF_WEEK);
calendarField = Calendar.DAY_OF_WEEK;
}
case MONTHLY -> {
// Check if the day of the month is correct
adjustedFrequencyValue = Math.min(recurringTransaction.getFrequencyValue(), today.getActualMaximum(Calendar.DAY_OF_MONTH));
calendarField = Calendar.DAY_OF_MONTH;
}
case YEARLY -> {
adjustedFrequencyValue = recurringTransaction.getFrequencyValue();
if (today.isLeapYear(today.get(Calendar.YEAR)) && today.get(Calendar.DAY_OF_YEAR) >= 31 + 29) {
// We're just pretending that Feb 29th doesn't exist here...
adjustedFrequencyValue -= 1;
}
calendarField = Calendar.DAY_OF_YEAR;
}
default -> throw new IllegalStateException("Unexpected value: " + recurringTransaction.getFrequencyUnit());
}
if (adjustedFrequencyValue == today.get(calendarField)) {
createTransaction(recurringTransaction, today);
}
});
}
private void createTransaction(RecurringTransaction recurringTransaction, Calendar transactionCalendar) {
transactionCalendar.set(Calendar.HOUR, 0);
transactionCalendar.set(Calendar.MINUTE, 0);
transactionCalendar.set(Calendar.SECOND, 0);
transactionCalendar.set(Calendar.MILLISECOND, 0);
transactionCalendar.add(Calendar.SECOND, recurringTransaction.getTimeOfDayInSeconds());
transactionRepository.save(new Transaction(
recurringTransaction.getTitle(),
recurringTransaction.getDescription(),
transactionCalendar.toInstant(),
recurringTransaction.getAmount(),
recurringTransaction.getCategory(),
recurringTransaction.isExpense(),
recurringTransaction.getCreatedBy(),
recurringTransaction.getBudget(),
recurringTransaction
));
}
}

View file

@ -2,9 +2,13 @@ package com.wbrawner.budgetserver.transaction;
import com.wbrawner.budgetserver.budget.Budget; import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.category.Category; import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.recurrence.RecurringTransaction;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.budgetserver.user.User;
import javax.persistence.*; import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import java.time.Instant; import java.time.Instant;
import static com.wbrawner.budgetserver.Utils.randomId; import static com.wbrawner.budgetserver.Utils.randomId;
@ -26,19 +30,24 @@ public class Transaction implements Comparable<Transaction> {
@ManyToOne @ManyToOne
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private Budget budget; private Budget budget;
@ManyToOne
private RecurringTransaction recurrence;
public Transaction() { public Transaction() {
this(null, null, null, null, null, null, null, null); this(null, null, null, null, null, null, null, null, null);
} }
public Transaction(String title, public Transaction(
String description, String title,
Instant date, String description,
Long amount, Instant date,
Category category, Long amount,
Boolean expense, Category category,
User createdBy, Boolean expense,
Budget budget) { User createdBy,
Budget budget,
RecurringTransaction recurrence
) {
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date; this.date = date;
@ -47,11 +56,10 @@ public class Transaction implements Comparable<Transaction> {
this.expense = expense; this.expense = expense;
this.createdBy = createdBy; this.createdBy = createdBy;
this.budget = budget; this.budget = budget;
this.recurrence = recurrence;
} }
public String getId() { public String getId() {
// This should only be set from Hibernate so it shouldn't actually be null ever
//noinspection ConstantConditions
return id; return id;
} }
@ -115,6 +123,14 @@ public class Transaction implements Comparable<Transaction> {
this.budget = budget; this.budget = budget;
} }
public RecurringTransaction getRecurrence() {
return this.recurrence;
}
public void setRecurrence(RecurringTransaction recurrence) {
this.recurrence = recurrence;
}
@Override @Override
public int compareTo(Transaction other) { public int compareTo(Transaction other) {
return this.date.compareTo(other.date); return this.date.compareTo(other.date);

View file

@ -144,7 +144,8 @@ public class TransactionController {
category, category,
request.getExpense(), request.getExpense(),
getCurrentUser(), getCurrentUser(),
budget budget,
null
)))); ))));
} }

View file

@ -7,7 +7,7 @@ buildscript {
} }
dependencies { dependencies {
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.2.2.RELEASE" classpath "org.springframework.boot:spring-boot-gradle-plugin:2.4.3"
} }
} }