Skip to content

Commit

Permalink
Merge pull request #3 from agilitytestbed/extension-history
Browse files Browse the repository at this point in the history
Merge implementation of Balance History extension into master
  • Loading branch information
oplosthee authored Jun 10, 2018
2 parents 9ca9dd9 + 91d4ffd commit 98c1319
Show file tree
Hide file tree
Showing 4 changed files with 363 additions and 8 deletions.
246 changes: 246 additions & 0 deletions src/main/java/nl/utwente/ing/controller/BalanceHistoryController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/*
* Copyright (c) 2018, Tom Leemreize <https://github.com/oplosthee>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package nl.utwente.ing.controller;

import com.google.gson.*;
import nl.utwente.ing.controller.database.DBConnection;
import nl.utwente.ing.model.HistoryItem;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Type;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;

@RestController
@RequestMapping("/api/v1/balance/history")
public class BalanceHistoryController {

private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");

/**
* Returns the history of the balance of a bank account using candlestick datapoints. The result is formatted
* according to the API specification: https://app.swaggerhub.com/apis/djhuistra/INGHonours-balanceHistory/
*
* @param headerSessionID the session ID present in the header of the request
* @param querySessionID the session ID present in the URL of the request
* @param interval the interval period, such as a week or month
* @param count the number of interval items to return
* @param response the response shown to the user, necessary to edit the status code of the response
* @return a JSON serialized representation of the bank account's history.
*/
@RequestMapping(value = "", method = RequestMethod.GET, produces = "application/json")
public String getBalanceHistory(@RequestHeader(value = "X-session-ID", required = false) String headerSessionID,
@RequestParam(value = "session_id", required = false) String querySessionID,
@RequestParam(value = "interval", defaultValue = "month", required = false) String interval,
@RequestParam(value = "intervals", defaultValue = "50", required = false) int count,
HttpServletResponse response) {
String sessionID = headerSessionID != null ? headerSessionID : querySessionID;

// Intervals have a minimum of 1 and a maximum of 200.
if (count < 1 || count > 200) {
response.setStatus(405);
return null;
}

Calendar calendar = Calendar.getInstance();
int intervalType;
switch (interval) {
case "hour": intervalType = Calendar.HOUR;
break;
case "day": intervalType = Calendar.DAY_OF_YEAR;
break;
case "week": intervalType = Calendar.WEEK_OF_YEAR;
break;
case "month": intervalType = Calendar.MONTH;
break;
case "year": intervalType = Calendar.YEAR;
break;
default: response.setStatus(405);
return null;
}

// Set the calendar to the date of the earliest transaction possible.
// This is done so we don't fetch transactions that are not included in the history anyway.
calendar.add(intervalType, count * -1);

// Select all transactions for the current user, starting at the earliest transaction.
String query = "SELECT DISTINCT date, amount, type " +
"FROM transactions " +
"WHERE session_id = ? " +
"AND date > ? " +
"ORDER BY date DESC";

// Get the close of the last group, which is effectively the current balance of the account.
String sumQuery = "SELECT (total_pos.total - total_neg.total) " +
"FROM (" +
" (SELECT COALESCE(SUM(dep.amount), 0) AS total FROM transactions dep WHERE dep.type = \"deposit\" AND session_id = ? AND date < ?) AS total_pos, " +
" (SELECT COALESCE(SUM(with.amount), 0) AS total FROM transactions with WHERE with.type = \"withdrawal\" AND session_id = ? AND date < ?) AS total_neg " +
")";

try (Connection connection = DBConnection.instance.getConnection();
PreparedStatement transactionsStatement = connection.prepareStatement(query);
PreparedStatement sumStatement = connection.prepareStatement(sumQuery);
PreparedStatement endSumStatement = connection.prepareStatement(sumQuery)
){
// Setting up the transaction statement
transactionsStatement.setString(1, sessionID);
transactionsStatement.setString(2, DATE_FORMAT.format(calendar.getTime()));
// Setting up the sum statement
sumStatement.setString(1, sessionID);
sumStatement.setString(2, DATE_FORMAT.format(Calendar.getInstance().getTime()));
sumStatement.setString(3, sessionID);
sumStatement.setString(4, DATE_FORMAT.format(Calendar.getInstance().getTime()));
// Setting up the end sum statement
endSumStatement.setString(1, sessionID);
endSumStatement.setString(2, DATE_FORMAT.format(calendar.getTime()));
endSumStatement.setString(3, sessionID);
endSumStatement.setString(4, DATE_FORMAT.format(calendar.getTime()));

ResultSet transactionsSet = transactionsStatement.executeQuery();
ResultSet sumSet = sumStatement.executeQuery();
ResultSet endSumSet = endSumStatement.executeQuery();

long sum = 0;
while (sumSet.next()) {
sum = sumSet.getLong(1);
}

long endSum = 0;
while (endSumSet.next()) {
endSum = endSumSet.getLong(1);
}

// Reset the calendar the the current date.
calendar = Calendar.getInstance();

// Set the reference calendar back one unit so we can find all transactions in that unit.
calendar.add(intervalType, -1);

List<HistoryItem> historyItems = new LinkedList<>();

// Create an initial HistoryItem with the balance of the account as values.
HistoryItem currentItem = new HistoryItem(sum, calendar.getTimeInMillis() / 1000);
historyItems.add(currentItem);

int groupCount = 0;

// In case there are no transactions, fill the results with groups representing the sum and no movement.
if (transactionsSet.isAfterLast()) {
while (historyItems.size() < count) {
calendar.add(intervalType, -1);
historyItems.add(new HistoryItem(sum, calendar.getTimeInMillis() / 1000));
}
}

while (transactionsSet.next()) {
// A calendar containing the date of the current transaction, used for comparing.
Calendar pointer = Calendar.getInstance();
pointer.setTime(DATE_FORMAT.parse(transactionsSet.getString("date")));

while (calendar.after(pointer)) {
calendar.add(intervalType, -1);
currentItem = new HistoryItem(currentItem.getOpen(), calendar.getTimeInMillis() / 1000);
historyItems.add(currentItem);
}

long amount = transactionsSet.getLong("amount");
if ("withdrawal".equals(transactionsSet.getString("type"))) {
// Negate amount in case it was a withdrawal.
amount = amount * -1;
}

if (pointer.before(calendar)) {
// We just entered a new group, we can stop adding to the old history item and create a new one.
groupCount++;

// Stop once the maximum amount specified by the user has been reached.
if (groupCount >= count) {
break;
}

// Some data from the old group has to be carried over to the new group.
calendar.add(intervalType, -1);
currentItem = new HistoryItem(currentItem.getOpen(), calendar.getTimeInMillis() / 1000);
historyItems.add(currentItem);
}

// Update the open value, which is the "current" value.
currentItem.setOpen(currentItem.getOpen() - amount);

if (currentItem.getOpen() > currentItem.getHigh()) {
currentItem.setHigh(currentItem.getOpen());
}

if (currentItem.getOpen() < currentItem.getLow()) {
currentItem.setLow(currentItem.getOpen());
}

currentItem.setVolume(currentItem.getVolume() + Math.abs(amount));
}

// In case not enough transactions were retrieved from the database to fill the count:
// Similarly to the case of the empty set, create items of the last sum without any movement.
while (historyItems.size() < count) {
calendar.add(intervalType, -1);
historyItems.add(new HistoryItem(endSum, calendar.getTimeInMillis() / 1000));
}

GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(HistoryItem.class, new HistoryAdapter());
return gsonBuilder.create().toJson(historyItems);
} catch (SQLException | ParseException e) {
e.printStackTrace();
response.setStatus(500);
return null;
}
}
}

