From 9df0b910225776132fa2e2fcade8c6f3d7879891 Mon Sep 17 00:00:00 2001
From: Robert Goldmann <deadlocker@gmx.de>
Date: Tue, 30 Jun 2020 21:41:20 +0200
Subject: [PATCH] Fixed #526 - duplicated recurring transactions after old
 database is migrated to BudgetMaster 2.4.0

---
 pom.xml                                       |   4 +-
 .../budgetmaster/settings/Settings.java       |  13 +
 .../settings/SettingsService.java             |   4 +
 .../transactions/TransactionRepository.java   |   1 +
 .../utils/eventlistener/DateRepair.java       | 222 ++++++++++++++++++
 .../eventlistener/UpdateInstalledVersion.java |  40 ++++
 .../resources/templates/settings/settings.ftl |   1 +
 7 files changed, 283 insertions(+), 2 deletions(-)
 create mode 100644 src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/DateRepair.java
 create mode 100644 src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/UpdateInstalledVersion.java

diff --git a/pom.xml b/pom.xml
index a49fdb4ff..e6659eee5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
 
     <groupId>de.deadlocker8</groupId>
     <artifactId>BudgetMaster</artifactId>
-    <version>2.4.0</version>
+    <version>2.4.1</version>
     <name>BudgetMaster</name>
 
     <repositories>
@@ -70,7 +70,7 @@
 
         <app.versionDate>${maven.build.timestamp}</app.versionDate>
         <maven.build.timestamp.format>dd.MM.yy</maven.build.timestamp.format>
-        <app.versionCode>23</app.versionCode>
+        <app.versionCode>24</app.versionCode>
         <app.author>Robert Goldmann</app.author>
 
         <project.outputDirectory>build/${project.version}</project.outputDirectory>
diff --git a/src/main/java/de/deadlocker8/budgetmaster/settings/Settings.java b/src/main/java/de/deadlocker8/budgetmaster/settings/Settings.java
index 4dd8a5984..7d03c617c 100644
--- a/src/main/java/de/deadlocker8/budgetmaster/settings/Settings.java
+++ b/src/main/java/de/deadlocker8/budgetmaster/settings/Settings.java
@@ -28,6 +28,7 @@ public class Settings
 	private Integer autoBackupDays;
 	private AutoBackupTime autoBackupTime;
 	private Integer autoBackupFilesToKeep;
+	private Integer installedVersionCode;
 
 	public Settings()
 	{
@@ -48,6 +49,7 @@ public class Settings
 		defaultSettings.setAutoBackupDays(1);
 		defaultSettings.setAutoBackupTime(AutoBackupTime.TIME_00);
 		defaultSettings.setAutoBackupFilesToKeep(3);
+		defaultSettings.setInstalledVersionCode(0);
 
 		return defaultSettings;
 	}
@@ -186,6 +188,16 @@ public class Settings
 		this.autoBackupFilesToKeep = autoBackupFilesToKeep;
 	}
 
+	public Integer getInstalledVersionCode()
+	{
+		return installedVersionCode;
+	}
+
+	public void setInstalledVersionCode(Integer installedVersionCode)
+	{
+		this.installedVersionCode = installedVersionCode;
+	}
+
 	@Override
 	public String toString()
 	{
@@ -203,6 +215,7 @@ public class Settings
 				", autoBackupDays=" + autoBackupDays +
 				", autoBackupTime=" + autoBackupTime +
 				", autoBackupFilesToKeep=" + autoBackupFilesToKeep +
+				", installedVersionCode=" + installedVersionCode +
 				'}';
 	}
 }
\ No newline at end of file
diff --git a/src/main/java/de/deadlocker8/budgetmaster/settings/SettingsService.java b/src/main/java/de/deadlocker8/budgetmaster/settings/SettingsService.java
index 26f437d33..27ea656ec 100644
--- a/src/main/java/de/deadlocker8/budgetmaster/settings/SettingsService.java
+++ b/src/main/java/de/deadlocker8/budgetmaster/settings/SettingsService.java
@@ -77,6 +77,10 @@ public class SettingsService
 		{
 			settings.setAutoBackupFilesToKeep(defaultSettings.getAutoBackupFilesToKeep());
 		}
+		if(settings.getInstalledVersionCode() == null)
+		{
+			settings.setInstalledVersionCode(defaultSettings.getInstalledVersionCode());
+		}
 	}
 
 	@SuppressWarnings("OptionalGetWithoutIsPresent")
diff --git a/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionRepository.java b/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionRepository.java
index 5ad31f0dc..f418aab76 100644
--- a/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionRepository.java
+++ b/src/main/java/de/deadlocker8/budgetmaster/transactions/TransactionRepository.java
@@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query;
 import java.util.List;
 
 
