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 javax.validation.constraints.NotNull;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@ -23,7 +22,6 @@ public class BudgetRequest {
this.users.addAll(users);
}
@NotNull
public Set<UserPermissionRequest> getUsers() {
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.category.Category;
import com.wbrawner.budgetserver.recurrence.RecurringTransaction;
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 static com.wbrawner.budgetserver.Utils.randomId;
@ -26,19 +30,24 @@ public class Transaction implements Comparable<Transaction> {
@ManyToOne
@JoinColumn(nullable = false)
private Budget budget;
@ManyToOne
private RecurringTransaction recurrence;
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,
String description,
Instant date,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget) {
public Transaction(
String title,
String description,
Instant date,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget,
RecurringTransaction recurrence
) {
this.title = title;
this.description = description;
this.date = date;
@ -47,11 +56,10 @@ public class Transaction implements Comparable<Transaction> {
this.expense = expense;
this.createdBy = createdBy;
this.budget = budget;
this.recurrence = recurrence;
}
public String getId() {
// This should only be set from Hibernate so it shouldn't actually be null ever
//noinspection ConstantConditions
return id;
}
@ -115,6 +123,14 @@ public class Transaction implements Comparable<Transaction> {
this.budget = budget;
}
public RecurringTransaction getRecurrence() {
return this.recurrence;
}
public void setRecurrence(RecurringTransaction recurrence) {
this.recurrence = recurrence;
}
@Override
public int compareTo(Transaction other) {
return this.date.compareTo(other.date);

View file

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

View file

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