V1 - Personal Account and Budgeting Done
Personal Account and Budgeting
This commit is contained in:
2
kifi-api/.gitattributes
vendored
Normal file
2
kifi-api/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
33
kifi-api/.gitignore
vendored
Normal file
33
kifi-api/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
HELP.md
|
||||
target/
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
3
kifi-api/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
kifi-api/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
13
kifi-api/Dockerfile
Normal file
13
kifi-api/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY .mvn/ .mvn
|
||||
COPY mvnw pom.xml ./
|
||||
RUN ./mvnw dependency:go-offline
|
||||
COPY src ./src
|
||||
RUN ./mvnw clean package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
360
kifi-api/Kifi-API.postman_collection.json
Normal file
360
kifi-api/Kifi-API.postman_collection.json
Normal file
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Kifi API",
|
||||
"description": "Postman Collection for Kifi API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "https://app.technobeesolutions.in/api/kifi",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "token",
|
||||
"value": "YOUR_JWT_TOKEN",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{token}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Auth",
|
||||
"item": [
|
||||
{
|
||||
"name": "Signup",
|
||||
"request": {
|
||||
"auth": { "type": "noauth" },
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"phone\": \"+1234567890\",\n \"password\": \"securepassword\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/auth/signup",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "auth", "signup" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Verify OTP",
|
||||
"request": {
|
||||
"auth": { "type": "noauth" },
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"phone\": \"+1234567890\",\n \"otp\": \"123456\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/auth/verify-otp",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "auth", "verify-otp" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Login",
|
||||
"request": {
|
||||
"auth": { "type": "noauth" },
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"phone\": \"+1234567890\",\n \"password\": \"securepassword\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/auth/login",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "auth", "login" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wallets",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Wallets",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/wallets",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "wallets" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Create Wallet",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"My Family Wallet\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/wallets",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "wallets" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Invite User",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userPhone\": \"+1987654321\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/wallets/1/invite",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "wallets", "1", "invite" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Transactions",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Transactions",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/transactions",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "transactions" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Add Transaction",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"amount\": 50.0,\n \"type\": \"EXPENSE\",\n \"categoryId\": 1,\n \"merchantId\": 1,\n \"date\": \"2023-10-01T10:00:00\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/transactions",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "transactions" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update Transaction",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"amount\": 75.0,\n \"type\": \"EXPENSE\",\n \"categoryId\": 1,\n \"merchantId\": 1,\n \"date\": \"2023-10-01T10:00:00\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/transactions/1",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "transactions", "1" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete Transaction",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/transactions/1",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "transactions", "1" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Budgets",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Budgets",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/budgets",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "budgets" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Add/Update Budget",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"categoryId\": 1,\n \"limit\": 500.0,\n \"month\": 10,\n \"year\": 2023\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/budgets",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "budgets" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete Budget",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/budgets/1",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "budgets", "1" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Categories",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Categories",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/categories",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "categories" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Add Category",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Groceries\",\n \"icon\": \"shopping_cart\",\n \"color\": \"#FF0000\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/categories",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "categories" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Merchants",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Merchants",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/merchants",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "merchants" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Add Merchant",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Walmart\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/merchants",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "merchants" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Recurring Transactions",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Recurring Transactions",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/recurring-transactions",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "recurring-transactions" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Add Recurring Transaction",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"amount\": 15.99,\n \"type\": \"EXPENSE\",\n \"categoryId\": 1,\n \"frequency\": \"MONTHLY\",\n \"nextDate\": \"2023-11-01T10:00:00\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/recurring-transactions",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "recurring-transactions" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete Recurring Transaction",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/recurring-transactions/1",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "recurring-transactions", "1" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Reports",
|
||||
"item": [
|
||||
{
|
||||
"name": "Export CSV",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{base_url}}/reports/export",
|
||||
"host": [ "{{base_url}}" ],
|
||||
"path": [ "reports", "export" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
20
kifi-api/build_n_push.sh
Executable file
20
kifi-api/build_n_push.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
REGISTRY="hub.technobeesolutions.in"
|
||||
USERNAME="technobee_admin"
|
||||
PASSWORD='M@tr!x#149@dm!N'
|
||||
IMAGE_NAME="kifi-api"
|
||||
TAG="latest"
|
||||
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||
|
||||
# Stop on any error
|
||||
set -e
|
||||
|
||||
echo "Logging into Docker registry: $REGISTRY..."
|
||||
echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin
|
||||
|
||||
echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME..."
|
||||
docker buildx build --platform linux/amd64 -t "$FULL_IMAGE_NAME" --push .
|
||||
|
||||
echo "Done! Image built and pushed successfully."
|
||||
30
kifi-api/docker-compose.yml
Normal file
30
kifi-api/docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
api:
|
||||
image: hub.technobeesolutions.in/kifi-api:latest
|
||||
container_name: kifi-api
|
||||
ports:
|
||||
- "8056:8080"
|
||||
environment:
|
||||
- SPRING_R2DBC_URL=r2dbc:postgresql://host.docker.internal:5432/kifi
|
||||
- SPRING_R2DBC_USERNAME=postgres
|
||||
- SPRING_R2DBC_PASSWORD=M@triXPostgr3s@6202
|
||||
- SPRING_DATA_REDIS_HOST=redis
|
||||
- SPRING_DATA_REDIS_PORT=6379
|
||||
- SPRING_DATA_REDIS_PASSWORD=M@triXR3d1s@6202
|
||||
- SPRING_MAIL_HOST=smtp.gmail.com
|
||||
- SPRING_MAIL_PORT=587
|
||||
- SPRING_MAIL_USERNAME=technobeesolutions@gmail.com
|
||||
- SPRING_MAIL_PASSWORD=lrideibfakickldg
|
||||
- MINIO_SERVICE_URL=http://minio-service:1500
|
||||
- MINIO_SERVICE_BUCKET=kifi
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
commons-network:
|
||||
ipv4_address: 172.19.0.224
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
commons-network:
|
||||
external: true
|
||||
name: commons_commons-network
|
||||
295
kifi-api/mvnw
vendored
Executable file
295
kifi-api/mvnw
vendored
Executable file
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
189
kifi-api/mvnw.cmd
vendored
Normal file
189
kifi-api/mvnw.cmd
vendored
Normal file
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
171
kifi-api/pom.xml
Normal file
171
kifi-api/pom.xml
Normal file
@@ -0,0 +1,171 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.0</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.kifi</groupId>
|
||||
<artifactId>kifi-api</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>kifi-api</name>
|
||||
<description>Kifi Backend API</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>r2dbc-postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis-reactive-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-csv</artifactId>
|
||||
<version>1.10.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>default-testCompile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>testCompile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
16
kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java
Normal file
16
kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.kifi.api;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class KifiApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(KifiApiApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.kifi.api.config;
|
||||
|
||||
import com.kifi.api.security.AuthenticationManager;
|
||||
import com.kifi.api.security.SecurityContextRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final SecurityContextRepository securityContextRepository;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
||||
return http
|
||||
.exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
|
||||
.authenticationEntryPoint((swe, e) ->
|
||||
Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))
|
||||
)
|
||||
.accessDeniedHandler((swe, e) ->
|
||||
Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN))
|
||||
)
|
||||
)
|
||||
.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.authenticationManager(authenticationManager)
|
||||
.securityContextRepository(securityContextRepository)
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers(HttpMethod.OPTIONS).permitAll()
|
||||
.pathMatchers("/api/kifi/auth/**").permitAll()
|
||||
.pathMatchers("/api/kifi/health/**").permitAll()
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.kifi.api.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient.Builder webClientBuilder() {
|
||||
return WebClient.builder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.dto.AuthRequest;
|
||||
import com.kifi.api.dto.OtpVerificationRequest;
|
||||
import com.kifi.api.dto.ResetPasswordRequest;
|
||||
import com.kifi.api.service.AuthService;
|
||||
import com.kifi.api.service.CryptoService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
@GetMapping("/public-key")
|
||||
public Mono<ResponseEntity<Object>> getPublicKey() {
|
||||
return Mono.just(ResponseEntity.ok().body((Object) Map.of("publicKey", cryptoService.getPublicKeyBase64())));
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public Mono<ResponseEntity<Object>> signup(@RequestBody AuthRequest request) {
|
||||
return authService.signup(request)
|
||||
.map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/verify-otp")
|
||||
public Mono<ResponseEntity<Object>> verifyOtp(@RequestBody OtpVerificationRequest request) {
|
||||
return authService.verifyOtp(request.getEmail(), request.getOtp())
|
||||
.map(response -> ResponseEntity.ok().body((Object) response))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<Object>> login(@RequestBody AuthRequest request) {
|
||||
return authService.login(request)
|
||||
.map(response -> ResponseEntity.ok().body((Object) response))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.status(401).body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public Mono<ResponseEntity<Object>> forgotPassword(@RequestBody Map<String, String> request) {
|
||||
String email = request.get("email");
|
||||
return authService.forgotPassword(email)
|
||||
.map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public Mono<ResponseEntity<Object>> resetPassword(@RequestBody ResetPasswordRequest request) {
|
||||
return authService.resetPassword(request.getEmail(), request.getOtp(), request.getNewPassword())
|
||||
.map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.Budget;
|
||||
import com.kifi.api.dto.BudgetSummary;
|
||||
import com.kifi.api.service.BudgetService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/budgets")
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetController {
|
||||
private final BudgetService budgetService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Budget>> getBudgets(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return budgetService.getBudgets(userId).collectList();
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public Mono<List<BudgetSummary>> getBudgetSummaries(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return budgetService.getBudgetSummaries(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Budget> addOrUpdateBudget(Authentication authentication, @RequestBody Budget budget) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return budgetService.addOrUpdateBudget(userId, budget);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<Void> deleteBudget(@PathVariable Long id) {
|
||||
return budgetService.deleteBudget(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.Category;
|
||||
import com.kifi.api.service.CategoryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/categories")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryController {
|
||||
private final CategoryService categoryService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Category>> getCategories(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return categoryService.getCategories(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Category> addCategory(Authentication authentication, @RequestBody Category category) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return categoryService.addCategory(userId, category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/health")
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping
|
||||
public Mono<Map<String, String>> healthCheck() {
|
||||
return Mono.just(Map.of("status", "UP", "message", "Kifi API is running."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import com.kifi.api.service.RecurringTransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/recurring-transactions")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringTransactionController {
|
||||
|
||||
private final RecurringTransactionService service;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<RecurringTransaction>> getRecurringTransactions(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return service.getRecurringTransactions(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<RecurringTransaction> addRecurringTransaction(Authentication authentication, @RequestBody RecurringTransaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return service.addRecurringTransaction(userId, transaction);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Mono<RecurringTransaction> updateRecurringTransaction(@PathVariable Long id, Authentication authentication, @RequestBody RecurringTransaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return service.updateRecurringTransaction(id, userId, transaction);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<Void> deleteRecurringTransaction(@PathVariable Long id) {
|
||||
return service.deleteRecurringTransaction(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.service.ReportService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/reports")
|
||||
@RequiredArgsConstructor
|
||||
public class ReportController {
|
||||
private final ReportService reportService;
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
public Mono<ResponseEntity<byte[]>> exportTransactions(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return reportService.exportTransactionsToCsv(userId)
|
||||
.map(bytes -> ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"transactions.csv\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv"))
|
||||
.body(bytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.service.TransactionService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/transactions")
|
||||
@RequiredArgsConstructor
|
||||
public class TransactionController {
|
||||
private final TransactionService transactionService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Transaction>> getTransactions(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return transactionService.getTransactions(userId).collectList();
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public Mono<Page<Transaction>> searchTransactions(
|
||||
Authentication authentication,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) Long walletId,
|
||||
@RequestParam(required = false) Long categoryId,
|
||||
@RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate startDate,
|
||||
@RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate endDate,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return transactionService.searchTransactions(userId, type, walletId, categoryId, startDate, endDate, search, pageable);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Transaction> addTransaction(Authentication authentication, @RequestBody Transaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return transactionService.addTransaction(userId, transaction);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Mono<Transaction> updateTransaction(Authentication authentication, @PathVariable Long id, @RequestBody Transaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return transactionService.updateTransaction(id, userId, transaction);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<Void> deleteTransaction(@PathVariable Long id) {
|
||||
return transactionService.deleteTransaction(id);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/attachments", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Mono<com.kifi.api.entity.TransactionAttachment> addAttachment(@PathVariable Long id, @RequestPart("file") org.springframework.http.codec.multipart.FilePart filePart) {
|
||||
return org.springframework.core.io.buffer.DataBufferUtils.join(filePart.content())
|
||||
.flatMap(dataBuffer -> {
|
||||
byte[] bytes = new byte[dataBuffer.readableByteCount()];
|
||||
dataBuffer.read(bytes);
|
||||
org.springframework.core.io.buffer.DataBufferUtils.release(dataBuffer);
|
||||
String base64Content = java.util.Base64.getEncoder().encodeToString(bytes);
|
||||
String fileName = filePart.filename();
|
||||
String contentType = filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "application/octet-stream";
|
||||
return transactionService.addAttachment(id, fileName, contentType, base64Content);
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/attachments/{attachmentId}/content")
|
||||
public Mono<org.springframework.http.ResponseEntity<byte[]>> downloadAttachment(@PathVariable Long id, @PathVariable Long attachmentId) {
|
||||
return transactionService.downloadAttachment(attachmentId)
|
||||
.map(response -> {
|
||||
byte[] decodedBytes = java.util.Base64.getDecoder().decode(response.getBase64Content());
|
||||
return org.springframework.http.ResponseEntity.ok()
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_TYPE, "image/jpeg") // Ideally dynamic based on db
|
||||
.body(decodedBytes);
|
||||
});
|
||||
}
|
||||
|
||||
@DeleteMapping("/attachments/{attachmentId}")
|
||||
public Mono<Void> deleteAttachment(@PathVariable Long attachmentId) {
|
||||
return transactionService.deleteAttachment(attachmentId);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/close-investment")
|
||||
public Mono<Transaction> closeInvestment(Authentication authentication, @PathVariable Long id, @RequestBody java.util.Map<String, Object> payload) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
java.math.BigDecimal maturityAmount = new java.math.BigDecimal(payload.get("maturityAmount").toString());
|
||||
java.time.LocalDate closingDate = payload.get("closingDate") != null ? java.time.LocalDate.parse(payload.get("closingDate").toString()) : java.time.LocalDate.now();
|
||||
Long toWalletId = payload.get("toWalletId") != null ? Long.valueOf(payload.get("toWalletId").toString()) : null;
|
||||
return transactionService.closeInvestment(id, userId, maturityAmount, closingDate, toWalletId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.UserWallet;
|
||||
import com.kifi.api.entity.Wallet;
|
||||
import com.kifi.api.entity.WalletInvitation;
|
||||
import com.kifi.api.service.WalletService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/wallets")
|
||||
@RequiredArgsConstructor
|
||||
public class WalletController {
|
||||
|
||||
private final WalletService walletService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Wallet>> getWallets(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.getWalletsForUser(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Wallet> createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance());
|
||||
}
|
||||
|
||||
@PutMapping("/{walletId}")
|
||||
public Mono<Wallet> editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{walletId}")
|
||||
public Mono<Void> deleteWallet(@PathVariable Long walletId, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.deleteWallet(walletId, userId);
|
||||
}
|
||||
|
||||
@PostMapping("/{walletId}/invite")
|
||||
public Mono<WalletInvitation> inviteUser(@PathVariable Long walletId, @RequestBody InviteRequest request, Authentication authentication) {
|
||||
Long inviterId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.inviteUserByEmail(inviterId, walletId, request.getEmail());
|
||||
}
|
||||
|
||||
@GetMapping("/invitations")
|
||||
public Mono<List<WalletInvitation>> getInvitations(Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
return walletService.getPendingInvitations(email).collectList();
|
||||
}
|
||||
|
||||
@PostMapping("/invitations/{id}/accept")
|
||||
public Mono<Void> acceptInvitation(@PathVariable Long id, Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.respondToInvitation(id, email, userId, true);
|
||||
}
|
||||
|
||||
@PostMapping("/invitations/{id}/reject")
|
||||
public Mono<Void> rejectInvitation(@PathVariable Long id, Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.respondToInvitation(id, email, userId, false);
|
||||
}
|
||||
|
||||
@GetMapping("/{walletId}/members")
|
||||
public Mono<List<com.kifi.api.dto.WalletMemberDTO>> getWalletMembers(@PathVariable Long walletId, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.getWalletMembers(walletId, userId).collectList();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{walletId}/members/{memberIdToRemove}")
|
||||
public Mono<Void> removeWalletMember(@PathVariable Long walletId, @PathVariable Long memberIdToRemove, Authentication authentication) {
|
||||
Long ownerId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.removeWalletMember(walletId, ownerId, memberIdToRemove);
|
||||
}
|
||||
|
||||
@GetMapping("/user-contacts")
|
||||
public Mono<List<String>> getKnownContacts(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.getKnownContacts(userId).collectList();
|
||||
}
|
||||
|
||||
@Data
|
||||
static class CreateWalletRequest {
|
||||
private String name;
|
||||
private String nature;
|
||||
private String icon;
|
||||
private String color;
|
||||
private String currency;
|
||||
private java.math.BigDecimal initialBalance;
|
||||
}
|
||||
|
||||
@Data
|
||||
static class InviteRequest {
|
||||
private String email;
|
||||
}
|
||||
}
|
||||
9
kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java
Normal file
9
kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AuthRequest {
|
||||
private String email;
|
||||
private String password;
|
||||
}
|
||||
16
kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java
Normal file
16
kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthResponse {
|
||||
private String token;
|
||||
private Long userId;
|
||||
private String email;
|
||||
}
|
||||
14
kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java
Normal file
14
kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import com.kifi.api.entity.Budget;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetSummary {
|
||||
private Budget budget;
|
||||
private BigDecimal spentAmount;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class OtpVerificationRequest {
|
||||
private String email;
|
||||
private String otp;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ResetPasswordRequest {
|
||||
private String email;
|
||||
private String otp;
|
||||
private String newPassword;
|
||||
}
|
||||
19
kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java
Normal file
19
kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WalletMemberDTO {
|
||||
private Long userId;
|
||||
private String email;
|
||||
private String role;
|
||||
private LocalDateTime joinedAt;
|
||||
}
|
||||
21
kifi-api/src/main/java/com/kifi/api/entity/Budget.java
Normal file
21
kifi-api/src/main/java/com/kifi/api/entity/Budget.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("budgets")
|
||||
public class Budget {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long categoryId;
|
||||
private Long walletId;
|
||||
private BigDecimal monthlyLimit;
|
||||
private Boolean isShared;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
24
kifi-api/src/main/java/com/kifi/api/entity/Category.java
Normal file
24
kifi-api/src/main/java/com/kifi/api/entity/Category.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("categories")
|
||||
public class Category {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String name;
|
||||
private String iconName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("recurring_transactions")
|
||||
public class RecurringTransaction {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long categoryId;
|
||||
private Long fromWalletId;
|
||||
private Long toWalletId;
|
||||
private String type;
|
||||
private BigDecimal amount;
|
||||
private String frequency; // DAILY, WEEKLY, MONTHLY, YEARLY
|
||||
private LocalDate nextExecutionDate;
|
||||
private LocalDate endDate;
|
||||
private String status; // ACTIVE, PAUSED
|
||||
private String description;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
50
kifi-api/src/main/java/com/kifi/api/entity/Transaction.java
Normal file
50
kifi-api/src/main/java/com/kifi/api/entity/Transaction.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("transactions")
|
||||
public class Transaction {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long categoryId;
|
||||
private Long fromWalletId;
|
||||
private Long toWalletId;
|
||||
private String notes;
|
||||
private String type; // INCOME or EXPENSE or INVESTMENT
|
||||
private BigDecimal amount;
|
||||
private LocalDate date;
|
||||
private String description;
|
||||
|
||||
// Payable fields
|
||||
private LocalDate dueDate;
|
||||
private String alertSchedule;
|
||||
private java.time.LocalTime alertTime;
|
||||
|
||||
// Investment fields
|
||||
private String investmentStatus; // OPEN or CLOSED
|
||||
private BigDecimal maturityAmount;
|
||||
private BigDecimal profitLoss;
|
||||
private LocalDate closingDate;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@org.springframework.data.annotation.Transient
|
||||
private java.util.List<TransactionItem> items;
|
||||
|
||||
@org.springframework.data.annotation.Transient
|
||||
private java.util.List<TransactionAttachment> attachments;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("transaction_attachments")
|
||||
public class TransactionAttachment {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long transactionId;
|
||||
private String fileName;
|
||||
private String filePath;
|
||||
private String contentType;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("transaction_items")
|
||||
public class TransactionItem {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long transactionId;
|
||||
private String name;
|
||||
private BigDecimal amount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
public enum TransactionType {
|
||||
INCOME,
|
||||
EXPENSE
|
||||
}
|
||||
26
kifi-api/src/main/java/com/kifi/api/entity/User.java
Normal file
26
kifi-api/src/main/java/com/kifi/api/entity/User.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("users")
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
@Builder.Default
|
||||
private Boolean enabled = false;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
18
kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java
Normal file
18
kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("user_wallets")
|
||||
public class UserWallet {
|
||||
@Id
|
||||
private Long id; // R2DBC sometimes needs a synthetic ID, but we have a composite PK in DB. We might need to just use a regular ID if this gives issues, but let's try mapping.
|
||||
private Long userId;
|
||||
private Long walletId;
|
||||
private String role;
|
||||
private LocalDateTime joinedAt;
|
||||
}
|
||||
22
kifi-api/src/main/java/com/kifi/api/entity/Wallet.java
Normal file
22
kifi-api/src/main/java/com/kifi/api/entity/Wallet.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("wallets")
|
||||
public class Wallet {
|
||||
@Id
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long ownerId;
|
||||
private String nature;
|
||||
private java.math.BigDecimal balance;
|
||||
private String currency;
|
||||
private String icon;
|
||||
private String color;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("wallet_invitations")
|
||||
public class WalletInvitation {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long walletId;
|
||||
private Long inviterId;
|
||||
private String inviteeEmail;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Budget;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public interface BudgetRepository extends R2dbcRepository<Budget, Long> {
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT b.* FROM budgets b WHERE b.user_id = :userId OR (b.is_shared = TRUE AND b.wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))")
|
||||
Flux<Budget> findByUserId(Long userId);
|
||||
reactor.core.publisher.Mono<Void> deleteByWalletId(Long walletId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Category;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface CategoryRepository extends R2dbcRepository<Category, Long> {
|
||||
Flux<Category> findByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Repository
|
||||
public interface RecurringTransactionRepository extends R2dbcRepository<RecurringTransaction, Long> {
|
||||
Flux<RecurringTransaction> findByUserId(Long userId);
|
||||
|
||||
@Query("SELECT * FROM recurring_transactions WHERE status = 'ACTIVE' AND next_execution_date <= :date")
|
||||
Flux<RecurringTransaction> findDueTransactions(LocalDate date);
|
||||
|
||||
reactor.core.publisher.Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.TransactionAttachment;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface TransactionAttachmentRepository extends ReactiveCrudRepository<TransactionAttachment, Long> {
|
||||
Flux<TransactionAttachment> findByTransactionId(Long transactionId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.TransactionItem;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface TransactionItemRepository extends ReactiveCrudRepository<TransactionItem, Long> {
|
||||
Flux<TransactionItem> findByTransactionId(Long transactionId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface TransactionRepository extends R2dbcRepository<Transaction, Long>, TransactionRepositoryCustom {
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT t.* FROM transactions t WHERE t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) ORDER BY t.date DESC")
|
||||
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
|
||||
Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public interface TransactionRepositoryCustom {
|
||||
Mono<Page<Transaction>> findTransactionsWithFilters(
|
||||
Long userId,
|
||||
String type,
|
||||
Long walletId,
|
||||
Long categoryId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String search,
|
||||
Pageable pageable
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class TransactionRepositoryImpl implements TransactionRepositoryCustom {
|
||||
|
||||
private final DatabaseClient databaseClient;
|
||||
|
||||
public TransactionRepositoryImpl(DatabaseClient databaseClient) {
|
||||
this.databaseClient = databaseClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Page<Transaction>> findTransactionsWithFilters(
|
||||
Long userId,
|
||||
String type,
|
||||
Long walletId,
|
||||
Long categoryId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String search,
|
||||
Pageable pageable) {
|
||||
StringBuilder baseQuery = new StringBuilder("FROM transactions t WHERE (t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))");
|
||||
|
||||
if (type != null && !type.isEmpty()) {
|
||||
baseQuery.append(" AND t.type = :type");
|
||||
}
|
||||
if (walletId != null) {
|
||||
baseQuery.append(" AND (t.from_wallet_id = :walletId OR t.to_wallet_id = :walletId)");
|
||||
}
|
||||
if (categoryId != null) {
|
||||
baseQuery.append(" AND t.category_id = :categoryId");
|
||||
}
|
||||
if (startDate != null) {
|
||||
baseQuery.append(" AND t.date >= :startDate");
|
||||
}
|
||||
if (endDate != null) {
|
||||
baseQuery.append(" AND t.date <= :endDate");
|
||||
}
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
baseQuery.append(" AND t.description ILIKE :search");
|
||||
}
|
||||
|
||||
String dataQueryStr = "SELECT t.* " + baseQuery.toString() + " ORDER BY t.date DESC LIMIT " + pageable.getPageSize() + " OFFSET " + pageable.getOffset();
|
||||
String countQueryStr = "SELECT COUNT(t.id) " + baseQuery.toString();
|
||||
|
||||
DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId);
|
||||
DatabaseClient.GenericExecuteSpec countSpec = databaseClient.sql(countQueryStr).bind("userId", userId);
|
||||
|
||||
if (type != null && !type.isEmpty()) {
|
||||
dataSpec = dataSpec.bind("type", type);
|
||||
countSpec = countSpec.bind("type", type);
|
||||
}
|
||||
if (walletId != null) {
|
||||
dataSpec = dataSpec.bind("walletId", walletId);
|
||||
countSpec = countSpec.bind("walletId", walletId);
|
||||
}
|
||||
if (categoryId != null) {
|
||||
dataSpec = dataSpec.bind("categoryId", categoryId);
|
||||
countSpec = countSpec.bind("categoryId", categoryId);
|
||||
}
|
||||
if (startDate != null) {
|
||||
dataSpec = dataSpec.bind("startDate", startDate);
|
||||
countSpec = countSpec.bind("startDate", startDate);
|
||||
}
|
||||
if (endDate != null) {
|
||||
dataSpec = dataSpec.bind("endDate", endDate);
|
||||
countSpec = countSpec.bind("endDate", endDate);
|
||||
}
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
dataSpec = dataSpec.bind("search", "%" + search.trim() + "%");
|
||||
countSpec = countSpec.bind("search", "%" + search.trim() + "%");
|
||||
}
|
||||
|
||||
Mono<List<Transaction>> transactionsMono = dataSpec.map((row, metadata) -> {
|
||||
Transaction t = new Transaction();
|
||||
t.setId(row.get("id", Long.class));
|
||||
t.setUserId(row.get("user_id", Long.class));
|
||||
t.setFromWalletId(row.get("from_wallet_id", Long.class));
|
||||
t.setToWalletId(row.get("to_wallet_id", Long.class));
|
||||
t.setCategoryId(row.get("category_id", Long.class));
|
||||
t.setType(row.get("type", String.class));
|
||||
t.setAmount(row.get("amount", BigDecimal.class));
|
||||
t.setDate(row.get("date", LocalDate.class));
|
||||
t.setDescription(row.get("description", String.class));
|
||||
t.setInvestmentStatus(row.get("investment_status", String.class));
|
||||
t.setProfitLoss(row.get("profit_loss", BigDecimal.class));
|
||||
return t;
|
||||
}).all().collectList();
|
||||
|
||||
Mono<Long> countMono = countSpec.map((row, metadata) -> row.get(0, Long.class)).first().defaultIfEmpty(0L);
|
||||
|
||||
return Mono.zip(transactionsMono, countMono)
|
||||
.map(tuple -> new PageImpl<>(tuple.getT1(), pageable, tuple.getT2()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.User;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends R2dbcRepository<User, Long> {
|
||||
Mono<User> findByEmail(String email);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.UserWallet;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface UserWalletRepository extends ReactiveCrudRepository<UserWallet, Long> {
|
||||
Flux<UserWallet> findByUserId(Long userId);
|
||||
Flux<UserWallet> findByWalletId(Long walletId);
|
||||
Mono<Void> deleteByWalletId(Long walletId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.WalletInvitation;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface WalletInvitationRepository extends R2dbcRepository<WalletInvitation, Long> {
|
||||
Flux<WalletInvitation> findByInviteeEmailAndStatus(String inviteeEmail, String status);
|
||||
reactor.core.publisher.Mono<Void> deleteByWalletId(Long walletId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Wallet;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public interface WalletRepository extends ReactiveCrudRepository<Wallet, Long> {
|
||||
Flux<Wallet> findByOwnerId(Long ownerId);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.kifi.api.scheduler;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.repository.RecurringTransactionRepository;
|
||||
import com.kifi.api.service.TransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RecurringTransactionScheduler {
|
||||
|
||||
private final RecurringTransactionRepository recurringRepository;
|
||||
private final TransactionService transactionService;
|
||||
|
||||
// Runs every day at midnight
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
public void processRecurringTransactions() {
|
||||
LocalDate today = LocalDate.now();
|
||||
log.info("Starting recurring transaction processing for {}", today);
|
||||
|
||||
recurringRepository.findDueTransactions(today)
|
||||
.flatMap(this::processSingle)
|
||||
.subscribe(
|
||||
success -> log.info("Processed recurring transaction: {}", success.getId()),
|
||||
error -> log.error("Error processing recurring transactions", error),
|
||||
() -> log.info("Finished processing recurring transactions for {}", today)
|
||||
);
|
||||
}
|
||||
|
||||
private Mono<RecurringTransaction> processSingle(RecurringTransaction rt) {
|
||||
Transaction t = Transaction.builder()
|
||||
.userId(rt.getUserId())
|
||||
.categoryId(rt.getCategoryId())
|
||||
.fromWalletId(rt.getFromWalletId())
|
||||
.toWalletId(rt.getToWalletId())
|
||||
.type(rt.getType())
|
||||
.amount(rt.getAmount())
|
||||
.date(rt.getNextExecutionDate()) // Process it as of the execution date
|
||||
.description(rt.getDescription())
|
||||
.notes("Auto-generated from recurring transaction")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Calculate next date
|
||||
LocalDate nextDate = rt.getNextExecutionDate();
|
||||
switch (rt.getFrequency()) {
|
||||
case "DAILY" -> nextDate = nextDate.plusDays(1);
|
||||
case "WEEKLY" -> nextDate = nextDate.plusWeeks(1);
|
||||
case "MONTHLY" -> nextDate = nextDate.plusMonths(1);
|
||||
case "YEARLY" -> nextDate = nextDate.plusYears(1);
|
||||
}
|
||||
rt.setNextExecutionDate(nextDate);
|
||||
if (rt.getEndDate() != null && nextDate.isAfter(rt.getEndDate())) {
|
||||
rt.setStatus("COMPLETED");
|
||||
}
|
||||
|
||||
return transactionService.addTransaction(rt.getUserId(), t)
|
||||
.then(recurringRepository.save(rt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.kifi.api.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthenticationManager implements ReactiveAuthenticationManager {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> authenticate(Authentication authentication) {
|
||||
String authToken = authentication.getCredentials().toString();
|
||||
|
||||
try {
|
||||
if (jwtUtil.validateToken(authToken)) {
|
||||
Claims claims = jwtUtil.getAllClaimsFromToken(authToken);
|
||||
String email = claims.getSubject();
|
||||
Long userId = claims.get("userId", Long.class);
|
||||
|
||||
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
|
||||
email,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
|
||||
auth.setDetails(userId);
|
||||
|
||||
return Mono.just(auth);
|
||||
} else {
|
||||
return Mono.empty();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
68
kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java
Normal file
68
kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.kifi.api.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration}")
|
||||
private String expirationTime;
|
||||
|
||||
private SecretKey getSignKey() {
|
||||
byte[] keyBytes = secret.getBytes();
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
public Claims getAllClaimsFromToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSignKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public String getEmailFromToken(String token) {
|
||||
return getAllClaimsFromToken(token).getSubject();
|
||||
}
|
||||
|
||||
public Date getExpirationDateFromToken(String token) {
|
||||
return getAllClaimsFromToken(token).getExpiration();
|
||||
}
|
||||
|
||||
private Boolean isTokenExpired(String token) {
|
||||
final Date expiration = getExpirationDateFromToken(token);
|
||||
return expiration.before(new Date());
|
||||
}
|
||||
|
||||
public String generateToken(String email, Long userId) {
|
||||
HashMap<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userId);
|
||||
|
||||
long expirationTimeLong = Long.parseLong(expirationTime);
|
||||
final Date createdDate = new Date();
|
||||
final Date expirationDate = new Date(createdDate.getTime() + expirationTimeLong);
|
||||
|
||||
return Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(email)
|
||||
.issuedAt(createdDate)
|
||||
.expiration(expirationDate)
|
||||
.signWith(getSignKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Boolean validateToken(String token) {
|
||||
return !isTokenExpired(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.kifi.api.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextImpl;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityContextRepository implements ServerSecurityContextRepository {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
@Override
|
||||
public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
|
||||
throw new UnsupportedOperationException("Not supported yet.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SecurityContext> load(ServerWebExchange exchange) {
|
||||
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String authToken = authHeader.substring(7);
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(authToken, authToken);
|
||||
return this.authenticationManager.authenticate(auth)
|
||||
.map(SecurityContextImpl::new);
|
||||
}
|
||||
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
157
kifi-api/src/main/java/com/kifi/api/service/AuthService.java
Normal file
157
kifi-api/src/main/java/com/kifi/api/service/AuthService.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.dto.AuthRequest;
|
||||
import com.kifi.api.dto.AuthResponse;
|
||||
import com.kifi.api.entity.User;
|
||||
import com.kifi.api.repository.UserRepository;
|
||||
import com.kifi.api.security.JwtUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Random;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final EmailService emailService;
|
||||
private final ReactiveStringRedisTemplate redisTemplate;
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
private String decryptField(String value) {
|
||||
try {
|
||||
return cryptoService.decrypt(value);
|
||||
} catch (Exception e) {
|
||||
// If decryption fails, treat as plaintext (backward compatibility)
|
||||
log.warn("RSA decryption failed, treating as plaintext: {}", e.getMessage());
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<String> signup(AuthRequest request) {
|
||||
String email = decryptField(request.getEmail());
|
||||
String password = decryptField(request.getPassword());
|
||||
|
||||
return userRepository.findByEmail(email)
|
||||
.flatMap(existingUser -> {
|
||||
if (existingUser.getEnabled()) {
|
||||
return Mono.error(new RuntimeException("User already exists and is verified"));
|
||||
}
|
||||
return generateAndSendOtp(existingUser, "REGISTRATION");
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
User newUser = User.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(password))
|
||||
.enabled(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
return userRepository.save(newUser).flatMap(u -> generateAndSendOtp(u, "REGISTRATION"));
|
||||
}))
|
||||
.map(u -> "OTP sent to your email.");
|
||||
}
|
||||
|
||||
private Mono<User> generateAndSendOtp(User user, String purpose) {
|
||||
String otp = String.format("%06d", new Random().nextInt(999999));
|
||||
String redisKey = "OTP:" + purpose + ":" + user.getEmail();
|
||||
return redisTemplate.opsForValue()
|
||||
.set(redisKey, otp, Duration.ofMinutes(5))
|
||||
.then(purpose.equals("REGISTRATION")
|
||||
? emailService.sendRegistrationOtp(user.getEmail(), otp)
|
||||
: emailService.sendResetPasswordOtp(user.getEmail(), otp))
|
||||
.thenReturn(user);
|
||||
}
|
||||
|
||||
public Mono<AuthResponse> verifyOtp(String email, String otp) {
|
||||
String redisKey = "OTP:REGISTRATION:" + email;
|
||||
return redisTemplate.opsForValue().get(redisKey)
|
||||
.switchIfEmpty(
|
||||
// Fallback to old key format for backward compatibility
|
||||
redisTemplate.opsForValue().get("OTP:" + email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("OTP not found or expired")))
|
||||
)
|
||||
.flatMap(savedOtp -> {
|
||||
if (!savedOtp.equals(otp)) {
|
||||
return Mono.error(new RuntimeException("Invalid OTP"));
|
||||
}
|
||||
return userRepository.findByEmail(email);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
user.setEnabled(true);
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> redisTemplate.opsForValue().delete(redisKey).thenReturn(user))
|
||||
.map(user -> AuthResponse.builder()
|
||||
.token(jwtUtil.generateToken(user.getEmail(), user.getId()))
|
||||
.userId(user.getId())
|
||||
.email(user.getEmail())
|
||||
.build());
|
||||
}
|
||||
|
||||
public Mono<AuthResponse> login(AuthRequest request) {
|
||||
String email = decryptField(request.getEmail());
|
||||
String password = decryptField(request.getPassword());
|
||||
|
||||
return userRepository.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Invalid email or password")))
|
||||
.flatMap(user -> {
|
||||
if (!user.getEnabled()) {
|
||||
return Mono.error(new RuntimeException("Email not verified"));
|
||||
}
|
||||
if (passwordEncoder.matches(password, user.getPassword())) {
|
||||
return Mono.just(AuthResponse.builder()
|
||||
.token(jwtUtil.generateToken(user.getEmail(), user.getId()))
|
||||
.userId(user.getId())
|
||||
.email(user.getEmail())
|
||||
.build());
|
||||
} else {
|
||||
return Mono.error(new RuntimeException("Invalid email or password"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<String> forgotPassword(String email) {
|
||||
return userRepository.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("No account found with this email")))
|
||||
.flatMap(user -> {
|
||||
if (!user.getEnabled()) {
|
||||
return Mono.error(new RuntimeException("Account not verified yet"));
|
||||
}
|
||||
return generateAndSendOtp(user, "RESET");
|
||||
})
|
||||
.map(u -> "Password reset OTP sent to your email.");
|
||||
}
|
||||
|
||||
public Mono<String> resetPassword(String email, String otp, String encryptedNewPassword) {
|
||||
String redisKey = "OTP:RESET:" + email;
|
||||
String newPassword = decryptField(encryptedNewPassword);
|
||||
|
||||
return redisTemplate.opsForValue().get(redisKey)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("OTP not found or expired")))
|
||||
.flatMap(savedOtp -> {
|
||||
if (!savedOtp.equals(otp)) {
|
||||
return Mono.error(new RuntimeException("Invalid OTP"));
|
||||
}
|
||||
return userRepository.findByEmail(email);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> redisTemplate.opsForValue().delete(redisKey).thenReturn(user))
|
||||
.map(u -> "Password reset successfully.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.dto.BudgetSummary;
|
||||
import com.kifi.api.entity.Budget;
|
||||
import com.kifi.api.repository.BudgetRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetService {
|
||||
private final BudgetRepository budgetRepository;
|
||||
private final DatabaseClient databaseClient;
|
||||
|
||||
public Flux<Budget> getBudgets(Long userId) {
|
||||
return budgetRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
public Flux<BudgetSummary> getBudgetSummaries(Long userId) {
|
||||
return budgetRepository.findByUserId(userId)
|
||||
.flatMap(budget -> {
|
||||
String sql = "SELECT COALESCE(SUM(amount), 0) FROM transactions " +
|
||||
"WHERE type = 'EXPENSE' AND " +
|
||||
"date >= date_trunc('month', current_date) AND " +
|
||||
"(category_id = :categoryId OR from_wallet_id = :walletId) AND " +
|
||||
"(user_id = :userId OR from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))";
|
||||
|
||||
return databaseClient.sql(sql)
|
||||
.bind("userId", userId)
|
||||
.bind("categoryId", budget.getCategoryId() != null ? budget.getCategoryId() : -1)
|
||||
.bind("walletId", budget.getWalletId() != null ? budget.getWalletId() : -1)
|
||||
.map((row, rowMetadata) -> row.get(0, BigDecimal.class))
|
||||
.first()
|
||||
.defaultIfEmpty(BigDecimal.ZERO)
|
||||
.map(spent -> BudgetSummary.builder().budget(budget).spentAmount(spent).build());
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Budget> addOrUpdateBudget(Long userId, Budget budget) {
|
||||
return budgetRepository.findByUserId(userId)
|
||||
.filter(b -> (b.getCategoryId() != null && b.getCategoryId().equals(budget.getCategoryId())) ||
|
||||
(b.getWalletId() != null && b.getWalletId().equals(budget.getWalletId())))
|
||||
.next()
|
||||
.flatMap(existing -> {
|
||||
existing.setMonthlyLimit(budget.getMonthlyLimit());
|
||||
if (budget.getIsShared() != null) {
|
||||
existing.setIsShared(budget.getIsShared());
|
||||
}
|
||||
return budgetRepository.save(existing);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
budget.setUserId(userId);
|
||||
budget.setCreatedAt(LocalDateTime.now());
|
||||
if (budget.getIsShared() == null) {
|
||||
budget.setIsShared(false);
|
||||
}
|
||||
return budgetRepository.save(budget);
|
||||
}));
|
||||
}
|
||||
|
||||
public Mono<Void> deleteBudget(Long id) {
|
||||
return budgetRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.Category;
|
||||
import com.kifi.api.repository.CategoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
public Flux<Category> getCategories(Long userId) {
|
||||
return categoryRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
public Mono<Category> addCategory(Long userId, Category category) {
|
||||
category.setUserId(userId);
|
||||
category.setCreatedAt(LocalDateTime.now());
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Base64;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CryptoService {
|
||||
|
||||
private KeyPair keyPair;
|
||||
|
||||
public CryptoService() {
|
||||
try {
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||
keyGen.initialize(2048);
|
||||
this.keyPair = keyGen.generateKeyPair();
|
||||
log.info("RSA Key Pair generated successfully for CryptoService.");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate RSA Key Pair", e);
|
||||
throw new RuntimeException("Failed to initialize CryptoService", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getPublicKeyBase64() {
|
||||
PublicKey publicKey = keyPair.getPublic();
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedBase64) {
|
||||
try {
|
||||
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64);
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
|
||||
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||
return new String(decryptedBytes, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to decrypt RSA payload", e);
|
||||
throw new RuntimeException("Failed to decrypt payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
172
kifi-api/src/main/java/com/kifi/api/service/EmailService.java
Normal file
172
kifi-api/src/main/java/com/kifi/api/service/EmailService.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class EmailService {
|
||||
|
||||
private final JavaMailSender javaMailSender;
|
||||
|
||||
public String buildEmailTemplate(String title, String subtitle, String otp, String footerNote) {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;padding:40px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="460" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6C63FF 0%%,#48C6EF 100%%);padding:32px 40px;text-align:center;">
|
||||
<h1 style="margin:0;color:#ffffff;font-size:28px;font-weight:700;letter-spacing:1px;">Kifi</h1>
|
||||
<p style="margin:6px 0 0;color:rgba(255,255,255,0.85);font-size:13px;font-weight:400;">Smart Financial Management</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:36px 40px 28px;">
|
||||
<h2 style="margin:0 0 8px;color:#1a1a2e;font-size:22px;font-weight:600;">%s</h2>
|
||||
<p style="margin:0 0 28px;color:#6b7280;font-size:15px;line-height:1.6;">%s</p>
|
||||
<!-- OTP Box -->
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding:20px 0;">
|
||||
<div style="display:inline-block;background:linear-gradient(135deg,#6C63FF08,#48C6EF12);border:2px solid #6C63FF;border-radius:16px;padding:20px 48px;">
|
||||
<span style="font-size:36px;font-weight:700;letter-spacing:12px;color:#6C63FF;font-family:'Courier New',monospace;">%s</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:24px 0 0;color:#9ca3af;font-size:13px;text-align:center;line-height:1.5;">
|
||||
⏱ This code expires in <strong style="color:#6C63FF;">5 minutes</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="padding:0 40px;">
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0;">
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:24px 40px 32px;text-align:center;">
|
||||
<p style="margin:0 0 8px;color:#9ca3af;font-size:12px;line-height:1.5;">%s</p>
|
||||
<p style="margin:0;color:#d1d5db;font-size:11px;">© 2026 Kifi by Sarascore. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(title, subtitle, otp, footerNote);
|
||||
}
|
||||
|
||||
public Mono<Void> sendRegistrationOtp(String to, String otp) {
|
||||
String html = buildEmailTemplate(
|
||||
"Welcome to Kifi! 🎉",
|
||||
"Thank you for signing up. Please use the verification code below to complete your registration.",
|
||||
otp,
|
||||
"If you didn't create an account, you can safely ignore this email."
|
||||
);
|
||||
return sendEmail(to, "Kifi – Verify Your Email", html);
|
||||
}
|
||||
|
||||
public String buildInvitationEmailTemplate(String title, String subtitle, String footerNote) {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;padding:40px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="460" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6C63FF 0%%,#48C6EF 100%%);padding:32px 40px;text-align:center;">
|
||||
<h1 style="margin:0;color:#ffffff;font-size:28px;font-weight:700;letter-spacing:1px;">Kifi</h1>
|
||||
<p style="margin:6px 0 0;color:rgba(255,255,255,0.85);font-size:13px;font-weight:400;">Smart Financial Management</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:36px 40px 28px;">
|
||||
<h2 style="margin:0 0 8px;color:#1a1a2e;font-size:22px;font-weight:600;">%s</h2>
|
||||
<p style="margin:0 0 28px;color:#6b7280;font-size:15px;line-height:1.6;">%s</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="padding:0 40px;">
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0;">
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:24px 40px 32px;text-align:center;">
|
||||
<p style="margin:0 0 8px;color:#9ca3af;font-size:12px;line-height:1.5;">%s</p>
|
||||
<p style="margin:0;color:#d1d5db;font-size:11px;">© 2026 Kifi by Sarascore. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(title, subtitle, footerNote);
|
||||
}
|
||||
|
||||
public Mono<Void> sendResetPasswordOtp(String to, String otp) {
|
||||
String html = buildEmailTemplate(
|
||||
"Reset Your Password 🔐",
|
||||
"We received a request to reset your password. Use the code below to set a new password.",
|
||||
otp,
|
||||
"If you didn't request a password reset, please ignore this email. Your account is safe."
|
||||
);
|
||||
return sendEmail(to, "Kifi – Password Reset Code", html);
|
||||
}
|
||||
|
||||
// Keep old method for backward compatibility
|
||||
public Mono<Void> sendOtpEmail(String to, String otp) {
|
||||
return sendRegistrationOtp(to, otp);
|
||||
}
|
||||
|
||||
public Mono<Void> sendEmail(String to, String subject, String htmlContent) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
try {
|
||||
MimeMessage message = javaMailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(htmlContent, true);
|
||||
javaMailSender.send(message);
|
||||
log.info("Email sent to: {} with subject: {}", to, subject);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send email", e);
|
||||
throw new RuntimeException("Failed to send email");
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class MinioServiceClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
@Value("${minio.service.url:http://minio-service:1500}")
|
||||
private String minioServiceUrl;
|
||||
|
||||
@Value("${minio.service.bucket:kifi}")
|
||||
private String bucketName;
|
||||
|
||||
public MinioServiceClient(WebClient.Builder webClientBuilder) {
|
||||
this.webClient = webClientBuilder.build();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioUploadRequest {
|
||||
private String bucketName;
|
||||
private String directoryPath;
|
||||
private String contentType;
|
||||
private String fileName;
|
||||
private String fileContentBase64;
|
||||
private boolean overwrite;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioDeleteRequest {
|
||||
private String bucketName;
|
||||
private String type;
|
||||
private String filePath;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private String filePath;
|
||||
}
|
||||
|
||||
public Mono<MinioResponse> uploadFile(String directoryPath, String contentType, String fileName, String base64Content) {
|
||||
MinioUploadRequest request = new MinioUploadRequest();
|
||||
request.setBucketName(bucketName);
|
||||
request.setDirectoryPath(directoryPath);
|
||||
request.setContentType(contentType);
|
||||
request.setFileName(fileName);
|
||||
request.setFileContentBase64(base64Content);
|
||||
request.setOverwrite(true);
|
||||
|
||||
return webClient.post()
|
||||
.uri(minioServiceUrl + "/api/v1/minio/upload")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MinioResponse.class);
|
||||
}
|
||||
|
||||
public Mono<MinioResponse> deleteFile(String contentType, String filePath) {
|
||||
MinioDeleteRequest request = new MinioDeleteRequest();
|
||||
request.setBucketName(bucketName);
|
||||
request.setType(contentType);
|
||||
request.setFilePath(filePath);
|
||||
|
||||
return webClient.method(org.springframework.http.HttpMethod.DELETE)
|
||||
.uri(minioServiceUrl + "/api/v1/minio/file")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MinioResponse.class);
|
||||
}
|
||||
@Data
|
||||
public static class MinioDownloadRequest {
|
||||
private String bucketName;
|
||||
private String type;
|
||||
private String filePath;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioDownloadResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private String base64Content;
|
||||
}
|
||||
|
||||
public Mono<MinioDownloadResponse> downloadFile(String contentType, String filePath) {
|
||||
MinioDownloadRequest request = new MinioDownloadRequest();
|
||||
request.setBucketName(bucketName);
|
||||
request.setType(contentType);
|
||||
request.setFilePath(filePath);
|
||||
|
||||
return webClient.post()
|
||||
.uri(minioServiceUrl + "/api/v1/minio/download")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MinioDownloadResponse.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import com.kifi.api.repository.RecurringTransactionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringTransactionService {
|
||||
private final RecurringTransactionRepository repository;
|
||||
|
||||
public Flux<RecurringTransaction> getRecurringTransactions(Long userId) {
|
||||
return repository.findByUserId(userId);
|
||||
}
|
||||
|
||||
public Mono<RecurringTransaction> addRecurringTransaction(Long userId, RecurringTransaction transaction) {
|
||||
transaction.setUserId(userId);
|
||||
if (transaction.getStatus() == null) transaction.setStatus("ACTIVE");
|
||||
transaction.setCreatedAt(LocalDateTime.now());
|
||||
return repository.save(transaction);
|
||||
}
|
||||
|
||||
public Mono<RecurringTransaction> updateRecurringTransaction(Long id, Long userId, RecurringTransaction updated) {
|
||||
return repository.findById(id)
|
||||
.filter(rt -> rt.getUserId().equals(userId))
|
||||
.flatMap(existing -> {
|
||||
existing.setAmount(updated.getAmount());
|
||||
existing.setFrequency(updated.getFrequency());
|
||||
existing.setNextExecutionDate(updated.getNextExecutionDate());
|
||||
existing.setEndDate(updated.getEndDate());
|
||||
existing.setStatus(updated.getStatus());
|
||||
existing.setDescription(updated.getDescription());
|
||||
existing.setFromWalletId(updated.getFromWalletId());
|
||||
existing.setToWalletId(updated.getToWalletId());
|
||||
existing.setCategoryId(updated.getCategoryId());
|
||||
return repository.save(existing);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteRecurringTransaction(Long id) {
|
||||
return repository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.repository.TransactionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVPrinter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
private final TransactionRepository transactionRepository;
|
||||
|
||||
public Mono<byte[]> exportTransactionsToCsv(Long userId) {
|
||||
return transactionRepository.findVisibleTransactionsForUser(userId)
|
||||
.collectList()
|
||||
.map(this::generateCsvBytes);
|
||||
}
|
||||
|
||||
private byte[] generateCsvBytes(List<Transaction> transactions) {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
PrintWriter pw = new PrintWriter(out, true, StandardCharsets.UTF_8);
|
||||
CSVPrinter csvPrinter = new CSVPrinter(pw, CSVFormat.DEFAULT.builder().setHeader("ID", "Date", "Type", "Amount", "Description").build())) {
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
for (Transaction tx : transactions) {
|
||||
csvPrinter.printRecord(
|
||||
tx.getId(),
|
||||
tx.getDate() != null ? tx.getDate().format(formatter) : "",
|
||||
tx.getType(),
|
||||
tx.getAmount(),
|
||||
tx.getDescription() != null ? tx.getDescription() : ""
|
||||
);
|
||||
}
|
||||
csvPrinter.flush();
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error generating CSV", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.entity.TransactionAttachment;
|
||||
import com.kifi.api.entity.TransactionItem;
|
||||
import com.kifi.api.repository.TransactionAttachmentRepository;
|
||||
import com.kifi.api.repository.TransactionItemRepository;
|
||||
import com.kifi.api.repository.TransactionRepository;
|
||||
import com.kifi.api.repository.WalletRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TransactionService {
|
||||
private final TransactionRepository transactionRepository;
|
||||
private final TransactionItemRepository transactionItemRepository;
|
||||
private final TransactionAttachmentRepository transactionAttachmentRepository;
|
||||
private final WalletRepository walletRepository;
|
||||
private final MinioServiceClient minioServiceClient;
|
||||
|
||||
public Flux<Transaction> getTransactions(Long userId) {
|
||||
return transactionRepository.findVisibleTransactionsForUser(userId)
|
||||
.flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
public Mono<Page<Transaction>> searchTransactions(Long userId, String type, Long walletId, Long categoryId, LocalDate startDate, LocalDate endDate, String search, Pageable pageable) {
|
||||
return transactionRepository.findTransactionsWithFilters(userId, type, walletId, categoryId, startDate, endDate, search, pageable)
|
||||
.flatMap(page -> Flux.fromIterable(page.getContent())
|
||||
.flatMap(this::populateItemsAndAttachments)
|
||||
.collectList()
|
||||
.map(populatedList -> new org.springframework.data.domain.PageImpl<>(populatedList, pageable, page.getTotalElements())));
|
||||
}
|
||||
|
||||
private Mono<Transaction> populateItemsAndAttachments(Transaction transaction) {
|
||||
Mono<List<TransactionItem>> itemsMono = transactionItemRepository.findByTransactionId(transaction.getId()).collectList();
|
||||
Mono<List<TransactionAttachment>> attachmentsMono = transactionAttachmentRepository.findByTransactionId(transaction.getId()).collectList();
|
||||
|
||||
return Mono.zip(itemsMono, attachmentsMono).map(tuple -> {
|
||||
transaction.setItems(tuple.getT1());
|
||||
transaction.setAttachments(tuple.getT2());
|
||||
return transaction;
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Transaction> addTransaction(Long userId, Transaction transaction) {
|
||||
transaction.setUserId(userId);
|
||||
transaction.setCreatedAt(LocalDateTime.now());
|
||||
if ("INVESTMENT".equals(transaction.getType())) {
|
||||
transaction.setInvestmentStatus("OPEN");
|
||||
}
|
||||
|
||||
Mono<Void> updateBalances = updateWalletBalances(transaction.getFromWalletId(), transaction.getToWalletId(), transaction.getAmount());
|
||||
|
||||
return updateBalances.then(transactionRepository.save(transaction)).flatMap(savedTx -> {
|
||||
Mono<Void> itemsMono = Mono.empty();
|
||||
if (transaction.getItems() != null && !transaction.getItems().isEmpty()) {
|
||||
for (TransactionItem item : transaction.getItems()) {
|
||||
item.setTransactionId(savedTx.getId());
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
itemsMono = transactionItemRepository.saveAll(transaction.getItems()).then();
|
||||
}
|
||||
return itemsMono.thenReturn(savedTx);
|
||||
}).flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
private Mono<Void> updateWalletBalances(Long fromWalletId, Long toWalletId, BigDecimal amount) {
|
||||
Mono<Void> deductFrom = fromWalletId != null ? walletRepository.findById(fromWalletId)
|
||||
.flatMap(w -> {
|
||||
w.setBalance(w.getBalance().subtract(amount));
|
||||
return walletRepository.save(w);
|
||||
}).then() : Mono.empty();
|
||||
|
||||
Mono<Void> addTo = toWalletId != null ? walletRepository.findById(toWalletId)
|
||||
.flatMap(w -> {
|
||||
w.setBalance(w.getBalance().add(amount));
|
||||
return walletRepository.save(w);
|
||||
}).then() : Mono.empty();
|
||||
|
||||
return deductFrom.then(addTo);
|
||||
}
|
||||
|
||||
public Mono<TransactionAttachment> addAttachment(Long transactionId, String fileName, String contentType, String base64Content) {
|
||||
String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName;
|
||||
String directoryPath = "transactions/" + transactionId;
|
||||
|
||||
return minioServiceClient.uploadFile(directoryPath, contentType, uniqueFileName, base64Content)
|
||||
.flatMap(minioResponse -> {
|
||||
if (minioResponse.isSuccess()) {
|
||||
TransactionAttachment attachment = TransactionAttachment.builder()
|
||||
.transactionId(transactionId)
|
||||
.fileName(fileName)
|
||||
.filePath(minioResponse.getFilePath())
|
||||
.contentType(contentType)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
return transactionAttachmentRepository.save(attachment);
|
||||
} else {
|
||||
return Mono.error(new RuntimeException("Failed to upload to MinIO"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteAttachment(Long attachmentId) {
|
||||
return transactionAttachmentRepository.findById(attachmentId)
|
||||
.flatMap(attachment ->
|
||||
minioServiceClient.deleteFile(attachment.getContentType(), attachment.getFilePath())
|
||||
.then(transactionAttachmentRepository.delete(attachment))
|
||||
);
|
||||
}
|
||||
|
||||
public Mono<MinioServiceClient.MinioDownloadResponse> downloadAttachment(Long attachmentId) {
|
||||
return transactionAttachmentRepository.findById(attachmentId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Attachment not found")))
|
||||
.flatMap(attachment ->
|
||||
minioServiceClient.downloadFile(attachment.getContentType(), attachment.getFilePath())
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Transaction> updateTransaction(Long id, Long userId, Transaction updatedTransaction) {
|
||||
return transactionRepository.findById(id)
|
||||
.filter(t -> t.getUserId().equals(userId))
|
||||
.flatMap(t -> {
|
||||
// Revert old balances
|
||||
Mono<Void> revertBalances = updateWalletBalances(t.getToWalletId(), t.getFromWalletId(), t.getAmount());
|
||||
|
||||
// Apply new balances
|
||||
Mono<Void> applyNewBalances = updateWalletBalances(updatedTransaction.getFromWalletId(), updatedTransaction.getToWalletId(), updatedTransaction.getAmount());
|
||||
|
||||
t.setCategoryId(updatedTransaction.getCategoryId());
|
||||
t.setFromWalletId(updatedTransaction.getFromWalletId());
|
||||
t.setToWalletId(updatedTransaction.getToWalletId());
|
||||
t.setNotes(updatedTransaction.getNotes());
|
||||
t.setType(updatedTransaction.getType());
|
||||
t.setAmount(updatedTransaction.getAmount());
|
||||
t.setDate(updatedTransaction.getDate());
|
||||
t.setDescription(updatedTransaction.getDescription());
|
||||
|
||||
return revertBalances.then(applyNewBalances).then(transactionRepository.save(t));
|
||||
}).flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Transaction> closeInvestment(Long id, Long userId, BigDecimal maturityAmount, LocalDate closingDate, Long destinationWalletId) {
|
||||
return transactionRepository.findById(id)
|
||||
.filter(t -> t.getUserId().equals(userId) && "OPEN".equals(t.getInvestmentStatus()))
|
||||
.flatMap(t -> {
|
||||
t.setInvestmentStatus("CLOSED");
|
||||
t.setClosingDate(closingDate);
|
||||
t.setMaturityAmount(maturityAmount);
|
||||
BigDecimal profitLoss = maturityAmount.subtract(t.getAmount());
|
||||
t.setProfitLoss(profitLoss);
|
||||
|
||||
Mono<Void> profitLossProcess = Mono.empty();
|
||||
if (profitLoss.compareTo(BigDecimal.ZERO) > 0) {
|
||||
Transaction profit = new Transaction();
|
||||
profit.setUserId(userId);
|
||||
profit.setType("INCOME");
|
||||
profit.setAmount(profitLoss);
|
||||
profit.setDate(closingDate);
|
||||
profit.setToWalletId(t.getToWalletId());
|
||||
profit.setDescription("Profit from " + (t.getDescription() != null ? t.getDescription() : "Investment"));
|
||||
profitLossProcess = transactionRepository.save(profit)
|
||||
.flatMap(savedProfit -> updateWalletBalances(null, savedProfit.getToWalletId(), profitLoss));
|
||||
} else if (profitLoss.compareTo(BigDecimal.ZERO) < 0) {
|
||||
Transaction loss = new Transaction();
|
||||
loss.setUserId(userId);
|
||||
loss.setType("EXPENSE");
|
||||
loss.setAmount(profitLoss.abs());
|
||||
loss.setDate(closingDate);
|
||||
loss.setFromWalletId(t.getToWalletId());
|
||||
loss.setDescription("Loss from " + (t.getDescription() != null ? t.getDescription() : "Investment"));
|
||||
profitLossProcess = transactionRepository.save(loss)
|
||||
.flatMap(savedLoss -> updateWalletBalances(savedLoss.getFromWalletId(), null, profitLoss.abs()));
|
||||
}
|
||||
|
||||
// Create transfer transaction from the investment wallet to the savings wallet
|
||||
if (destinationWalletId != null) {
|
||||
Transaction transfer = new Transaction();
|
||||
transfer.setUserId(userId);
|
||||
transfer.setType("TRANSFER");
|
||||
transfer.setAmount(maturityAmount);
|
||||
transfer.setDate(closingDate);
|
||||
transfer.setFromWalletId(t.getToWalletId()); // The money was in the investment wallet
|
||||
transfer.setToWalletId(destinationWalletId);
|
||||
transfer.setDescription("Closure of " + (t.getDescription() != null ? t.getDescription() : "Investment"));
|
||||
|
||||
return transactionRepository.save(t)
|
||||
.then(profitLossProcess)
|
||||
.then(transactionRepository.save(transfer))
|
||||
.flatMap(savedTransfer -> updateWalletBalances(savedTransfer.getFromWalletId(), savedTransfer.getToWalletId(), maturityAmount))
|
||||
.thenReturn(t);
|
||||
} else {
|
||||
return transactionRepository.save(t).then(profitLossProcess).thenReturn(t);
|
||||
}
|
||||
}).flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Void> deleteTransaction(Long id) {
|
||||
return transactionRepository.findById(id).flatMap(t -> {
|
||||
Mono<Void> revertBalances = updateWalletBalances(t.getToWalletId(), t.getFromWalletId(), t.getAmount());
|
||||
return revertBalances.then(
|
||||
transactionAttachmentRepository.findByTransactionId(id)
|
||||
.flatMap(attachment -> minioServiceClient.deleteFile(attachment.getContentType(), attachment.getFilePath()))
|
||||
.then(transactionRepository.deleteById(id))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
192
kifi-api/src/main/java/com/kifi/api/service/WalletService.java
Normal file
192
kifi-api/src/main/java/com/kifi/api/service/WalletService.java
Normal file
@@ -0,0 +1,192 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.UserWallet;
|
||||
import com.kifi.api.entity.Wallet;
|
||||
import com.kifi.api.entity.WalletInvitation;
|
||||
import com.kifi.api.repository.BudgetRepository;
|
||||
import com.kifi.api.repository.RecurringTransactionRepository;
|
||||
import com.kifi.api.repository.TransactionRepository;
|
||||
import com.kifi.api.repository.UserWalletRepository;
|
||||
import com.kifi.api.repository.WalletInvitationRepository;
|
||||
import com.kifi.api.repository.WalletRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletService {
|
||||
private final WalletRepository walletRepository;
|
||||
private final UserWalletRepository userWalletRepository;
|
||||
private final WalletInvitationRepository walletInvitationRepository;
|
||||
private final TransactionRepository transactionRepository;
|
||||
private final BudgetRepository budgetRepository;
|
||||
private final RecurringTransactionRepository recurringTransactionRepository;
|
||||
private final EmailService emailService;
|
||||
private final com.kifi.api.repository.UserRepository userRepository;
|
||||
|
||||
public Mono<Wallet> createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance) {
|
||||
Wallet wallet = new Wallet();
|
||||
wallet.setOwnerId(ownerId);
|
||||
wallet.setName(name);
|
||||
wallet.setNature(nature != null ? nature : "CASH");
|
||||
wallet.setBalance(initialBalance != null ? initialBalance : java.math.BigDecimal.ZERO);
|
||||
wallet.setCurrency(currency != null ? currency : "INR");
|
||||
wallet.setIcon(icon);
|
||||
wallet.setColor(color);
|
||||
wallet.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
return walletRepository.save(wallet)
|
||||
.flatMap(savedWallet -> {
|
||||
UserWallet uw = new UserWallet();
|
||||
uw.setUserId(ownerId);
|
||||
uw.setWalletId(savedWallet.getId());
|
||||
uw.setRole("OWNER");
|
||||
uw.setJoinedAt(LocalDateTime.now());
|
||||
return userWalletRepository.save(uw).thenReturn(savedWallet);
|
||||
});
|
||||
}
|
||||
|
||||
public Flux<Wallet> getWalletsForUser(Long userId) {
|
||||
return userWalletRepository.findByUserId(userId)
|
||||
.flatMap(uw -> walletRepository.findById(uw.getWalletId()));
|
||||
}
|
||||
|
||||
public Mono<UserWallet> inviteUserToWallet(Long walletId, Long userIdToInvite) {
|
||||
UserWallet uw = new UserWallet();
|
||||
uw.setUserId(userIdToInvite);
|
||||
uw.setWalletId(walletId);
|
||||
uw.setRole("MEMBER");
|
||||
uw.setJoinedAt(LocalDateTime.now());
|
||||
return userWalletRepository.save(uw);
|
||||
}
|
||||
|
||||
public Mono<WalletInvitation> inviteUserByEmail(Long inviterId, Long walletId, String inviteeEmail) {
|
||||
return walletRepository.findById(walletId)
|
||||
.flatMap(wallet -> {
|
||||
WalletInvitation invitation = new WalletInvitation();
|
||||
invitation.setWalletId(walletId);
|
||||
invitation.setInviterId(inviterId);
|
||||
invitation.setInviteeEmail(inviteeEmail);
|
||||
invitation.setStatus("PENDING");
|
||||
invitation.setCreatedAt(LocalDateTime.now());
|
||||
return walletInvitationRepository.save(invitation)
|
||||
.flatMap(savedInv -> {
|
||||
String emailHtml = emailService.buildInvitationEmailTemplate(
|
||||
"Wallet Invitation 🤝",
|
||||
"You have been invited to join the wallet: " + wallet.getName(),
|
||||
"Please log in to the Kifi app with this email to accept or decline the invitation."
|
||||
);
|
||||
return emailService.sendEmail(inviteeEmail, "Kifi – Wallet Invitation", emailHtml)
|
||||
.thenReturn(savedInv)
|
||||
.onErrorResume(e -> Mono.just(savedInv)); // Return saved invitation even if email fails
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public Flux<WalletInvitation> getPendingInvitations(String email) {
|
||||
return walletInvitationRepository.findByInviteeEmailAndStatus(email, "PENDING");
|
||||
}
|
||||
|
||||
public Mono<Void> respondToInvitation(Long invitationId, String email, Long userId, boolean accept) {
|
||||
return walletInvitationRepository.findById(invitationId)
|
||||
.filter(inv -> inv.getInviteeEmail().equalsIgnoreCase(email) && "PENDING".equals(inv.getStatus()))
|
||||
.flatMap(inv -> {
|
||||
inv.setStatus(accept ? "ACCEPTED" : "REJECTED");
|
||||
return walletInvitationRepository.save(inv)
|
||||
.flatMap(savedInv -> {
|
||||
if (accept) {
|
||||
UserWallet uw = new UserWallet();
|
||||
uw.setUserId(userId);
|
||||
uw.setWalletId(savedInv.getWalletId());
|
||||
uw.setRole("MEMBER");
|
||||
uw.setJoinedAt(LocalDateTime.now());
|
||||
return userWalletRepository.save(uw).then();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Wallet> editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency) {
|
||||
return walletRepository.findById(walletId)
|
||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||
.flatMap(w -> {
|
||||
if (name != null) w.setName(name);
|
||||
if (nature != null) w.setNature(nature);
|
||||
if (icon != null) w.setIcon(icon);
|
||||
if (color != null) w.setColor(color);
|
||||
if (currency != null) w.setCurrency(currency);
|
||||
return walletRepository.save(w);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteWallet(Long walletId, Long ownerId) {
|
||||
return walletRepository.findById(walletId)
|
||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||
.flatMap(w -> transactionRepository.countByFromWalletIdOrToWalletId(walletId, walletId)
|
||||
.flatMap(txCount -> recurringTransactionRepository.countByFromWalletIdOrToWalletId(walletId, walletId)
|
||||
.flatMap(rtCount -> {
|
||||
if (txCount > 0 || rtCount > 0) {
|
||||
return Mono.error(new RuntimeException("Cannot delete wallet with transactions"));
|
||||
}
|
||||
return budgetRepository.deleteByWalletId(walletId)
|
||||
.then(walletInvitationRepository.deleteByWalletId(walletId))
|
||||
.then(userWalletRepository.deleteByWalletId(walletId))
|
||||
.then(walletRepository.deleteById(walletId));
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public Flux<com.kifi.api.dto.WalletMemberDTO> getWalletMembers(Long walletId, Long requestingUserId) {
|
||||
// Ensure requesting user has access to this wallet
|
||||
return userWalletRepository.findByUserId(requestingUserId)
|
||||
.filter(uw -> uw.getWalletId().equals(walletId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("You do not have access to this wallet")))
|
||||
.next()
|
||||
.flatMapMany(uw -> userWalletRepository.findByWalletId(walletId)
|
||||
.flatMap(memberUw -> userRepository.findById(memberUw.getUserId())
|
||||
.map(user -> com.kifi.api.dto.WalletMemberDTO.builder()
|
||||
.userId(user.getId())
|
||||
.email(user.getEmail())
|
||||
.role(memberUw.getRole())
|
||||
.joinedAt(memberUw.getJoinedAt())
|
||||
.build()
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public Mono<Void> removeWalletMember(Long walletId, Long ownerId, Long memberIdToRemove) {
|
||||
return walletRepository.findById(walletId)
|
||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||
.flatMap(w -> {
|
||||
if (ownerId.equals(memberIdToRemove)) {
|
||||
return Mono.error(new RuntimeException("Cannot remove the owner of the wallet"));
|
||||
}
|
||||
return userWalletRepository.findByWalletId(walletId)
|
||||
.filter(uw -> uw.getUserId().equals(memberIdToRemove))
|
||||
.next()
|
||||
.flatMap(uw -> userWalletRepository.delete(uw));
|
||||
});
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private org.springframework.r2dbc.core.DatabaseClient databaseClient;
|
||||
|
||||
public Flux<String> getKnownContacts(Long currentUserId) {
|
||||
String sql = "SELECT DISTINCT u.email FROM users u JOIN user_wallets uw ON u.id = uw.user_id WHERE uw.wallet_id IN (SELECT wallet_id FROM user_wallets WHERE user_id = :userId) AND u.id != :userId";
|
||||
return databaseClient.sql(sql)
|
||||
.bind("userId", currentUserId)
|
||||
.map((row, rowMetadata) -> row.get("email", String.class))
|
||||
.all();
|
||||
}
|
||||
}
|
||||
48
kifi-api/src/main/resources/application.yml
Normal file
48
kifi-api/src/main/resources/application.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
spring:
|
||||
application:
|
||||
name: kifi-api
|
||||
|
||||
r2dbc:
|
||||
url: r2dbc:postgresql://103.125.129.116:5333/kifi
|
||||
username: postgres
|
||||
password: M@triXPostgr3s@6202
|
||||
pool:
|
||||
initial-size: 5
|
||||
max-size: 20
|
||||
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
schema-locations: classpath:schema.sql
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: 103.125.129.116
|
||||
port: 7901
|
||||
password: M@triXR3d1s@6202
|
||||
|
||||
mail:
|
||||
host: smtp.gmail.com
|
||||
port: 587
|
||||
username: technobeesolutions@gmail.com
|
||||
password: lrideibfakickldg
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
minio:
|
||||
service:
|
||||
url: http://103.125.129.116:1500
|
||||
bucket: kifi
|
||||
|
||||
jwt:
|
||||
secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
|
||||
expiration: 604800000 # 7 days
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.data.r2dbc: DEBUG
|
||||
org.springframework.r2dbc: DEBUG
|
||||
134
kifi-api/src/main/resources/schema.sql
Normal file
134
kifi-api/src/main/resources/schema.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255),
|
||||
enabled BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS merchants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
icon_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
icon_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
type VARCHAR(50) NOT NULL, -- 'INCOME' or 'EXPENSE' or 'INVESTMENT'
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
monthly_limit DECIMAL(10, 2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
frequency VARCHAR(50) NOT NULL,
|
||||
next_execution_date DATE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
nature VARCHAR(50) DEFAULT 'CASH', -- 'CASH', 'DEPOSIT', 'EXPENSE', 'INCOME', 'SAVINGS', 'LOAN', etc.
|
||||
balance DECIMAL(15, 2) DEFAULT 0.00,
|
||||
currency VARCHAR(10) DEFAULT 'INR',
|
||||
icon VARCHAR(255),
|
||||
color VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_wallets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
wallet_id INTEGER REFERENCES wallets(id),
|
||||
role VARCHAR(50) DEFAULT 'MEMBER',
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, wallet_id)
|
||||
);
|
||||
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS nature VARCHAR(50) DEFAULT 'CASH';
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS balance DECIMAL(15, 2) DEFAULT 0.00;
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS currency VARCHAR(10) DEFAULT 'INR';
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS icon VARCHAR(255);
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS color VARCHAR(50);
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS due_date DATE;
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_schedule VARCHAR(50);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_time TIME;
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS investment_status VARCHAR(20) DEFAULT 'OPEN';
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS maturity_amount DECIMAL(10, 2);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS profit_loss DECIMAL(10, 2);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS closing_date DATE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(1024) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE budgets ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE budgets ADD COLUMN IF NOT EXISTS is_shared BOOLEAN DEFAULT FALSE;
|
||||
-- Ensure budget can be either category-specific or wallet-specific
|
||||
ALTER TABLE budgets ALTER COLUMN category_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'ACTIVE';
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS end_date DATE;
|
||||
ALTER TABLE recurring_transactions ALTER COLUMN category_id DROP NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
wallet_id INTEGER REFERENCES wallets(id),
|
||||
inviter_id INTEGER REFERENCES users(id),
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'PENDING',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.kifi.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class KifiApiApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user