+@SuppressWarnings("SqlResolve")
 public interface TransactionRepository extends JpaRepository<Transaction, Integer>, JpaSpecificationExecutor<Transaction>
 {
 	List<Transaction> findAllByAccountAndDateBetweenOrderByDateDesc(Account account, DateTime startDate, DateTime endDate);
diff --git a/src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/DateRepair.java b/src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/DateRepair.java
new file mode 100644
index 000000000..6733de828
--- /dev/null
+++ b/src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/DateRepair.java
@@ -0,0 +1,222 @@
+package de.deadlocker8.budgetmaster.utils.eventlistener;
+
+import de.deadlocker8.budgetmaster.repeating.RepeatingOption;
+import de.deadlocker8.budgetmaster.repeating.RepeatingOptionRepository;
+import de.deadlocker8.budgetmaster.repeating.RepeatingTransactionUpdater;
+import de.deadlocker8.budgetmaster.settings.SettingsService;
+import de.deadlocker8.budgetmaster.transactions.Transaction;
+import de.deadlocker8.budgetmaster.transactions.TransactionRepository;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.persistence.EntityManager;
+import javax.persistence.Query;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Component
+public class DateRepair
+{
+	private static final Logger LOGGER = LoggerFactory.getLogger(DateRepair.class);
+
+	private static final int DATE_REPAIR_VERSION_CODE = 23;
+
+	private final TransactionRepository transactionRepository;
+	private final RepeatingOptionRepository repeatingOptionRepository;
+	private final RepeatingTransactionUpdater repeatingTransactionUpdater;
+	private final SettingsService settingsService;
+	private final EntityManager entityManager;
+
+	@Autowired
+	public DateRepair(TransactionRepository transactionRepository, RepeatingOptionRepository repeatingOptionRepository, RepeatingTransactionUpdater repeatingTransactionUpdater, SettingsService settingsService, EntityManager entityManager)
+	{
+		this.transactionRepository = transactionRepository;
+		this.repeatingOptionRepository = repeatingOptionRepository;
+		this.repeatingTransactionUpdater = repeatingTransactionUpdater;
+		this.settingsService = settingsService;
+		this.entityManager = entityManager;
+	}
+
+	@EventListener
+	@Transactional
+	@Order(1)
+	public void onApplicationEvent(ApplicationStartedEvent event)
+	{
+		checkForPossibleDuplicates();
+		if(settingsService.getSettings().getInstalledVersionCode() <= DATE_REPAIR_VERSION_CODE)
+		{
+			deleteOldRepeatingTransactions();
+
+			repairTransactionDates();
+			repairRepeatingOptionsDates();
+
+			LOGGER.debug("Re-created repeating transactions");
+			repeatingTransactionUpdater.updateRepeatingTransactions(DateTime.now());
+		}
+	}
+
+	private void deleteOldRepeatingTransactions()
+	{
+		int numberOfDeletedItems = 0;
+
+		for(RepeatingOption repeatingOption : repeatingOptionRepository.findAll())
+		{
+			final List<Transaction> referringTransactions = repeatingOption.getReferringTransactions();
+			final List<Transaction> transactionsSorted = referringTransactions.stream()
+					.sorted(Comparator.comparing(Transaction::getDate))
+					.collect(Collectors.toList());
+
+			final List<Transaction> transactions = transactionsSorted.subList(1, transactionsSorted.size());
+			numberOfDeletedItems += transactions.size();
+
+			for(Transaction transaction : transactions)
+			{
+				final Query nativeQuery = entityManager.createNativeQuery("DELETE FROM `transaction` WHERE id=:ID");
+				nativeQuery.setParameter("ID", transaction.getID());
+				nativeQuery.executeUpdate();
+			}
+		}
+
+		LOGGER.debug(MessageFormat.format("Deleted {0} wrong repeating transactions", numberOfDeletedItems));
+	}
+
+	private void checkForPossibleDuplicates()
+	{
+		final List<Transaction> transactions = transactionRepository.findAll();
+		final List<Transaction> alreadyScanned = new ArrayList<>();
+		final List<List<Transaction>> duplicated = new ArrayList<>();
+
+		for(Transaction transaction : transactions)
+		{
+			for(Transaction scannedTransaction : alreadyScanned)
+			{
+				if(isPossibleDuplicate(transaction, scannedTransaction))
+				{
+					final ArrayList<Transaction> entry = new ArrayList<>();
+					entry.add(scannedTransaction);
+					entry.add(transaction);
+					duplicated.add(entry);
+				}
+			}
+
+			alreadyScanned.add(transaction);
+		}
+
+		if(!duplicated.isEmpty())
+		{
+			final List<List<Integer>> duplicatedIDs = duplicated.stream()
+					.map(entry -> entry.stream().map(Transaction::getID).collect(Collectors.toList()))
+					.collect(Collectors.toList());
+			LOGGER.warn(MessageFormat.format("Found {0} possible duplicated transactions: {1}", duplicated.size(), duplicatedIDs));
+		}
+	}
+
+	private boolean isPossibleDuplicate(Transaction transaction1, Transaction transaction2)
+	{
+		if(transaction1.getRepeatingOption() == null || transaction2.getRepeatingOption() == null)
+		{
+			return false;
+		}
+
+		if(!transaction1.getRepeatingOption().equals(transaction2.getRepeatingOption()))
+		{
+			return false;
+		}
+
+		return areDatesSimilar(transaction1.getDate(), transaction2.getDate());
+	}
+
+	private boolean areDatesSimilar(DateTime date1, DateTime date2)
+	{
+		if(date1.getYear() != date2.getYear())
+		{
+			return false;
+		}
+
+		if(date1.getMonthOfYear() != date2.getMonthOfYear())
+		{
+			return false;
+		}
+
+		return Math.abs(date1.getDayOfMonth() - date2.getDayOfMonth()) <= 1;
+	}
+
+	private void repairTransactionDates()
+	{
+		int numberOfRepairedTransactions = 0;
+
+		final List<Transaction> transactions = transactionRepository.findAll();
+		for(Transaction transaction : transactions)
+		{
+			final DateTime date = transaction.getDate();
+			if(dateNeedsToBeRepaired(date))
+			{
+				final DateTime fixedDate = getRepairedDate(date);
+				transaction.setDate(fixedDate);
+				numberOfRepairedTransactions++;
+			}
+		}
+
+		LOGGER.debug(MessageFormat.format("Repaired {0}/{1} transaction dates", numberOfRepairedTransactions, transactions.size()));
+	}
+
+	private void repairRepeatingOptionsDates()
+	{
+		int numberOfRepairedItems = 0;
+
+		final List<RepeatingOption> repeatingOptions = repeatingOptionRepository.findAll();
+		for(RepeatingOption repeatingOption : repeatingOptions)
+		{
+			final DateTime date = repeatingOption.getStartDate();
+			if(dateNeedsToBeRepaired(date))
+			{
+				final DateTime fixedDate = getRepairedDate(date);
+				repeatingOption.setStartDate(fixedDate);
+				numberOfRepairedItems++;
+			}
+		}
+
+		LOGGER.debug(MessageFormat.format("Repaired {0}/{1} repeating option dates", numberOfRepairedItems, repeatingOptions.size()));
+	}
+
+	private DateTime getRepairedDate(DateTime date)
+	{
+		return date.plusHours(6)
+				.withZone(DateTimeZone.UTC)
+				.withHourOfDay(0)
+				.withMinuteOfHour(0)
+				.withSecondOfMinute(0)
+				.withMillisOfSecond(0);
+	}
+
+	private boolean dateNeedsToBeRepaired(DateTime date)
+	{
+		if(date.getHourOfDay() != 0)
+		{
+			return true;
+		}
+
+		if(date.getMinuteOfHour() != 0)
+		{
+			return true;
+		}
+
+		if(date.getSecondOfMinute() != 0)
+		{
+			return true;
+		}
+
+		return date.getMillisOfSecond() != 0;
+	}
+}
diff --git a/src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/UpdateInstalledVersion.java b/src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/UpdateInstalledVersion.java
new file mode 100644
index 000000000..9fc9065b8
--- /dev/null
+++ b/src/main/java/de/deadlocker8/budgetmaster/utils/eventlistener/UpdateInstalledVersion.java
@@ -0,0 +1,40 @@
+package de.deadlocker8.budgetmaster.utils.eventlistener;
+
+import de.deadlocker8.budgetmaster.Build;
+import de.deadlocker8.budgetmaster.settings.SettingsService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.text.MessageFormat;
+
+@Component
+public class UpdateInstalledVersion
+{
+	private static final Logger LOGGER = LoggerFactory.getLogger(UpdateInstalledVersion.class);
+
+	private final SettingsService settingsService;
+
+	@Autowired
+	public UpdateInstalledVersion(SettingsService settingsService)
+	{
+		this.settingsService = settingsService;
+	}
+
+	@EventListener
+	@Transactional
+	@Order(2)
+	public void onApplicationEvent(ApplicationStartedEvent event)
+	{
+		final Build build = Build.getInstance();
+		final int runningVersionCode = Integer.parseInt(build.getVersionCode());
+
+		LOGGER.debug(MessageFormat.format("Updated installedVersionCode to {0}", runningVersionCode));
+		settingsService.getSettings().setInstalledVersionCode(runningVersionCode);
+	}
+}
\ No newline at end of file
diff --git a/src/main/resources/templates/settings/settings.ftl b/src/main/resources/templates/settings/settings.ftl
index 434bda646..f1d99668a 100644
--- a/src/main/resources/templates/settings/settings.ftl
+++ b/src/main/resources/templates/settings/settings.ftl
@@ -24,6 +24,7 @@
                         <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
                         <input type="hidden" name="ID" value="${settings.getID()?c}">
                         <input type="hidden" name="lastBackupReminderDate" value="${dateService.getLongDateString(settings.getLastBackupReminderDate())}">
+                        <input type="hidden" name="installedVersionCode" value="${settings.getInstalledVersionCode()}">
 
                         <#-- password -->
                         <div class="row">
-- 
GitLab