diff --git a/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingOption.java b/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingOption.java index 0a75c767836998eec85741023ae6e554aa8daf3c..de2d39aac8c28e5532fdc2a4a4bc59474db12b77 100644 --- a/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingOption.java +++ b/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingOption.java @@ -2,6 +2,9 @@ package de.deadlocker8.budgetmaster.repeating; import com.google.gson.annotations.Expose; import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEnd; +import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEndAfterXTimes; +import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEndDate; +import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEndNever; import de.deadlocker8.budgetmaster.repeating.modifier.RepeatingModifier; import de.deadlocker8.budgetmaster.transactions.Transaction; import org.springframework.format.annotation.DateTimeFormat; @@ -132,6 +135,35 @@ public class RepeatingOption return dates; } + /*** + * Returns whether this repeating option has ended before the given date. + */ + public boolean hasEndedBefore(LocalDate date) + { + if(endOption instanceof RepeatingEndNever) + { + return false; + } + + if(endOption instanceof RepeatingEndDate) + { + final LocalDate endDate = (LocalDate) endOption.getValue(); + return endDate.isBefore(date); + } + + if(endOption instanceof RepeatingEndAfterXTimes) + { + // Use a date fetch limit far into future to really calculate all dates. The date calculation will finish + // as soon as the number of repetitions is reached and therefore never calculate dates until the year 3000. + final List<LocalDate> repeatingDates = getRepeatingDates(LocalDate.of(3000, 1, 1)); + final LocalDate lastDate = repeatingDates.get(repeatingDates.size() - 1); + + return lastDate.isBefore(date); + } + + throw new UnsupportedOperationException("Unknown repeating end option type"); + } + @Override public String toString() { diff --git a/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingTransactionUpdater.java b/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingTransactionUpdater.java index 3f86564ea0a485e7c7da795e7319d7710b10aad2..85f3f4fcfb4e94fb4e8529dcf8fc8270de51b099 100644 --- a/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingTransactionUpdater.java +++ b/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/repeating/RepeatingTransactionUpdater.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; @Service @@ -53,4 +54,24 @@ public class RepeatingTransactionUpdater return false; } + + /** + * Returns all repeating transactions that have not ended before the given date. + */ + public List<Transaction> getActiveRepeatingTransactionsAfter(LocalDate date) + { + final List<RepeatingOption> repeatingOptions = repeatingOptionRepository.findAllByOrderByStartDateAsc(); + final List<RepeatingOption> activeRepeatingOptions = repeatingOptions.stream() + .filter(repeatingOption -> !repeatingOption.hasEndedBefore(date)) + .toList(); + + final List<Transaction> activeTransactions = new ArrayList<>(); + for(RepeatingOption repeatingOption : activeRepeatingOptions) + { + final List<Transaction> transactions = transactionService.getRepository().findAllByRepeatingOption(repeatingOption); + activeTransactions.add(transactions.get(0)); + } + + return activeTransactions; + } } \ No newline at end of file diff --git a/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionController.java b/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionController.java index 10990e1e50efd1674adab9661b331c9c55c6bc98..b26ff55c412e536a550b288584de3ac5c1bcd873 100644 --- a/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionController.java +++ b/BudgetMasterServer/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionController.java @@ -57,6 +57,7 @@ public class TransactionController extends BaseController public static final String REDIRECT_NEW_TRANSACTION = "redirect:/transactions/newTransaction/normal"; public static final String NEW_TRANSACTION = "transactions/newTransactionNormal"; public static final String CHANGE_TYPE = "transactions/changeTypeModal"; + public static final String RECURRING_OVERVIEW = "transactions/recurringOverview"; } private static final String CONTINUE = "continue"; @@ -468,4 +469,12 @@ public class TransactionController extends BaseController } return ReturnValues.NEW_TRANSACTION; } + + @GetMapping("/recurringOverview") + public String recurringOverview(Model model) + { + final List<Transaction> activeRepeatingTransactions = repeatingTransactionUpdater.getActiveRepeatingTransactionsAfter(LocalDate.now()); + model.addAttribute(TransactionModelAttributes.ALL_ENTITIES, activeRepeatingTransactions); + return ReturnValues.RECURRING_OVERVIEW; + } } \ No newline at end of file diff --git a/BudgetMasterServer/src/main/resources/languages/base_de.properties b/BudgetMasterServer/src/main/resources/languages/base_de.properties index d7a232adb9bb90afa909afe583588d3c36863615..3cf6a2132e173a0b9013270e87262c63975efe52 100644 --- a/BudgetMasterServer/src/main/resources/languages/base_de.properties +++ b/BudgetMasterServer/src/main/resources/languages/base_de.properties @@ -365,6 +365,8 @@ transaction.new.label.account=Konto transaction.new.label.transfer.account=Zielkonto transaction.new.label.repeating=Wiederholung transaction.new.label.repeating.all=Alle +transactions.recurring.headline=Aktive wiederholende Buchungen +transactions.recurring.placeholder=Keine aktiven wiederholenden Buchungen repeating.button.add=Wiederholung hinzufügen repeating.button.remove=Wiederholung entfernen diff --git a/BudgetMasterServer/src/main/resources/languages/base_en.properties b/BudgetMasterServer/src/main/resources/languages/base_en.properties index b98c327d5f007700658800af9fbfb01f30f4407d..810fa857a9caee4c05c89fea29c94d4ac6dc3cac 100644 --- a/BudgetMasterServer/src/main/resources/languages/base_en.properties +++ b/BudgetMasterServer/src/main/resources/languages/base_en.properties @@ -365,6 +365,8 @@ transaction.new.label.account=Account transaction.new.label.transfer.account=Destination Account transaction.new.label.repeating=Repeating transaction.new.label.repeating.all=Every +transactions.recurring.headline=Active Recurring Transactions +transactions.recurring.placeholder=No active recurring transactions repeating.button.add=Add repetition repeating.button.remove=Remove repetition diff --git a/BudgetMasterServer/src/main/resources/templates/helpers/navbar.ftl b/BudgetMasterServer/src/main/resources/templates/helpers/navbar.ftl index 3a815470e1803ba57debddb2c2e90784afd849ba..ec860d4aefeec69d5ab7b952637c6eec10a60f51 100644 --- a/BudgetMasterServer/src/main/resources/templates/helpers/navbar.ftl +++ b/BudgetMasterServer/src/main/resources/templates/helpers/navbar.ftl @@ -149,7 +149,7 @@ <#if activeID == "transactions" || activeID == "templates" || activeID == "recurring"> <li class="sub-menu <#if activeID == "transactions">active</#if>"><a href="<@s.url '${link}'/>" class="waves-effect no-padding"><div class="stripe ${activeColor}"></div><i class="material-icons">${icon}</i>${text}</a></li> <li class="sub-menu sub-menu-entry <#if activeID == "templates">active</#if>"><a href="<@s.url '/templates'/>" class="waves-effect no-padding"><div class="stripe ${activeColor}"></div><i class="material-icons">${entityType.TEMPLATE.getIcon()}</i>${locale.getString("menu.transactions.templates")}</a></li> - <li class="sub-menu sub-menu-entry <#if activeID == "recurring">active</#if>"><a href="<@s.url '/templates'/>" class="waves-effect no-padding"><div class="stripe ${activeColor}"></div><i class="material-icons">${entityType.RECURRING_TRANSACTIONS.getIcon()}</i>${locale.getString("menu.transactions.recurring")}</a></li> + <li class="sub-menu sub-menu-entry <#if activeID == "recurring">active</#if>"><a href="<@s.url '/transactions/recurringOverview'/>" class="waves-effect no-padding"><div class="stripe ${activeColor}"></div><i class="material-icons">${entityType.RECURRING_TRANSACTIONS.getIcon()}</i>${locale.getString("menu.transactions.recurring")}</a></li> <#else> <li><a href="<@s.url '${link}'/>" class="waves-effect"><i class="material-icons">${icon}</i>${text}</a></li> </#if> diff --git a/BudgetMasterServer/src/main/resources/templates/transactions/recurringOverview.ftl b/BudgetMasterServer/src/main/resources/templates/transactions/recurringOverview.ftl new file mode 100644 index 0000000000000000000000000000000000000000..709632930a3ed5c916905abd28fc1149a20704e9 --- /dev/null +++ b/BudgetMasterServer/src/main/resources/templates/transactions/recurringOverview.ftl @@ -0,0 +1,44 @@ +<html> + <head> + <#import "../helpers/header.ftl" as header> + <@header.globals/> + <@header.header "BudgetMaster - ${locale.getString('transactions.recurring.headline')}"/> + <@header.style "transactions"/> + <@header.style "search"/> + <#import "/spring.ftl" as s> + </head> + <@header.body> + <#import "../helpers/navbar.ftl" as navbar> + <@navbar.navbar "recurring" settings/> + + <#import "../search/searchMacros.ftl" as searchMacros> + + <main> + <div class="card main-card background-color"> + <div class="container"> + <div class="section center-align"> + <div class="headline">${locale.getString("transactions.recurring.headline")}</div> + </div> + </div> + + <@header.content> + <#if transactions?has_content> + <@searchMacros.renderTransactions transactions=transactions openLinksInNewTab=false/> + <#else> + <#-- show placeholde text if there are no active recurring transactions --> + <br> + <div class="row"> + <div class="col s12"> + <div class="headline-small center-align">${locale.getString("transactions.recurring.placeholder")}</div> + </div> + </div> + </#if> + </@header.content> + </div> + </main> + + <!-- Scripts--> + <#import "../helpers/scripts.ftl" as scripts> + <@scripts.scripts/> + </@header.body> +</html> \ No newline at end of file diff --git a/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingOptionTest.java b/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingOptionTest.java index 44e88c344a7ef1ee0a31840107feb23ff4053397..6ad5268505ae8fe7980fbd62bab78b4b9391fd2c 100644 --- a/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingOptionTest.java +++ b/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingOptionTest.java @@ -200,4 +200,73 @@ class RepeatingOptionTest assertThat(repeatingOption.getRepeatingDates(dateFetchLimit)) .isEqualTo(expected); } + + // test hasEndedBefore() + + @Test + void test_HasEndedBefore_EndNever() + { + LocalDate startDate = LocalDate.of(2018, 4, 22); + RepeatingOption repeatingOption = new RepeatingOption(startDate, + new RepeatingModifierDays(3), + new RepeatingEndNever()); + + LocalDate date = LocalDate.of(2018, 5, 2); + + assertThat(repeatingOption.hasEndedBefore(date)).isFalse(); + } + + @Test + void test_HasEndedBefore_EndDate_NotEnded() + { + LocalDate startDate = LocalDate.of(2018, 4, 30); + LocalDate endDate = LocalDate.of(2019, 9, 28); + RepeatingOption repeatingOption = new RepeatingOption(startDate, + new RepeatingModifierYears(1), + new RepeatingEndDate(endDate)); + + LocalDate date = LocalDate.of(2018, 5, 2); + + assertThat(repeatingOption.hasEndedBefore(date)).isFalse(); + } + + @Test + void test_HasEndedBefore_EndDate_HasEnded() + { + LocalDate startDate = LocalDate.of(2018, 4, 30); + LocalDate endDate = LocalDate.of(2019, 9, 28); + RepeatingOption repeatingOption = new RepeatingOption(startDate, + new RepeatingModifierYears(1), + new RepeatingEndDate(endDate)); + + LocalDate date = LocalDate.of(2019, 9, 29); + + assertThat(repeatingOption.hasEndedBefore(date)).isTrue(); + } + + @Test + void test_HasEndedBefore_EndAfterXTimes_NotEnded() + { + LocalDate startDate = LocalDate.of(2018, 4, 30); + RepeatingOption repeatingOption = new RepeatingOption(startDate, + new RepeatingModifierYears(1), + new RepeatingEndAfterXTimes(2)); + + LocalDate date = LocalDate.of(2020, 4, 29); + + assertThat(repeatingOption.hasEndedBefore(date)).isFalse(); + } + + @Test + void test_HasEndedBefore_EndAfterXTimes_HasEnded() + { + LocalDate startDate = LocalDate.of(2018, 4, 30); + RepeatingOption repeatingOption = new RepeatingOption(startDate, + new RepeatingModifierYears(1), + new RepeatingEndAfterXTimes(2)); + + LocalDate date = LocalDate.of(2022, 9, 29); + + assertThat(repeatingOption.hasEndedBefore(date)).isTrue(); + } } \ No newline at end of file diff --git a/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingTransactionUpdaterTest.java b/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingTransactionUpdaterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f6e5116205a0d4fd60b809a21a77237f21daddab --- /dev/null +++ b/BudgetMasterServer/src/test/java/de/deadlocker8/budgetmaster/unit/repeating/RepeatingTransactionUpdaterTest.java @@ -0,0 +1,124 @@ +package de.deadlocker8.budgetmaster.unit.repeating; + +import de.deadlocker8.budgetmaster.accounts.Account; +import de.deadlocker8.budgetmaster.accounts.AccountType; +import de.deadlocker8.budgetmaster.repeating.RepeatingOption; +import de.deadlocker8.budgetmaster.repeating.RepeatingOptionRepository; +import de.deadlocker8.budgetmaster.repeating.RepeatingTransactionUpdater; +import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEndAfterXTimes; +import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEndDate; +import de.deadlocker8.budgetmaster.repeating.endoption.RepeatingEndNever; +import de.deadlocker8.budgetmaster.repeating.modifier.RepeatingModifierDays; +import de.deadlocker8.budgetmaster.repeating.modifier.RepeatingModifierMonths; +import de.deadlocker8.budgetmaster.repeating.modifier.RepeatingModifierYears; +import de.deadlocker8.budgetmaster.transactions.Transaction; +import de.deadlocker8.budgetmaster.transactions.TransactionRepository; +import de.deadlocker8.budgetmaster.transactions.TransactionService; +import de.deadlocker8.budgetmaster.unit.helpers.LocalizedTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +@ExtendWith(SpringExtension.class) +@LocalizedTest +class RepeatingTransactionUpdaterTest +{ + @Mock + private TransactionService transactionService; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private RepeatingOptionRepository repeatingOptionRepository; + + private Transaction TRANSACTION_1; + private Transaction TRANSACTION_2; + private Transaction TRANSACTION_3; + private Transaction TRANSACTION_4; + + private RepeatingTransactionUpdater repeatingTransactionUpdater; + + @BeforeEach + void beforeEach() + { + final RepeatingOption REPEATING_OPTION_END_NEVER = new RepeatingOption(LocalDate.of(2022, 12, 1), + new RepeatingModifierDays(3), + new RepeatingEndNever()); + + final RepeatingOption REPEATING_OPTION_END_DATE = new RepeatingOption(LocalDate.of(2022, 10, 24), + new RepeatingModifierMonths(1), + new RepeatingEndDate(LocalDate.of(2023, 2, 15))); + + final RepeatingOption REPEATING_OPTION_END_AFTER_X_TIMES = new RepeatingOption(LocalDate.of(2018, 4, 30), + new RepeatingModifierYears(1), + new RepeatingEndAfterXTimes(2)); + + final Account account = new Account("Account", AccountType.CUSTOM); + + TRANSACTION_1 = new Transaction(); + TRANSACTION_1.setName("abc"); + TRANSACTION_1.setAmount(700); + TRANSACTION_1.setAccount(account); + TRANSACTION_1.setIsExpenditure(true); + TRANSACTION_1.setRepeatingOption(REPEATING_OPTION_END_NEVER); + Mockito.when(transactionRepository.findAllByRepeatingOption(REPEATING_OPTION_END_NEVER)).thenReturn(List.of(TRANSACTION_1)); + + TRANSACTION_2 = new Transaction(); + TRANSACTION_2.setName("Lorem"); + TRANSACTION_2.setAmount(200); + TRANSACTION_2.setAccount(account); + TRANSACTION_2.setIsExpenditure(true); + TRANSACTION_2.setRepeatingOption(REPEATING_OPTION_END_DATE); + + TRANSACTION_3 = new Transaction(); + TRANSACTION_3.setName("Ipsum"); + TRANSACTION_3.setAmount(75); + TRANSACTION_3.setAccount(account); + TRANSACTION_3.setIsExpenditure(true); + TRANSACTION_3.setRepeatingOption(REPEATING_OPTION_END_AFTER_X_TIMES); + Mockito.when(transactionRepository.findAllByRepeatingOption(REPEATING_OPTION_END_DATE)).thenReturn(List.of(TRANSACTION_2, TRANSACTION_3)); + + TRANSACTION_4 = new Transaction(); + TRANSACTION_4.setName("dolor"); + TRANSACTION_4.setAmount(50); + TRANSACTION_4.setAccount(account); + TRANSACTION_4.setIsExpenditure(true); + TRANSACTION_4.setRepeatingOption(REPEATING_OPTION_END_AFTER_X_TIMES); + Mockito.when(transactionRepository.findAllByRepeatingOption(REPEATING_OPTION_END_AFTER_X_TIMES)).thenReturn(List.of(TRANSACTION_4)); + + Mockito.when(transactionService.getRepository()).thenReturn(transactionRepository); + Mockito.when(repeatingOptionRepository.findAllByOrderByStartDateAsc()).thenReturn(List.of(REPEATING_OPTION_END_NEVER, REPEATING_OPTION_END_DATE, REPEATING_OPTION_END_AFTER_X_TIMES)); + repeatingTransactionUpdater = new RepeatingTransactionUpdater(transactionService, repeatingOptionRepository); + } + + @Test + void test_getActiveRepeatingTransactionsAfter() + { + assertThat(repeatingTransactionUpdater.getActiveRepeatingTransactionsAfter(LocalDate.of(2023, 1, 20))) + .containsExactly(TRANSACTION_1, TRANSACTION_2); + } + + @Test + void test_getActiveRepeatingTransactionsAfter_2() + { + assertThat(repeatingTransactionUpdater.getActiveRepeatingTransactionsAfter(LocalDate.of(2017, 1, 20))) + .containsExactly(TRANSACTION_1, TRANSACTION_2, TRANSACTION_4); + } + + @Test + void test_getActiveRepeatingTransactionsAfter_3() + { + assertThat(repeatingTransactionUpdater.getActiveRepeatingTransactionsAfter(LocalDate.of(2023, 10, 1))) + .containsExactly(TRANSACTION_1); + } +}