class HistoryAdapter implements JsonSerializer<HistoryItem> {

/**
* A custom serializer for GSON to use to serialize a HistoryItem into the proper JSON representation formatted
* according to the API. Formats the monetary values according to the specification as they are internally
* stored in a long as cents.
*/
@Override
public JsonElement serialize(HistoryItem historyItem, Type type, JsonSerializationContext jsonSerializationContext) {
JsonObject object = new JsonObject();
// Formats the values in the database according to the API specification.
object.addProperty("open", historyItem.getOpen() / 100.0);
object.addProperty("close", historyItem.getClose() / 100.0);
object.addProperty("high", historyItem.getHigh() / 100.0);
object.addProperty("low", historyItem.getLow() / 100.0);
object.addProperty("volume", historyItem.getVolume() / 100.0);
object.addProperty("timestamp", historyItem.getTimestamp());
return object;
}
}
43 changes: 35 additions & 8 deletions src/main/java/nl/utwente/ing/controller/TransactionController.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;

Expand Down Expand Up @@ -216,7 +218,7 @@ public String createTransaction(@RequestHeader(value = "X-session-id", required
response.setStatus(500);
return null;
}
} catch (JsonSyntaxException | NumberFormatException e) {
} catch (JsonParseException | NumberFormatException e) {
e.printStackTrace();
response.setStatus(405);
return null;
Expand Down Expand Up @@ -349,7 +351,7 @@ public String updateTransaction(@RequestHeader(value = "X-session-ID", required
response.setStatus(500);
return null;
}
} catch (JsonSyntaxException | NumberFormatException e) {
} catch (JsonParseException | NumberFormatException e) {
e.printStackTrace();
response.setStatus(405);
return null;
Expand Down Expand Up @@ -433,6 +435,8 @@ public String assignCategoryToTransaction(@RequestHeader(value = "X-session-ID",

class TransactionAdapter implements JsonDeserializer<Transaction>, JsonSerializer<Transaction> {

private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");

/**
* A custom deserializer for GSON to use to deserialize a Transaction formatted according to the API specification
* to Transaction object. Ensures that the amount field is properly converted to cents to work with the internally
Expand All @@ -443,17 +447,40 @@ public Transaction deserialize(JsonElement json, java.lang.reflect.Type type,
JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
JsonObject jsonObject = json.getAsJsonObject();

String date = jsonObject.get("date").getAsString();
Long amount = Long.valueOf(jsonObject.get("amount").getAsString().replace(".", ""));
JsonElement dateElement = jsonObject.get("date");
JsonElement amountElement = jsonObject.get("amount");
JsonElement typeElement = jsonObject.get("type");
JsonElement ibanElement = jsonObject.get("externalIBAN");

// Check whether all the fields are present to avoid NullPointerExceptions later on.
if (dateElement == null || amountElement == null || typeElement == null || ibanElement == null) {
throw new JsonParseException("Missing one or more required fields");
}

String date;
try {
date = dateElement.getAsString();
// Check whether the date was formatted properly by parsing it.
DATE_FORMAT.parse(date);
} catch (ParseException e) {
throw new JsonParseException("Invalid date specified");
}

Long amount = Long.valueOf(amountElement.getAsString().replace(".", ""));

// Description is not present in earlier versions of the API so might be left out, check for null for safety.
JsonElement descriptionElement = jsonObject.get("description");
String description = (descriptionElement == null) ? null : descriptionElement.getAsString();

String externalIBAN = jsonObject.get("externalIBAN").getAsString();
Type transactionType = Type.valueOf(jsonObject.get("type").getAsString());
Category category = null;
String externalIBAN = ibanElement.getAsString();

String typeString = typeElement.getAsString();
if (!("deposit".equals(typeString) || "withdrawal".equals(typeString))) {
throw new JsonParseException("Invalid type specified");
}
Type transactionType = Type.valueOf(typeString);

Category category = null;
if (json.getAsJsonObject().has("category")) {
category = new Category(jsonObject.get("category").getAsJsonObject().get("id").getAsInt(),
jsonObject.get("category").getAsJsonObject().get("name").getAsString());
Expand All @@ -465,7 +492,7 @@ public Transaction deserialize(JsonElement json, java.lang.reflect.Type type,
/**
* A custom serializer for GSON to use to serialize a Transaction into the proper JSON representation formatted
* according to the API. Does not serialize null values unlike the default serializer. Formats the amount according
* to the specification as they are internally stored in a double as cents.
* to the specification as they are internally stored in a long as cents.
*/
@Override
public JsonElement serialize(Transaction transaction, java.lang.reflect.Type type,
Expand Down
Loading

0 comments on commit 98c1319

Please sign in to comment.