V1 - Personal Account and Budgeting Done

Personal Account and Budgeting
This commit is contained in:
2026-08-16 20:13:21 +05:30
commit 17c0526ed6
247 changed files with 19002 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "KifiApiApplication",
"request": "launch",
"mainClass": "com.kifi.api.KifiApiApplication",
"projectName": "kifi-api"
}
]
}

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic"
}

2
kifi-api/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

33
kifi-api/.gitignore vendored Normal file
View 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/

View 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
View 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"]

View 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
View 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."

View 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
View 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
View 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
View 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>

View 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()))));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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."));
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.dto;
import lombok.Data;
@Data
public class AuthRequest {
private String email;
private String password;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.dto;
import lombok.Data;
@Data
public class OtpVerificationRequest {
private String email;
private String otp;
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
package com.kifi.api.entity;
public enum TransactionType {
INCOME,
EXPENSE
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
);
}

View File

@@ -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()));
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}
}

View 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);
}
}

View File

@@ -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();
}
}

View 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.");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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))
);
});
}
}

View 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();
}
}

View 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

View 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
);

View File

@@ -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() {
}
}

45
kifi-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
kifi-app/.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "8b872868494e429d94fa06dca855c306438b22c0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: android
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: ios
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: linux
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: macos
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: web
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: windows
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

16
kifi-app/README.md Normal file
View File

@@ -0,0 +1,16 @@
# kifi_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
kifi-app/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,50 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.sarascore.kifi_app"
compileSdk = 37
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.sarascore.kifi_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,4 @@
-dontwarn com.google.mlkit.vision.text.chinese.**
-dontwarn com.google.mlkit.vision.text.devanagari.**
-dontwarn com.google.mlkit.vision.text.japanese.**
-dontwarn com.google.mlkit.vision.text.korean.**

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="kifi_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.sarascore.kifi_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

BIN
kifi-app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

View File

@@ -0,0 +1,4 @@
flutter_icons:
android: true
ios: true
image_path: "assets/icon.png"

34
kifi-app/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

Some files were not shown because too many files have changed in this diff Show More