Revamp stage one - individual account and account setup fixed

This commit is contained in:
2026-08-25 19:45:24 +05:30
parent 82c1a891c0
commit 53c1f62373
52 changed files with 3980 additions and 4020 deletions

View File

@@ -48,6 +48,7 @@ public class SecurityConfig {
.pathMatchers("/api/kifi-v2/auth/**").permitAll()
.pathMatchers("/api/kifi-v2/health/**").permitAll()
.pathMatchers("/api/kifi-v2/wallets/test/**").permitAll()
.pathMatchers("/public/legal/**").permitAll()
.anyExchange().authenticated()
)
.build();

View File

@@ -0,0 +1,38 @@
package com.kifi.api.controller;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StreamUtils;
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.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/public/legal")
public class LegalController {
@GetMapping(value = "/terms", produces = MediaType.TEXT_HTML_VALUE)
public Mono<ResponseEntity<String>> getTerms() {
return loadResource("policies/terms-and-conditions.html");
}
@GetMapping(value = "/privacy", produces = MediaType.TEXT_HTML_VALUE)
public Mono<ResponseEntity<String>> getPrivacy() {
return loadResource("policies/privacy-policy.html");
}
private Mono<ResponseEntity<String>> loadResource(String path) {
return Mono.fromCallable(() -> {
ClassPathResource resource = new ClassPathResource(path);
if (resource.exists()) {
String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(content);
}
return ResponseEntity.notFound().build();
});
}
}

View File

@@ -0,0 +1,45 @@
package com.kifi.api.controller;
import com.kifi.api.dto.SetupRequestDTO;
import com.kifi.api.service.SetupService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import java.util.Map;
@RestController
@RequestMapping("/api/kifi-v2/account")
@RequiredArgsConstructor
public class SetupController {
private final SetupService setupService;
@GetMapping("/setup/status")
public Mono<ResponseEntity<Map<String, String>>> getSetupStatus(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return setupService.getSetupStatus(userId)
.map(ResponseEntity::ok);
}
@GetMapping("/username/availability")
public Mono<ResponseEntity<Map<String, Boolean>>> isUsernameAvailable(@RequestParam String username) {
return setupService.isUsernameAvailable(username)
.map(available -> ResponseEntity.ok(Map.of("available", available)));
}
@PostMapping("/setup")
public Mono<ResponseEntity<Map<String, String>>> completeSetup(Authentication authentication, @RequestBody SetupRequestDTO request, org.springframework.web.server.ServerWebExchange exchange) {
Long userId = Long.valueOf(authentication.getDetails().toString());
String userAgent = exchange.getRequest().getHeaders().getFirst("User-Agent");
String ipAddress = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
if (ipAddress == null && exchange.getRequest().getRemoteAddress() != null) {
ipAddress = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
}
return setupService.completeSetup(userId, request, ipAddress, userAgent)
.map(message -> ResponseEntity.ok(Map.of("message", message)))
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("message", e.getMessage()))));
}
}

View File

@@ -0,0 +1,23 @@
package com.kifi.api.dto;
import lombok.Data;
@Data
public class SetupRequestDTO {
private String accountType;
private String name;
private String username;
private String mobileNumber;
private String natureOfBusiness;
private String businessName;
private String address;
private Long stateId;
private String emailId;
private String gstin;
private String panNumber;
private String msmeNumber;
private Boolean termsAccepted;
private Boolean privacyAccepted;
}

View File

