From ec7655834b46e624673aebc14d82a04c83f83851 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sat, 3 Dec 2022 10:37:55 +0100 Subject: [PATCH 01/30] First stab at adding accounts config Signed-off-by: David Caro --- README.md | 23 +++++++++++++++++++ build.gradle | 7 ++++++ .../server/ServerApplication.java | 2 -- .../server/config/AccountsConfig.java | 18 +++++++++++++++ .../server/expenses/ExpensesService.java | 17 +++++++------- src/main/resources/application-accounts.yml | 13 +++++++++++ src/main/resources/application.properties | 7 ------ src/main/resources/application.yml | 13 +++++++++++ 8 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 README.md create mode 100644 src/main/java/com/shareexpenses/server/config/AccountsConfig.java create mode 100644 src/main/resources/application-accounts.yml delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d6fea7 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +## Dev install + +You'll need java 11 jdk installed, on fedora: + +> sudo dnf install java-11-openjdk-devel + + +### Running it locally +You will need a mariadb/mysql instance running on localhost, you can run something like: + + sudo podman run \ + --name expenses_db \ + --detach \ + -p 3306:3306 \ + --rm \ + --env MARIADB_DATABASE=expenses \ + --env MARIADB_USER=expenses \ + --env 'MARIADB_PASSWORD=GMtC40W8R*9IQt^l9' \ + --env MYSQL_RANDOM_ROOT_PASSWORD=true \ + mariadb:latest + +Then you can run the application with: +> ./gradlew bootRun \ No newline at end of file diff --git a/build.gradle b/build.gradle index c2d09d8..fe27681 100644 --- a/build.gradle +++ b/build.gradle @@ -37,3 +37,10 @@ dependencies { test { useJUnitPlatform() } + + +bootRun { + jvmArgs = [ + "-Dspring.config.additional-location=file:../home-lab-secrets/home_automation/expenses/" + ] +} \ No newline at end of file diff --git a/src/main/java/com/shareexpenses/server/ServerApplication.java b/src/main/java/com/shareexpenses/server/ServerApplication.java index eaccbcc..da6585f 100644 --- a/src/main/java/com/shareexpenses/server/ServerApplication.java +++ b/src/main/java/com/shareexpenses/server/ServerApplication.java @@ -1,10 +1,8 @@ package com.shareexpenses.server; -import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@Slf4j @SpringBootApplication public class ServerApplication { diff --git a/src/main/java/com/shareexpenses/server/config/AccountsConfig.java b/src/main/java/com/shareexpenses/server/config/AccountsConfig.java new file mode 100644 index 0000000..0f8eec9 --- /dev/null +++ b/src/main/java/com/shareexpenses/server/config/AccountsConfig.java @@ -0,0 +1,18 @@ +package com.shareexpenses.server.config; + +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import lombok.Setter; + +@Configuration +@EnableConfigurationProperties +@ConfigurationProperties(prefix="accounts-config") +@Setter +public class AccountsConfig { + public List> accounts; +} diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java index ea6c2e8..66043c5 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java @@ -2,28 +2,29 @@ import com.shareexpenses.server.account.Account; import com.shareexpenses.server.account.AccountRepository; -import com.shareexpenses.server.currency.Currency; +import com.shareexpenses.server.config.AccountsConfig; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.persistence.EntityNotFoundException; -import java.time.Instant; -import java.time.LocalTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.TemporalAdjusters; -import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; @Service +@Slf4j @RequiredArgsConstructor public class ExpensesService { private final ExpensesRepository expensesRepository; private final AccountRepository accountRepository; + @Autowired + AccountsConfig accountConfig; + public List getAllExpenses() { + log.warn("Config file accounts={}", accountConfig.accounts); return expensesRepository.findAll(); } diff --git a/src/main/resources/application-accounts.yml b/src/main/resources/application-accounts.yml new file mode 100644 index 0000000..14a59b7 --- /dev/null +++ b/src/main/resources/application-accounts.yml @@ -0,0 +1,13 @@ +# Example accounts configuration +accounts-config: + accounts: + - owner: "david" + type: "transferwise" + profile-id: "dummy-david-profile-id" + api-bearer-token: "dummy-david-bearer-token" + private-key: "some-david-private-key" + - owner: "dinika" + type: "transferwise" + profile-id: "dummy-dini-profile-id" + api-bearer-token: "dummy-dini-bearer-token" + private-key: "some-dini-private-key" diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 5155062..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,7 +0,0 @@ -spring.profiles.active: local -spring.jpa.hibernate.ddl-auto=update -spring.jpa.database=mysql -spring.datasource.url=jdbc:mysql://localhost:3306/expenses -spring.datasource.username=expenses -spring.datasource.password=GMtC40W8R*9IQt^l9 -spring.data.rest.basePath=/api diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ab9c8b5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + profiles: + active: "local" + include: + - "accounts" + jpa: + hibernate.ddl-auto: "update" + database: "mysql" + datasource: + url: "jdbc:mysql://localhost:3306/expenses" + username: "expenses" + password: "GMtC40W8R*9IQt^l9" + data.rest.basePath: "/api" From 88f6bdc96e3d7c7daf1a33377243cf4bdbf0177d Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 4 Dec 2022 00:50:41 +0100 Subject: [PATCH 02/30] expenses-64 // retrieve balance ids for all accounts --- .../server/config/AccountConfig.java | 16 +++++++ .../server/config/AccountsConfig.java | 3 +- .../server/discovery/DiscoverController.java | 24 +++++++++++ .../server/discovery/WiseBalance.java | 11 +++++ .../server/discovery/WiseService.java | 42 +++++++++++++++++++ .../server/expenses/ExpensesService.java | 4 +- 6 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/shareexpenses/server/config/AccountConfig.java create mode 100644 src/main/java/com/shareexpenses/server/discovery/DiscoverController.java create mode 100644 src/main/java/com/shareexpenses/server/discovery/WiseBalance.java create mode 100644 src/main/java/com/shareexpenses/server/discovery/WiseService.java diff --git a/src/main/java/com/shareexpenses/server/config/AccountConfig.java b/src/main/java/com/shareexpenses/server/config/AccountConfig.java new file mode 100644 index 0000000..11fe22f --- /dev/null +++ b/src/main/java/com/shareexpenses/server/config/AccountConfig.java @@ -0,0 +1,16 @@ +package com.shareexpenses.server.config; + +import lombok.*; + +// TODO: Limit access to fields +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class AccountConfig { + String owner; + String type; + String profileId; + String apiBearerToken; + String privateKey; +} diff --git a/src/main/java/com/shareexpenses/server/config/AccountsConfig.java b/src/main/java/com/shareexpenses/server/config/AccountsConfig.java index 0f8eec9..c02679c 100644 --- a/src/main/java/com/shareexpenses/server/config/AccountsConfig.java +++ b/src/main/java/com/shareexpenses/server/config/AccountsConfig.java @@ -1,7 +1,6 @@ package com.shareexpenses.server.config; import java.util.List; -import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -14,5 +13,5 @@ @ConfigurationProperties(prefix="accounts-config") @Setter public class AccountsConfig { - public List> accounts; + public List accounts; } diff --git a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java b/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java new file mode 100644 index 0000000..fb3318d --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java @@ -0,0 +1,24 @@ +package com.shareexpenses.server.discovery; + +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.repository.query.Param; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.sql.Timestamp; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/discover") +public class DiscoverController { + + @Autowired WiseService wiseService; + + @GetMapping + public void discoverExpenses() { + wiseService.discoverExpensesBetween(); + } +} diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseBalance.java b/src/main/java/com/shareexpenses/server/discovery/WiseBalance.java new file mode 100644 index 0000000..028653e --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/WiseBalance.java @@ -0,0 +1,11 @@ +package com.shareexpenses.server.discovery; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; + +@Setter +@Getter +public class WiseBalance implements Serializable { + private String id; +} diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseService.java b/src/main/java/com/shareexpenses/server/discovery/WiseService.java new file mode 100644 index 0000000..f674f06 --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/WiseService.java @@ -0,0 +1,42 @@ +package com.shareexpenses.server.discovery; + +import com.shareexpenses.server.config.AccountsConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.Collections; + +@Slf4j +@Service +public class WiseService { + + @Autowired + AccountsConfig accountsConfig; + + public void discoverExpensesBetween() { + accountsConfig.accounts.forEach(account -> { + String balanceUrl = getBalancesForProfile(account.getProfileId()); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + account.getApiBearerToken()); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + RestTemplate template = new RestTemplate(); + HttpEntity request = new HttpEntity(headers); + ResponseEntity wiseBalancesResp = template.exchange(balanceUrl, HttpMethod.GET, request, WiseBalance[].class); + + Arrays.asList(wiseBalancesResp.getBody()).forEach(wiseBalance -> { + log.info("Wise Balance {} ownser {}", wiseBalance.getId(), account.getOwner()); + }); + }); + } + + + private String getBalancesForProfile(String profileId) { + return "https://api.transferwise.com/v4/profiles/" + profileId + "/balances?types=STANDARD"; + } +} diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java index 66043c5..a5051e5 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java @@ -24,7 +24,9 @@ public class ExpensesService { AccountsConfig accountConfig; public List getAllExpenses() { - log.warn("Config file accounts={}", accountConfig.accounts); + accountConfig.accounts.forEach(accountConfig1 -> { + log.warn("Account profile {}", accountConfig1.getProfileId()); + }); return expensesRepository.findAll(); } From 9ef0a0e790ffdc6ff1f4c6ee7127174bb8d7e366 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 4 Dec 2022 21:10:11 +0100 Subject: [PATCH 03/30] Expenses-64 // Retrieve transactions for both accounts --- build.gradle | 5 +- .../server/discovery/WiseService.java | 96 ++++++++++++++- .../Balance.java} | 4 +- .../wise_entities/BalanceStatement.java | 13 ++ .../discovery/wise_entities/Transaction.java | 40 ++++++ .../server/utils/DigitalSignatures.java | 114 ++++++++++++++++++ 6 files changed, 266 insertions(+), 6 deletions(-) rename src/main/java/com/shareexpenses/server/discovery/{WiseBalance.java => wise_entities/Balance.java} (52%) create mode 100644 src/main/java/com/shareexpenses/server/discovery/wise_entities/BalanceStatement.java create mode 100644 src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java create mode 100644 src/main/java/com/shareexpenses/server/utils/DigitalSignatures.java diff --git a/build.gradle b/build.gradle index fe27681..34b556c 100644 --- a/build.gradle +++ b/build.gradle @@ -23,13 +23,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + // Needed to sign digital signatures for wise accounts + implementation 'org.bouncycastle:bcprov-jdk15on:1.64' + implementation 'org.bouncycastle:bcpkix-jdk15on:1.64' + compileOnly 'org.projectlombok:lombok:1.18.16' annotationProcessor 'org.projectlombok:lombok:1.18.16' testCompileOnly 'org.projectlombok:lombok:1.18.16' testAnnotationProcessor 'org.projectlombok:lombok:1.18.16' -// compile group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'mysql:mysql-connector-java' } diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseService.java b/src/main/java/com/shareexpenses/server/discovery/WiseService.java index f674f06..bce4dbd 100644 --- a/src/main/java/com/shareexpenses/server/discovery/WiseService.java +++ b/src/main/java/com/shareexpenses/server/discovery/WiseService.java @@ -1,19 +1,31 @@ package com.shareexpenses.server.discovery; import com.shareexpenses.server.config.AccountsConfig; +import com.shareexpenses.server.discovery.wise_entities.Balance; +import com.shareexpenses.server.discovery.wise_entities.BalanceStatement; +import com.shareexpenses.server.utils.DigitalSignatures; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.*; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.stereotype.Service; +import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestTemplate; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; @Slf4j @Service public class WiseService { + private static final String WISE_BASE_URL = "https://api.transferwise.com/"; + @Autowired AccountsConfig accountsConfig; @@ -27,16 +39,94 @@ public void discoverExpensesBetween() { RestTemplate template = new RestTemplate(); HttpEntity request = new HttpEntity(headers); - ResponseEntity wiseBalancesResp = template.exchange(balanceUrl, HttpMethod.GET, request, WiseBalance[].class); + ResponseEntity wiseBalancesResp = template.exchange(balanceUrl, HttpMethod.GET, request, Balance[].class); Arrays.asList(wiseBalancesResp.getBody()).forEach(wiseBalance -> { - log.info("Wise Balance {} ownser {}", wiseBalance.getId(), account.getOwner()); + log.info("Wise Balance {} owner {}", wiseBalance.getId(), account.getOwner()); + getStatementForBalanceId( + account.getProfileId(), + wiseBalance.getId(), + "2022-12-01", + "2022-12-05", + account.getApiBearerToken(), + account.getPrivateKey() + ); }); }); } + @SneakyThrows + private void getStatementForBalanceId(String profileId, String balanceId, String from, String to, String bearerToken, String privateKey) { + String statementUrl = getBalanceStatementUrl(profileId, balanceId, from, to); + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + bearerToken); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + Map params = new HashMap<>(); + params.put("fromTime", "2022-10-01T00:00:00.000Z"); + params.put("toTime", "2022-10-10T00:00:00.000Z"); + params.put("type", "COMPACT"); + + RestTemplate template = new RestTemplate(); + template.setErrorHandler(new Wise2faErrorHandler()); + HttpEntity request = new HttpEntity(headers); + + ResponseEntity maybeBalanceStatement = template.exchange(statementUrl, HttpMethod.GET, request, Object.class, params); + + if (maybeBalanceStatement.getStatusCode() == HttpStatus.FORBIDDEN) { + String x2faApprovalHeader = maybeBalanceStatement.getHeaders().getFirst("x-2fa-approval"); + assert x2faApprovalHeader != null; + byte[] signedHeader = DigitalSignatures.sign(privateKey, x2faApprovalHeader.getBytes(StandardCharsets.UTF_8)); + String base64EncodedSignature = DigitalSignatures.encodeToBase64(signedHeader); + + HttpHeaders authorizedHeaders = new HttpHeaders(); + authorizedHeaders.set("Authorization", "Bearer " + bearerToken); + authorizedHeaders.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + authorizedHeaders.set("X-Signature", base64EncodedSignature); + authorizedHeaders.set("x-2fa-approval", x2faApprovalHeader); + + RestTemplate authTemplate = new RestTemplate(); + HttpEntity authRequest = new HttpEntity(authorizedHeaders); + ResponseEntity balanceStatement = authTemplate.exchange(statementUrl, HttpMethod.GET, authRequest, BalanceStatement.class, params); + log.info("Balance Statement after 2fa approval {}", balanceStatement.getBody()); + + balanceStatement.getBody().getTransactions().forEach(wiseTransaction -> { + log.info("Wise transaction {}", wiseTransaction.getReferenceNumber()); + }); + + } else if (maybeBalanceStatement.getStatusCode() == HttpStatus.OK || maybeBalanceStatement.getStatusCode() == HttpStatus.CREATED) { + log.info("Balance statement without 2fa approval {}", maybeBalanceStatement.getBody()); + } else { + log.warn("There was an error fetching balance statement {}", maybeBalanceStatement.getStatusCode()); + } + } + + class Wise2faErrorHandler extends DefaultResponseErrorHandler { + @Override + public void handleError(ClientHttpResponse response) throws IOException { + log.info("Received Error", response.getStatusText()); + } + } private String getBalancesForProfile(String profileId) { - return "https://api.transferwise.com/v4/profiles/" + profileId + "/balances?types=STANDARD"; + return WISE_BASE_URL + "v4/profiles/" + profileId + "/balances?types=STANDARD"; + } + + private String getBalanceStatementUrl(String profileId, String balanceId, String from, String to) { + return WISE_BASE_URL + "v1/profiles/" + + profileId + + "/balance-statements/" + + balanceId + + "/statement.json?intervalStart={fromTime}" + + "&intervalEnd={toTime}" + + "&type={type}"; + } + + private String normalizePrivateKey(String privateKey) { + return privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\n", "") + .trim(); } } diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseBalance.java b/src/main/java/com/shareexpenses/server/discovery/wise_entities/Balance.java similarity index 52% rename from src/main/java/com/shareexpenses/server/discovery/WiseBalance.java rename to src/main/java/com/shareexpenses/server/discovery/wise_entities/Balance.java index 028653e..49b7dd1 100644 --- a/src/main/java/com/shareexpenses/server/discovery/WiseBalance.java +++ b/src/main/java/com/shareexpenses/server/discovery/wise_entities/Balance.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery; +package com.shareexpenses.server.discovery.wise_entities; import lombok.Getter; import lombok.Setter; @@ -6,6 +6,6 @@ @Setter @Getter -public class WiseBalance implements Serializable { +public class Balance implements Serializable { private String id; } diff --git a/src/main/java/com/shareexpenses/server/discovery/wise_entities/BalanceStatement.java b/src/main/java/com/shareexpenses/server/discovery/wise_entities/BalanceStatement.java new file mode 100644 index 0000000..9467d26 --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/wise_entities/BalanceStatement.java @@ -0,0 +1,13 @@ +package com.shareexpenses.server.discovery.wise_entities; + +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.util.List; + +@Setter +@Getter +public class BalanceStatement implements Serializable { + public List transactions; +} diff --git a/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java b/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java new file mode 100644 index 0000000..e62272e --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java @@ -0,0 +1,40 @@ +package com.shareexpenses.server.discovery.wise_entities; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.io.Serializable; + +@Setter +@Getter +public class Transaction implements Serializable { + private String referenceNumber; + private String date; + private Amount amount; + private Details details; + private String type; + + @Setter + @Getter + @NoArgsConstructor + private static class Amount implements Serializable { + private Double value; + private String currency; + } + + @Getter + @Setter + @NoArgsConstructor + private static class Details implements Serializable { + private String category; + private String description; + private Merchant merchant; + } + + @Getter + @Setter + @NoArgsConstructor + private static class Merchant implements Serializable { + private String name; + } +} diff --git a/src/main/java/com/shareexpenses/server/utils/DigitalSignatures.java b/src/main/java/com/shareexpenses/server/utils/DigitalSignatures.java new file mode 100644 index 0000000..a788be3 --- /dev/null +++ b/src/main/java/com/shareexpenses/server/utils/DigitalSignatures.java @@ -0,0 +1,114 @@ +package com.shareexpenses.server.utils; + +import java.security.Security; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMException; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.file.Path; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.Base64; + +/** + * Utility methods to sign data with private key. +*/ +public class DigitalSignatures { + /** + * Default signature algorithm. + */ + public static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * Signs data with provided private key reader. + * + * @param privateKeyReader private key reader + * @param dataToSign data to sign + * @return signature for provided data + * @throws IOException in case of error reading private key + * @throws InvalidKeyException in case of provided key being invalid + */ + public static byte[] sign(Reader privateKeyReader, byte[] dataToSign) throws IOException, InvalidKeyException { + PrivateKey key = privateKeyFromReader(privateKeyReader); + + try { + Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM, BouncyCastleProvider.PROVIDER_NAME); + signer.initSign(key); + signer.update(dataToSign); + return signer.sign(); + } catch (NoSuchAlgorithmException | NoSuchProviderException | SignatureException e) { + throw new RuntimeException(e); + } + } + + /** + * Signs data with provided private key. + * + * @param privateKeyFileContents private key file contents + * @param dataToSign data to sign + * @return signature for provided data + * @throws IOException in case of error reading private key + * @throws InvalidKeyException in case of provided key being invalid + */ + public static byte[] sign(String privateKeyFileContents, byte[] dataToSign) throws IOException, InvalidKeyException { + return sign(new StringReader(privateKeyFileContents), dataToSign); + } + + /** + * Signs data with provided private key. + * + * @param privateKeyFilePath path to private key file + * @param dataToSign data to sign + * @return signature for provided data + * @throws IOException in case of error reading private key + * @throws InvalidKeyException in case of provided key being invalid + */ + public static byte[] sign(Path privateKeyFilePath, byte[] dataToSign) throws IOException, InvalidKeyException { + return sign(new FileReader(privateKeyFilePath.toFile()), dataToSign); + } + + /** + * Encodes byte array with Base64 (RFC 4648). + * + * @param bytes byte array + * @return array in Base64 encoding + */ + public static String encodeToBase64(byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + private static PrivateKey privateKeyFromReader(Reader keyReader) throws IOException, InvalidKeyException { + try (PEMParser pemParser = new PEMParser(keyReader)) { + Object object = pemParser.readObject(); + + if (!(object instanceof PEMKeyPair)) { + throw new InvalidKeyException("Provided key is not a private key in PEM format"); + } + + PrivateKeyInfo privateKeyInfo = ((PEMKeyPair) object).getPrivateKeyInfo(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME); + + try { + return converter.getPrivateKey(privateKeyInfo); + } catch (PEMException e) { + + throw new InvalidKeyException(e); + } + } + } +} + From 4899b64f8c88b14366f9b48470edf726fb5fb4f3 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 11 Dec 2022 21:22:27 +0100 Subject: [PATCH 04/30] Add transactions from wise to expenses to review --- .../server/discovery/DiscoverController.java | 4 +- .../server/discovery/ExpenseInReview.java | 45 +++++++++++++++++ .../discovery/ExpenseInReviewRepository.java | 10 ++++ .../server/discovery/WiseService.java | 50 +++++++++++++++++-- .../discovery/wise_entities/Transaction.java | 9 ++-- .../server/utils/DateTimeUtils.java | 29 +++++++++++ .../server/StringToTimestamp.java | 23 +++++++++ 7 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java create mode 100644 src/main/java/com/shareexpenses/server/discovery/ExpenseInReviewRepository.java create mode 100644 src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java create mode 100644 src/test/java/com/shareexpenses/server/StringToTimestamp.java diff --git a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java b/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java index fb3318d..40b5c3b 100644 --- a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java @@ -18,7 +18,7 @@ public class DiscoverController { @Autowired WiseService wiseService; @GetMapping - public void discoverExpenses() { - wiseService.discoverExpensesBetween(); + public int discoverExpenses() { + return wiseService.discoverExpensesBetween(); } } diff --git a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java new file mode 100644 index 0000000..e51c30e --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java @@ -0,0 +1,45 @@ +package com.shareexpenses.server.discovery; + +import lombok.*; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import java.sql.Timestamp; + +@Entity +@Table(name = "expenses_in_review") +@Builder +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ExpenseInReview { + + @Id + private String externalId; + + @Column(nullable = false) + private Timestamp date; + + @Column + private Timestamp reviewUntil; + + @Column(nullable = false) + private Float amount; + + // TODO: Enum for currency + @Column(nullable = false) + private String currency; + + @Column + private String externalCategory; + + @Column + private String description; + + @Column + private String merchantName; + +} diff --git a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReviewRepository.java b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReviewRepository.java new file mode 100644 index 0000000..4695267 --- /dev/null +++ b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReviewRepository.java @@ -0,0 +1,10 @@ +package com.shareexpenses.server.discovery; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExpenseInReviewRepository extends JpaRepository { + + boolean existsById(String s); + + +} diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseService.java b/src/main/java/com/shareexpenses/server/discovery/WiseService.java index bce4dbd..b286b28 100644 --- a/src/main/java/com/shareexpenses/server/discovery/WiseService.java +++ b/src/main/java/com/shareexpenses/server/discovery/WiseService.java @@ -3,6 +3,8 @@ import com.shareexpenses.server.config.AccountsConfig; import com.shareexpenses.server.discovery.wise_entities.Balance; import com.shareexpenses.server.discovery.wise_entities.BalanceStatement; +import com.shareexpenses.server.discovery.wise_entities.Transaction; +import com.shareexpenses.server.utils.DateTimeUtils; import com.shareexpenses.server.utils.DigitalSignatures; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -19,17 +21,25 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; @Slf4j @Service public class WiseService { + @Autowired + private ExpenseInReviewRepository expensesInReviewQueue; + + @Autowired + private DateTimeUtils dateTimeUtils; private static final String WISE_BASE_URL = "https://api.transferwise.com/"; @Autowired AccountsConfig accountsConfig; - public void discoverExpensesBetween() { + public int discoverExpensesBetween() { + AtomicInteger newTransactions = new AtomicInteger(); + accountsConfig.accounts.forEach(account -> { String balanceUrl = getBalancesForProfile(account.getProfileId()); @@ -43,7 +53,7 @@ public void discoverExpensesBetween() { Arrays.asList(wiseBalancesResp.getBody()).forEach(wiseBalance -> { log.info("Wise Balance {} owner {}", wiseBalance.getId(), account.getOwner()); - getStatementForBalanceId( + var count = getStatementForBalanceId( account.getProfileId(), wiseBalance.getId(), "2022-12-01", @@ -51,12 +61,14 @@ public void discoverExpensesBetween() { account.getApiBearerToken(), account.getPrivateKey() ); + newTransactions.getAndAdd(count); }); }); + return newTransactions.get(); } @SneakyThrows - private void getStatementForBalanceId(String profileId, String balanceId, String from, String to, String bearerToken, String privateKey) { + private int getStatementForBalanceId(String profileId, String balanceId, String from, String to, String bearerToken, String privateKey) { String statementUrl = getBalanceStatementUrl(profileId, balanceId, from, to); HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + bearerToken); @@ -71,7 +83,7 @@ private void getStatementForBalanceId(String profileId, String balanceId, String template.setErrorHandler(new Wise2faErrorHandler()); HttpEntity request = new HttpEntity(headers); - ResponseEntity maybeBalanceStatement = template.exchange(statementUrl, HttpMethod.GET, request, Object.class, params); + ResponseEntity maybeBalanceStatement = template.exchange(statementUrl, HttpMethod.GET, request, BalanceStatement.class, params); if (maybeBalanceStatement.getStatusCode() == HttpStatus.FORBIDDEN) { String x2faApprovalHeader = maybeBalanceStatement.getHeaders().getFirst("x-2fa-approval"); @@ -90,15 +102,43 @@ private void getStatementForBalanceId(String profileId, String balanceId, String ResponseEntity balanceStatement = authTemplate.exchange(statementUrl, HttpMethod.GET, authRequest, BalanceStatement.class, params); log.info("Balance Statement after 2fa approval {}", balanceStatement.getBody()); + AtomicInteger newUnreviewedTransactions = new AtomicInteger(); balanceStatement.getBody().getTransactions().forEach(wiseTransaction -> { + + if(isCreditTransaction(wiseTransaction) || expensesInReviewQueue.existsById(wiseTransaction.getReferenceNumber())) { + // We can ignore the `wiseTransaction` if it represents a credit (money added to TW account) or if it is already added in the expensesInReview table. + return; + } + + ExpenseInReview expenseInReview = ExpenseInReview.builder() + .externalId(wiseTransaction.getReferenceNumber()) + .date(dateTimeUtils.unixTimestampFromISOString(wiseTransaction.getDate())) + .amount(Math.abs(wiseTransaction.getAmount().getValue())) + .currency(wiseTransaction.getAmount().getCurrency()) + .externalCategory(wiseTransaction.getDetails().getCategory()) + .description(wiseTransaction.getDetails().getDescription()) + .merchantName(wiseTransaction.getDetails().getMerchant() == null + ? null + : wiseTransaction.getDetails().getMerchant().getName() + ) + .reviewUntil(null) + .build(); + + expensesInReviewQueue.save(expenseInReview); + newUnreviewedTransactions.getAndIncrement(); log.info("Wise transaction {}", wiseTransaction.getReferenceNumber()); }); - + return newUnreviewedTransactions.get(); } else if (maybeBalanceStatement.getStatusCode() == HttpStatus.OK || maybeBalanceStatement.getStatusCode() == HttpStatus.CREATED) { log.info("Balance statement without 2fa approval {}", maybeBalanceStatement.getBody()); } else { log.warn("There was an error fetching balance statement {}", maybeBalanceStatement.getStatusCode()); } + return 0; + } + + private boolean isCreditTransaction(Transaction wiseTransaction) { + return wiseTransaction.getType().equalsIgnoreCase("credit"); } class Wise2faErrorHandler extends DefaultResponseErrorHandler { diff --git a/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java b/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java index e62272e..f5c9ef4 100644 --- a/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java +++ b/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java @@ -4,6 +4,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import java.io.Serializable; +import java.util.Optional; @Setter @Getter @@ -17,15 +18,15 @@ public class Transaction implements Serializable { @Setter @Getter @NoArgsConstructor - private static class Amount implements Serializable { - private Double value; + public static class Amount implements Serializable { + private Float value; private String currency; } @Getter @Setter @NoArgsConstructor - private static class Details implements Serializable { + public static class Details implements Serializable { private String category; private String description; private Merchant merchant; @@ -34,7 +35,7 @@ private static class Details implements Serializable { @Getter @Setter @NoArgsConstructor - private static class Merchant implements Serializable { + public static class Merchant implements Serializable { private String name; } } diff --git a/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java b/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java new file mode 100644 index 0000000..fc6f026 --- /dev/null +++ b/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java @@ -0,0 +1,29 @@ +package com.shareexpenses.server.utils; + +import lombok.SneakyThrows; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.sql.Timestamp; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.TimeZone; + +@Component +public class DateTimeUtils { + + private static final String ISO_DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; + private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); + + + private static SimpleDateFormat getDateTimeFormatter() { + SimpleDateFormat dateTimeFormatter = new SimpleDateFormat(ISO_DATE_PATTERN); + dateTimeFormatter.setTimeZone(UTC_TIMEZONE); + return dateTimeFormatter; + } + + @SneakyThrows + public Timestamp unixTimestampFromISOString(String isoDate) { + return new Timestamp(getDateTimeFormatter().parse(isoDate).getTime()); + } +} diff --git a/src/test/java/com/shareexpenses/server/StringToTimestamp.java b/src/test/java/com/shareexpenses/server/StringToTimestamp.java new file mode 100644 index 0000000..759b726 --- /dev/null +++ b/src/test/java/com/shareexpenses/server/StringToTimestamp.java @@ -0,0 +1,23 @@ +package com.shareexpenses.server; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; + +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.util.TimeZone; + +@Slf4j +public class StringToTimestamp { + + @Test + @SneakyThrows + void convertsDateFromTransferWiseToTimestamp() { + String wiseDate = "2022-10-09T09:37:33.460954Z"; + SimpleDateFormat sd = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sd.setTimeZone(TimeZone.getTimeZone("UTC")); + var r = sd.parse(wiseDate).getTime(); + log.info("Timestamp EXPENSES {}", r); + } +} From 77bbf6cf1da82bb9f94096eedbe4f9b49624ca1b Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 11 Dec 2022 21:45:54 +0100 Subject: [PATCH 05/30] Add schema for expenses_in_review --- db/schema.sql | 107 ++++++++++++++++++++++++----- src/main/resources/application.yml | 2 +- 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index 7c9db49..2f1b9a4 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,13 +1,13 @@ --- MariaDB dump 10.19 Distrib 10.5.18-MariaDB, for Linux (x86_64) +-- MySQL dump 10.13 Distrib 8.0.31, for Linux (x86_64) -- -- Host: 127.0.0.1 Database: expenses -- ------------------------------------------------------ --- Server version 10.8.3-MariaDB-1:10.8.3+maria~jammy +-- Server version 8.0.31 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8mb4 */; +/*!50503 SET NAMES utf8mb4 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; @@ -15,19 +15,54 @@ /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +-- +-- Table structure for table `account` +-- + +DROP TABLE IF EXISTS `account`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `account` ( + `id` bigint NOT NULL, + `name` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `accounts` -- DROP TABLE IF EXISTS `accounts`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `accounts` ( - `id` int(11) NOT NULL, + `id` int NOT NULL, `name` varchar(100) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `id_UNIQUE` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `expense` +-- + +DROP TABLE IF EXISTS `expense`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `expense` ( + `id` bigint NOT NULL, + `amount` double NOT NULL, + `category` varchar(255) NOT NULL, + `currency` int NOT NULL, + `description` varchar(255) DEFAULT NULL, + `time` datetime(6) NOT NULL, + `account_id` bigint NOT NULL, + PRIMARY KEY (`id`), + KEY `FKl7p96uyhqlv3raecactp2uet4` (`account_id`), + CONSTRAINT `FKl7p96uyhqlv3raecactp2uet4` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -36,10 +71,10 @@ CREATE TABLE `accounts` ( DROP TABLE IF EXISTS `expense_sequence`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `expense_sequence` ( - `next_val` bigint(20) DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `next_val` bigint DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -48,20 +83,41 @@ CREATE TABLE `expense_sequence` ( DROP TABLE IF EXISTS `expenses`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `expenses` ( - `id` int(11) NOT NULL, + `id` int NOT NULL, `amount` decimal(10,2) NOT NULL, `currency` varchar(10) NOT NULL DEFAULT 'EUR', - `timestamp` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `timestamp` timestamp NOT NULL, `category` varchar(45) NOT NULL, `description` varchar(200) DEFAULT NULL, - `account_id` int(11) NOT NULL, + `account_id` int NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `id_UNIQUE` (`id`), KEY `account_idx` (`account_id`), CONSTRAINT `account` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `expenses_in_review` +-- + +DROP TABLE IF EXISTS `expenses_in_review`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `expenses_in_review` ( + `external_id` varchar(255) NOT NULL, + `date` timestamp NOT NULL, + `amount` float NOT NULL, + `currency` varchar(10) NOT NULL, + `external_category` varchar(255) DEFAULT NULL, + `description` varchar(255) DEFAULT NULL, + `merchant_name` varchar(255) DEFAULT NULL, + `review_until` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`external_id`), + UNIQUE KEY `external_id_UNIQUE` (`external_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -70,10 +126,25 @@ CREATE TABLE `expenses` ( DROP TABLE IF EXISTS `hibernate_sequence`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `hibernate_sequence` ( - `next_val` bigint(20) DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `next_val` bigint DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user` ( + `id` int NOT NULL, + `email` varchar(255) DEFAULT NULL, + `name` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -85,4 +156,4 @@ CREATE TABLE `hibernate_sequence` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2022-12-11 20:37:14 +-- Dump completed on 2022-12-11 21:42:03 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ab9c8b5..f12a3eb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,5 +9,5 @@ spring: datasource: url: "jdbc:mysql://localhost:3306/expenses" username: "expenses" - password: "GMtC40W8R*9IQt^l9" + password: "dummypass" data.rest.basePath: "/api" From 0868cc51f3015edc7fe40d55f935b0e1e29de16b Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 12 Mar 2023 21:56:42 +0100 Subject: [PATCH 06/30] Send discovered expenses to frontend --- .../server/discovery/DiscoverController.java | 5 ++- .../server/discovery/ExpenseInReview.java | 2 +- .../server/discovery/WiseService.java | 39 ++++++++++++++----- .../server/expenses/Expense.java | 3 ++ .../server/expenses/ExpensesRepository.java | 2 + .../server/utils/DateTimeUtils.java | 20 +++++++++- 6 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java b/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java index 40b5c3b..640710b 100644 --- a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java @@ -17,8 +17,9 @@ public class DiscoverController { @Autowired WiseService wiseService; + // dates are in format 'YYYY-MM-DD' (i.e., no time info is provided) @GetMapping - public int discoverExpenses() { - return wiseService.discoverExpensesBetween(); + public int discoverExpenses(@RequestParam String fromDate, @RequestParam String toDate) { + return wiseService.discoverExpensesBetween(fromDate, toDate); } } diff --git a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java index e51c30e..f0c33f4 100644 --- a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java +++ b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java @@ -21,7 +21,7 @@ public class ExpenseInReview { private String externalId; @Column(nullable = false) - private Timestamp date; + private Timestamp date; @Column private Timestamp reviewUntil; diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseService.java b/src/main/java/com/shareexpenses/server/discovery/WiseService.java index b286b28..9513155 100644 --- a/src/main/java/com/shareexpenses/server/discovery/WiseService.java +++ b/src/main/java/com/shareexpenses/server/discovery/WiseService.java @@ -4,6 +4,7 @@ import com.shareexpenses.server.discovery.wise_entities.Balance; import com.shareexpenses.server.discovery.wise_entities.BalanceStatement; import com.shareexpenses.server.discovery.wise_entities.Transaction; +import com.shareexpenses.server.expenses.ExpensesRepository; import com.shareexpenses.server.utils.DateTimeUtils; import com.shareexpenses.server.utils.DigitalSignatures; import lombok.SneakyThrows; @@ -14,9 +15,9 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestTemplate; - import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -30,6 +31,9 @@ public class WiseService { @Autowired private ExpenseInReviewRepository expensesInReviewQueue; + @Autowired + private ExpensesRepository expensesRepository; + @Autowired private DateTimeUtils dateTimeUtils; private static final String WISE_BASE_URL = "https://api.transferwise.com/"; @@ -37,9 +41,15 @@ public class WiseService { @Autowired AccountsConfig accountsConfig; - public int discoverExpensesBetween() { + public int discoverExpensesBetween(String fromDate, String toDate) { AtomicInteger newTransactions = new AtomicInteger(); + Timestamp fromTimestamp = dateTimeUtils.timestampFromDateStringWithoutTime(fromDate); + Timestamp toTimestamp = dateTimeUtils.timestampFromDateStringWithoutTime(toDate); + + // fromDate should be before or equal to the toDate + assert (fromTimestamp.before(toTimestamp)); + accountsConfig.accounts.forEach(account -> { String balanceUrl = getBalancesForProfile(account.getProfileId()); @@ -56,8 +66,8 @@ public int discoverExpensesBetween() { var count = getStatementForBalanceId( account.getProfileId(), wiseBalance.getId(), - "2022-12-01", - "2022-12-05", + fromDate, + toDate, account.getApiBearerToken(), account.getPrivateKey() ); @@ -67,6 +77,7 @@ public int discoverExpensesBetween() { return newTransactions.get(); } + // YYYY-MM-DD or YYYY-MM-DD HH:MM:SS or TImestamp @SneakyThrows private int getStatementForBalanceId(String profileId, String balanceId, String from, String to, String bearerToken, String privateKey) { String statementUrl = getBalanceStatementUrl(profileId, balanceId, from, to); @@ -75,8 +86,8 @@ private int getStatementForBalanceId(String profileId, String balanceId, String headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); Map params = new HashMap<>(); - params.put("fromTime", "2022-10-01T00:00:00.000Z"); - params.put("toTime", "2022-10-10T00:00:00.000Z"); + params.put("fromTime", dateTimeUtils.dateStringToWiseString(from)); + params.put("toTime", dateTimeUtils.dateStringToWiseString(to)); params.put("type", "COMPACT"); RestTemplate template = new RestTemplate(); @@ -103,10 +114,17 @@ private int getStatementForBalanceId(String profileId, String balanceId, String log.info("Balance Statement after 2fa approval {}", balanceStatement.getBody()); AtomicInteger newUnreviewedTransactions = new AtomicInteger(); - balanceStatement.getBody().getTransactions().forEach(wiseTransaction -> { - if(isCreditTransaction(wiseTransaction) || expensesInReviewQueue.existsById(wiseTransaction.getReferenceNumber())) { - // We can ignore the `wiseTransaction` if it represents a credit (money added to TW account) or if it is already added in the expensesInReview table. + balanceStatement.getBody().getTransactions().forEach(wiseTransaction -> { + log.info("Checking wise transaction {}", wiseTransaction.getReferenceNumber()); + + if(isCreditTransaction(wiseTransaction) + || expensesInReviewQueue.existsById(wiseTransaction.getReferenceNumber()) + || expensesRepository.existsByExternalId(wiseTransaction.getReferenceNumber()) + ) { + // We can ignore the `wiseTransaction` if it represents a credit (money added to TW account) + // or if it is already added in the expensesInReview table + // or an expense already exists with same externalId return; } @@ -128,12 +146,15 @@ private int getStatementForBalanceId(String profileId, String balanceId, String newUnreviewedTransactions.getAndIncrement(); log.info("Wise transaction {}", wiseTransaction.getReferenceNumber()); }); + return newUnreviewedTransactions.get(); + } else if (maybeBalanceStatement.getStatusCode() == HttpStatus.OK || maybeBalanceStatement.getStatusCode() == HttpStatus.CREATED) { log.info("Balance statement without 2fa approval {}", maybeBalanceStatement.getBody()); } else { log.warn("There was an error fetching balance statement {}", maybeBalanceStatement.getStatusCode()); } + return 0; } diff --git a/src/main/java/com/shareexpenses/server/expenses/Expense.java b/src/main/java/com/shareexpenses/server/expenses/Expense.java index 23439cf..90777a6 100644 --- a/src/main/java/com/shareexpenses/server/expenses/Expense.java +++ b/src/main/java/com/shareexpenses/server/expenses/Expense.java @@ -20,6 +20,9 @@ public class Expense { @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "expense_sequence") private Long id; + @Column + private String externalId; + @Column(nullable = false) private Double amount; diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java index 32dfab1..98cd7fe 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java @@ -5,4 +5,6 @@ public interface ExpensesRepository extends JpaRepository { List findAll(); + + boolean existsByExternalId(String externalId); } diff --git a/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java b/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java index fc6f026..f61329a 100644 --- a/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java +++ b/src/main/java/com/shareexpenses/server/utils/DateTimeUtils.java @@ -3,9 +3,7 @@ import lombok.SneakyThrows; import org.springframework.stereotype.Component; -import javax.annotation.PostConstruct; import java.sql.Timestamp; -import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.TimeZone; @@ -22,8 +20,26 @@ private static SimpleDateFormat getDateTimeFormatter() { return dateTimeFormatter; } + @SneakyThrows public Timestamp unixTimestampFromISOString(String isoDate) { return new Timestamp(getDateTimeFormatter().parse(isoDate).getTime()); } + + /** + * Converts a date of format "YYYY-MM-DD" to a timestamp after adding timing info (always 00:00:00) to it. + */ + public Timestamp timestampFromDateStringWithoutTime(String isoDate) { + return Timestamp.valueOf(isoDate + " 00:00:00"); + } + + /** + * Adds time (and UTC timezone) to a date string without time. + * + * @param isoDate - format 'YYYY-MM-DD' + * @return UTC date string - format 'YYYY-MM-DDT00:00:00.000Z' + */ + public String dateStringToWiseString(String isoDate) { + return isoDate + "T00:00:00.000Z"; + } } From 5aef398ac09d3a66962739cb6e7d13bfba1fb2ba Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 12 Mar 2023 22:17:43 +0100 Subject: [PATCH 07/30] Expenses in review should be linked to accounts --- .../java/com/shareexpenses/server/config/AccountConfig.java | 1 + .../com/shareexpenses/server/discovery/ExpenseInReview.java | 3 +++ .../com/shareexpenses/server/discovery/WiseService.java | 6 ++++-- src/main/resources/application.yml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/shareexpenses/server/config/AccountConfig.java b/src/main/java/com/shareexpenses/server/config/AccountConfig.java index 11fe22f..6b24b9a 100644 --- a/src/main/java/com/shareexpenses/server/config/AccountConfig.java +++ b/src/main/java/com/shareexpenses/server/config/AccountConfig.java @@ -11,6 +11,7 @@ public class AccountConfig { String owner; String type; String profileId; + Long accountId; String apiBearerToken; String privateKey; } diff --git a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java index f0c33f4..3e744ed 100644 --- a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java +++ b/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java @@ -20,6 +20,9 @@ public class ExpenseInReview { @Id private String externalId; + @Column(nullable = false) + private Long accountId; + @Column(nullable = false) private Timestamp date; diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseService.java b/src/main/java/com/shareexpenses/server/discovery/WiseService.java index 9513155..dc99d2a 100644 --- a/src/main/java/com/shareexpenses/server/discovery/WiseService.java +++ b/src/main/java/com/shareexpenses/server/discovery/WiseService.java @@ -65,11 +65,12 @@ public int discoverExpensesBetween(String fromDate, String toDate) { log.info("Wise Balance {} owner {}", wiseBalance.getId(), account.getOwner()); var count = getStatementForBalanceId( account.getProfileId(), + account.getAccountId(), wiseBalance.getId(), fromDate, toDate, account.getApiBearerToken(), - account.getPrivateKey() + account.getPrivateKey() ); newTransactions.getAndAdd(count); }); @@ -79,7 +80,7 @@ public int discoverExpensesBetween(String fromDate, String toDate) { // YYYY-MM-DD or YYYY-MM-DD HH:MM:SS or TImestamp @SneakyThrows - private int getStatementForBalanceId(String profileId, String balanceId, String from, String to, String bearerToken, String privateKey) { + private int getStatementForBalanceId(String profileId, Long accountId, String balanceId, String from, String to, String bearerToken, String privateKey) { String statementUrl = getBalanceStatementUrl(profileId, balanceId, from, to); HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + bearerToken); @@ -130,6 +131,7 @@ private int getStatementForBalanceId(String profileId, String balanceId, String ExpenseInReview expenseInReview = ExpenseInReview.builder() .externalId(wiseTransaction.getReferenceNumber()) + .accountId(accountId) .date(dateTimeUtils.unixTimestampFromISOString(wiseTransaction.getDate())) .amount(Math.abs(wiseTransaction.getAmount().getValue())) .currency(wiseTransaction.getAmount().getCurrency()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f12a3eb..ab9c8b5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,5 +9,5 @@ spring: datasource: url: "jdbc:mysql://localhost:3306/expenses" username: "expenses" - password: "dummypass" + password: "GMtC40W8R*9IQt^l9" data.rest.basePath: "/api" From 9769ebc45bcb50a46c212fbf4f1dff89fddbfe91 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sat, 27 May 2023 20:41:25 +0200 Subject: [PATCH 08/30] dev: Fix db setup Signed-off-by: David Caro --- db/fake_data.sql | 2 +- db/schema.sql | 16 ++++++++-------- scripts/start_devdb.sh | 22 ++++++++++++++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/db/fake_data.sql b/db/fake_data.sql index 2ac78e2..aee2d78 100644 --- a/db/fake_data.sql +++ b/db/fake_data.sql @@ -41,7 +41,7 @@ UNLOCK TABLES; LOCK TABLES `expenses` WRITE; /*!40000 ALTER TABLE `expenses` DISABLE KEYS */; -INSERT INTO `expenses` VALUES (2,29.90,'EUR','2021-08-14 00:00:00','eating-out','Pizza',1),(3,4.10,'EUR','2021-08-14 00:00:00','eating-out','Pain au Chocolat',1),(4,38.99,'EUR','2021-08-13 00:00:00','groceries','Grand frais',1) +INSERT INTO `expenses` VALUES (2,29.90,'EUR','2021-08-14 00:00:00','eating-out','Pizza',1),(3,4.10,'EUR','2021-08-14 00:00:00','eating-out','Pain au Chocolat',1),(4,38.99,'EUR','2021-08-13 00:00:00','groceries','Grand frais',1); /*!40000 ALTER TABLE `expenses` ENABLE KEYS */; UNLOCK TABLES; diff --git a/db/schema.sql b/db/schema.sql index 2f1b9a4..1ab0cac 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -26,7 +26,7 @@ CREATE TABLE `account` ( `id` bigint NOT NULL, `name` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -41,7 +41,7 @@ CREATE TABLE `accounts` ( `name` varchar(100) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `id_UNIQUE` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -62,7 +62,7 @@ CREATE TABLE `expense` ( PRIMARY KEY (`id`), KEY `FKl7p96uyhqlv3raecactp2uet4` (`account_id`), CONSTRAINT `FKl7p96uyhqlv3raecactp2uet4` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -74,7 +74,7 @@ DROP TABLE IF EXISTS `expense_sequence`; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `expense_sequence` ( `next_val` bigint DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -96,7 +96,7 @@ CREATE TABLE `expenses` ( UNIQUE KEY `id_UNIQUE` (`id`), KEY `account_idx` (`account_id`), CONSTRAINT `account` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -117,7 +117,7 @@ CREATE TABLE `expenses_in_review` ( `review_until` timestamp NULL DEFAULT NULL, PRIMARY KEY (`external_id`), UNIQUE KEY `external_id_UNIQUE` (`external_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -129,7 +129,7 @@ DROP TABLE IF EXISTS `hibernate_sequence`; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `hibernate_sequence` ( `next_val` bigint DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -144,7 +144,7 @@ CREATE TABLE `user` ( `email` varchar(255) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; diff --git a/scripts/start_devdb.sh b/scripts/start_devdb.sh index 3b67b1d..49a4e83 100755 --- a/scripts/start_devdb.sh +++ b/scripts/start_devdb.sh @@ -4,6 +4,8 @@ set -o errexit set -o nounset set -o pipefail +hash podman && DOCKER=podman || DOCKER=docker + check_if_db_alive() { mysql \ @@ -21,8 +23,8 @@ check_if_db_alive() { start_mariadb() { local name="expenses_devdb" - docker rm -f "$name" || : - docker run \ + $DOCKER rm -f "$name" || : + $DOCKER run \ --name="$name" \ --detach \ --publish=3306:3306 \ @@ -38,6 +40,7 @@ start_mariadb() { count=$((count + 1)) if [[ $count -ge 5 ]]; then echo "The db container never came up!" + echo "You might want to try running with sudo :/" return 1 fi echo "Checking againg in 5s..." @@ -57,6 +60,18 @@ create_db() { < db/schema.sql } +populate_db() { + echo "populating with dummy data" + mysql \ + --host 127.0.0.1 \ + --port 3306 \ + --protocol=tcp \ + --user expenses \ + --password='dummypass' \ + expenses \ + < db/fake_data.sql +} + main() { @@ -64,6 +79,9 @@ main() { create_db echo "Your development db is now ready, you can access it with:" echo " mysql --host=127.0.0.1 --port=3306 --protocol=tcp --user=expenses --password=dummypass expenses" + if [[ $1 == "populate" ]]; then + populate_db + fi } From 7de015fc4be19dd5141b55b37151ba1fa25b6166 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sat, 27 May 2023 21:20:46 +0200 Subject: [PATCH 09/30] Update readme with server & db installation instructions --- README.md | 45 +++++++++++++++++++----------- scripts/start_devdb.sh | 7 +++-- src/main/resources/application.yml | 2 +- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8d6fea7..452bc15 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,33 @@ You'll need java 11 jdk installed, on fedora: > sudo dnf install java-11-openjdk-devel +### Installing java 11 -### Running it locally -You will need a mariadb/mysql instance running on localhost, you can run something like: - - sudo podman run \ - --name expenses_db \ - --detach \ - -p 3306:3306 \ - --rm \ - --env MARIADB_DATABASE=expenses \ - --env MARIADB_USER=expenses \ - --env 'MARIADB_PASSWORD=GMtC40W8R*9IQt^l9' \ - --env MYSQL_RANDOM_ROOT_PASSWORD=true \ - mariadb:latest - -Then you can run the application with: -> ./gradlew bootRun \ No newline at end of file +In case you have multiple versions of java installed, you will have to configure your +system to use java 11. The following java tools will have to be updated: + +```bash +# find java tools that need to be configured to use version 11 +$ sudo alternatives --list | grep java | grep manual +java_sdk_openjdk manual /usr/lib/jvm/java-11-openjdk-11.0.19.0.7-1.fc38.x86_64 +jre_openjdk manual /usr/lib/jvm/java-11-openjdk-11.0.19.0.7-1.fc38.x86_64 +java manual /usr/lib/jvm/java-11-openjdk-11.0.19.0.7-1.fc38.x86_64/bin/java +javac manual /usr/lib/jvm/java-11-openjdk-11.0.19.0.7-1.fc38.x86_64/bin/javac +``` +This is how you can configure the above java tools to use version 11 +```bash +$ for alternative in $(sudo alternatives --list | grep java | awk '{print $1}' | grep -v '\(17\|11\)'); do sudo alternatives --config "$alternative"; done +``` + +### Running the server locally + +1. Start the database locally - Simply run the following script to start the server. The script also populates the database with some dummy data: +> Make sure you are in the root directory of this project to run the following command. +```bash +$ sudo ./scripts/start_devdb.sh populate +``` + +2. Start the server locally - You can either use the "Run" button of your IDE (such as intellij) to run the server application or run the following command: +```bash +./gradlew bootRun +``` diff --git a/scripts/start_devdb.sh b/scripts/start_devdb.sh index 49a4e83..489bce7 100755 --- a/scripts/start_devdb.sh +++ b/scripts/start_devdb.sh @@ -5,7 +5,10 @@ set -o nounset set -o pipefail hash podman && DOCKER=podman || DOCKER=docker - +hash mysql || { + echo "Unable to find a mysql client. Please install one, using a command such as:" + echo "sudo dnf install mariadb" +} check_if_db_alive() { mysql \ @@ -43,7 +46,7 @@ start_mariadb() { echo "You might want to try running with sudo :/" return 1 fi - echo "Checking againg in 5s..." + echo "Checking again in 5s..." sleep 5 done return 0 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ab9c8b5..f12a3eb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,5 +9,5 @@ spring: datasource: url: "jdbc:mysql://localhost:3306/expenses" username: "expenses" - password: "GMtC40W8R*9IQt^l9" + password: "dummypass" data.rest.basePath: "/api" From b237f502d0e01ebd12cad72364521d8740498a16 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 4 Jun 2023 17:58:40 +0200 Subject: [PATCH 10/30] Add dev dockerfile and start script Signed-off-by: David Caro --- Dockerfile.dev | 8 ++++++++ scripts/start_dev.sh | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 Dockerfile.dev create mode 100755 scripts/start_dev.sh diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..9db2894 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,8 @@ +# FROM --platform=arm64 docker.io/library/gradle:jdk11 +# In dev we use x86, in prod we use arm64 (raspberry) +FROM docker.io/library/gradle:jdk11 +#VOLUME . /src +EXPOSE 8080/tcp +USER 1000 +WORKDIR /src +CMD ["/src/gradlew", "bootRun"] \ No newline at end of file diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh new file mode 100755 index 0000000..c3ce976 --- /dev/null +++ b/scripts/start_dev.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +hash podman && DOCKER="sudo podman" || DOCKER=docker + + + +$DOCKER build -f Dockerfile.dev -t expenses-server:dev +$DOCKER run \ + --tty \ + --interactive \ + --volume $PWD:/src:rw,idmap \ + --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,idmap \ + --publish-all \ + --rm \ + expenses-server:dev \ No newline at end of file From 793b5245fdc4bc91689a81164ab13db72eee364b Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 4 Jun 2023 18:33:46 +0200 Subject: [PATCH 11/30] Pass args to start_dev Signed-off-by: David Caro --- Dockerfile.dev | 1 - scripts/start_dev.sh | 7 ++++++- scripts/start_devdb.sh | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 9db2894..2f3b5f5 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,6 @@ # FROM --platform=arm64 docker.io/library/gradle:jdk11 # In dev we use x86, in prod we use arm64 (raspberry) FROM docker.io/library/gradle:jdk11 -#VOLUME . /src EXPOSE 8080/tcp USER 1000 WORKDIR /src diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index c3ce976..d89b97b 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -8,6 +8,7 @@ hash podman && DOCKER="sudo podman" || DOCKER=docker +set -x $DOCKER build -f Dockerfile.dev -t expenses-server:dev $DOCKER run \ --tty \ @@ -15,5 +16,9 @@ $DOCKER run \ --volume $PWD:/src:rw,idmap \ --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,idmap \ --publish-all \ + --name expenses-server-dev \ --rm \ - expenses-server:dev \ No newline at end of file + expenses-server:dev \ + "/src/gradlew"\ + "bootRun" \ + "$@" \ No newline at end of file diff --git a/scripts/start_devdb.sh b/scripts/start_devdb.sh index 489bce7..6c7afe4 100755 --- a/scripts/start_devdb.sh +++ b/scripts/start_devdb.sh @@ -82,7 +82,7 @@ main() { create_db echo "Your development db is now ready, you can access it with:" echo " mysql --host=127.0.0.1 --port=3306 --protocol=tcp --user=expenses --password=dummypass expenses" - if [[ $1 == "populate" ]]; then + if [[ ${1:-""} == "populate" ]]; then populate_db fi } From 33d9d581acbef70b90fc5ec8050fc2f5649723c9 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 4 Jun 2023 18:49:39 +0200 Subject: [PATCH 12/30] Use container database when starting dev Signed-off-by: David Caro --- scripts/start_dev.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index d89b97b..112b70c 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -15,10 +15,11 @@ $DOCKER run \ --interactive \ --volume $PWD:/src:rw,idmap \ --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,idmap \ - --publish-all \ + --publish 8080:8080 \ --name expenses-server-dev \ --rm \ expenses-server:dev \ "/src/gradlew"\ "bootRun" \ + "--args='--spring.datasource.url=jdbc:mysql://$(hostname -i):3306/expenses'" \ "$@" \ No newline at end of file From d45d9ba617991a004ad0d6b321f56eb04fdd8db5 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 4 Jun 2023 19:05:21 +0200 Subject: [PATCH 13/30] Update db schema and fake data Signed-off-by: David Caro --- db/fake_data.sql | 4 +-- db/schema.sql | 66 ++++++++++++++++++++++-------------------- scripts/start_devdb.sh | 2 +- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/db/fake_data.sql b/db/fake_data.sql index aee2d78..a9fdfe0 100644 --- a/db/fake_data.sql +++ b/db/fake_data.sql @@ -21,7 +21,7 @@ LOCK TABLES `accounts` WRITE; /*!40000 ALTER TABLE `accounts` DISABLE KEYS */; -INSERT INTO `accounts` VALUES (0,'fake account 1'),(1,'fake account 2'); +INSERT INTO `accounts` VALUES (0,'fake account 1'),(1,'fake account 2'),(2,'fake account 3'),(3,'fake account 4'); /*!40000 ALTER TABLE `accounts` ENABLE KEYS */; UNLOCK TABLES; @@ -41,7 +41,7 @@ UNLOCK TABLES; LOCK TABLES `expenses` WRITE; /*!40000 ALTER TABLE `expenses` DISABLE KEYS */; -INSERT INTO `expenses` VALUES (2,29.90,'EUR','2021-08-14 00:00:00','eating-out','Pizza',1),(3,4.10,'EUR','2021-08-14 00:00:00','eating-out','Pain au Chocolat',1),(4,38.99,'EUR','2021-08-13 00:00:00','groceries','Grand frais',1); +INSERT INTO `expenses` VALUES (2,29.90,'EUR','2021-08-14 00:00:00','eating-out','Pizza',1, NULL),(3,4.10,'EUR','2021-08-14 00:00:00','eating-out','Pain au Chocolat',1, "somedummyexternalid"),(4,38.99,'EUR','2021-08-13 00:00:00','groceries','Grand frais',1, NULL); /*!40000 ALTER TABLE `expenses` ENABLE KEYS */; UNLOCK TABLES; diff --git a/db/schema.sql b/db/schema.sql index 1ab0cac..c92a50b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,13 +1,13 @@ --- MySQL dump 10.13 Distrib 8.0.31, for Linux (x86_64) +-- MariaDB dump 10.19 Distrib 10.5.19-MariaDB, for Linux (x86_64) -- -- Host: 127.0.0.1 Database: expenses -- ------------------------------------------------------ --- Server version 8.0.31 +-- Server version 10.8.3-MariaDB-1:10.8.3+maria~jammy /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!50503 SET NAMES utf8mb4 */; +/*!40101 SET NAMES utf8mb4 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; @@ -21,12 +21,12 @@ DROP TABLE IF EXISTS `account`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `account` ( - `id` bigint NOT NULL, + `id` bigint(20) NOT NULL, `name` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -35,13 +35,13 @@ CREATE TABLE `account` ( DROP TABLE IF EXISTS `accounts`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `accounts` ( - `id` int NOT NULL, + `id` int(11) NOT NULL, `name` varchar(100) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `id_UNIQUE` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -50,19 +50,19 @@ CREATE TABLE `accounts` ( DROP TABLE IF EXISTS `expense`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `expense` ( - `id` bigint NOT NULL, + `id` bigint(20) NOT NULL, `amount` double NOT NULL, `category` varchar(255) NOT NULL, - `currency` int NOT NULL, + `currency` int(11) NOT NULL, `description` varchar(255) DEFAULT NULL, `time` datetime(6) NOT NULL, - `account_id` bigint NOT NULL, + `account_id` bigint(20) NOT NULL, PRIMARY KEY (`id`), KEY `FKl7p96uyhqlv3raecactp2uet4` (`account_id`), CONSTRAINT `FKl7p96uyhqlv3raecactp2uet4` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -71,10 +71,10 @@ CREATE TABLE `expense` ( DROP TABLE IF EXISTS `expense_sequence`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `expense_sequence` ( - `next_val` bigint DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + `next_val` bigint(20) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -83,20 +83,21 @@ CREATE TABLE `expense_sequence` ( DROP TABLE IF EXISTS `expenses`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `expenses` ( - `id` int NOT NULL, + `id` int(11) NOT NULL, `amount` decimal(10,2) NOT NULL, `currency` varchar(10) NOT NULL DEFAULT 'EUR', - `timestamp` timestamp NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), `category` varchar(45) NOT NULL, `description` varchar(200) DEFAULT NULL, - `account_id` int NOT NULL, + `account_id` int(11) NOT NULL, + `external_id` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `id_UNIQUE` (`id`), KEY `account_idx` (`account_id`), CONSTRAINT `account` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -105,19 +106,20 @@ CREATE TABLE `expenses` ( DROP TABLE IF EXISTS `expenses_in_review`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `expenses_in_review` ( `external_id` varchar(255) NOT NULL, - `date` timestamp NOT NULL, + `date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), `amount` float NOT NULL, `currency` varchar(10) NOT NULL, `external_category` varchar(255) DEFAULT NULL, `description` varchar(255) DEFAULT NULL, `merchant_name` varchar(255) DEFAULT NULL, `review_until` timestamp NULL DEFAULT NULL, + `account_id` bigint(20) NOT NULL, PRIMARY KEY (`external_id`), UNIQUE KEY `external_id_UNIQUE` (`external_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -126,10 +128,10 @@ CREATE TABLE `expenses_in_review` ( DROP TABLE IF EXISTS `hibernate_sequence`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `hibernate_sequence` ( - `next_val` bigint DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + `next_val` bigint(20) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -138,13 +140,13 @@ CREATE TABLE `hibernate_sequence` ( DROP TABLE IF EXISTS `user`; /*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `user` ( - `id` int NOT NULL, + `id` int(11) NOT NULL, `email` varchar(255) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -156,4 +158,4 @@ CREATE TABLE `user` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2022-12-11 21:42:03 +-- Dump completed on 2023-06-04 18:52:57 diff --git a/scripts/start_devdb.sh b/scripts/start_devdb.sh index 6c7afe4..1c65c88 100755 --- a/scripts/start_devdb.sh +++ b/scripts/start_devdb.sh @@ -4,7 +4,7 @@ set -o errexit set -o nounset set -o pipefail -hash podman && DOCKER=podman || DOCKER=docker +hash podman && DOCKER="sudo podman" || DOCKER=docker hash mysql || { echo "Unable to find a mysql client. Please install one, using a command such as:" echo "sudo dnf install mariadb" From 5f54c7ad162751b9815700cc2654e17c931f5779 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 4 Jun 2023 19:13:50 +0200 Subject: [PATCH 14/30] start_dev.sh: use the local network ip Signed-off-by: David Caro --- scripts/start_dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index 112b70c..d4543b4 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -21,5 +21,5 @@ $DOCKER run \ expenses-server:dev \ "/src/gradlew"\ "bootRun" \ - "--args='--spring.datasource.url=jdbc:mysql://$(hostname -i):3306/expenses'" \ + "--args='--spring.datasource.url=jdbc:mysql://$(hostname -i | grep -Po '192.168.1.\w*($| )'):3306/expenses'" \ "$@" \ No newline at end of file From 57d870e60d424f1c9dce3a121a52e7c4d6343dea Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 4 Jun 2023 19:15:49 +0200 Subject: [PATCH 15/30] start_dev.sh: remove trailing space Signed-off-by: David Caro --- scripts/start_dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index d4543b4..84e8223 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -21,5 +21,5 @@ $DOCKER run \ expenses-server:dev \ "/src/gradlew"\ "bootRun" \ - "--args='--spring.datasource.url=jdbc:mysql://$(hostname -i | grep -Po '192.168.1.\w*($| )'):3306/expenses'" \ + "--args='--spring.datasource.url=jdbc:mysql://$(hostname -i | grep -Po '192.168.1.\w*(?:$| )'):3306/expenses'" \ "$@" \ No newline at end of file From 4a90c2bb278a68c440a483cbe9f217308c5c5b7e Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 11 Jun 2023 12:02:28 +0200 Subject: [PATCH 16/30] Pin mariadb to lts Signed-off-by: David Caro --- scripts/start_devdb.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/start_devdb.sh b/scripts/start_devdb.sh index 1c65c88..afea86a 100755 --- a/scripts/start_devdb.sh +++ b/scripts/start_devdb.sh @@ -8,6 +8,7 @@ hash podman && DOCKER="sudo podman" || DOCKER=docker hash mysql || { echo "Unable to find a mysql client. Please install one, using a command such as:" echo "sudo dnf install mariadb" + exit 1 } check_if_db_alive() { @@ -36,7 +37,7 @@ start_mariadb() { --env='MARIADB_USER=expenses' \ --env='MARIADB_PASSWORD=dummypass' \ --env='MYSQL_ROOT_PASSWORD=dummypass' \ - mariadb:latest + mariadb:lts echo "Waiting for the db to come up..." local count=0 while ! check_if_db_alive; do From 426425520517a06b56712a76cc66e6a9e6651752 Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 11 Jun 2023 12:06:06 +0200 Subject: [PATCH 17/30] Add github workflow Signed-off-by: David Caro --- .github/workflows/test.yaml | 31 +++++++++ build.gradle | 1 + scripts/start_dev.sh | 67 ++++++++++++++----- .../server/expenses/ExpensesRepository.java | 7 +- .../server/expenses/ExpensesService.java | 57 ++++++++-------- src/main/resources/application.yml | 4 ++ 6 files changed, 124 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..1273910 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,31 @@ +name: CI +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Setup Podman + run: | + sudo apt update + sudo apt-get -y install podman + - name: Get source + uses: actions/checkout@v3 + with: + path: "expenses-server" + - name: start app + run: | + cd expenses-server + ./scripts/start_devdb.sh populate + + # fake accoutns config + mkdir -p ../home-lab-secrets/home_automation/expenses/ + echo "accounts-config: {'accounts': []}" > ../home-lab-secrets/home_automation/expenses/application-accounts.yml + + podman run --rm mariadb + ./scripts/start_dev.sh diff --git a/build.gradle b/build.gradle index 34b556c..738db8d 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' // Needed to sign digital signatures for wise accounts implementation 'org.bouncycastle:bcprov-jdk15on:1.64' diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index 84e8223..faed45c 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -7,19 +7,56 @@ set -o pipefail hash podman && DOCKER="sudo podman" || DOCKER=docker - set -x -$DOCKER build -f Dockerfile.dev -t expenses-server:dev -$DOCKER run \ - --tty \ - --interactive \ - --volume $PWD:/src:rw,idmap \ - --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,idmap \ - --publish 8080:8080 \ - --name expenses-server-dev \ - --rm \ - expenses-server:dev \ - "/src/gradlew"\ - "bootRun" \ - "--args='--spring.datasource.url=jdbc:mysql://$(hostname -i | grep -Po '192.168.1.\w*(?:$| )'):3306/expenses'" \ - "$@" \ No newline at end of file + +check_if_backend_alive() { + curl --silent http://127.0.0.1:8080/actuator/health | grep "UP" || { + echo "failed to get the health of the backend server" + curl -v http://127.0.0.1:8080/actuator/health + echo "container logs:" + $DOCKER logs expenses-server-dev + return 1 + } + echo "Your development server is up and running at http://127.0.0.1:8080" + return 0 +} + +main() { + $DOCKER build -f Dockerfile.dev -t expenses-server:dev + $DOCKER rm -f expenses-server-dev || : + db_host=$(hostname -i | grep -Po '192.168.1.\w*(?:$| )') + if [[ $db_host == "" ]]; then + # fallback in case we run on ci + db_host=$(hostname -i | awk '{print $1}') + fi + $DOCKER run \ + --tty \ + --interactive \ + --user $UID \ + --volume $PWD:/src:rw \ + --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw \ + --publish 8080:8080 \ + --name expenses-server-dev \ + --detach \ + expenses-server:dev \ + "/src/gradlew"\ + "bootRun" \ + "--args='--spring.datasource.url=jdbc:mysql://${db_host}:3306/expenses'" \ + "$@" + + + echo "Waiting for the backend to come up..." + local count=0 + while ! check_if_backend_alive; do + count=$((count + 1)) + if [[ $count -ge 15 ]]; then + echo "The server container never came up!" + echo "You might want to try running with sudo :/" + return 1 + fi + echo "Checking again in 5s..." + sleep 5 + done +} + +main "$@" \ No newline at end of file diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java index 98cd7fe..48988c5 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesRepository.java @@ -1,10 +1,15 @@ package com.shareexpenses.server.expenses; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; -public interface ExpensesRepository extends JpaRepository { +public interface ExpensesRepository extends JpaRepository { List findAll(); + @Query(value = "SELECT 1 FROM expenses limit 1", nativeQuery = true) + // The return value does not really matter, just that it did not blow up + List checkDB(); + boolean existsByExternalId(String externalId); } diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java index a5051e5..5d7abe9 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java @@ -30,46 +30,49 @@ public List getAllExpenses() { return expensesRepository.findAll(); } - public Expense addExpense(IncomingExpenseDTO newExpense) { - Account account = this - .accountRepository - .findById(newExpense.getAccountId()) - .orElseThrow(() -> new EntityNotFoundException("No account found with id " + newExpense.getAccountId() + ".")); + Account account = this.accountRepository + .findById(newExpense.getAccountId()) + .orElseThrow(() -> new EntityNotFoundException("No account found with id " + newExpense.getAccountId() + ".")); Expense expense = Expense - .builder() - .description(newExpense.getDescription()) - .amount(newExpense.getAmount()) - .currency(newExpense.getCurrency().label) - .timestamp(newExpense.getTimestamp()) - .account(account) - .category(newExpense.getCategory()) - .build(); + .builder() + .description(newExpense.getDescription()) + .amount(newExpense.getAmount()) + .currency(newExpense.getCurrency().label) + .timestamp(newExpense.getTimestamp()) + .account(account) + .category(newExpense.getCategory()) + .build(); return this.expensesRepository.save(expense); } public Expense updateExpense(Long id, IncomingExpenseDTO updatedExpense) { - Account account = this - .accountRepository - .findById(updatedExpense.getAccountId()) - .orElseThrow(() -> new EntityNotFoundException("No account found with id " + updatedExpense.getAccountId() + ".")); + Account account = this.accountRepository + .findById(updatedExpense.getAccountId()) + .orElseThrow( + () -> new EntityNotFoundException("No account found with id " + updatedExpense.getAccountId() + ".")); Expense expense = Expense - .builder() - .id(id) - .description(updatedExpense.getDescription()) - .amount(updatedExpense.getAmount()) - .currency(updatedExpense.getCurrency().label) - .timestamp(updatedExpense.getTimestamp()) - .account(account) - .category(updatedExpense.getCategory()) - .build(); + .builder() + .id(id) + .description(updatedExpense.getDescription()) + .amount(updatedExpense.getAmount()) + .currency(updatedExpense.getCurrency().label) + .timestamp(updatedExpense.getTimestamp()) + .account(account) + .category(updatedExpense.getCategory()) + .build(); return this.expensesRepository.save(expense); } public void deleteExpense(long expenseId) { - Expense expense = this.expensesRepository.findById(expenseId).orElseThrow(() -> new EntityNotFoundException("No expense found with id " + expenseId + ".")); + Expense expense = this.expensesRepository.findById(expenseId) + .orElseThrow(() -> new EntityNotFoundException("No expense found with id " + expenseId + ".")); this.expensesRepository.delete(expense); } + + public void checkDB() { + this.expensesRepository.checkDB(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f12a3eb..b1e3759 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,3 +11,7 @@ spring: username: "expenses" password: "dummypass" data.rest.basePath: "/api" +management: + endpoint: + health: + show-details: always From 91194c0ac9f2a7bb8fe91c838ca07e6b786f862a Mon Sep 17 00:00:00 2001 From: David Caro Date: Mon, 12 Jun 2023 09:33:20 +0200 Subject: [PATCH 18/30] Fix account id available when fetching tw balance --- db/fake_data.sql | 2 +- db/migrations/2023-06-11-add-accounts-config.sql | 1 + db/schema.sql | 14 -------------- .../DiscoverController.java | 9 +++++++-- .../ExpenseInReview.java | 0 .../ExpenseInReviewRepository.java | 0 .../WiseService.java | 0 .../wise_entities/Balance.java | 0 .../wise_entities/BalanceStatement.java | 0 .../wise_entities/Transaction.java | 0 10 files changed, 9 insertions(+), 17 deletions(-) create mode 100644 db/migrations/2023-06-11-add-accounts-config.sql rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/DiscoverController.java (80%) rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/ExpenseInReview.java (100%) rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/ExpenseInReviewRepository.java (100%) rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/WiseService.java (100%) rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/wise_entities/Balance.java (100%) rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/wise_entities/BalanceStatement.java (100%) rename src/main/java/com/shareexpenses/server/{discovery => expenses-in-review}/wise_entities/Transaction.java (100%) diff --git a/db/fake_data.sql b/db/fake_data.sql index a9fdfe0..16e6573 100644 --- a/db/fake_data.sql +++ b/db/fake_data.sql @@ -21,7 +21,7 @@ LOCK TABLES `accounts` WRITE; /*!40000 ALTER TABLE `accounts` DISABLE KEYS */; -INSERT INTO `accounts` VALUES (0,'fake account 1'),(1,'fake account 2'),(2,'fake account 3'),(3,'fake account 4'); +INSERT INTO `accounts` VALUES (0,'Joint Revolut'),(1,'Wise David'),(2,'Wise Dini'),(3,'Revolut David'),(4,'Revolut Dini'); /*!40000 ALTER TABLE `accounts` ENABLE KEYS */; UNLOCK TABLES; diff --git a/db/migrations/2023-06-11-add-accounts-config.sql b/db/migrations/2023-06-11-add-accounts-config.sql new file mode 100644 index 0000000..06f7249f --- /dev/null +++ b/db/migrations/2023-06-11-add-accounts-config.sql @@ -0,0 +1 @@ +alter table expenses_in_review modify column review_until timestamp not null default '1970-01-01 00:00:01.0' \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index c92a50b..5e747ff 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -15,20 +15,6 @@ /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; --- --- Table structure for table `account` --- - -DROP TABLE IF EXISTS `account`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `account` ( - `id` bigint(20) NOT NULL, - `name` varchar(255) DEFAULT NULL, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - -- -- Table structure for table `accounts` -- diff --git a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java b/src/main/java/com/shareexpenses/server/expenses-in-review/DiscoverController.java similarity index 80% rename from src/main/java/com/shareexpenses/server/discovery/DiscoverController.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/DiscoverController.java index 640710b..d0e8127 100644 --- a/src/main/java/com/shareexpenses/server/discovery/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/expenses-in-review/DiscoverController.java @@ -12,14 +12,19 @@ @AllArgsConstructor @RestController -@RequestMapping("/api/discover") +@RequestMapping("/api/expenses-in-review") public class DiscoverController { @Autowired WiseService wiseService; // dates are in format 'YYYY-MM-DD' (i.e., no time info is provided) - @GetMapping + @GetMapping("/discover") public int discoverExpenses(@RequestParam String fromDate, @RequestParam String toDate) { return wiseService.discoverExpensesBetween(fromDate, toDate); } + + @GetMapping("/count") + public int getExpensesToReviewCount() { + return wiseService.getExpensesToReviewCount(); + } } diff --git a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java b/src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReview.java similarity index 100% rename from src/main/java/com/shareexpenses/server/discovery/ExpenseInReview.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReview.java diff --git a/src/main/java/com/shareexpenses/server/discovery/ExpenseInReviewRepository.java b/src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReviewRepository.java similarity index 100% rename from src/main/java/com/shareexpenses/server/discovery/ExpenseInReviewRepository.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReviewRepository.java diff --git a/src/main/java/com/shareexpenses/server/discovery/WiseService.java b/src/main/java/com/shareexpenses/server/expenses-in-review/WiseService.java similarity index 100% rename from src/main/java/com/shareexpenses/server/discovery/WiseService.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/WiseService.java diff --git a/src/main/java/com/shareexpenses/server/discovery/wise_entities/Balance.java b/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Balance.java similarity index 100% rename from src/main/java/com/shareexpenses/server/discovery/wise_entities/Balance.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Balance.java diff --git a/src/main/java/com/shareexpenses/server/discovery/wise_entities/BalanceStatement.java b/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/BalanceStatement.java similarity index 100% rename from src/main/java/com/shareexpenses/server/discovery/wise_entities/BalanceStatement.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/BalanceStatement.java diff --git a/src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java b/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Transaction.java similarity index 100% rename from src/main/java/com/shareexpenses/server/discovery/wise_entities/Transaction.java rename to src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Transaction.java From fb7f2aef40dd62102d7e36d81875a38c67e2f179 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 18 Jun 2023 15:06:43 +0200 Subject: [PATCH 19/30] Implement API to show count of expenses to review --- scripts/start_dev.sh | 4 ++-- .../DiscoverController.java | 4 ++-- .../ExpenseInReview.java | 2 +- .../ExpenseInReviewRepository.java | 2 +- .../WiseService.java | 12 ++++++++---- .../wise_entities/Balance.java | 2 +- .../wise_entities/BalanceStatement.java | 2 +- .../wise_entities/Transaction.java | 2 +- 8 files changed, 17 insertions(+), 13 deletions(-) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/DiscoverController.java (90%) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/ExpenseInReview.java (93%) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/ExpenseInReviewRepository.java (78%) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/WiseService.java (95%) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/wise_entities/Balance.java (70%) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/wise_entities/BalanceStatement.java (76%) rename src/main/java/com/shareexpenses/server/{expenses-in-review => expenses_in_review}/wise_entities/Transaction.java (92%) diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index faed45c..4df0421 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -33,8 +33,8 @@ main() { --tty \ --interactive \ --user $UID \ - --volume $PWD:/src:rw \ - --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw \ + --volume $PWD:/src:rw,z \ + --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,z \ --publish 8080:8080 \ --name expenses-server-dev \ --detach \ diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/DiscoverController.java b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java similarity index 90% rename from src/main/java/com/shareexpenses/server/expenses-in-review/DiscoverController.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java index d0e8127..d57a7d1 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery; +package com.shareexpenses.server.expenses_in_review; import lombok.AllArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; @@ -24,7 +24,7 @@ public int discoverExpenses(@RequestParam String fromDate, @RequestParam String } @GetMapping("/count") - public int getExpensesToReviewCount() { + public long getExpensesToReviewCount() { return wiseService.getExpensesToReviewCount(); } } diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReview.java b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReview.java similarity index 93% rename from src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReview.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReview.java index 3e744ed..e164fcc 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReview.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReview.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery; +package com.shareexpenses.server.expenses_in_review; import lombok.*; diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReviewRepository.java b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java similarity index 78% rename from src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReviewRepository.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java index 4695267..191ffb3 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/ExpenseInReviewRepository.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery; +package com.shareexpenses.server.expenses_in_review; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/WiseService.java b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java similarity index 95% rename from src/main/java/com/shareexpenses/server/expenses-in-review/WiseService.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java index dc99d2a..b366a08 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/WiseService.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java @@ -1,10 +1,10 @@ -package com.shareexpenses.server.discovery; +package com.shareexpenses.server.expenses_in_review; import com.shareexpenses.server.config.AccountsConfig; -import com.shareexpenses.server.discovery.wise_entities.Balance; -import com.shareexpenses.server.discovery.wise_entities.BalanceStatement; -import com.shareexpenses.server.discovery.wise_entities.Transaction; import com.shareexpenses.server.expenses.ExpensesRepository; +import com.shareexpenses.server.expenses_in_review.wise_entities.Balance; +import com.shareexpenses.server.expenses_in_review.wise_entities.BalanceStatement; +import com.shareexpenses.server.expenses_in_review.wise_entities.Transaction; import com.shareexpenses.server.utils.DateTimeUtils; import com.shareexpenses.server.utils.DigitalSignatures; import lombok.SneakyThrows; @@ -164,6 +164,10 @@ private boolean isCreditTransaction(Transaction wiseTransaction) { return wiseTransaction.getType().equalsIgnoreCase("credit"); } + public long getExpensesToReviewCount() { + return this.expensesInReviewQueue.count(); + } + class Wise2faErrorHandler extends DefaultResponseErrorHandler { @Override public void handleError(ClientHttpResponse response) throws IOException { diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Balance.java b/src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/Balance.java similarity index 70% rename from src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Balance.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/Balance.java index 49b7dd1..d1426dc 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Balance.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/Balance.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery.wise_entities; +package com.shareexpenses.server.expenses_in_review.wise_entities; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/BalanceStatement.java b/src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/BalanceStatement.java similarity index 76% rename from src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/BalanceStatement.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/BalanceStatement.java index 9467d26..29bf94c 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/BalanceStatement.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/BalanceStatement.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery.wise_entities; +package com.shareexpenses.server.expenses_in_review.wise_entities; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Transaction.java b/src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/Transaction.java similarity index 92% rename from src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Transaction.java rename to src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/Transaction.java index f5c9ef4..fa3b1b4 100644 --- a/src/main/java/com/shareexpenses/server/expenses-in-review/wise_entities/Transaction.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/wise_entities/Transaction.java @@ -1,4 +1,4 @@ -package com.shareexpenses.server.discovery.wise_entities; +package com.shareexpenses.server.expenses_in_review.wise_entities; import lombok.Getter; import lombok.NoArgsConstructor; From be5b9bce55d6d9e4d47f5ef25ef939da01655127 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 18 Jun 2023 16:00:09 +0200 Subject: [PATCH 20/30] Keep the gradle cache between builds for faster startup --- scripts/start_dev.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index 4df0421..f1e145d 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -22,6 +22,7 @@ check_if_backend_alive() { } main() { + mkdir -p .gradle $DOCKER build -f Dockerfile.dev -t expenses-server:dev $DOCKER rm -f expenses-server-dev || : db_host=$(hostname -i | grep -Po '192.168.1.\w*(?:$| )') @@ -35,6 +36,8 @@ main() { --user $UID \ --volume $PWD:/src:rw,z \ --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,z \ + --volume $PWD/.gradle:/home/gradle/.gradle:rw,z \ + --userns=keep-id \ --publish 8080:8080 \ --name expenses-server-dev \ --detach \ @@ -57,6 +60,7 @@ main() { echo "Checking again in 5s..." sleep 5 done + $DOCKER logs -f expenses-server-dev } main "$@" \ No newline at end of file From 9a3891b3cb0d6e4c3c8bf76724ff69ed153ca624 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 18 Jun 2023 19:52:02 +0200 Subject: [PATCH 21/30] Implement api to start review --- .../DiscoverController.java | 4 +++ .../ExpenseInReviewRepository.java | 4 +++ .../expenses_in_review/WiseService.java | 30 +++++++++++++++---- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java index d57a7d1..5aa1403 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RestController; import java.sql.Timestamp; +import java.util.List; @AllArgsConstructor @RestController @@ -27,4 +28,7 @@ public int discoverExpenses(@RequestParam String fromDate, @RequestParam String public long getExpensesToReviewCount() { return wiseService.getExpensesToReviewCount(); } + + @GetMapping("/start-review") + public List startReview() { return wiseService.startReview(); } } diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java index 191ffb3..1885008 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java @@ -2,9 +2,13 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.sql.Timestamp; +import java.util.List; + public interface ExpenseInReviewRepository extends JpaRepository { boolean existsById(String s); + List findByReviewUntilBefore(Timestamp timestamp); } diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java index b366a08..19898f0 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java @@ -18,10 +18,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.sql.Timestamp; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.TemporalAmount; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; @Slf4j @@ -78,6 +78,16 @@ public int discoverExpensesBetween(String fromDate, String toDate) { return newTransactions.get(); } + public List startReview() { + List allExpenses = this.expensesInReviewQueue.findByReviewUntilBefore(Timestamp.from(Instant.now())); + + Timestamp anHourFromNow = getAnHourFromNow(); + allExpenses.stream().forEach(expense -> expense.setReviewUntil(anHourFromNow)); + this.expensesInReviewQueue.saveAll(allExpenses); + + return allExpenses; + } + // YYYY-MM-DD or YYYY-MM-DD HH:MM:SS or TImestamp @SneakyThrows private int getStatementForBalanceId(String profileId, Long accountId, String balanceId, String from, String to, String bearerToken, String privateKey) { @@ -141,7 +151,7 @@ private int getStatementForBalanceId(String profileId, Long accountId, String ba ? null : wiseTransaction.getDetails().getMerchant().getName() ) - .reviewUntil(null) + .reviewUntil(getAnHourAgo()) // Setting the reviewUntil date as an hour ago, makes them eligible to be reviewed right away. .build(); expensesInReviewQueue.save(expenseInReview); @@ -164,6 +174,16 @@ private boolean isCreditTransaction(Transaction wiseTransaction) { return wiseTransaction.getType().equalsIgnoreCase("credit"); } + private Timestamp getAnHourAgo() { + var oneHourAgo = Instant.now().minus(Duration.ofHours(1)); + return Timestamp.from(oneHourAgo); + } + + private Timestamp getAnHourFromNow() { + var oneHourFromNow = Instant.now().plus(Duration.ofHours(1)); + return Timestamp.from(oneHourFromNow); + } + public long getExpensesToReviewCount() { return this.expensesInReviewQueue.count(); } From 5e18094bca581c65a1c0b6cd349b77149f666be4 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 18 Jun 2023 19:56:32 +0200 Subject: [PATCH 22/30] Add script to connect to dev db --- scripts/mysql_dev.sh | 2 ++ 1 file changed, 2 insertions(+) create mode 100755 scripts/mysql_dev.sh diff --git a/scripts/mysql_dev.sh b/scripts/mysql_dev.sh new file mode 100755 index 0000000..0e2983c --- /dev/null +++ b/scripts/mysql_dev.sh @@ -0,0 +1,2 @@ +!#/bin/bash +mysql --host=127.0.0.1 --port=3306 --protocol=tcp --user=expenses --password=dummypass expenses \ No newline at end of file From 177ce34d6ea162eda523cd7047cc3d3d2d2f459b Mon Sep 17 00:00:00 2001 From: David Caro Date: Sun, 25 Jun 2023 19:54:32 +0200 Subject: [PATCH 23/30] scripts: add upgrade_db script This will apply and keep track of the upgrades done to the db. --- db/upgrades/00-add-upgrades-table.sql | 6 ++ scripts/upgrade_db.sh | 98 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 db/upgrades/00-add-upgrades-table.sql create mode 100755 scripts/upgrade_db.sh diff --git a/db/upgrades/00-add-upgrades-table.sql b/db/upgrades/00-add-upgrades-table.sql new file mode 100644 index 0000000..0e525fa --- /dev/null +++ b/db/upgrades/00-add-upgrades-table.sql @@ -0,0 +1,6 @@ +create table +if not exists +db_upgrades( + filename varchar(255) not null primary key, + md5 varchar(255) not null +) \ No newline at end of file diff --git a/scripts/upgrade_db.sh b/scripts/upgrade_db.sh new file mode 100755 index 0000000..e010990 --- /dev/null +++ b/scripts/upgrade_db.sh @@ -0,0 +1,98 @@ +#!/bin/bash + + +set -o nounset +set -o pipefail +set -o errexit +shopt -s nullglob + +UPGRADES_TABLE=db_upgrades +THIS_DIR="$(dirname $(realpath ${0}))" +REPO_DIR="$(realpath "$THIS_DIR/..")" +UPGRADES_DIR="$REPO_DIR/db/upgrades" + +MYSQL="mysql \ + --host=127.0.0.1 \ + --port=3306 \ + --protocol=tcp \ + --user=expenses \ + --password=dummypass \ + expenses" + +get_applied_upgrades() { + $MYSQL --execute="select filename from $UPGRADES_TABLE" \ + | tail -n+2 +} + +get_upgrades_to_apply() { + echo "$UPGRADES_DIR"/*.sql +} + +is_in() { + local what="$1" + shift + local where="$@" + local elem + + for elem in "${where[@]}"; do + if [[ "$what" == "$elem" ]]; then + return 0 + fi + done + return 1 +} + +apply() { + local upgrade="${1?}" + local upgrade_md5=$(md5sum "$upgrade"| awk '{print $1}') + $MYSQL <"$1" + $MYSQL --execute="insert into $UPGRADES_TABLE(filename, md5) values ('${upgrade##*/}', '$upgrade_md5');" +} + +ensure_table(){ + $MYSQL \ + --skip-column-names \ + --silent \ + --execute="create table if not exists $UPGRADES_TABLE(filename varchar(255) not null primary key, md5 varchar(255) not null)" +} + +main() { + ensure_table + + if [[ "${1:-}" == "-v" ]]; then + shift + set -x + fi + + local all_to_apply=( + $(get_upgrades_to_apply) + ) + echo "Found file upgrades:" + for upgrade in "${all_to_apply[@]}"; do + echo " $upgrade" + done + + local all_applied=( + $(get_applied_upgrades) + ) + echo "Applied upgrades:" + for upgrade in "${all_applied[@]}"; do + echo " $upgrade" + done + + local to_apply + + echo "Applying new upgrades:" + for to_apply in "${all_to_apply[@]}"; do + if ! is_in "${to_apply##*/}" "${all_applied[@]}"; then + echo -n " $to_apply: " + apply "$to_apply" + echo "ok" + else + echo " $to_apply: already applied, skipped" + fi + done +} + + +main "$@" \ No newline at end of file From 337e03e831fd3cd1e59f788d01104f59b519cdaf Mon Sep 17 00:00:00 2001 From: David Caro Date: Fri, 30 Jun 2023 22:20:32 +0200 Subject: [PATCH 24/30] ci: add verbose running Signed-off-by: David Caro --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1273910..8c650d5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -20,6 +20,7 @@ jobs: path: "expenses-server" - name: start app run: | + set -x cd expenses-server ./scripts/start_devdb.sh populate From 7299e2a7e77b7ba290371d35fc5870b123e8baa1 Mon Sep 17 00:00:00 2001 From: David Caro Date: Fri, 30 Jun 2023 22:21:40 +0200 Subject: [PATCH 25/30] ci: first api tests Signed-off-by: David Caro --- .github/workflows/test.yaml | 17 ++++-- api-tests/helpers.bash | 85 ++++++++++++++++++++++++++ api-tests/test_expenses.bats | 114 +++++++++++++++++++++++++++++++++++ db/fake_data.sql | 10 --- db/schema.sql | 13 +--- scripts/start_dev.sh | 13 +++- scripts/start_devdb.sh | 2 +- 7 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 api-tests/helpers.bash create mode 100644 api-tests/test_expenses.bats diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8c650d5..fca261a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,10 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - - name: Setup Podman + - name: Install packages run: | sudo apt update - sudo apt-get -y install podman + sudo apt-get -y install podman python3-virtualenv jq + python -m virtualenv tests-venv + source tests-venv/bin/activate + pip install bats-core-pkg - name: Get source uses: actions/checkout@v3 with: @@ -22,11 +25,15 @@ jobs: run: | set -x cd expenses-server - ./scripts/start_devdb.sh populate + ./scripts/start_devdb.sh # fake accoutns config mkdir -p ../home-lab-secrets/home_automation/expenses/ echo "accounts-config: {'accounts': []}" > ../home-lab-secrets/home_automation/expenses/application-accounts.yml - podman run --rm mariadb - ./scripts/start_dev.sh + ./scripts/start_dev.sh notail + - name: run tests + run: | + source tests-venv/bin/activate + cd expenses-server + bats_core_pkg --verbose-run --trace api-tests/ diff --git a/api-tests/helpers.bash b/api-tests/helpers.bash new file mode 100644 index 0000000..6b78200 --- /dev/null +++ b/api-tests/helpers.bash @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +BASE_URL=http://127.0.0.1:8080 + +do_post() { + local path="${1?}" + local data="${2?}" + curl \ + -X POST \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' \ + -d "$data" +} + +do_put() { + local path="${1?}" + local data="${2?}" + curl \ + -X PUT \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' \ + -d "$data" +} + +do_get() { + local path="${1?}" + curl \ + -X GET \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' +} + +do_delete() { + local path="${1?}" + curl \ + -X DELETE \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' +} + +is_equal() { + local left="${1?}" + local right="${2?}" + diff <( printf '%s' "$left" ) <( printf "%s" "$right" ) \ + && return 0 + echo -e "is_equal failed\nleft: $left\nright: $right" >&2 + return 1 +} + +match_regex() { + local regex="${1?}" + local what="${2?}" + [[ "$what" =~ $regex ]] && return 0 + echo -e "match_regex failed\nregex: '$regex'\nwhat: $what" >&2 + return 1 +} + +json_has_equal() { + local key="${1?}" + local value="${2?}" + local data="${3?}" + local cur_value=$(echo "$data" | jq -r ".$key") \ + && is_equal "$cur_value" "$value" \ + && return 0 + echo -e "json_has_equal: key '$key' with value '$value' not found in \n$data" >&2 + return 1 +} + +json_has_match() { + local key="${1?}" + local match="${2?}" + local data="${3?}" + local cur_value=$(echo "$data" | jq -r ".$key") + match_regex "$match" "$cur_value" && return 0 + echo -e "json_has_match: key '$key' value '$cur_value' does not match '$match'" >&2 + return 1 +} + +json_get() { + local key="${1?}" + jq -r ".$key" +} \ No newline at end of file diff --git a/api-tests/test_expenses.bats b/api-tests/test_expenses.bats new file mode 100644 index 0000000..682c9ad --- /dev/null +++ b/api-tests/test_expenses.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +# per whole file +setup_file() { + curdir="${BATS_TEST_FILENAME%/*}" + db_host="127.0.0.1" + test_db="expenses" + test_db_pass="dummypass" + test_db_user="expenses" + mysql="mysql \ + --host=$db_host \ + --port=3306 \ + --protocol=tcp \ + --user=$test_db_user \ + --password=$test_db_pass" + + $mysql -e "drop database if exists $test_db" + $mysql -e "create database $test_db" + $mysql "$test_db" < $curdir/../db/schema.sql + $mysql -e "insert into accounts values (0, 'Test account 0'),(1, 'Test account 1')" "$test_db" + $mysql -e "select * from expenses;" "$test_db" +} + + +# per test +setup() { + load helpers +} + +@test "I can get the expenses (empty database)" { + run do_get "api/expenses" + + [[ "$status" == "0" ]] + is_equal \ + '[]' \ + "$output" +} + +@test "I can create a new expense" { + new_expense='{ + "currency": "EUR", + "amount": "42.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_post "api/expenses" "$new_expense" + + [[ "$status" == "0" ]] + json_has_match 'amount' '42.0' "$output" + json_has_match 'category' 'eating-out' "$output" + json_has_match 'description' 'Some fake expense number 1' "$output" + json_has_match 'account.id' '1' "$output" +} + +@test "I can update an expense" { + new_expense='{ + "currency": "EUR", + "amount": "42.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_post "api/expenses" "$new_expense" + + [[ "$status" == "0" ]] + json_has_match 'amount' '42.0' "$output" + + new_expense_id="$(echo "$output" | json_get "id")" + modified_expense='{ + "currency": "EUR", + "amount": "84.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_put "api/expenses/$new_expense_id" "$modified_expense" + + [[ "$status" == "0" ]] + json_has_match "amount" '84.0' "$output" + + run do_get "api/expenses" + + json_has_match "[] | select( .id == $new_expense_id) | .amount" '84.0' "$output" +} + +@test "I can delete an expense" { + new_expense='{ + "currency": "EUR", + "amount": "42.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_post "api/expenses" "$new_expense" + + [[ "$status" == "0" ]] + json_has_match 'amount' '42.0' "$output" + + new_expense_id="$(echo "$output" | json_get "id")" + + run do_delete "api/expenses/$new_expense_id" + + [[ "$status" == "0" ]] + [[ "$output" == "$new_expense_id" ]] + + run do_get "api/expenses" + + json_has_match "[] | select( .id == $new_expense_id)" '' "$output" +} \ No newline at end of file diff --git a/db/fake_data.sql b/db/fake_data.sql index 16e6573..d17051b 100644 --- a/db/fake_data.sql +++ b/db/fake_data.sql @@ -45,16 +45,6 @@ INSERT INTO `expenses` VALUES (2,29.90,'EUR','2021-08-14 00:00:00','eating-out', /*!40000 ALTER TABLE `expenses` ENABLE KEYS */; UNLOCK TABLES; --- --- Dumping data for table `hibernate_sequence` --- - -LOCK TABLES `hibernate_sequence` WRITE; -/*!40000 ALTER TABLE `hibernate_sequence` DISABLE KEYS */; -INSERT INTO `hibernate_sequence` VALUES (1); -/*!40000 ALTER TABLE `hibernate_sequence` ENABLE KEYS */; -UNLOCK TABLES; - /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; diff --git a/db/schema.sql b/db/schema.sql index 5e747ff..ca6400f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -61,6 +61,7 @@ DROP TABLE IF EXISTS `expense_sequence`; CREATE TABLE `expense_sequence` ( `next_val` bigint(20) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT INTO `expense_sequence` VALUES (1); /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -108,18 +109,6 @@ CREATE TABLE `expenses_in_review` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; --- --- Table structure for table `hibernate_sequence` --- - -DROP TABLE IF EXISTS `hibernate_sequence`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `hibernate_sequence` ( - `next_val` bigint(20) DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - -- -- Table structure for table `user` -- diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index f1e145d..dba310d 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -24,12 +24,17 @@ check_if_backend_alive() { main() { mkdir -p .gradle $DOCKER build -f Dockerfile.dev -t expenses-server:dev - $DOCKER rm -f expenses-server-dev || : - db_host=$(hostname -i | grep -Po '192.168.1.\w*(?:$| )') + $DOCKER rm -f expenses-server-dev || : 2>/dev/null + db_host="$(hostname -i | grep -Po '192.168.1.\w*(?:$| )' || :)" + do_tail=yes if [[ $db_host == "" ]]; then # fallback in case we run on ci db_host=$(hostname -i | awk '{print $1}') fi + if [[ "${1:-}" == "notail" ]]; then + do_tail=no + shift + fi $DOCKER run \ --tty \ --interactive \ @@ -60,7 +65,9 @@ main() { echo "Checking again in 5s..." sleep 5 done - $DOCKER logs -f expenses-server-dev + if [[ "$do_tail" == "yes" ]]; then + $DOCKER logs -f expenses-server-dev + fi } main "$@" \ No newline at end of file diff --git a/scripts/start_devdb.sh b/scripts/start_devdb.sh index afea86a..891fa9a 100755 --- a/scripts/start_devdb.sh +++ b/scripts/start_devdb.sh @@ -27,7 +27,7 @@ check_if_db_alive() { start_mariadb() { local name="expenses_devdb" - $DOCKER rm -f "$name" || : + $DOCKER rm -f "$name" || : 2>/dev/null $DOCKER run \ --name="$name" \ --detach \ From 648b28c38d2c62721ac57b4978ce47ad76b55572 Mon Sep 17 00:00:00 2001 From: David Caro Date: Fri, 14 Jul 2023 16:22:46 +0200 Subject: [PATCH 26/30] functional-tests: don't expect floats It turns out that depending on the version of 'jq', it will show floats with or without `.0`. Signed-off-by: David Caro --- api-tests/test_expenses.bats | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api-tests/test_expenses.bats b/api-tests/test_expenses.bats index 682c9ad..cd2642e 100644 --- a/api-tests/test_expenses.bats +++ b/api-tests/test_expenses.bats @@ -48,7 +48,7 @@ setup() { run do_post "api/expenses" "$new_expense" [[ "$status" == "0" ]] - json_has_match 'amount' '42.0' "$output" + json_has_match 'amount' '42' "$output" json_has_match 'category' 'eating-out' "$output" json_has_match 'description' 'Some fake expense number 1' "$output" json_has_match 'account.id' '1' "$output" @@ -66,7 +66,7 @@ setup() { run do_post "api/expenses" "$new_expense" [[ "$status" == "0" ]] - json_has_match 'amount' '42.0' "$output" + json_has_match 'amount' '42' "$output" new_expense_id="$(echo "$output" | json_get "id")" modified_expense='{ @@ -80,11 +80,11 @@ setup() { run do_put "api/expenses/$new_expense_id" "$modified_expense" [[ "$status" == "0" ]] - json_has_match "amount" '84.0' "$output" + json_has_match "amount" '84' "$output" run do_get "api/expenses" - json_has_match "[] | select( .id == $new_expense_id) | .amount" '84.0' "$output" + json_has_match "[] | select( .id == $new_expense_id) | .amount" '84' "$output" } @test "I can delete an expense" { @@ -99,7 +99,7 @@ setup() { run do_post "api/expenses" "$new_expense" [[ "$status" == "0" ]] - json_has_match 'amount' '42.0' "$output" + json_has_match 'amount' '42' "$output" new_expense_id="$(echo "$output" | json_get "id")" From df0dbf45ac075f3ef83691b7d2b1abd53faf6053 Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 2 Jul 2023 17:29:35 +0200 Subject: [PATCH 27/30] Inline hour from now --- .../shareexpenses/server/expenses_in_review/WiseService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java index 19898f0..128b192 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java @@ -81,8 +81,7 @@ public int discoverExpensesBetween(String fromDate, String toDate) { public List startReview() { List allExpenses = this.expensesInReviewQueue.findByReviewUntilBefore(Timestamp.from(Instant.now())); - Timestamp anHourFromNow = getAnHourFromNow(); - allExpenses.stream().forEach(expense -> expense.setReviewUntil(anHourFromNow)); + allExpenses.stream().forEach(expense -> expense.setReviewUntil(getAnHourFromNow())); this.expensesInReviewQueue.saveAll(allExpenses); return allExpenses; From b9f2c3f5c72b3cea1ee91d9fb885573d42008d2c Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Sun, 2 Jul 2023 18:13:58 +0200 Subject: [PATCH 28/30] Release expenses from the queue of review --- .../server/expenses_in_review/DiscoverController.java | 10 ++++++---- .../expenses_in_review/ExpenseInReviewRepository.java | 8 ++++++++ .../server/expenses_in_review/WiseService.java | 8 ++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java index 5aa1403..c47d91f 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java @@ -3,10 +3,7 @@ import lombok.AllArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.repository.query.Param; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.sql.Timestamp; import java.util.List; @@ -31,4 +28,9 @@ public long getExpensesToReviewCount() { @GetMapping("/start-review") public List startReview() { return wiseService.startReview(); } + + @PostMapping("/release") + public void releaseExpenses(@RequestBody List expenseIds) { + wiseService.releaseExpenses(expenseIds); + } } diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java index 1885008..aae40e3 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/ExpenseInReviewRepository.java @@ -1,6 +1,9 @@ package com.shareexpenses.server.expenses_in_review; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.sql.Timestamp; import java.util.List; @@ -11,4 +14,9 @@ public interface ExpenseInReviewRepository extends JpaRepository findByReviewUntilBefore(Timestamp timestamp); + + + @Modifying + @Query("update ExpenseInReview e set e.reviewUntil = current_timestamp() where e.externalId = :externalId") + int updateReviewUntilForExternalId(@Param("externalId") String externalId); } diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java index 128b192..89889b2 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java @@ -13,6 +13,7 @@ import org.springframework.http.*; import org.springframework.http.client.ClientHttpResponse; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestTemplate; import java.io.IOException; @@ -87,6 +88,13 @@ public List startReview() { return allExpenses; } + @Transactional + public void releaseExpenses(List expenseIds) { + expenseIds.forEach(expenseId -> { + this.expensesInReviewQueue.updateReviewUntilForExternalId(expenseId); + }); + } + // YYYY-MM-DD or YYYY-MM-DD HH:MM:SS or TImestamp @SneakyThrows private int getStatementForBalanceId(String profileId, Long accountId, String balanceId, String from, String to, String bearerToken, String privateKey) { From 140736420ab370c81c964348743e89893f19e41d Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Fri, 14 Jul 2023 16:49:09 +0200 Subject: [PATCH 29/30] Accept/Reject expense --- build.gradle | 2 +- .../server/expenses/Expense.java | 4 ++-- .../server/expenses/ExpensesController.java | 3 ++- .../server/expenses/ExpensesService.java | 4 +++- .../DiscoverController.java | 15 +++++++++++++-- .../expenses_in_review/WiseService.java | 19 ++++++++++++++++--- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index 738db8d..2b2bf7f 100644 --- a/build.gradle +++ b/build.gradle @@ -47,4 +47,4 @@ bootRun { jvmArgs = [ "-Dspring.config.additional-location=file:../home-lab-secrets/home_automation/expenses/" ] -} \ No newline at end of file +} diff --git a/src/main/java/com/shareexpenses/server/expenses/Expense.java b/src/main/java/com/shareexpenses/server/expenses/Expense.java index 90777a6..7235a03 100644 --- a/src/main/java/com/shareexpenses/server/expenses/Expense.java +++ b/src/main/java/com/shareexpenses/server/expenses/Expense.java @@ -15,12 +15,12 @@ @NoArgsConstructor @AllArgsConstructor @SequenceGenerator(name="expense_sequence", initialValue=1, allocationSize=100) -public class Expense { +public class Expense { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "expense_sequence") private Long id; - @Column + @Column(nullable = true) private String externalId; @Column(nullable = false) diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesController.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesController.java index 25db2cd..55f5bbd 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesController.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesController.java @@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Optional; @AllArgsConstructor @RestController @@ -20,7 +21,7 @@ public List getAllExpenses() { @PostMapping public Expense addExpense(@RequestBody IncomingExpenseDTO incomingExpenseDTO) { - return this.expensesService.addExpense(incomingExpenseDTO); + return this.expensesService.addExpense(incomingExpenseDTO, Optional.empty()); } @PutMapping("/{expenseId}") diff --git a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java index 5d7abe9..6bf2851 100644 --- a/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java +++ b/src/main/java/com/shareexpenses/server/expenses/ExpensesService.java @@ -11,6 +11,7 @@ import javax.persistence.EntityNotFoundException; import java.util.List; +import java.util.Optional; @Service @Slf4j @@ -30,7 +31,7 @@ public List getAllExpenses() { return expensesRepository.findAll(); } - public Expense addExpense(IncomingExpenseDTO newExpense) { + public Expense addExpense(IncomingExpenseDTO newExpense, Optional externalId) { Account account = this.accountRepository .findById(newExpense.getAccountId()) .orElseThrow(() -> new EntityNotFoundException("No account found with id " + newExpense.getAccountId() + ".")); @@ -43,6 +44,7 @@ public Expense addExpense(IncomingExpenseDTO newExpense) { .timestamp(newExpense.getTimestamp()) .account(account) .category(newExpense.getCategory()) + .externalId(externalId.orElse(null)) .build(); return this.expensesRepository.save(expense); } diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java index c47d91f..d2b60cc 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/DiscoverController.java @@ -1,11 +1,11 @@ package com.shareexpenses.server.expenses_in_review; +import com.shareexpenses.server.expenses.Expense; +import com.shareexpenses.server.expenses.IncomingExpenseDTO; import lombok.AllArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.repository.query.Param; import org.springframework.web.bind.annotation.*; -import java.sql.Timestamp; import java.util.List; @AllArgsConstructor @@ -33,4 +33,15 @@ public long getExpensesToReviewCount() { public void releaseExpenses(@RequestBody List expenseIds) { wiseService.releaseExpenses(expenseIds); } + + @PostMapping("/{externalId}") + public Expense acceptExpenses(@PathVariable String externalId, @RequestBody IncomingExpenseDTO updatedExpense) { + return wiseService.acceptExpense(externalId, updatedExpense); + } + + @DeleteMapping("/{externalId}") + public void rejectExpense(@PathVariable String externalId) { + wiseService.rejectExpense(externalId); + } + } diff --git a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java index 89889b2..73bafd6 100644 --- a/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java +++ b/src/main/java/com/shareexpenses/server/expenses_in_review/WiseService.java @@ -1,7 +1,10 @@ package com.shareexpenses.server.expenses_in_review; import com.shareexpenses.server.config.AccountsConfig; +import com.shareexpenses.server.expenses.Expense; import com.shareexpenses.server.expenses.ExpensesRepository; +import com.shareexpenses.server.expenses.ExpensesService; +import com.shareexpenses.server.expenses.IncomingExpenseDTO; import com.shareexpenses.server.expenses_in_review.wise_entities.Balance; import com.shareexpenses.server.expenses_in_review.wise_entities.BalanceStatement; import com.shareexpenses.server.expenses_in_review.wise_entities.Transaction; @@ -21,7 +24,6 @@ import java.sql.Timestamp; import java.time.Duration; import java.time.Instant; -import java.time.temporal.TemporalAmount; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; @@ -35,6 +37,9 @@ public class WiseService { @Autowired private ExpensesRepository expensesRepository; + @Autowired + private ExpensesService expensesService; + @Autowired private DateTimeUtils dateTimeUtils; private static final String WISE_BASE_URL = "https://api.transferwise.com/"; @@ -129,6 +134,7 @@ private int getStatementForBalanceId(String profileId, Long accountId, String ba RestTemplate authTemplate = new RestTemplate(); HttpEntity authRequest = new HttpEntity(authorizedHeaders); ResponseEntity balanceStatement = authTemplate.exchange(statementUrl, HttpMethod.GET, authRequest, BalanceStatement.class, params); + log.info("Balance Statement after 2fa approval {}", balanceStatement.getBody()); AtomicInteger newUnreviewedTransactions = new AtomicInteger(); @@ -137,8 +143,6 @@ private int getStatementForBalanceId(String profileId, Long accountId, String ba log.info("Checking wise transaction {}", wiseTransaction.getReferenceNumber()); if(isCreditTransaction(wiseTransaction) - || expensesInReviewQueue.existsById(wiseTransaction.getReferenceNumber()) - || expensesRepository.existsByExternalId(wiseTransaction.getReferenceNumber()) ) { // We can ignore the `wiseTransaction` if it represents a credit (money added to TW account) // or if it is already added in the expensesInReview table @@ -195,6 +199,15 @@ public long getExpensesToReviewCount() { return this.expensesInReviewQueue.count(); } + public Expense acceptExpense(String externalId, IncomingExpenseDTO updatedExpense) { + this.expensesInReviewQueue.deleteById(externalId); + return this.expensesService.addExpense(updatedExpense, Optional.of(externalId)); + } + + public void rejectExpense(String externalId) { + this.expensesInReviewQueue.deleteById(externalId); + } + class Wise2faErrorHandler extends DefaultResponseErrorHandler { @Override public void handleError(ClientHttpResponse response) throws IOException { From 88935107257a76c25b10c394e25b350f5525c0dd Mon Sep 17 00:00:00 2001 From: David Caro Date: Sat, 24 Feb 2024 23:24:50 +0100 Subject: [PATCH 30/30] build: fix logging to stdout and dockerfile to match Otherwise we were not logging to the right outputs. Signed-off-by: David Caro --- Dockerfile.dev | 3 ++- scripts/build.sh | 18 ++++++++++++++++++ scripts/start_dev.sh | 14 +++++++++----- .../shareexpenses/server/config/MvcConfig.java | 3 +++ src/main/resources/application.yml | 11 +++++++++++ src/main/resources/log4j.properties | 17 +++++++++-------- 6 files changed, 52 insertions(+), 14 deletions(-) create mode 100755 scripts/build.sh diff --git a/Dockerfile.dev b/Dockerfile.dev index 2f3b5f5..a386152 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,6 +1,7 @@ # FROM --platform=arm64 docker.io/library/gradle:jdk11 # In dev we use x86, in prod we use arm64 (raspberry) -FROM docker.io/library/gradle:jdk11 +FROM --platform=amd64 docker.io/library/gradle:jdk11 +ENV GRADLE_USER_HOME=/src/.gradle EXPOSE 8080/tcp USER 1000 WORKDIR /src diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..821454d --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +CURFILE="$(realpath $0)" +PROJECTDIR="${CURFILE%/*/*}" +PLATFORM="${PLATFORM:-linux/arm64}" +rm -rf "$PROJECTDIR/.gradle/caches" +podman run \ + --rm \ + -ti \ + --user $UID \ + --entrypoint /src/gradlew \ + --userns=keep-id \ + --env "GRADLE_USER_HOME=/src/.gradle" \ + --volume "$PROJECTDIR:/src:rw,z" \ + --platform="$PLATFORM" \ + --workdir /src \ + docker.io/library/gradle:jdk11 \ + build -x test diff --git a/scripts/start_dev.sh b/scripts/start_dev.sh index dba310d..b9ca9d7 100755 --- a/scripts/start_dev.sh +++ b/scripts/start_dev.sh @@ -4,6 +4,9 @@ set -o errexit set -o nounset set -o pipefail +CURFILE="$(realpath $0)" +PROJECTDIR="${CURFILE%/*/*}" + hash podman && DOCKER="sudo podman" || DOCKER=docker @@ -22,7 +25,8 @@ check_if_backend_alive() { } main() { - mkdir -p .gradle + mkdir -p "$PROJECTDIR"/.gradle + rm -rf "$PROJECTDIR/.gradle/caches" $DOCKER build -f Dockerfile.dev -t expenses-server:dev $DOCKER rm -f expenses-server-dev || : 2>/dev/null db_host="$(hostname -i | grep -Po '192.168.1.\w*(?:$| )' || :)" @@ -39,9 +43,9 @@ main() { --tty \ --interactive \ --user $UID \ - --volume $PWD:/src:rw,z \ - --volume $PWD/../home-lab-secrets/:/home-lab-secrets:rw,z \ - --volume $PWD/.gradle:/home/gradle/.gradle:rw,z \ + --volume $PROJECTDIR:/src:rw,z \ + --volume $PROJECTDIR/../home-lab-secrets/:/home-lab-secrets:rw,z \ + --volume $PROJECTDIR/.gradle:/home/gradle/.gradle:rw,z \ --userns=keep-id \ --publish 8080:8080 \ --name expenses-server-dev \ @@ -49,7 +53,7 @@ main() { expenses-server:dev \ "/src/gradlew"\ "bootRun" \ - "--args='--spring.datasource.url=jdbc:mysql://${db_host}:3306/expenses'" \ + "--args=--spring.datasource.url=jdbc:mysql://${db_host}:3306/expenses --spring.datasource.password=dummypass" \ "$@" diff --git a/src/main/java/com/shareexpenses/server/config/MvcConfig.java b/src/main/java/com/shareexpenses/server/config/MvcConfig.java index 121e966..5f7253d 100644 --- a/src/main/java/com/shareexpenses/server/config/MvcConfig.java +++ b/src/main/java/com/shareexpenses/server/config/MvcConfig.java @@ -1,6 +1,7 @@ package com.shareexpenses.server.config; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -8,6 +9,8 @@ public class MvcConfig implements WebMvcConfigurer { public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/").setViewName("forward:/index.html"); + registry.setOrder(Ordered.HIGHEST_PRECEDENCE); registry.addViewController("/login").setViewName("login"); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b1e3759..4cef640 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,3 +15,14 @@ management: endpoint: health: show-details: always + +server: + tomcat: + accesslog: + enabled: true + directory: "/dev" + prefix: stdout + buffered: false + suffix: + file-date-format: + pattern: "[ACCESS] %{org.apache.catalina.AccessLog.RemoteAddr}r %l %t %D %F %B %S vcap_request_id:%{X-Vcap-Request-Id}i" \ No newline at end of file diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties index ad3357e..8edf9d6 100644 --- a/src/main/resources/log4j.properties +++ b/src/main/resources/log4j.properties @@ -1,10 +1,11 @@ # Define the root logger with appender file -log4j.rootLogger = DEBUG, FILE +#log4j.rootLogger = DEBUG, FILE +#log4j.rootLogger = DEBUG -# Define the file appender -log4j.appender.FILE=org.apache.log4j.FileAppender -log4j.appender.FILE.File=${log}/log.out - -# Define the layout for file appender -log4j.appender.FILE.layout=org.apache.log4j.PatternLayout -log4j.appender.FILE.layout.conversionPattern=%m%n +# # Define the file appender +# log4j.appender.FILE=org.apache.log4j.FileAppender +# log4j.appender.FILE.File=${log}/log.out +# +# # Define the layout for file appender +# log4j.appender.FILE.layout=org.apache.log4j.PatternLayout +# log4j.appender.FILE.layout.conversionPattern=%m%n