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

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