@@ -23,6 +23,12 @@ public class User {
private Boolean enabled = false;
@Builder.Default
private String profileType = "INDIVIDUAL";
private String username;
private String name;
private String mobileNumber;
@Builder.Default
private String setupStatus = "NOT_STARTED";
private LocalDateTime setupCompletedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,29 @@
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("user_consents")
public class UserConsent {
@Id
private Long id;
private Long userId;
private String consentType;
private String policyVersion;
@Builder.Default
private Boolean accepted = true;
private LocalDateTime acceptedAt;
private String ipAddress;
private String userAgent;
private LocalDateTime createdAt;
}

View File

@@ -30,6 +30,8 @@ public class BusinessProfile {
private String emailId;
private String panNumber;
private String gstin;
private String natureOfBusiness;
private String msmeNumber;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository;
import com.kifi.api.entity.UserConsent;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
public interface UserConsentRepository extends ReactiveCrudRepository<UserConsent, Long> {
Mono<UserConsent> findByUserIdAndConsentTypeAndPolicyVersion(Long userId, String consentType, String policyVersion);
}

View File

@@ -11,4 +11,7 @@ import reactor.core.publisher.Mono;
public interface UserRepository extends R2dbcRepository<User, Long> {
Mono<User> findByEmail(String email);
Flux<User> findByEmailContainingIgnoreCase(String email);
Mono<User> findByUsername(String username);
Mono<User> findByEmailOrUsername(String email, String username);
}

View File

@@ -104,7 +104,7 @@ public class AuthService {
String email = decryptField(request.getEmail());
String password = decryptField(request.getPassword());
return userRepository.findByEmail(email)
return userRepository.findByEmailOrUsername(email, email)
.switchIfEmpty(Mono.error(new RuntimeException("Invalid email or password")))
.flatMap(user -> {
if (!user.getEnabled()) {

View File

@@ -41,7 +41,7 @@ public class CryptoService {
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
return new String(decryptedBytes, "UTF-8");
} catch (Exception e) {
log.error("Failed to decrypt RSA payload", e);
log.warn("Failed to decrypt RSA payload: {}", e.getMessage());
throw new RuntimeException("Failed to decrypt payload");
}
}

View File

@@ -0,0 +1,114 @@
package com.kifi.api.service;
import com.kifi.api.dto.SetupRequestDTO;
import com.kifi.api.entity.User;
import com.kifi.api.entity.UserConsent;
import com.kifi.api.entity.business.BusinessProfile;
import com.kifi.api.repository.UserConsentRepository;
import com.kifi.api.repository.UserRepository;
import com.kifi.api.repository.business.BusinessProfileRepository;
import com.kifi.api.util.CryptoUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class SetupService {
private final UserRepository userRepository;
private final BusinessProfileRepository businessProfileRepository;
private final UserConsentRepository userConsentRepository;
private final CryptoUtils cryptoUtils;
public Mono<java.util.Map<String, String>> getSetupStatus(Long userId) {
return userRepository.findById(userId)
.map(u -> {
java.util.Map<String, String> map = new java.util.HashMap<>();
map.put("status", u.getSetupStatus() != null ? u.getSetupStatus() : "NOT_STARTED");
map.put("profileType", u.getProfileType() != null ? u.getProfileType() : "INDIVIDUAL");
map.put("name", u.getName() != null ? u.getName() : "");
map.put("email", u.getEmail() != null ? u.getEmail() : "");
return map;
})
.defaultIfEmpty(java.util.Map.of("status", "NOT_STARTED", "profileType", "INDIVIDUAL"));
}
public Mono<Boolean> isUsernameAvailable(String username) {
return userRepository.findByUsername(username)
.hasElement()
.map(exists -> !exists);
}
@Transactional
public Mono<String> completeSetup(Long userId, SetupRequestDTO request, String ipAddress, String userAgent) {
return userRepository.findById(userId)
.switchIfEmpty(Mono.error(new RuntimeException("User not found")))
.flatMap(user -> {
user.setUsername(request.getUsername());
user.setName(request.getName());
user.setMobileNumber(cryptoUtils.encrypt(request.getMobileNumber()));
user.setProfileType(request.getAccountType());
user.setSetupStatus("COMPLETED");
user.setSetupCompletedAt(LocalDateTime.now());
user.setUpdatedAt(LocalDateTime.now());
Mono<User> userSaveMono = userRepository.save(user);
Mono<BusinessProfile> businessProfileMono;
if ("BUSINESS".equalsIgnoreCase(request.getAccountType())) {
businessProfileMono = businessProfileRepository.findByUserId(userId)
.defaultIfEmpty(BusinessProfile.builder().userId(userId).build())
.flatMap(bp -> {
bp.setBusinessName(request.getBusinessName() != null ? request.getBusinessName() : request.getName());
bp.setNatureOfBusiness(request.getNatureOfBusiness());
bp.setAddress(request.getAddress());
bp.setStateId(request.getStateId());
bp.setEmailId(request.getEmailId());
bp.setGstin(cryptoUtils.encrypt(request.getGstin()));
bp.setPanNumber(cryptoUtils.encrypt(request.getPanNumber()));
bp.setMsmeNumber(cryptoUtils.encrypt(request.getMsmeNumber()));
bp.setCreatedAt(bp.getCreatedAt() == null ? LocalDateTime.now() : bp.getCreatedAt());
bp.setUpdatedAt(LocalDateTime.now());
return businessProfileRepository.save(bp);
});
} else {
businessProfileMono = Mono.just(new BusinessProfile());
}
Mono<UserConsent> termsConsent = userConsentRepository.save(
UserConsent.builder()
.userId(userId)
.consentType("TERMS_AND_CONDITIONS")
.policyVersion("1.0")
.accepted(request.getTermsAccepted())
.acceptedAt(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(userAgent)
.createdAt(LocalDateTime.now())
.build()
);
Mono<UserConsent> privacyConsent = userConsentRepository.save(
UserConsent.builder()
.userId(userId)
.consentType("PRIVACY_POLICY")
.policyVersion("1.0")
.accepted(request.getPrivacyAccepted())
.acceptedAt(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(userAgent)
.createdAt(LocalDateTime.now())
.build()
);
return Mono.zip(userSaveMono, businessProfileMono, termsConsent, privacyConsent)
.map(tuple -> "Setup completed successfully");
});
}
}

View File

@@ -0,0 +1,46 @@
package com.kifi.api.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
@Component
public class CryptoUtils {
private final String secret;
public CryptoUtils(@Value("${app.encryption.secret:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}") String secret) {
// Ensure the secret is at least 16 bytes for AES
this.secret = secret.substring(0, 16);
}
public String encrypt(String plainText) {
if (plainText == null) return null;
try {
SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (Exception e) {
throw new RuntimeException("Error encrypting data", e);
}
}
public String decrypt(String encryptedText) {
if (encryptedText == null) return null;
try {
SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
return new String(decryptedBytes);
} catch (Exception e) {
// Might not be encrypted or key changed, return original or throw. For safety, return as is if decoding fails (e.g., existing unencrypted data).
return encryptedText;
}
}
}

View File

@@ -15,7 +15,7 @@ spring:
sql:
init:
mode: never
mode: always
schema-locations: classpath:schema.sql
data:

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Privacy Policy</title>
<style>body { font-family: sans-serif; padding: 20px; line-height: 1.6; color: #333; }</style>
</head>
<body>
<h1>Privacy Policy</h1>
<p>Last updated: August 25, 2026</p>
<h2>1. Information Collected</h2>
<p>We collect personal information such as your name, username, and email. We may optionally collect your mobile number. For business accounts, we collect business names, addresses, tax identifiers (PAN, GSTIN), and MSME details.</p>
<h2>2. Purpose of Processing</h2>
<p>We use this information to provide the Kifi service, facilitate accounting and invoice generation, and manage your user account.</p>
<h2>3. Encryption and Security</h2>
<p>Sensitive Personally Identifiable Information (PII) such as Mobile Numbers, PAN, GSTIN, and MSME numbers are encrypted at rest in our database. We mask these identifiers in the application interface unless legally required (e.g., on an invoice).</p>
<h2>4. Data Storage and Sharing</h2>
<p>Your data is securely stored. We do not sell your personal information. We may share data with third-party service providers acting on our behalf, or when legally compelled.</p>
<h2>5. User Rights</h2>
<p>You have the right to access, update, or request deletion of your personal data through the application settings or by contacting our support.</p>
<h2>6. Consent</h2>
<p>By completing the account setup, you explicitly consent to the collection and processing of your data as described in this policy.</p>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Terms and Conditions</title>
<style>body { font-family: sans-serif; padding: 20px; line-height: 1.6; color: #333; }</style>
</head>
<body>
<h1>Terms and Conditions</h1>
<p>Last updated: August 25, 2026</p>
<h2>1. Application Usage</h2>
<p>By using the Kifi application, you agree to comply with these terms. Kifi is an enterprise-grade financial and project management tool.</p>
<h2>2. User Account Responsibilities</h2>
<p>You are responsible for maintaining the confidentiality of your account credentials. The accuracy of the information provided during account setup, including business details, is your responsibility.</p>
<h2>3. Financial Information Disclaimer</h2>
<p>Kifi provides personal and business accounting features. We do not provide financial, legal, or tax advice. You should consult a professional for your specific accounting and tax obligations.</p>
<h2>4. Prohibited Use</h2>
<p>You may not use Kifi for illegal activities, nor may you compromise the security or integrity of the platform.</p>
<h2>5. Limitation of Liability</h2>
<p>We provide the service "as is". We are not liable for direct, indirect, incidental, or consequential damages resulting from your use of the service or any data loss.</p>
<h2>6. Changes and Termination</h2>
<p>We reserve the right to suspend or terminate accounts that violate these terms. We may modify these terms at any time; continued use constitutes acceptance.</p>
</body>
</html>

View File

@@ -398,3 +398,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ACCOUNT SETUP FLOW MIGRATIONS --
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS mobile_number VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_status VARCHAR(50) DEFAULT 'NOT_STARTED';
ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_completed_at TIMESTAMP;
ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS nature_of_business VARCHAR(50);
ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS msme_number VARCHAR(255);
CREATE TABLE IF NOT EXISTS user_consents (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
consent_type VARCHAR(100) NOT NULL,
policy_version VARCHAR(50) NOT NULL,
accepted BOOLEAN DEFAULT TRUE,
accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(100),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);