V1 - Personal Account and Budgeting Done
Personal Account and Budgeting
This commit is contained in:
16
kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java
Normal file
16
kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.kifi.api;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class KifiApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(KifiApiApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.kifi.api.config;
|
||||
|
||||
import com.kifi.api.security.AuthenticationManager;
|
||||
import com.kifi.api.security.SecurityContextRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final SecurityContextRepository securityContextRepository;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
||||
return http
|
||||
.exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
|
||||
.authenticationEntryPoint((swe, e) ->
|
||||
Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))
|
||||
)
|
||||
.accessDeniedHandler((swe, e) ->
|
||||
Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN))
|
||||
)
|
||||
)
|
||||
.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.authenticationManager(authenticationManager)
|
||||
.securityContextRepository(securityContextRepository)
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers(HttpMethod.OPTIONS).permitAll()
|
||||
.pathMatchers("/api/kifi/auth/**").permitAll()
|
||||
.pathMatchers("/api/kifi/health/**").permitAll()
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.kifi.api.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient.Builder webClientBuilder() {
|
||||
return WebClient.builder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.dto.AuthRequest;
|
||||
import com.kifi.api.dto.OtpVerificationRequest;
|
||||
import com.kifi.api.dto.ResetPasswordRequest;
|
||||
import com.kifi.api.service.AuthService;
|
||||
import com.kifi.api.service.CryptoService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
@GetMapping("/public-key")
|
||||
public Mono<ResponseEntity<Object>> getPublicKey() {
|
||||
return Mono.just(ResponseEntity.ok().body((Object) Map.of("publicKey", cryptoService.getPublicKeyBase64())));
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public Mono<ResponseEntity<Object>> signup(@RequestBody AuthRequest request) {
|
||||
return authService.signup(request)
|
||||
.map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/verify-otp")
|
||||
public Mono<ResponseEntity<Object>> verifyOtp(@RequestBody OtpVerificationRequest request) {
|
||||
return authService.verifyOtp(request.getEmail(), request.getOtp())
|
||||
.map(response -> ResponseEntity.ok().body((Object) response))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<Object>> login(@RequestBody AuthRequest request) {
|
||||
return authService.login(request)
|
||||
.map(response -> ResponseEntity.ok().body((Object) response))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.status(401).body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public Mono<ResponseEntity<Object>> forgotPassword(@RequestBody Map<String, String> request) {
|
||||
String email = request.get("email");
|
||||
return authService.forgotPassword(email)
|
||||
.map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public Mono<ResponseEntity<Object>> resetPassword(@RequestBody ResetPasswordRequest request) {
|
||||
return authService.resetPassword(request.getEmail(), request.getOtp(), request.getNewPassword())
|
||||
.map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.Budget;
|
||||
import com.kifi.api.dto.BudgetSummary;
|
||||
import com.kifi.api.service.BudgetService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/budgets")
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetController {
|
||||
private final BudgetService budgetService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Budget>> getBudgets(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return budgetService.getBudgets(userId).collectList();
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public Mono<List<BudgetSummary>> getBudgetSummaries(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return budgetService.getBudgetSummaries(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Budget> addOrUpdateBudget(Authentication authentication, @RequestBody Budget budget) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return budgetService.addOrUpdateBudget(userId, budget);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<Void> deleteBudget(@PathVariable Long id) {
|
||||
return budgetService.deleteBudget(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.Category;
|
||||
import com.kifi.api.service.CategoryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/categories")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryController {
|
||||
private final CategoryService categoryService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Category>> getCategories(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return categoryService.getCategories(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Category> addCategory(Authentication authentication, @RequestBody Category category) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return categoryService.addCategory(userId, category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/health")
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping
|
||||
public Mono<Map<String, String>> healthCheck() {
|
||||
return Mono.just(Map.of("status", "UP", "message", "Kifi API is running."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import com.kifi.api.service.RecurringTransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/recurring-transactions")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringTransactionController {
|
||||
|
||||
private final RecurringTransactionService service;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<RecurringTransaction>> getRecurringTransactions(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return service.getRecurringTransactions(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<RecurringTransaction> addRecurringTransaction(Authentication authentication, @RequestBody RecurringTransaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return service.addRecurringTransaction(userId, transaction);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Mono<RecurringTransaction> updateRecurringTransaction(@PathVariable Long id, Authentication authentication, @RequestBody RecurringTransaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return service.updateRecurringTransaction(id, userId, transaction);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<Void> deleteRecurringTransaction(@PathVariable Long id) {
|
||||
return service.deleteRecurringTransaction(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.service.ReportService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/reports")
|
||||
@RequiredArgsConstructor
|
||||
public class ReportController {
|
||||
private final ReportService reportService;
|
||||
|
||||
@GetMapping(value = "/export", produces = "text/csv")
|
||||
public Mono<ResponseEntity<byte[]>> exportTransactions(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return reportService.exportTransactionsToCsv(userId)
|
||||
.map(bytes -> ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"transactions.csv\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv"))
|
||||
.body(bytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.service.TransactionService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/transactions")
|
||||
@RequiredArgsConstructor
|
||||
public class TransactionController {
|
||||
private final TransactionService transactionService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Transaction>> getTransactions(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return transactionService.getTransactions(userId).collectList();
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public Mono<Page<Transaction>> searchTransactions(
|
||||
Authentication authentication,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) Long walletId,
|
||||
@RequestParam(required = false) Long categoryId,
|
||||
@RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate startDate,
|
||||
@RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate endDate,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return transactionService.searchTransactions(userId, type, walletId, categoryId, startDate, endDate, search, pageable);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Transaction> addTransaction(Authentication authentication, @RequestBody Transaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return transactionService.addTransaction(userId, transaction);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Mono<Transaction> updateTransaction(Authentication authentication, @PathVariable Long id, @RequestBody Transaction transaction) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return transactionService.updateTransaction(id, userId, transaction);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<Void> deleteTransaction(@PathVariable Long id) {
|
||||
return transactionService.deleteTransaction(id);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/attachments", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Mono<com.kifi.api.entity.TransactionAttachment> addAttachment(@PathVariable Long id, @RequestPart("file") org.springframework.http.codec.multipart.FilePart filePart) {
|
||||
return org.springframework.core.io.buffer.DataBufferUtils.join(filePart.content())
|
||||
.flatMap(dataBuffer -> {
|
||||
byte[] bytes = new byte[dataBuffer.readableByteCount()];
|
||||
dataBuffer.read(bytes);
|
||||
org.springframework.core.io.buffer.DataBufferUtils.release(dataBuffer);
|
||||
String base64Content = java.util.Base64.getEncoder().encodeToString(bytes);
|
||||
String fileName = filePart.filename();
|
||||
String contentType = filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "application/octet-stream";
|
||||
return transactionService.addAttachment(id, fileName, contentType, base64Content);
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/attachments/{attachmentId}/content")
|
||||
public Mono<org.springframework.http.ResponseEntity<byte[]>> downloadAttachment(@PathVariable Long id, @PathVariable Long attachmentId) {
|
||||
return transactionService.downloadAttachment(attachmentId)
|
||||
.map(response -> {
|
||||
byte[] decodedBytes = java.util.Base64.getDecoder().decode(response.getBase64Content());
|
||||
return org.springframework.http.ResponseEntity.ok()
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_TYPE, "image/jpeg") // Ideally dynamic based on db
|
||||
.body(decodedBytes);
|
||||
});
|
||||
}
|
||||
|
||||
@DeleteMapping("/attachments/{attachmentId}")
|
||||
public Mono<Void> deleteAttachment(@PathVariable Long attachmentId) {
|
||||
return transactionService.deleteAttachment(attachmentId);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/close-investment")
|
||||
public Mono<Transaction> closeInvestment(Authentication authentication, @PathVariable Long id, @RequestBody java.util.Map<String, Object> payload) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
java.math.BigDecimal maturityAmount = new java.math.BigDecimal(payload.get("maturityAmount").toString());
|
||||
java.time.LocalDate closingDate = payload.get("closingDate") != null ? java.time.LocalDate.parse(payload.get("closingDate").toString()) : java.time.LocalDate.now();
|
||||
Long toWalletId = payload.get("toWalletId") != null ? Long.valueOf(payload.get("toWalletId").toString()) : null;
|
||||
return transactionService.closeInvestment(id, userId, maturityAmount, closingDate, toWalletId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.entity.UserWallet;
|
||||
import com.kifi.api.entity.Wallet;
|
||||
import com.kifi.api.entity.WalletInvitation;
|
||||
import com.kifi.api.service.WalletService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi/wallets")
|
||||
@RequiredArgsConstructor
|
||||
public class WalletController {
|
||||
|
||||
private final WalletService walletService;
|
||||
|
||||
@GetMapping
|
||||
public Mono<List<Wallet>> getWallets(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.getWalletsForUser(userId).collectList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<Wallet> createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance());
|
||||
}
|
||||
|
||||
@PutMapping("/{walletId}")
|
||||
public Mono<Wallet> editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{walletId}")
|
||||
public Mono<Void> deleteWallet(@PathVariable Long walletId, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.deleteWallet(walletId, userId);
|
||||
}
|
||||
|
||||
@PostMapping("/{walletId}/invite")
|
||||
public Mono<WalletInvitation> inviteUser(@PathVariable Long walletId, @RequestBody InviteRequest request, Authentication authentication) {
|
||||
Long inviterId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.inviteUserByEmail(inviterId, walletId, request.getEmail());
|
||||
}
|
||||
|
||||
@GetMapping("/invitations")
|
||||
public Mono<List<WalletInvitation>> getInvitations(Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
return walletService.getPendingInvitations(email).collectList();
|
||||
}
|
||||
|
||||
@PostMapping("/invitations/{id}/accept")
|
||||
public Mono<Void> acceptInvitation(@PathVariable Long id, Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.respondToInvitation(id, email, userId, true);
|
||||
}
|
||||
|
||||
@PostMapping("/invitations/{id}/reject")
|
||||
public Mono<Void> rejectInvitation(@PathVariable Long id, Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.respondToInvitation(id, email, userId, false);
|
||||
}
|
||||
|
||||
@GetMapping("/{walletId}/members")
|
||||
public Mono<List<com.kifi.api.dto.WalletMemberDTO>> getWalletMembers(@PathVariable Long walletId, Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.getWalletMembers(walletId, userId).collectList();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{walletId}/members/{memberIdToRemove}")
|
||||
public Mono<Void> removeWalletMember(@PathVariable Long walletId, @PathVariable Long memberIdToRemove, Authentication authentication) {
|
||||
Long ownerId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.removeWalletMember(walletId, ownerId, memberIdToRemove);
|
||||
}
|
||||
|
||||
@GetMapping("/user-contacts")
|
||||
public Mono<List<String>> getKnownContacts(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return walletService.getKnownContacts(userId).collectList();
|
||||
}
|
||||
|
||||
@Data
|
||||
static class CreateWalletRequest {
|
||||
private String name;
|
||||
private String nature;
|
||||
private String icon;
|
||||
private String color;
|
||||
private String currency;
|
||||
private java.math.BigDecimal initialBalance;
|
||||
}
|
||||
|
||||
@Data
|
||||
static class InviteRequest {
|
||||
private String email;
|
||||
}
|
||||
}
|
||||
9
kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java
Normal file
9
kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AuthRequest {
|
||||
private String email;
|
||||
private String password;
|
||||
}
|
||||
16
kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java
Normal file
16
kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthResponse {
|
||||
private String token;
|
||||
private Long userId;
|
||||
private String email;
|
||||
}
|
||||
14
kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java
Normal file
14
kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import com.kifi.api.entity.Budget;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetSummary {
|
||||
private Budget budget;
|
||||
private BigDecimal spentAmount;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class OtpVerificationRequest {
|
||||
private String email;
|
||||
private String otp;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ResetPasswordRequest {
|
||||
private String email;
|
||||
private String otp;
|
||||
private String newPassword;
|
||||
}
|
||||
19
kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java
Normal file
19
kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WalletMemberDTO {
|
||||
private Long userId;
|
||||
private String email;
|
||||
private String role;
|
||||
private LocalDateTime joinedAt;
|
||||
}
|
||||
21
kifi-api/src/main/java/com/kifi/api/entity/Budget.java
Normal file
21
kifi-api/src/main/java/com/kifi/api/entity/Budget.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("budgets")
|
||||
public class Budget {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long categoryId;
|
||||
private Long walletId;
|
||||
private BigDecimal monthlyLimit;
|
||||
private Boolean isShared;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
24
kifi-api/src/main/java/com/kifi/api/entity/Category.java
Normal file
24
kifi-api/src/main/java/com/kifi/api/entity/Category.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("categories")
|
||||
public class Category {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String name;
|
||||
private String iconName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("recurring_transactions")
|
||||
public class RecurringTransaction {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long categoryId;
|
||||
private Long fromWalletId;
|
||||
private Long toWalletId;
|
||||
private String type;
|
||||
private BigDecimal amount;
|
||||
private String frequency; // DAILY, WEEKLY, MONTHLY, YEARLY
|
||||
private LocalDate nextExecutionDate;
|
||||
private LocalDate endDate;
|
||||
private String status; // ACTIVE, PAUSED
|
||||
private String description;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
50
kifi-api/src/main/java/com/kifi/api/entity/Transaction.java
Normal file
50
kifi-api/src/main/java/com/kifi/api/entity/Transaction.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("transactions")
|
||||
public class Transaction {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long categoryId;
|
||||
private Long fromWalletId;
|
||||
private Long toWalletId;
|
||||
private String notes;
|
||||
private String type; // INCOME or EXPENSE or INVESTMENT
|
||||
private BigDecimal amount;
|
||||
private LocalDate date;
|
||||
private String description;
|
||||
|
||||
// Payable fields
|
||||
private LocalDate dueDate;
|
||||
private String alertSchedule;
|
||||
private java.time.LocalTime alertTime;
|
||||
|
||||
// Investment fields
|
||||
private String investmentStatus; // OPEN or CLOSED
|
||||
private BigDecimal maturityAmount;
|
||||
private BigDecimal profitLoss;
|
||||
private LocalDate closingDate;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@org.springframework.data.annotation.Transient
|
||||
private java.util.List<TransactionItem> items;
|
||||
|
||||
@org.springframework.data.annotation.Transient
|
||||
private java.util.List<TransactionAttachment> attachments;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("transaction_attachments")
|
||||
public class TransactionAttachment {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long transactionId;
|
||||
private String fileName;
|
||||
private String filePath;
|
||||
private String contentType;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("transaction_items")
|
||||
public class TransactionItem {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long transactionId;
|
||||
private String name;
|
||||
private BigDecimal amount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
public enum TransactionType {
|
||||
INCOME,
|
||||
EXPENSE
|
||||
}
|
||||
26
kifi-api/src/main/java/com/kifi/api/entity/User.java
Normal file
26
kifi-api/src/main/java/com/kifi/api/entity/User.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("users")
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
@Builder.Default
|
||||
private Boolean enabled = false;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
18
kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java
Normal file
18
kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("user_wallets")
|
||||
public class UserWallet {
|
||||
@Id
|
||||
private Long id; // R2DBC sometimes needs a synthetic ID, but we have a composite PK in DB. We might need to just use a regular ID if this gives issues, but let's try mapping.
|
||||
private Long userId;
|
||||
private Long walletId;
|
||||
private String role;
|
||||
private LocalDateTime joinedAt;
|
||||
}
|
||||
22
kifi-api/src/main/java/com/kifi/api/entity/Wallet.java
Normal file
22
kifi-api/src/main/java/com/kifi/api/entity/Wallet.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("wallets")
|
||||
public class Wallet {
|
||||
@Id
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long ownerId;
|
||||
private String nature;
|
||||
private java.math.BigDecimal balance;
|
||||
private String currency;
|
||||
private String icon;
|
||||
private String color;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Table("wallet_invitations")
|
||||
public class WalletInvitation {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long walletId;
|
||||
private Long inviterId;
|
||||
private String inviteeEmail;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Budget;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public interface BudgetRepository extends R2dbcRepository<Budget, Long> {
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT b.* FROM budgets b WHERE b.user_id = :userId OR (b.is_shared = TRUE AND b.wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))")
|
||||
Flux<Budget> findByUserId(Long userId);
|
||||
reactor.core.publisher.Mono<Void> deleteByWalletId(Long walletId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Category;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface CategoryRepository extends R2dbcRepository<Category, Long> {
|
||||
Flux<Category> findByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Repository
|
||||
public interface RecurringTransactionRepository extends R2dbcRepository<RecurringTransaction, Long> {
|
||||
Flux<RecurringTransaction> findByUserId(Long userId);
|
||||
|
||||
@Query("SELECT * FROM recurring_transactions WHERE status = 'ACTIVE' AND next_execution_date <= :date")
|
||||
Flux<RecurringTransaction> findDueTransactions(LocalDate date);
|
||||
|
||||
reactor.core.publisher.Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.TransactionAttachment;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface TransactionAttachmentRepository extends ReactiveCrudRepository<TransactionAttachment, Long> {
|
||||
Flux<TransactionAttachment> findByTransactionId(Long transactionId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.TransactionItem;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface TransactionItemRepository extends ReactiveCrudRepository<TransactionItem, Long> {
|
||||
Flux<TransactionItem> findByTransactionId(Long transactionId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface TransactionRepository extends R2dbcRepository<Transaction, Long>, TransactionRepositoryCustom {
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT t.* FROM transactions t WHERE t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) ORDER BY t.date DESC")
|
||||
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
|
||||
Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public interface TransactionRepositoryCustom {
|
||||
Mono<Page<Transaction>> findTransactionsWithFilters(
|
||||
Long userId,
|
||||
String type,
|
||||
Long walletId,
|
||||
Long categoryId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String search,
|
||||
Pageable pageable
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class TransactionRepositoryImpl implements TransactionRepositoryCustom {
|
||||
|
||||
private final DatabaseClient databaseClient;
|
||||
|
||||
public TransactionRepositoryImpl(DatabaseClient databaseClient) {
|
||||
this.databaseClient = databaseClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Page<Transaction>> findTransactionsWithFilters(
|
||||
Long userId,
|
||||
String type,
|
||||
Long walletId,
|
||||
Long categoryId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String search,
|
||||
Pageable pageable) {
|
||||
StringBuilder baseQuery = new StringBuilder("FROM transactions t WHERE (t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))");
|
||||
|
||||
if (type != null && !type.isEmpty()) {
|
||||
baseQuery.append(" AND t.type = :type");
|
||||
}
|
||||
if (walletId != null) {
|
||||
baseQuery.append(" AND (t.from_wallet_id = :walletId OR t.to_wallet_id = :walletId)");
|
||||
}
|
||||
if (categoryId != null) {
|
||||
baseQuery.append(" AND t.category_id = :categoryId");
|
||||
}
|
||||
if (startDate != null) {
|
||||
baseQuery.append(" AND t.date >= :startDate");
|
||||
}
|
||||
if (endDate != null) {
|
||||
baseQuery.append(" AND t.date <= :endDate");
|
||||
}
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
baseQuery.append(" AND t.description ILIKE :search");
|
||||
}
|
||||
|
||||
String dataQueryStr = "SELECT t.* " + baseQuery.toString() + " ORDER BY t.date DESC LIMIT " + pageable.getPageSize() + " OFFSET " + pageable.getOffset();
|
||||
String countQueryStr = "SELECT COUNT(t.id) " + baseQuery.toString();
|
||||
|
||||
DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId);
|
||||
DatabaseClient.GenericExecuteSpec countSpec = databaseClient.sql(countQueryStr).bind("userId", userId);
|
||||
|
||||
if (type != null && !type.isEmpty()) {
|
||||
dataSpec = dataSpec.bind("type", type);
|
||||
countSpec = countSpec.bind("type", type);
|
||||
}
|
||||
if (walletId != null) {
|
||||
dataSpec = dataSpec.bind("walletId", walletId);
|
||||
countSpec = countSpec.bind("walletId", walletId);
|
||||
}
|
||||
if (categoryId != null) {
|
||||
dataSpec = dataSpec.bind("categoryId", categoryId);
|
||||
countSpec = countSpec.bind("categoryId", categoryId);
|
||||
}
|
||||
if (startDate != null) {
|
||||
dataSpec = dataSpec.bind("startDate", startDate);
|
||||
countSpec = countSpec.bind("startDate", startDate);
|
||||
}
|
||||
if (endDate != null) {
|
||||
dataSpec = dataSpec.bind("endDate", endDate);
|
||||
countSpec = countSpec.bind("endDate", endDate);
|
||||
}
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
dataSpec = dataSpec.bind("search", "%" + search.trim() + "%");
|
||||
countSpec = countSpec.bind("search", "%" + search.trim() + "%");
|
||||
}
|
||||
|
||||
Mono<List<Transaction>> transactionsMono = dataSpec.map((row, metadata) -> {
|
||||
Transaction t = new Transaction();
|
||||
t.setId(row.get("id", Long.class));
|
||||
t.setUserId(row.get("user_id", Long.class));
|
||||
t.setFromWalletId(row.get("from_wallet_id", Long.class));
|
||||
t.setToWalletId(row.get("to_wallet_id", Long.class));
|
||||
t.setCategoryId(row.get("category_id", Long.class));
|
||||
t.setType(row.get("type", String.class));
|
||||
t.setAmount(row.get("amount", BigDecimal.class));
|
||||
t.setDate(row.get("date", LocalDate.class));
|
||||
t.setDescription(row.get("description", String.class));
|
||||
t.setInvestmentStatus(row.get("investment_status", String.class));
|
||||
t.setProfitLoss(row.get("profit_loss", BigDecimal.class));
|
||||
return t;
|
||||
}).all().collectList();
|
||||
|
||||
Mono<Long> countMono = countSpec.map((row, metadata) -> row.get(0, Long.class)).first().defaultIfEmpty(0L);
|
||||
|
||||
return Mono.zip(transactionsMono, countMono)
|
||||
.map(tuple -> new PageImpl<>(tuple.getT1(), pageable, tuple.getT2()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.User;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends R2dbcRepository<User, Long> {
|
||||
Mono<User> findByEmail(String email);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.UserWallet;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface UserWalletRepository extends ReactiveCrudRepository<UserWallet, Long> {
|
||||
Flux<UserWallet> findByUserId(Long userId);
|
||||
Flux<UserWallet> findByWalletId(Long walletId);
|
||||
Mono<Void> deleteByWalletId(Long walletId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.WalletInvitation;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public interface WalletInvitationRepository extends R2dbcRepository<WalletInvitation, Long> {
|
||||
Flux<WalletInvitation> findByInviteeEmailAndStatus(String inviteeEmail, String status);
|
||||
reactor.core.publisher.Mono<Void> deleteByWalletId(Long walletId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.Wallet;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public interface WalletRepository extends ReactiveCrudRepository<Wallet, Long> {
|
||||
Flux<Wallet> findByOwnerId(Long ownerId);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.kifi.api.scheduler;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.repository.RecurringTransactionRepository;
|
||||
import com.kifi.api.service.TransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RecurringTransactionScheduler {
|
||||
|
||||
private final RecurringTransactionRepository recurringRepository;
|
||||
private final TransactionService transactionService;
|
||||
|
||||
// Runs every day at midnight
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
public void processRecurringTransactions() {
|
||||
LocalDate today = LocalDate.now();
|
||||
log.info("Starting recurring transaction processing for {}", today);
|
||||
|
||||
recurringRepository.findDueTransactions(today)
|
||||
.flatMap(this::processSingle)
|
||||
.subscribe(
|
||||
success -> log.info("Processed recurring transaction: {}", success.getId()),
|
||||
error -> log.error("Error processing recurring transactions", error),
|
||||
() -> log.info("Finished processing recurring transactions for {}", today)
|
||||
);
|
||||
}
|
||||
|
||||
private Mono<RecurringTransaction> processSingle(RecurringTransaction rt) {
|
||||
Transaction t = Transaction.builder()
|
||||
.userId(rt.getUserId())
|
||||
.categoryId(rt.getCategoryId())
|
||||
.fromWalletId(rt.getFromWalletId())
|
||||
.toWalletId(rt.getToWalletId())
|
||||
.type(rt.getType())
|
||||
.amount(rt.getAmount())
|
||||
.date(rt.getNextExecutionDate()) // Process it as of the execution date
|
||||
.description(rt.getDescription())
|
||||
.notes("Auto-generated from recurring transaction")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Calculate next date
|
||||
LocalDate nextDate = rt.getNextExecutionDate();
|
||||
switch (rt.getFrequency()) {
|
||||
case "DAILY" -> nextDate = nextDate.plusDays(1);
|
||||
case "WEEKLY" -> nextDate = nextDate.plusWeeks(1);
|
||||
case "MONTHLY" -> nextDate = nextDate.plusMonths(1);
|
||||
case "YEARLY" -> nextDate = nextDate.plusYears(1);
|
||||
}
|
||||
rt.setNextExecutionDate(nextDate);
|
||||
if (rt.getEndDate() != null && nextDate.isAfter(rt.getEndDate())) {
|
||||
rt.setStatus("COMPLETED");
|
||||
}
|
||||
|
||||
return transactionService.addTransaction(rt.getUserId(), t)
|
||||
.then(recurringRepository.save(rt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.kifi.api.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthenticationManager implements ReactiveAuthenticationManager {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> authenticate(Authentication authentication) {
|
||||
String authToken = authentication.getCredentials().toString();
|
||||
|
||||
try {
|
||||
if (jwtUtil.validateToken(authToken)) {
|
||||
Claims claims = jwtUtil.getAllClaimsFromToken(authToken);
|
||||
String email = claims.getSubject();
|
||||
Long userId = claims.get("userId", Long.class);
|
||||
|
||||
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
|
||||
email,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
|
||||
auth.setDetails(userId);
|
||||
|
||||
return Mono.just(auth);
|
||||
} else {
|
||||
return Mono.empty();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
68
kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java
Normal file
68
kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.kifi.api.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration}")
|
||||
private String expirationTime;
|
||||
|
||||
private SecretKey getSignKey() {
|
||||
byte[] keyBytes = secret.getBytes();
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
public Claims getAllClaimsFromToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSignKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public String getEmailFromToken(String token) {
|
||||
return getAllClaimsFromToken(token).getSubject();
|
||||
}
|
||||
|
||||
public Date getExpirationDateFromToken(String token) {
|
||||
return getAllClaimsFromToken(token).getExpiration();
|
||||
}
|
||||
|
||||
private Boolean isTokenExpired(String token) {
|
||||
final Date expiration = getExpirationDateFromToken(token);
|
||||
return expiration.before(new Date());
|
||||
}
|
||||
|
||||
public String generateToken(String email, Long userId) {
|
||||
HashMap<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userId);
|
||||
|
||||
long expirationTimeLong = Long.parseLong(expirationTime);
|
||||
final Date createdDate = new Date();
|
||||
final Date expirationDate = new Date(createdDate.getTime() + expirationTimeLong);
|
||||
|
||||
return Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(email)
|
||||
.issuedAt(createdDate)
|
||||
.expiration(expirationDate)
|
||||
.signWith(getSignKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Boolean validateToken(String token) {
|
||||
return !isTokenExpired(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.kifi.api.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextImpl;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityContextRepository implements ServerSecurityContextRepository {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
@Override
|
||||
public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
|
||||
throw new UnsupportedOperationException("Not supported yet.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SecurityContext> load(ServerWebExchange exchange) {
|
||||
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String authToken = authHeader.substring(7);
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(authToken, authToken);
|
||||
return this.authenticationManager.authenticate(auth)
|
||||
.map(SecurityContextImpl::new);
|
||||
}
|
||||
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
157
kifi-api/src/main/java/com/kifi/api/service/AuthService.java
Normal file
157
kifi-api/src/main/java/com/kifi/api/service/AuthService.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.dto.AuthRequest;
|
||||
import com.kifi.api.dto.AuthResponse;
|
||||
import com.kifi.api.entity.User;
|
||||
import com.kifi.api.repository.UserRepository;
|
||||
import com.kifi.api.security.JwtUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Random;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final EmailService emailService;
|
||||
private final ReactiveStringRedisTemplate redisTemplate;
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
private String decryptField(String value) {
|
||||
try {
|
||||
return cryptoService.decrypt(value);
|
||||
} catch (Exception e) {
|
||||
// If decryption fails, treat as plaintext (backward compatibility)
|
||||
log.warn("RSA decryption failed, treating as plaintext: {}", e.getMessage());
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<String> signup(AuthRequest request) {
|
||||
String email = decryptField(request.getEmail());
|
||||
String password = decryptField(request.getPassword());
|
||||
|
||||
return userRepository.findByEmail(email)
|
||||
.flatMap(existingUser -> {
|
||||
if (existingUser.getEnabled()) {
|
||||
return Mono.error(new RuntimeException("User already exists and is verified"));
|
||||
}
|
||||
return generateAndSendOtp(existingUser, "REGISTRATION");
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
User newUser = User.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(password))
|
||||
.enabled(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
return userRepository.save(newUser).flatMap(u -> generateAndSendOtp(u, "REGISTRATION"));
|
||||
}))
|
||||
.map(u -> "OTP sent to your email.");
|
||||
}
|
||||
|
||||
private Mono<User> generateAndSendOtp(User user, String purpose) {
|
||||
String otp = String.format("%06d", new Random().nextInt(999999));
|
||||
String redisKey = "OTP:" + purpose + ":" + user.getEmail();
|
||||
return redisTemplate.opsForValue()
|
||||
.set(redisKey, otp, Duration.ofMinutes(5))
|
||||
.then(purpose.equals("REGISTRATION")
|
||||
? emailService.sendRegistrationOtp(user.getEmail(), otp)
|
||||
: emailService.sendResetPasswordOtp(user.getEmail(), otp))
|
||||
.thenReturn(user);
|
||||
}
|
||||
|
||||
public Mono<AuthResponse> verifyOtp(String email, String otp) {
|
||||
String redisKey = "OTP:REGISTRATION:" + email;
|
||||
return redisTemplate.opsForValue().get(redisKey)
|
||||
.switchIfEmpty(
|
||||
// Fallback to old key format for backward compatibility
|
||||
redisTemplate.opsForValue().get("OTP:" + email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("OTP not found or expired")))
|
||||
)
|
||||
.flatMap(savedOtp -> {
|
||||
if (!savedOtp.equals(otp)) {
|
||||
return Mono.error(new RuntimeException("Invalid OTP"));
|
||||
}
|
||||
return userRepository.findByEmail(email);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
user.setEnabled(true);
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> redisTemplate.opsForValue().delete(redisKey).thenReturn(user))
|
||||
.map(user -> AuthResponse.builder()
|
||||
.token(jwtUtil.generateToken(user.getEmail(), user.getId()))
|
||||
.userId(user.getId())
|
||||
.email(user.getEmail())
|
||||
.build());
|
||||
}
|
||||
|
||||
public Mono<AuthResponse> login(AuthRequest request) {
|
||||
String email = decryptField(request.getEmail());
|
||||
String password = decryptField(request.getPassword());
|
||||
|
||||
return userRepository.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Invalid email or password")))
|
||||
.flatMap(user -> {
|
||||
if (!user.getEnabled()) {
|
||||
return Mono.error(new RuntimeException("Email not verified"));
|
||||
}
|
||||
if (passwordEncoder.matches(password, user.getPassword())) {
|
||||
return Mono.just(AuthResponse.builder()
|
||||
.token(jwtUtil.generateToken(user.getEmail(), user.getId()))
|
||||
.userId(user.getId())
|
||||
.email(user.getEmail())
|
||||
.build());
|
||||
} else {
|
||||
return Mono.error(new RuntimeException("Invalid email or password"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<String> forgotPassword(String email) {
|
||||
return userRepository.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("No account found with this email")))
|
||||
.flatMap(user -> {
|
||||
if (!user.getEnabled()) {
|
||||
return Mono.error(new RuntimeException("Account not verified yet"));
|
||||
}
|
||||
return generateAndSendOtp(user, "RESET");
|
||||
})
|
||||
.map(u -> "Password reset OTP sent to your email.");
|
||||
}
|
||||
|
||||
public Mono<String> resetPassword(String email, String otp, String encryptedNewPassword) {
|
||||
String redisKey = "OTP:RESET:" + email;
|
||||
String newPassword = decryptField(encryptedNewPassword);
|
||||
|
||||
return redisTemplate.opsForValue().get(redisKey)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("OTP not found or expired")))
|
||||
.flatMap(savedOtp -> {
|
||||
if (!savedOtp.equals(otp)) {
|
||||
return Mono.error(new RuntimeException("Invalid OTP"));
|
||||
}
|
||||
return userRepository.findByEmail(email);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> redisTemplate.opsForValue().delete(redisKey).thenReturn(user))
|
||||
.map(u -> "Password reset successfully.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.dto.BudgetSummary;
|
||||
import com.kifi.api.entity.Budget;
|
||||
import com.kifi.api.repository.BudgetRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetService {
|
||||
private final BudgetRepository budgetRepository;
|
||||
private final DatabaseClient databaseClient;
|
||||
|
||||
public Flux<Budget> getBudgets(Long userId) {
|
||||
return budgetRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
public Flux<BudgetSummary> getBudgetSummaries(Long userId) {
|
||||
return budgetRepository.findByUserId(userId)
|
||||
.flatMap(budget -> {
|
||||
String sql = "SELECT COALESCE(SUM(amount), 0) FROM transactions " +
|
||||
"WHERE type = 'EXPENSE' AND " +
|
||||
"date >= date_trunc('month', current_date) AND " +
|
||||
"(category_id = :categoryId OR from_wallet_id = :walletId) AND " +
|
||||
"(user_id = :userId OR from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))";
|
||||
|
||||
return databaseClient.sql(sql)
|
||||
.bind("userId", userId)
|
||||
.bind("categoryId", budget.getCategoryId() != null ? budget.getCategoryId() : -1)
|
||||
.bind("walletId", budget.getWalletId() != null ? budget.getWalletId() : -1)
|
||||
.map((row, rowMetadata) -> row.get(0, BigDecimal.class))
|
||||
.first()
|
||||
.defaultIfEmpty(BigDecimal.ZERO)
|
||||
.map(spent -> BudgetSummary.builder().budget(budget).spentAmount(spent).build());
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Budget> addOrUpdateBudget(Long userId, Budget budget) {
|
||||
return budgetRepository.findByUserId(userId)
|
||||
.filter(b -> (b.getCategoryId() != null && b.getCategoryId().equals(budget.getCategoryId())) ||
|
||||
(b.getWalletId() != null && b.getWalletId().equals(budget.getWalletId())))
|
||||
.next()
|
||||
.flatMap(existing -> {
|
||||
existing.setMonthlyLimit(budget.getMonthlyLimit());
|
||||
if (budget.getIsShared() != null) {
|
||||
existing.setIsShared(budget.getIsShared());
|
||||
}
|
||||
return budgetRepository.save(existing);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
budget.setUserId(userId);
|
||||
budget.setCreatedAt(LocalDateTime.now());
|
||||
if (budget.getIsShared() == null) {
|
||||
budget.setIsShared(false);
|
||||
}
|
||||
return budgetRepository.save(budget);
|
||||
}));
|
||||
}
|
||||
|
||||
public Mono<Void> deleteBudget(Long id) {
|
||||
return budgetRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.Category;
|
||||
import com.kifi.api.repository.CategoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
public Flux<Category> getCategories(Long userId) {
|
||||
return categoryRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
public Mono<Category> addCategory(Long userId, Category category) {
|
||||
category.setUserId(userId);
|
||||
category.setCreatedAt(LocalDateTime.now());
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Base64;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CryptoService {
|
||||
|
||||
private KeyPair keyPair;
|
||||
|
||||
public CryptoService() {
|
||||
try {
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||
keyGen.initialize(2048);
|
||||
this.keyPair = keyGen.generateKeyPair();
|
||||
log.info("RSA Key Pair generated successfully for CryptoService.");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate RSA Key Pair", e);
|
||||
throw new RuntimeException("Failed to initialize CryptoService", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getPublicKeyBase64() {
|
||||
PublicKey publicKey = keyPair.getPublic();
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedBase64) {
|
||||
try {
|
||||
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64);
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
|
||||
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||
return new String(decryptedBytes, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to decrypt RSA payload", e);
|
||||
throw new RuntimeException("Failed to decrypt payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
172
kifi-api/src/main/java/com/kifi/api/service/EmailService.java
Normal file
172
kifi-api/src/main/java/com/kifi/api/service/EmailService.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class EmailService {
|
||||
|
||||
private final JavaMailSender javaMailSender;
|
||||
|
||||
public String buildEmailTemplate(String title, String subtitle, String otp, String footerNote) {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;padding:40px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="460" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6C63FF 0%%,#48C6EF 100%%);padding:32px 40px;text-align:center;">
|
||||
<h1 style="margin:0;color:#ffffff;font-size:28px;font-weight:700;letter-spacing:1px;">Kifi</h1>
|
||||
<p style="margin:6px 0 0;color:rgba(255,255,255,0.85);font-size:13px;font-weight:400;">Smart Financial Management</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:36px 40px 28px;">
|
||||
<h2 style="margin:0 0 8px;color:#1a1a2e;font-size:22px;font-weight:600;">%s</h2>
|
||||
<p style="margin:0 0 28px;color:#6b7280;font-size:15px;line-height:1.6;">%s</p>
|
||||
<!-- OTP Box -->
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding:20px 0;">
|
||||
<div style="display:inline-block;background:linear-gradient(135deg,#6C63FF08,#48C6EF12);border:2px solid #6C63FF;border-radius:16px;padding:20px 48px;">
|
||||
<span style="font-size:36px;font-weight:700;letter-spacing:12px;color:#6C63FF;font-family:'Courier New',monospace;">%s</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:24px 0 0;color:#9ca3af;font-size:13px;text-align:center;line-height:1.5;">
|
||||
⏱ This code expires in <strong style="color:#6C63FF;">5 minutes</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="padding:0 40px;">
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0;">
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:24px 40px 32px;text-align:center;">
|
||||
<p style="margin:0 0 8px;color:#9ca3af;font-size:12px;line-height:1.5;">%s</p>
|
||||
<p style="margin:0;color:#d1d5db;font-size:11px;">© 2026 Kifi by Sarascore. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(title, subtitle, otp, footerNote);
|
||||
}
|
||||
|
||||
public Mono<Void> sendRegistrationOtp(String to, String otp) {
|
||||
String html = buildEmailTemplate(
|
||||
"Welcome to Kifi! 🎉",
|
||||
"Thank you for signing up. Please use the verification code below to complete your registration.",
|
||||
otp,
|
||||
"If you didn't create an account, you can safely ignore this email."
|
||||
);
|
||||
return sendEmail(to, "Kifi – Verify Your Email", html);
|
||||
}
|
||||
|
||||
public String buildInvitationEmailTemplate(String title, String subtitle, String footerNote) {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;padding:40px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="460" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6C63FF 0%%,#48C6EF 100%%);padding:32px 40px;text-align:center;">
|
||||
<h1 style="margin:0;color:#ffffff;font-size:28px;font-weight:700;letter-spacing:1px;">Kifi</h1>
|
||||
<p style="margin:6px 0 0;color:rgba(255,255,255,0.85);font-size:13px;font-weight:400;">Smart Financial Management</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:36px 40px 28px;">
|
||||
<h2 style="margin:0 0 8px;color:#1a1a2e;font-size:22px;font-weight:600;">%s</h2>
|
||||
<p style="margin:0 0 28px;color:#6b7280;font-size:15px;line-height:1.6;">%s</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="padding:0 40px;">
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0;">
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:24px 40px 32px;text-align:center;">
|
||||
<p style="margin:0 0 8px;color:#9ca3af;font-size:12px;line-height:1.5;">%s</p>
|
||||
<p style="margin:0;color:#d1d5db;font-size:11px;">© 2026 Kifi by Sarascore. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(title, subtitle, footerNote);
|
||||
}
|
||||
|
||||
public Mono<Void> sendResetPasswordOtp(String to, String otp) {
|
||||
String html = buildEmailTemplate(
|
||||
"Reset Your Password 🔐",
|
||||
"We received a request to reset your password. Use the code below to set a new password.",
|
||||
otp,
|
||||
"If you didn't request a password reset, please ignore this email. Your account is safe."
|
||||
);
|
||||
return sendEmail(to, "Kifi – Password Reset Code", html);
|
||||
}
|
||||
|
||||
// Keep old method for backward compatibility
|
||||
public Mono<Void> sendOtpEmail(String to, String otp) {
|
||||
return sendRegistrationOtp(to, otp);
|
||||
}
|
||||
|
||||
public Mono<Void> sendEmail(String to, String subject, String htmlContent) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
try {
|
||||
MimeMessage message = javaMailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(htmlContent, true);
|
||||
javaMailSender.send(message);
|
||||
log.info("Email sent to: {} with subject: {}", to, subject);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send email", e);
|
||||
throw new RuntimeException("Failed to send email");
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class MinioServiceClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
@Value("${minio.service.url:http://minio-service:1500}")
|
||||
private String minioServiceUrl;
|
||||
|
||||
@Value("${minio.service.bucket:kifi}")
|
||||
private String bucketName;
|
||||
|
||||
public MinioServiceClient(WebClient.Builder webClientBuilder) {
|
||||
this.webClient = webClientBuilder.build();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioUploadRequest {
|
||||
private String bucketName;
|
||||
private String directoryPath;
|
||||
private String contentType;
|
||||
private String fileName;
|
||||
private String fileContentBase64;
|
||||
private boolean overwrite;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioDeleteRequest {
|
||||
private String bucketName;
|
||||
private String type;
|
||||
private String filePath;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private String filePath;
|
||||
}
|
||||
|
||||
public Mono<MinioResponse> uploadFile(String directoryPath, String contentType, String fileName, String base64Content) {
|
||||
MinioUploadRequest request = new MinioUploadRequest();
|
||||
request.setBucketName(bucketName);
|
||||
request.setDirectoryPath(directoryPath);
|
||||
request.setContentType(contentType);
|
||||
request.setFileName(fileName);
|
||||
request.setFileContentBase64(base64Content);
|
||||
request.setOverwrite(true);
|
||||
|
||||
return webClient.post()
|
||||
.uri(minioServiceUrl + "/api/v1/minio/upload")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MinioResponse.class);
|
||||
}
|
||||
|
||||
public Mono<MinioResponse> deleteFile(String contentType, String filePath) {
|
||||
MinioDeleteRequest request = new MinioDeleteRequest();
|
||||
request.setBucketName(bucketName);
|
||||
request.setType(contentType);
|
||||
request.setFilePath(filePath);
|
||||
|
||||
return webClient.method(org.springframework.http.HttpMethod.DELETE)
|
||||
.uri(minioServiceUrl + "/api/v1/minio/file")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MinioResponse.class);
|
||||
}
|
||||
@Data
|
||||
public static class MinioDownloadRequest {
|
||||
private String bucketName;
|
||||
private String type;
|
||||
private String filePath;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MinioDownloadResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private String base64Content;
|
||||
}
|
||||
|
||||
public Mono<MinioDownloadResponse> downloadFile(String contentType, String filePath) {
|
||||
MinioDownloadRequest request = new MinioDownloadRequest();
|
||||
request.setBucketName(bucketName);
|
||||
request.setType(contentType);
|
||||
request.setFilePath(filePath);
|
||||
|
||||
return webClient.post()
|
||||
.uri(minioServiceUrl + "/api/v1/minio/download")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MinioDownloadResponse.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.RecurringTransaction;
|
||||
import com.kifi.api.repository.RecurringTransactionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringTransactionService {
|
||||
private final RecurringTransactionRepository repository;
|
||||
|
||||
public Flux<RecurringTransaction> getRecurringTransactions(Long userId) {
|
||||
return repository.findByUserId(userId);
|
||||
}
|
||||
|
||||
public Mono<RecurringTransaction> addRecurringTransaction(Long userId, RecurringTransaction transaction) {
|
||||
transaction.setUserId(userId);
|
||||
if (transaction.getStatus() == null) transaction.setStatus("ACTIVE");
|
||||
transaction.setCreatedAt(LocalDateTime.now());
|
||||
return repository.save(transaction);
|
||||
}
|
||||
|
||||
public Mono<RecurringTransaction> updateRecurringTransaction(Long id, Long userId, RecurringTransaction updated) {
|
||||
return repository.findById(id)
|
||||
.filter(rt -> rt.getUserId().equals(userId))
|
||||
.flatMap(existing -> {
|
||||
existing.setAmount(updated.getAmount());
|
||||
existing.setFrequency(updated.getFrequency());
|
||||
existing.setNextExecutionDate(updated.getNextExecutionDate());
|
||||
existing.setEndDate(updated.getEndDate());
|
||||
existing.setStatus(updated.getStatus());
|
||||
existing.setDescription(updated.getDescription());
|
||||
existing.setFromWalletId(updated.getFromWalletId());
|
||||
existing.setToWalletId(updated.getToWalletId());
|
||||
existing.setCategoryId(updated.getCategoryId());
|
||||
return repository.save(existing);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteRecurringTransaction(Long id) {
|
||||
return repository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.repository.TransactionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVPrinter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
private final TransactionRepository transactionRepository;
|
||||
|
||||
public Mono<byte[]> exportTransactionsToCsv(Long userId) {
|
||||
return transactionRepository.findVisibleTransactionsForUser(userId)
|
||||
.collectList()
|
||||
.map(this::generateCsvBytes);
|
||||
}
|
||||
|
||||
private byte[] generateCsvBytes(List<Transaction> transactions) {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
PrintWriter pw = new PrintWriter(out, true, StandardCharsets.UTF_8);
|
||||
CSVPrinter csvPrinter = new CSVPrinter(pw, CSVFormat.DEFAULT.builder().setHeader("ID", "Date", "Type", "Amount", "Description").build())) {
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
for (Transaction tx : transactions) {
|
||||
csvPrinter.printRecord(
|
||||
tx.getId(),
|
||||
tx.getDate() != null ? tx.getDate().format(formatter) : "",
|
||||
tx.getType(),
|
||||
tx.getAmount(),
|
||||
tx.getDescription() != null ? tx.getDescription() : ""
|
||||
);
|
||||
}
|
||||
csvPrinter.flush();
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error generating CSV", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.Transaction;
|
||||
import com.kifi.api.entity.TransactionAttachment;
|
||||
import com.kifi.api.entity.TransactionItem;
|
||||
import com.kifi.api.repository.TransactionAttachmentRepository;
|
||||
import com.kifi.api.repository.TransactionItemRepository;
|
||||
import com.kifi.api.repository.TransactionRepository;
|
||||
import com.kifi.api.repository.WalletRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TransactionService {
|
||||
private final TransactionRepository transactionRepository;
|
||||
private final TransactionItemRepository transactionItemRepository;
|
||||
private final TransactionAttachmentRepository transactionAttachmentRepository;
|
||||
private final WalletRepository walletRepository;
|
||||
private final MinioServiceClient minioServiceClient;
|
||||
|
||||
public Flux<Transaction> getTransactions(Long userId) {
|
||||
return transactionRepository.findVisibleTransactionsForUser(userId)
|
||||
.flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
public Mono<Page<Transaction>> searchTransactions(Long userId, String type, Long walletId, Long categoryId, LocalDate startDate, LocalDate endDate, String search, Pageable pageable) {
|
||||
return transactionRepository.findTransactionsWithFilters(userId, type, walletId, categoryId, startDate, endDate, search, pageable)
|
||||
.flatMap(page -> Flux.fromIterable(page.getContent())
|
||||
.flatMap(this::populateItemsAndAttachments)
|
||||
.collectList()
|
||||
.map(populatedList -> new org.springframework.data.domain.PageImpl<>(populatedList, pageable, page.getTotalElements())));
|
||||
}
|
||||
|
||||
private Mono<Transaction> populateItemsAndAttachments(Transaction transaction) {
|
||||
Mono<List<TransactionItem>> itemsMono = transactionItemRepository.findByTransactionId(transaction.getId()).collectList();
|
||||
Mono<List<TransactionAttachment>> attachmentsMono = transactionAttachmentRepository.findByTransactionId(transaction.getId()).collectList();
|
||||
|
||||
return Mono.zip(itemsMono, attachmentsMono).map(tuple -> {
|
||||
transaction.setItems(tuple.getT1());
|
||||
transaction.setAttachments(tuple.getT2());
|
||||
return transaction;
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Transaction> addTransaction(Long userId, Transaction transaction) {
|
||||
transaction.setUserId(userId);
|
||||
transaction.setCreatedAt(LocalDateTime.now());
|
||||
if ("INVESTMENT".equals(transaction.getType())) {
|
||||
transaction.setInvestmentStatus("OPEN");
|
||||
}
|
||||
|
||||
Mono<Void> updateBalances = updateWalletBalances(transaction.getFromWalletId(), transaction.getToWalletId(), transaction.getAmount());
|
||||
|
||||
return updateBalances.then(transactionRepository.save(transaction)).flatMap(savedTx -> {
|
||||
Mono<Void> itemsMono = Mono.empty();
|
||||
if (transaction.getItems() != null && !transaction.getItems().isEmpty()) {
|
||||
for (TransactionItem item : transaction.getItems()) {
|
||||
item.setTransactionId(savedTx.getId());
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
itemsMono = transactionItemRepository.saveAll(transaction.getItems()).then();
|
||||
}
|
||||
return itemsMono.thenReturn(savedTx);
|
||||
}).flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
private Mono<Void> updateWalletBalances(Long fromWalletId, Long toWalletId, BigDecimal amount) {
|
||||
Mono<Void> deductFrom = fromWalletId != null ? walletRepository.findById(fromWalletId)
|
||||
.flatMap(w -> {
|
||||
w.setBalance(w.getBalance().subtract(amount));
|
||||
return walletRepository.save(w);
|
||||
}).then() : Mono.empty();
|
||||
|
||||
Mono<Void> addTo = toWalletId != null ? walletRepository.findById(toWalletId)
|
||||
.flatMap(w -> {
|
||||
w.setBalance(w.getBalance().add(amount));
|
||||
return walletRepository.save(w);
|
||||
}).then() : Mono.empty();
|
||||
|
||||
return deductFrom.then(addTo);
|
||||
}
|
||||
|
||||
public Mono<TransactionAttachment> addAttachment(Long transactionId, String fileName, String contentType, String base64Content) {
|
||||
String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName;
|
||||
String directoryPath = "transactions/" + transactionId;
|
||||
|
||||
return minioServiceClient.uploadFile(directoryPath, contentType, uniqueFileName, base64Content)
|
||||
.flatMap(minioResponse -> {
|
||||
if (minioResponse.isSuccess()) {
|
||||
TransactionAttachment attachment = TransactionAttachment.builder()
|
||||
.transactionId(transactionId)
|
||||
.fileName(fileName)
|
||||
.filePath(minioResponse.getFilePath())
|
||||
.contentType(contentType)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
return transactionAttachmentRepository.save(attachment);
|
||||
} else {
|
||||
return Mono.error(new RuntimeException("Failed to upload to MinIO"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteAttachment(Long attachmentId) {
|
||||
return transactionAttachmentRepository.findById(attachmentId)
|
||||
.flatMap(attachment ->
|
||||
minioServiceClient.deleteFile(attachment.getContentType(), attachment.getFilePath())
|
||||
.then(transactionAttachmentRepository.delete(attachment))
|
||||
);
|
||||
}
|
||||
|
||||
public Mono<MinioServiceClient.MinioDownloadResponse> downloadAttachment(Long attachmentId) {
|
||||
return transactionAttachmentRepository.findById(attachmentId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Attachment not found")))
|
||||
.flatMap(attachment ->
|
||||
minioServiceClient.downloadFile(attachment.getContentType(), attachment.getFilePath())
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Transaction> updateTransaction(Long id, Long userId, Transaction updatedTransaction) {
|
||||
return transactionRepository.findById(id)
|
||||
.filter(t -> t.getUserId().equals(userId))
|
||||
.flatMap(t -> {
|
||||
// Revert old balances
|
||||
Mono<Void> revertBalances = updateWalletBalances(t.getToWalletId(), t.getFromWalletId(), t.getAmount());
|
||||
|
||||
// Apply new balances
|
||||
Mono<Void> applyNewBalances = updateWalletBalances(updatedTransaction.getFromWalletId(), updatedTransaction.getToWalletId(), updatedTransaction.getAmount());
|
||||
|
||||
t.setCategoryId(updatedTransaction.getCategoryId());
|
||||
t.setFromWalletId(updatedTransaction.getFromWalletId());
|
||||
t.setToWalletId(updatedTransaction.getToWalletId());
|
||||
t.setNotes(updatedTransaction.getNotes());
|
||||
t.setType(updatedTransaction.getType());
|
||||
t.setAmount(updatedTransaction.getAmount());
|
||||
t.setDate(updatedTransaction.getDate());
|
||||
t.setDescription(updatedTransaction.getDescription());
|
||||
|
||||
return revertBalances.then(applyNewBalances).then(transactionRepository.save(t));
|
||||
}).flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Transaction> closeInvestment(Long id, Long userId, BigDecimal maturityAmount, LocalDate closingDate, Long destinationWalletId) {
|
||||
return transactionRepository.findById(id)
|
||||
.filter(t -> t.getUserId().equals(userId) && "OPEN".equals(t.getInvestmentStatus()))
|
||||
.flatMap(t -> {
|
||||
t.setInvestmentStatus("CLOSED");
|
||||
t.setClosingDate(closingDate);
|
||||
t.setMaturityAmount(maturityAmount);
|
||||
BigDecimal profitLoss = maturityAmount.subtract(t.getAmount());
|
||||
t.setProfitLoss(profitLoss);
|
||||
|
||||
Mono<Void> profitLossProcess = Mono.empty();
|
||||
if (profitLoss.compareTo(BigDecimal.ZERO) > 0) {
|
||||
Transaction profit = new Transaction();
|
||||
profit.setUserId(userId);
|
||||
profit.setType("INCOME");
|
||||
profit.setAmount(profitLoss);
|
||||
profit.setDate(closingDate);
|
||||
profit.setToWalletId(t.getToWalletId());
|
||||
profit.setDescription("Profit from " + (t.getDescription() != null ? t.getDescription() : "Investment"));
|
||||
profitLossProcess = transactionRepository.save(profit)
|
||||
.flatMap(savedProfit -> updateWalletBalances(null, savedProfit.getToWalletId(), profitLoss));
|
||||
} else if (profitLoss.compareTo(BigDecimal.ZERO) < 0) {
|
||||
Transaction loss = new Transaction();
|
||||
loss.setUserId(userId);
|
||||
loss.setType("EXPENSE");
|
||||
loss.setAmount(profitLoss.abs());
|
||||
loss.setDate(closingDate);
|
||||
loss.setFromWalletId(t.getToWalletId());
|
||||
loss.setDescription("Loss from " + (t.getDescription() != null ? t.getDescription() : "Investment"));
|
||||
profitLossProcess = transactionRepository.save(loss)
|
||||
.flatMap(savedLoss -> updateWalletBalances(savedLoss.getFromWalletId(), null, profitLoss.abs()));
|
||||
}
|
||||
|
||||
// Create transfer transaction from the investment wallet to the savings wallet
|
||||
if (destinationWalletId != null) {
|
||||
Transaction transfer = new Transaction();
|
||||
transfer.setUserId(userId);
|
||||
transfer.setType("TRANSFER");
|
||||
transfer.setAmount(maturityAmount);
|
||||
transfer.setDate(closingDate);
|
||||
transfer.setFromWalletId(t.getToWalletId()); // The money was in the investment wallet
|
||||
transfer.setToWalletId(destinationWalletId);
|
||||
transfer.setDescription("Closure of " + (t.getDescription() != null ? t.getDescription() : "Investment"));
|
||||
|
||||
return transactionRepository.save(t)
|
||||
.then(profitLossProcess)
|
||||
.then(transactionRepository.save(transfer))
|
||||
.flatMap(savedTransfer -> updateWalletBalances(savedTransfer.getFromWalletId(), savedTransfer.getToWalletId(), maturityAmount))
|
||||
.thenReturn(t);
|
||||
} else {
|
||||
return transactionRepository.save(t).then(profitLossProcess).thenReturn(t);
|
||||
}
|
||||
}).flatMap(this::populateItemsAndAttachments);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Void> deleteTransaction(Long id) {
|
||||
return transactionRepository.findById(id).flatMap(t -> {
|
||||
Mono<Void> revertBalances = updateWalletBalances(t.getToWalletId(), t.getFromWalletId(), t.getAmount());
|
||||
return revertBalances.then(
|
||||
transactionAttachmentRepository.findByTransactionId(id)
|
||||
.flatMap(attachment -> minioServiceClient.deleteFile(attachment.getContentType(), attachment.getFilePath()))
|
||||
.then(transactionRepository.deleteById(id))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
192
kifi-api/src/main/java/com/kifi/api/service/WalletService.java
Normal file
192
kifi-api/src/main/java/com/kifi/api/service/WalletService.java
Normal file
@@ -0,0 +1,192 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.entity.UserWallet;
|
||||
import com.kifi.api.entity.Wallet;
|
||||
import com.kifi.api.entity.WalletInvitation;
|
||||
import com.kifi.api.repository.BudgetRepository;
|
||||
import com.kifi.api.repository.RecurringTransactionRepository;
|
||||
import com.kifi.api.repository.TransactionRepository;
|
||||
import com.kifi.api.repository.UserWalletRepository;
|
||||
import com.kifi.api.repository.WalletInvitationRepository;
|
||||
import com.kifi.api.repository.WalletRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletService {
|
||||
private final WalletRepository walletRepository;
|
||||
private final UserWalletRepository userWalletRepository;
|
||||
private final WalletInvitationRepository walletInvitationRepository;
|
||||
private final TransactionRepository transactionRepository;
|
||||
private final BudgetRepository budgetRepository;
|
||||
private final RecurringTransactionRepository recurringTransactionRepository;
|
||||
private final EmailService emailService;
|
||||
private final com.kifi.api.repository.UserRepository userRepository;
|
||||
|
||||
public Mono<Wallet> createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance) {
|
||||
Wallet wallet = new Wallet();
|
||||
wallet.setOwnerId(ownerId);
|
||||
wallet.setName(name);
|
||||
wallet.setNature(nature != null ? nature : "CASH");
|
||||
wallet.setBalance(initialBalance != null ? initialBalance : java.math.BigDecimal.ZERO);
|
||||
wallet.setCurrency(currency != null ? currency : "INR");
|
||||
wallet.setIcon(icon);
|
||||
wallet.setColor(color);
|
||||
wallet.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
return walletRepository.save(wallet)
|
||||
.flatMap(savedWallet -> {
|
||||
UserWallet uw = new UserWallet();
|
||||
uw.setUserId(ownerId);
|
||||
uw.setWalletId(savedWallet.getId());
|
||||
uw.setRole("OWNER");
|
||||
uw.setJoinedAt(LocalDateTime.now());
|
||||
return userWalletRepository.save(uw).thenReturn(savedWallet);
|
||||
});
|
||||
}
|
||||
|
||||
public Flux<Wallet> getWalletsForUser(Long userId) {
|
||||
return userWalletRepository.findByUserId(userId)
|
||||
.flatMap(uw -> walletRepository.findById(uw.getWalletId()));
|
||||
}
|
||||
|
||||
public Mono<UserWallet> inviteUserToWallet(Long walletId, Long userIdToInvite) {
|
||||
UserWallet uw = new UserWallet();
|
||||
uw.setUserId(userIdToInvite);
|
||||
uw.setWalletId(walletId);
|
||||
uw.setRole("MEMBER");
|
||||
uw.setJoinedAt(LocalDateTime.now());
|
||||
return userWalletRepository.save(uw);
|
||||
}
|
||||
|
||||
public Mono<WalletInvitation> inviteUserByEmail(Long inviterId, Long walletId, String inviteeEmail) {
|
||||
return walletRepository.findById(walletId)
|
||||
.flatMap(wallet -> {
|
||||
WalletInvitation invitation = new WalletInvitation();
|
||||
invitation.setWalletId(walletId);
|
||||
invitation.setInviterId(inviterId);
|
||||
invitation.setInviteeEmail(inviteeEmail);
|
||||
invitation.setStatus("PENDING");
|
||||
invitation.setCreatedAt(LocalDateTime.now());
|
||||
return walletInvitationRepository.save(invitation)
|
||||
.flatMap(savedInv -> {
|
||||
String emailHtml = emailService.buildInvitationEmailTemplate(
|
||||
"Wallet Invitation 🤝",
|
||||
"You have been invited to join the wallet: " + wallet.getName(),
|
||||
"Please log in to the Kifi app with this email to accept or decline the invitation."
|
||||
);
|
||||
return emailService.sendEmail(inviteeEmail, "Kifi – Wallet Invitation", emailHtml)
|
||||
.thenReturn(savedInv)
|
||||
.onErrorResume(e -> Mono.just(savedInv)); // Return saved invitation even if email fails
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public Flux<WalletInvitation> getPendingInvitations(String email) {
|
||||
return walletInvitationRepository.findByInviteeEmailAndStatus(email, "PENDING");
|
||||
}
|
||||
|
||||
public Mono<Void> respondToInvitation(Long invitationId, String email, Long userId, boolean accept) {
|
||||
return walletInvitationRepository.findById(invitationId)
|
||||
.filter(inv -> inv.getInviteeEmail().equalsIgnoreCase(email) && "PENDING".equals(inv.getStatus()))
|
||||
.flatMap(inv -> {
|
||||
inv.setStatus(accept ? "ACCEPTED" : "REJECTED");
|
||||
return walletInvitationRepository.save(inv)
|
||||
.flatMap(savedInv -> {
|
||||
if (accept) {
|
||||
UserWallet uw = new UserWallet();
|
||||
uw.setUserId(userId);
|
||||
uw.setWalletId(savedInv.getWalletId());
|
||||
uw.setRole("MEMBER");
|
||||
uw.setJoinedAt(LocalDateTime.now());
|
||||
return userWalletRepository.save(uw).then();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Wallet> editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency) {
|
||||
return walletRepository.findById(walletId)
|
||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||
.flatMap(w -> {
|
||||
if (name != null) w.setName(name);
|
||||
if (nature != null) w.setNature(nature);
|
||||
if (icon != null) w.setIcon(icon);
|
||||
if (color != null) w.setColor(color);
|
||||
if (currency != null) w.setCurrency(currency);
|
||||
return walletRepository.save(w);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteWallet(Long walletId, Long ownerId) {
|
||||
return walletRepository.findById(walletId)
|
||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||
.flatMap(w -> transactionRepository.countByFromWalletIdOrToWalletId(walletId, walletId)
|
||||
.flatMap(txCount -> recurringTransactionRepository.countByFromWalletIdOrToWalletId(walletId, walletId)
|
||||
.flatMap(rtCount -> {
|
||||
if (txCount > 0 || rtCount > 0) {
|
||||
return Mono.error(new RuntimeException("Cannot delete wallet with transactions"));
|
||||
}
|
||||
return budgetRepository.deleteByWalletId(walletId)
|
||||
.then(walletInvitationRepository.deleteByWalletId(walletId))
|
||||
.then(userWalletRepository.deleteByWalletId(walletId))
|
||||
.then(walletRepository.deleteById(walletId));
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public Flux<com.kifi.api.dto.WalletMemberDTO> getWalletMembers(Long walletId, Long requestingUserId) {
|
||||
// Ensure requesting user has access to this wallet
|
||||
return userWalletRepository.findByUserId(requestingUserId)
|
||||
.filter(uw -> uw.getWalletId().equals(walletId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("You do not have access to this wallet")))
|
||||
.next()
|
||||
.flatMapMany(uw -> userWalletRepository.findByWalletId(walletId)
|
||||
.flatMap(memberUw -> userRepository.findById(memberUw.getUserId())
|
||||
.map(user -> com.kifi.api.dto.WalletMemberDTO.builder()
|
||||
.userId(user.getId())
|
||||
.email(user.getEmail())
|
||||
.role(memberUw.getRole())
|
||||
.joinedAt(memberUw.getJoinedAt())
|
||||
.build()
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public Mono<Void> removeWalletMember(Long walletId, Long ownerId, Long memberIdToRemove) {
|
||||
return walletRepository.findById(walletId)
|
||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||
.flatMap(w -> {
|
||||
if (ownerId.equals(memberIdToRemove)) {
|
||||
return Mono.error(new RuntimeException("Cannot remove the owner of the wallet"));
|
||||
}
|
||||
return userWalletRepository.findByWalletId(walletId)
|
||||
.filter(uw -> uw.getUserId().equals(memberIdToRemove))
|
||||
.next()
|
||||
.flatMap(uw -> userWalletRepository.delete(uw));
|
||||
});
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private org.springframework.r2dbc.core.DatabaseClient databaseClient;
|
||||
|
||||
public Flux<String> getKnownContacts(Long currentUserId) {
|
||||
String sql = "SELECT DISTINCT u.email FROM users u JOIN user_wallets uw ON u.id = uw.user_id WHERE uw.wallet_id IN (SELECT wallet_id FROM user_wallets WHERE user_id = :userId) AND u.id != :userId";
|
||||
return databaseClient.sql(sql)
|
||||
.bind("userId", currentUserId)
|
||||
.map((row, rowMetadata) -> row.get("email", String.class))
|
||||
.all();
|
||||
}
|
||||
}
|
||||
48
kifi-api/src/main/resources/application.yml
Normal file
48
kifi-api/src/main/resources/application.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
spring:
|
||||
application:
|
||||
name: kifi-api
|
||||
|
||||
r2dbc:
|
||||
url: r2dbc:postgresql://103.125.129.116:5333/kifi
|
||||
username: postgres
|
||||
password: M@triXPostgr3s@6202
|
||||
pool:
|
||||
initial-size: 5
|
||||
max-size: 20
|
||||
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
schema-locations: classpath:schema.sql
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: 103.125.129.116
|
||||
port: 7901
|
||||
password: M@triXR3d1s@6202
|
||||
|
||||
mail:
|
||||
host: smtp.gmail.com
|
||||
port: 587
|
||||
username: technobeesolutions@gmail.com
|
||||
password: lrideibfakickldg
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
minio:
|
||||
service:
|
||||
url: http://103.125.129.116:1500
|
||||
bucket: kifi
|
||||
|
||||
jwt:
|
||||
secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
|
||||
expiration: 604800000 # 7 days
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.data.r2dbc: DEBUG
|
||||
org.springframework.r2dbc: DEBUG
|
||||
134
kifi-api/src/main/resources/schema.sql
Normal file
134
kifi-api/src/main/resources/schema.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255),
|
||||
enabled BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS merchants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
icon_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
icon_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
type VARCHAR(50) NOT NULL, -- 'INCOME' or 'EXPENSE' or 'INVESTMENT'
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
monthly_limit DECIMAL(10, 2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
frequency VARCHAR(50) NOT NULL,
|
||||
next_execution_date DATE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
nature VARCHAR(50) DEFAULT 'CASH', -- 'CASH', 'DEPOSIT', 'EXPENSE', 'INCOME', 'SAVINGS', 'LOAN', etc.
|
||||
balance DECIMAL(15, 2) DEFAULT 0.00,
|
||||
currency VARCHAR(10) DEFAULT 'INR',
|
||||
icon VARCHAR(255),
|
||||
color VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_wallets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
wallet_id INTEGER REFERENCES wallets(id),
|
||||
role VARCHAR(50) DEFAULT 'MEMBER',
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, wallet_id)
|
||||
);
|
||||
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS nature VARCHAR(50) DEFAULT 'CASH';
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS balance DECIMAL(15, 2) DEFAULT 0.00;
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS currency VARCHAR(10) DEFAULT 'INR';
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS icon VARCHAR(255);
|
||||
ALTER TABLE wallets ADD COLUMN IF NOT EXISTS color VARCHAR(50);
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS due_date DATE;
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_schedule VARCHAR(50);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_time TIME;
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS investment_status VARCHAR(20) DEFAULT 'OPEN';
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS maturity_amount DECIMAL(10, 2);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS profit_loss DECIMAL(10, 2);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS closing_date DATE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(1024) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE budgets ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE budgets ADD COLUMN IF NOT EXISTS is_shared BOOLEAN DEFAULT FALSE;
|
||||
-- Ensure budget can be either category-specific or wallet-specific
|
||||
ALTER TABLE budgets ALTER COLUMN category_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id);
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'ACTIVE';
|
||||
ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS end_date DATE;
|
||||
ALTER TABLE recurring_transactions ALTER COLUMN category_id DROP NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
wallet_id INTEGER REFERENCES wallets(id),
|
||||
inviter_id INTEGER REFERENCES users(id),
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'PENDING',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.kifi.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class KifiApiApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user