Inventory Management | Customer Management | Invoice Management Done | Commodity Rate Sync Done

This commit is contained in:
2026-08-20 20:35:41 +05:30
parent ba9a9fd8a4
commit 5f620022f3
101 changed files with 9091 additions and 240 deletions

View File

@@ -9,6 +9,10 @@ public class WebClientConfig {
@Bean
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
return WebClient
.builder().codecs(configurer ->
configurer.defaultCodecs()
.maxInMemorySize(10 * 1024 * 1024)
);
}
}

View File

@@ -28,13 +28,13 @@ public class WalletController {
@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());
return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate());
}
@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());
return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate());
}
@DeleteMapping("/{walletId}")
@@ -95,6 +95,11 @@ public class WalletController {
private String color;
private String currency;
private java.math.BigDecimal initialBalance;
private String subNature;
private java.math.BigDecimal creditLimit;
private java.math.BigDecimal fixedAmount;
private String paymentCycle;
private Integer cycleDate;
}
@Data

View File

@@ -1,6 +1,7 @@
package com.kifi.api.controller.business;
import com.kifi.api.entity.business.BusinessProfile;
import com.kifi.api.entity.business.BusinessFeature;
import com.kifi.api.service.business.BusinessService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -29,4 +30,18 @@ public class BusinessController {
return businessService.saveProfile(userId, profile)
.map(ResponseEntity::ok);
}
@GetMapping("/features")
public Mono<ResponseEntity<BusinessFeature>> getFeatures(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return businessService.getFeaturesByUserId(userId)
.map(ResponseEntity::ok);
}
@PostMapping("/features")
public Mono<ResponseEntity<BusinessFeature>> saveFeatures(Authentication authentication, @RequestBody BusinessFeature feature) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return businessService.saveFeatures(userId, feature)
.map(ResponseEntity::ok);
}
}

View File

@@ -0,0 +1,21 @@
package com.kifi.api.controller.business;
import com.kifi.api.entity.business.IndianState;
import com.kifi.api.repository.business.IndianStateRepository;
import lombok.RequiredArgsConstructor;
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.Flux;
@RestController
@RequestMapping("/api/kifi-v2/master")
@RequiredArgsConstructor
public class MasterDataController {
private final IndianStateRepository indianStateRepository;
@GetMapping("/states")
public Flux<IndianState> getIndianStates() {
return indianStateRepository.findAllByOrderByNameAsc();
}
}

View File

@@ -0,0 +1,94 @@
package com.kifi.api.controller.customer;
import com.kifi.api.entity.customer.Customer;
import com.kifi.api.service.customer.CustomerService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.core.io.buffer.DataBufferUtils;
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.Base64;
@RestController
@RequestMapping("/api/kifi-v2/customers")
@RequiredArgsConstructor
public class CustomerController {
private final CustomerService customerService;
@GetMapping
public Flux<Customer> getCustomers(
Authentication authentication,
@RequestParam(required = false) String search) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return customerService.getCustomers(userId, search);
}
@GetMapping("/{id}")
public Mono<Customer> getCustomerById(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return customerService.getCustomerById(id, userId);
}
@PostMapping
public Mono<Customer> createCustomer(
Authentication authentication,
@RequestBody Customer customer) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return customerService.createCustomer(userId, customer);
}
@PutMapping("/{id}")
public Mono<Customer> updateCustomer(
@PathVariable Long id,
Authentication authentication,
@RequestBody Customer customer) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return customerService.updateCustomer(id, userId, customer);
}
@DeleteMapping("/{id}")
public Mono<Void> deleteCustomer(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return customerService.deleteCustomer(id, userId);
}
@PostMapping(value = "/{id}/photo", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<Customer> uploadPhoto(
@PathVariable Long id,
Authentication authentication,
@RequestPart("file") FilePart filePart) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return DataBufferUtils.join(filePart.content())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
String base64Content = Base64.getEncoder().encodeToString(bytes);
String contentType = filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "image/jpeg";
return customerService.uploadPhoto(id, userId, contentType, base64Content);
});
}
@GetMapping("/{id}/photo/content")
public Mono<ResponseEntity<byte[]>> downloadPhoto(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return customerService.downloadPhoto(id, userId)
.map(base64Content -> {
byte[] decodedBytes = Base64.getDecoder().decode(base64Content);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "image/jpeg")
.body(decodedBytes);
});
}
}

View File

@@ -0,0 +1,45 @@
package com.kifi.api.controller.inventory;
import com.kifi.api.entity.inventory.ProductBom;
import com.kifi.api.service.inventory.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2/inventory/products")
@RequiredArgsConstructor
public class ProductBomController {
private final ProductService productService;
@GetMapping("/{id}/bom")
public Flux<ProductBom> getProductBom(Authentication authentication, @PathVariable Long id) {
Long userId = Long.valueOf(authentication.getDetails().toString());
// Validation could be added to ensure the user owns the parent product
return productService.getProductBom(id);
}
@PostMapping("/{id}/bom")
public Mono<ResponseEntity<ProductBom>> addOrUpdateBomItem(
Authentication authentication,
@PathVariable Long id,
@RequestBody ProductBom bomItem) {
Long userId = Long.valueOf(authentication.getDetails().toString());
bomItem.setParentProductId(id);
return productService.addOrUpdateBomItem(userId, bomItem)
.map(ResponseEntity::ok);
}
@DeleteMapping("/bom/{bomId}")
public Mono<ResponseEntity<Void>> deleteBomItem(
Authentication authentication,
@PathVariable Long bomId) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return productService.deleteBomItem(userId, bomId)
.then(Mono.just(ResponseEntity.noContent().<Void>build()));
}
}

View File

@@ -2,6 +2,9 @@ package com.kifi.api.controller.inventory;
import com.kifi.api.entity.inventory.ProductCategory;
import com.kifi.api.repository.inventory.ProductCategoryRepository;
import com.kifi.api.entity.inventory.CategoryRateHistory;
import com.kifi.api.repository.inventory.CategoryRateHistoryRepository;
import com.kifi.api.service.inventory.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -9,13 +12,18 @@ import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.Map;
@RestController
@RequestMapping("/api/kifi-v2/inventory/categories")
@RequiredArgsConstructor
public class ProductCategoryController {
private final ProductCategoryRepository categoryRepository;
private final CategoryRateHistoryRepository rateHistoryRepository;
private final ProductService productService;
@GetMapping
public Flux<ProductCategory> getCategories(Authentication authentication) {
@@ -36,13 +44,55 @@ public class ProductCategoryController {
public Mono<ResponseEntity<ProductCategory>> updateCategory(@PathVariable Long id, @RequestBody ProductCategory category) {
return categoryRepository.findById(id)
.flatMap(existing -> {
boolean rateChanged = category.getDailyRate() != null && !category.getDailyRate().equals(existing.getDailyRate());
existing.setName(category.getName());
existing.setParentCategoryId(category.getParentCategoryId());
existing.setIsCommodity(category.getIsCommodity());
existing.setDailyRate(category.getDailyRate());
return categoryRepository.save(existing);
Mono<ProductCategory> saveMono = categoryRepository.save(existing);
if (rateChanged && category.getDailyRate() != null) {
return saveMono.flatMap(saved ->
rateHistoryRepository.findByCategoryIdAndDate(id, LocalDate.now())
.defaultIfEmpty(CategoryRateHistory.builder()
.categoryId(id)
.date(LocalDate.now())
.createdAt(LocalDateTime.now())
.build())
.flatMap(history -> {
history.setRate(BigDecimal.valueOf(category.getDailyRate()));
history.setUpdatedAt(LocalDateTime.now());
return rateHistoryRepository.save(history);
})
.thenReturn(saved)
);
}
return saveMono;
})
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@GetMapping("/{id}/rate-history")
public Flux<CategoryRateHistory> getRateHistory(@PathVariable Long id) {
return rateHistoryRepository.findRecentHistoryByCategoryId(id);
}
@PostMapping("/{id}/sync-rates")
public Mono<ResponseEntity<Map<String, Object>>> syncRates(@PathVariable Long id) {
return categoryRepository.findById(id)
.flatMap(category ->
productService.syncCategoryRates(id, category.getDailyRate())
.count()
.map(count -> {
Map<String, Object> response = new java.util.HashMap<>();
response.put("success", true);
response.put("syncedCount", count);
response.put("message", "Successfully synced rates for " + count + " products.");
return ResponseEntity.ok(response);
})
)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
}

View File

@@ -23,11 +23,13 @@ public class ProductController {
@GetMapping
public Flux<Product> getProducts(
Authentication authentication,
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return productService.getProductsByUserId(userId, page, size);
return productService.getProductsByUserId(userId, search, page, size)
.doOnNext(p -> System.out.println("Returning product: " + p.getName() + ", currentStock: " + p.getCurrentStock()));
}
@PostMapping
@@ -78,4 +80,24 @@ public class ProductController {
public Mono<Void> deleteImage(@PathVariable Long imageId) {
return productService.deleteProductImage(imageId);
}
@PostMapping("/{id}/movements")
public Mono<ResponseEntity<com.kifi.api.entity.inventory.InventoryMovement>> adjustStock(
Authentication authentication,
@PathVariable Long id,
@RequestBody com.kifi.api.entity.inventory.InventoryMovement movement
) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return productService.adjustStock(userId, id, movement)
.map(ResponseEntity::ok);
}
@GetMapping("/{id}/movements")
public Flux<com.kifi.api.entity.inventory.InventoryMovement> getStockLedger(
Authentication authentication,
@PathVariable Long id
) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return productService.getProductStockLedger(userId, id);
}
}

View File

@@ -0,0 +1,51 @@
package com.kifi.api.controller.inventory;
import com.kifi.api.entity.inventory.UnitOfMeasure;
import com.kifi.api.service.inventory.UnitOfMeasureService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2/inventory/uom")
@RequiredArgsConstructor
public class UnitOfMeasureController {
private final UnitOfMeasureService uomService;
@GetMapping
public Flux<UnitOfMeasure> getUoms(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return uomService.getUomsByUserId(userId);
}
@GetMapping("/{id}")
public Mono<UnitOfMeasure> getUomById(@PathVariable Long id, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return uomService.getUomById(id, userId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<UnitOfMeasure> createUom(@RequestBody UnitOfMeasure uom, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
uom.setUserId(userId);
return uomService.createUom(uom);
}
@PutMapping("/{id}")
public Mono<UnitOfMeasure> updateUom(@PathVariable Long id, @RequestBody UnitOfMeasure uom, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return uomService.updateUom(id, uom, userId);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteUom(@PathVariable Long id, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return uomService.deleteUom(id, userId);
}
}

View File

@@ -0,0 +1,64 @@
package com.kifi.api.controller.invoice;
import com.kifi.api.entity.invoice.Invoice;
import com.kifi.api.service.invoice.InvoiceService;
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;
@RestController
@RequestMapping("/api/kifi-v2/invoices")
@RequiredArgsConstructor
public class InvoiceController {
private final InvoiceService invoiceService;
@GetMapping
public Flux<Invoice> getInvoices(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return invoiceService.getInvoices(userId);
}
@GetMapping("/{id}")
public Mono<Invoice> getInvoiceById(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return invoiceService.getInvoiceById(id, userId);
}
@PostMapping
public Mono<Invoice> createInvoice(
Authentication authentication,
@RequestBody Invoice invoice) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return invoiceService.createInvoice(userId, invoice);
}
@PutMapping("/{id}/finalize")
public Mono<Invoice> finalizeInvoice(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return invoiceService.finalizeInvoice(id, userId);
}
@PostMapping("/{id}/payments")
public Mono<com.kifi.api.entity.invoice.InvoicePayment> addPayment(
@PathVariable Long id,
Authentication authentication,
@RequestBody com.kifi.api.entity.invoice.InvoicePayment payment) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return invoiceService.addPaymentToInvoice(id, userId, payment);
}
@GetMapping("/{id}/payments")
public Flux<com.kifi.api.entity.invoice.InvoicePayment> getPayments(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return invoiceService.getPaymentsForInvoice(id, userId);
}
}

View File

@@ -18,5 +18,10 @@ public class Wallet {
private String currency;
private String icon;
private String color;
private String subNature;
private java.math.BigDecimal creditLimit;
private java.math.BigDecimal fixedAmount;
private String paymentCycle;
private Integer cycleDate;
private LocalDateTime createdAt;
}

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@@ -21,5 +22,16 @@ public class BusinessFeature {
private Boolean inventoryManagement;
private Boolean salesManagement;
private Boolean multiLocation;
@Column("bom_reduction_strategy")
private String bomReductionStrategy; // "COMPONENTS_ONLY" or "PARENT_AND_COMPONENTS"
@Column("stock_deduction_on_invoice")
private Boolean stockDeductionOnInvoice;
@Column("barcode_source")
private String barcodeSource;
@Column("created_at")
private LocalDateTime createdAt;
}

View File

@@ -23,6 +23,13 @@ public class BusinessProfile {
private String taxNumber;
private String currency;
private Boolean taxIncludedInPrice;
private String address;
private Long stateId;
private String contactPerson;
private String contactNumber;
private String emailId;
private String panNumber;
private String gstin;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.kifi.api.entity.business;
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;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("indian_states")
public class IndianState {
@Id
private Long id;
private String name;
private String gstCode;
}

View File

@@ -0,0 +1,48 @@
package com.kifi.api.entity.customer;
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.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("customers")
public class Customer {
@Id
private Long id;
@Column("user_id")
private Long userId;
private String name;
private String email;
private String phone;
private String address;
private String gstin;
@Column("father_name")
private String fatherName;
private String gender;
private Integer age;
@Column("id_number")
private String idNumber;
@Column("state_id")
private Integer stateId;
@Column("photo_url")
private String photoUrl;
@Column("created_at")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,27 @@
package com.kifi.api.entity.inventory;
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("category_rate_history")
public class CategoryRateHistory {
@Id
private Long id;
private Long categoryId;
private BigDecimal rate;
private LocalDate date;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -23,4 +23,7 @@ public class InventoryMovement {
private Long referenceTransactionId;
private String notes;
private LocalDateTime createdAt;
@org.springframework.data.annotation.Transient
private java.util.List<InventoryMovementItem> items;
}

View File

@@ -49,4 +49,7 @@ public class Product {
@Transient
private List<ProductImage> images;
@Transient
private BigDecimal currentStock;
}

View File

@@ -0,0 +1,91 @@
package com.kifi.api.entity.invoice;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Transient;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("invoices")
public class Invoice {
@Id
private Long id;
@Column("user_id")
private Long userId;
@Column("customer_id")
private Long customerId;
@Column("invoice_number")
private String invoiceNumber;
@Column("issue_date")
private LocalDate issueDate;
@Column("due_date")
private LocalDate dueDate;
private BigDecimal subtotal;
@Column("tax_total")
private BigDecimal taxTotal;
@Column("discount_total")
private BigDecimal discountTotal;
@Column("total_amount")
private BigDecimal totalAmount;
private String status; // DRAFT, SENT, PAID, PARTIAL, OVERDUE, CANCELLED
private String notes;
@Column("amount_paid")
private BigDecimal amountPaid;
@Column("next_payment_date")
private LocalDate nextPaymentDate;
// EMI fields
@Column("is_emi")
private Boolean isEmi;
@Column("emi_amount")
private BigDecimal emiAmount;
@Column("emi_cycle")
private String emiCycle; // MONTHLY, WEEKLY
@Column("emi_start_date")
private LocalDate emiStartDate;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
@Transient
private List<InvoiceItem> items;
@Transient
private String paymentMethod;
@Transient
private Long paymentWalletId;
@Transient
private List<InvoicePayment> payments;
}

View File

@@ -0,0 +1,47 @@
package com.kifi.api.entity.invoice;
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.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("invoice_items")
public class InvoiceItem {
@Id
private Long id;
@Column("invoice_id")
private Long invoiceId;
@Column("product_id")
private Long productId;
private String description;
private BigDecimal quantity;
@Column("unit_price")
private BigDecimal unitPrice;
@Column("tax_rate")
private BigDecimal taxRate;
private BigDecimal discount;
@Column("making_charge")
private BigDecimal makingCharge;
@Column("other_charges")
private BigDecimal otherCharges;
private BigDecimal total;
}

View File

@@ -0,0 +1,46 @@
package com.kifi.api.entity.invoice;
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.Column;
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("invoice_payments")
public class InvoicePayment {
@Id
private Long id;
@Column("invoice_id")
private Long invoiceId;
@Column("transaction_id")
private Long transactionId;
@Column("wallet_id")
private Long walletId;
private BigDecimal amount;
@Column("payment_date")
private LocalDate paymentDate;
@Column("emi_installment_number")
private Integer emiInstallmentNumber;
@Column("payment_method")
private String paymentMethod;
@Column("created_at")
private LocalDateTime createdAt;
}

View File

@@ -8,7 +8,7 @@ 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")
@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, t.id DESC")
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
}

View File

@@ -53,7 +53,7 @@ public class TransactionRepositoryImpl implements TransactionRepositoryCustom {
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 dataQueryStr = "SELECT t.* " + baseQuery.toString() + " ORDER BY t.date DESC, t.id DESC LIMIT " + pageable.getPageSize() + " OFFSET " + pageable.getOffset();
String countQueryStr = "SELECT COUNT(t.id) " + baseQuery.toString();
DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId);

View File

@@ -0,0 +1,11 @@
package com.kifi.api.repository.business;
import com.kifi.api.entity.business.IndianState;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
@Repository
public interface IndianStateRepository extends ReactiveCrudRepository<IndianState, Long> {
Flux<IndianState> findAllByOrderByNameAsc();
}

View File

@@ -0,0 +1,10 @@
package com.kifi.api.repository.customer;
import com.kifi.api.entity.customer.Customer;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface CustomerRepository extends ReactiveCrudRepository<Customer, Long> {
Flux<Customer> findByUserId(Long userId);
Flux<Customer> findByUserIdAndNameContainingIgnoreCase(Long userId, String name);
}

View File

@@ -0,0 +1,16 @@
package com.kifi.api.repository.inventory;
import com.kifi.api.entity.inventory.CategoryRateHistory;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
public interface CategoryRateHistoryRepository extends ReactiveCrudRepository<CategoryRateHistory, Long> {
Flux<CategoryRateHistory> findByCategoryIdOrderByDateDesc(Long categoryId);
Mono<CategoryRateHistory> findByCategoryIdAndDate(Long categoryId, LocalDate date);
@Query("SELECT * FROM category_rate_history WHERE category_id = :categoryId ORDER BY date DESC LIMIT 30")
Flux<CategoryRateHistory> findRecentHistoryByCategoryId(Long categoryId);
}

View File

@@ -8,4 +8,5 @@ import reactor.core.publisher.Flux;
public interface InventoryBalanceRepository extends ReactiveCrudRepository<InventoryBalance, Long> {
Mono<InventoryBalance> findByProductIdAndLocationId(Long productId, Long locationId);
Flux<InventoryBalance> findByLocationId(Long locationId);
Flux<InventoryBalance> findByProductId(Long productId);
}

View File

@@ -7,4 +7,9 @@ import reactor.core.publisher.Flux;
public interface ProductRepository extends ReactiveCrudRepository<Product, Long> {
Flux<Product> findByUserId(Long userId, Pageable pageable);
@org.springframework.data.r2dbc.repository.Query("SELECT * FROM products WHERE user_id = :userId AND (:keyword IS NULL OR :keyword = '' OR LOWER(name) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(sku) LIKE LOWER(CONCAT('%', :keyword, '%'))) OFFSET :offset LIMIT :limit")
Flux<Product> searchProducts(Long userId, String keyword, long offset, int limit);
Flux<Product> findByCategoryId(Long categoryId);
}

View File

@@ -0,0 +1,11 @@
package com.kifi.api.repository.invoice;
import com.kifi.api.entity.invoice.InvoiceItem;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface InvoiceItemRepository extends ReactiveCrudRepository<InvoiceItem, Long> {
Flux<InvoiceItem> findByInvoiceId(Long invoiceId);
Mono<Void> deleteByInvoiceId(Long invoiceId);
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository.invoice;
import com.kifi.api.entity.invoice.InvoicePayment;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface InvoicePaymentRepository extends ReactiveCrudRepository<InvoicePayment, Long> {
Flux<InvoicePayment> findByInvoiceId(Long invoiceId);
}

View File

@@ -0,0 +1,12 @@
package com.kifi.api.repository.invoice;
import com.kifi.api.entity.invoice.Invoice;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface InvoiceRepository extends ReactiveCrudRepository<Invoice, Long> {
Flux<Invoice> findByUserId(Long userId);
Flux<Invoice> findByUserIdAndCustomerId(Long userId, Long customerId);
Mono<Invoice> findByUserIdAndInvoiceNumber(Long userId, String invoiceNumber);
}

View File

@@ -11,10 +11,10 @@ public class MinioServiceClient {
private final WebClient webClient;
@Value("${minio.service.url:http://minio-service:1500}")
@Value("${spring.minio.service.url:http://minio-service:1500}")
private String minioServiceUrl;
@Value("${minio.service.bucket:kifi}")
@Value("${spring.minio.service.bucket:kifi}")
private String bucketName;
public MinioServiceClient(WebClient.Builder webClientBuilder) {

View File

@@ -29,7 +29,7 @@ public class WalletService {
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) {
public Mono<Wallet> createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) {
Wallet wallet = new Wallet();
wallet.setOwnerId(ownerId);
wallet.setName(name);
@@ -38,6 +38,11 @@ public class WalletService {
wallet.setCurrency(currency != null ? currency : "INR");
wallet.setIcon(icon);
wallet.setColor(color);
wallet.setSubNature(subNature);
wallet.setCreditLimit(creditLimit);
wallet.setFixedAmount(fixedAmount);
wallet.setPaymentCycle(paymentCycle);
wallet.setCycleDate(cycleDate);
wallet.setCreatedAt(LocalDateTime.now());
return walletRepository.save(wallet)
@@ -112,7 +117,7 @@ public class WalletService {
});
}
public Mono<Wallet> editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency) {
public Mono<Wallet> editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) {
return walletRepository.findById(walletId)
.filter(w -> w.getOwnerId().equals(ownerId))
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
@@ -122,6 +127,11 @@ public class WalletService {
if (icon != null) w.setIcon(icon);
if (color != null) w.setColor(color);
if (currency != null) w.setCurrency(currency);
if (subNature != null) w.setSubNature(subNature);
if (creditLimit != null) w.setCreditLimit(creditLimit);
if (fixedAmount != null) w.setFixedAmount(fixedAmount);
if (paymentCycle != null) w.setPaymentCycle(paymentCycle);
if (cycleDate != null) w.setCycleDate(cycleDate);
return walletRepository.save(w);
});
}

View File

@@ -1,7 +1,9 @@
package com.kifi.api.service.business;
import com.kifi.api.entity.business.BusinessProfile;
import com.kifi.api.entity.business.BusinessFeature;
import com.kifi.api.repository.business.BusinessProfileRepository;
import com.kifi.api.repository.business.BusinessFeatureRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@@ -12,10 +14,44 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor
public class BusinessService {
private final BusinessProfileRepository businessProfileRepository;
private final BusinessFeatureRepository businessFeatureRepository;
public Mono<BusinessProfile> getProfileByUserId(Long userId) {
return businessProfileRepository.findByUserId(userId);
}
public Mono<BusinessFeature> getFeaturesByUserId(Long userId) {
return businessFeatureRepository.findByUserId(userId)
.defaultIfEmpty(BusinessFeature.builder()
.userId(userId)
.inventoryManagement(false)
.salesManagement(false)
.multiLocation(false)
.bomReductionStrategy("COMPONENTS_ONLY")
.stockDeductionOnInvoice(true)
.createdAt(LocalDateTime.now())
.build());
}
public Mono<BusinessFeature> saveFeatures(Long userId, BusinessFeature feature) {
return businessFeatureRepository.findByUserId(userId)
.flatMap(existing -> {
if (feature.getInventoryManagement() != null) existing.setInventoryManagement(feature.getInventoryManagement());
if (feature.getSalesManagement() != null) existing.setSalesManagement(feature.getSalesManagement());
if (feature.getMultiLocation() != null) existing.setMultiLocation(feature.getMultiLocation());
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource());
return businessFeatureRepository.save(existing);
})
.switchIfEmpty(Mono.defer(() -> {
feature.setUserId(userId);
feature.setCreatedAt(LocalDateTime.now());
if (feature.getBomReductionStrategy() == null) feature.setBomReductionStrategy("COMPONENTS_ONLY");
if (feature.getStockDeductionOnInvoice() == null) feature.setStockDeductionOnInvoice(true);
return businessFeatureRepository.save(feature);
}));
}
public Mono<BusinessProfile> saveProfile(Long userId, BusinessProfile profile) {
return businessProfileRepository.findByUserId(userId)
@@ -23,6 +59,13 @@ public class BusinessService {
existing.setBusinessName(profile.getBusinessName());
existing.setIndustry(profile.getIndustry());
existing.setTaxNumber(profile.getTaxNumber());
existing.setAddress(profile.getAddress());
existing.setStateId(profile.getStateId());
existing.setContactPerson(profile.getContactPerson());
existing.setContactNumber(profile.getContactNumber());
existing.setEmailId(profile.getEmailId());
existing.setPanNumber(profile.getPanNumber());
existing.setGstin(profile.getGstin());
existing.setCurrency(profile.getCurrency() != null ? profile.getCurrency() : existing.getCurrency());
existing.setUpdatedAt(LocalDateTime.now());
return businessProfileRepository.save(existing);

View File

@@ -0,0 +1,84 @@
package com.kifi.api.service.customer;
import com.kifi.api.entity.customer.Customer;
import com.kifi.api.repository.customer.CustomerRepository;
import com.kifi.api.service.MinioServiceClient;
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 CustomerService {
private final CustomerRepository customerRepository;
private final MinioServiceClient minioServiceClient;
public Flux<Customer> getCustomers(Long userId, String search) {
if (search != null && !search.isEmpty()) {
return customerRepository.findByUserIdAndNameContainingIgnoreCase(userId, search);
}
return customerRepository.findByUserId(userId);
}
public Mono<Customer> getCustomerById(Long id, Long userId) {
return customerRepository.findById(id)
.filter(customer -> customer.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Customer not found or unauthorized")));
}
public Mono<Customer> createCustomer(Long userId, Customer customer) {
customer.setUserId(userId);
customer.setCreatedAt(LocalDateTime.now());
return customerRepository.save(customer);
}
public Mono<Customer> updateCustomer(Long id, Long userId, Customer customer) {
return getCustomerById(id, userId)
.flatMap(existing -> {
if (customer.getName() != null) existing.setName(customer.getName());
if (customer.getEmail() != null) existing.setEmail(customer.getEmail());
if (customer.getPhone() != null) existing.setPhone(customer.getPhone());
if (customer.getAddress() != null) existing.setAddress(customer.getAddress());
if (customer.getGstin() != null) existing.setGstin(customer.getGstin());
if (customer.getIdNumber() != null) existing.setIdNumber(customer.getIdNumber());
if (customer.getStateId() != null) existing.setStateId(customer.getStateId());
if (customer.getPhotoUrl() != null) existing.setPhotoUrl(customer.getPhotoUrl());
return customerRepository.save(existing);
});
}
public Mono<Void> deleteCustomer(Long id, Long userId) {
return getCustomerById(id, userId)
.flatMap(customerRepository::delete);
}
public Mono<Customer> uploadPhoto(Long id, Long userId, String contentType, String base64Content) {
return getCustomerById(id, userId)
.flatMap(customer -> {
String directoryPath = "users/" + userId + "/customers/" + id;
String fileName = "photo.jpg";
return minioServiceClient.uploadFile(directoryPath, contentType, fileName, base64Content)
.flatMap(response -> {
if (response.isSuccess()) {
customer.setPhotoUrl(response.getFilePath());
return customerRepository.save(customer);
} else {
return Mono.error(new RuntimeException("Failed to upload photo to Minio"));
}
});
});
}
public Mono<String> downloadPhoto(Long id, Long userId) {
return getCustomerById(id, userId)
.flatMap(customer -> {
if (customer.getPhotoUrl() == null) return Mono.empty();
return minioServiceClient.downloadFile("photo", customer.getPhotoUrl())
.map(com.kifi.api.service.MinioServiceClient.MinioDownloadResponse::getBase64Content);
});
}
}

View File

@@ -4,6 +4,17 @@ import com.kifi.api.entity.inventory.Product;
import com.kifi.api.entity.inventory.ProductImage;
import com.kifi.api.repository.inventory.ProductRepository;
import com.kifi.api.repository.inventory.ProductImageRepository;
import com.kifi.api.repository.inventory.InventoryBalanceRepository;
import com.kifi.api.repository.inventory.InventoryMovementRepository;
import com.kifi.api.repository.inventory.InventoryMovementItemRepository;
import com.kifi.api.repository.inventory.InventoryLocationRepository;
import com.kifi.api.repository.inventory.ProductBomRepository;
import com.kifi.api.repository.business.BusinessFeatureRepository;
import com.kifi.api.entity.inventory.InventoryBalance;
import com.kifi.api.entity.inventory.InventoryLocation;
import com.kifi.api.entity.inventory.InventoryMovement;
import com.kifi.api.entity.inventory.InventoryMovementItem;
import com.kifi.api.entity.inventory.ProductBom;
import com.kifi.api.service.MinioServiceClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -12,21 +23,42 @@ import reactor.core.publisher.Mono;
import org.springframework.data.domain.PageRequest;
import java.util.UUID;
import java.time.LocalDateTime;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final ProductImageRepository productImageRepository;
private final InventoryBalanceRepository inventoryBalanceRepository;
private final InventoryMovementRepository inventoryMovementRepository;
private final InventoryMovementItemRepository inventoryMovementItemRepository;
private final InventoryLocationRepository inventoryLocationRepository;
private final ProductBomRepository productBomRepository;
private final BusinessFeatureRepository businessFeatureRepository;
private final MinioServiceClient minioServiceClient;
public Flux<Product> getProductsByUserId(Long userId, int page, int size) {
return productRepository.findByUserId(userId, PageRequest.of(page, size))
public Flux<Product> getProductsByUserId(Long userId, String search, int page, int size) {
long offset = (long) page * size;
Flux<Product> productSource = (search != null && !search.trim().isEmpty())
? productRepository.searchProducts(userId, search, offset, size)
: productRepository.findByUserId(userId, PageRequest.of(page, size));
return productSource
.flatMap(product -> productImageRepository.findByProductId(product.getId()).collectList()
.map(images -> {
product.setImages(images);
return product;
})
)
.flatMap(product -> inventoryBalanceRepository.findByProductId(product.getId())
.map(InventoryBalance::getQuantity)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.map(totalStock -> {
product.setCurrentStock(totalStock);
return product;
})
.defaultIfEmpty(product)
);
}
@@ -48,6 +80,7 @@ public class ProductService {
existingProduct.setName(updatedProduct.getName());
existingProduct.setSku(updatedProduct.getSku());
existingProduct.setCategoryId(updatedProduct.getCategoryId());
existingProduct.setUomId(updatedProduct.getUomId());
existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice());
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
existingProduct.setGstRate(updatedProduct.getGstRate());
@@ -106,4 +139,191 @@ public class ProductService {
minioServiceClient.downloadFile(image.getContentType(), image.getFilePath())
);
}
public Flux<Product> syncCategoryRates(Long categoryId, Double dailyRate) {
if (dailyRate == null) return Flux.empty();
return productRepository.findByCategoryId(categoryId)
.filter(p -> Boolean.TRUE.equals(p.getAutoCalculatePrice()))
.flatMap(product -> {
double weight = product.getWeight() != null ? product.getWeight().doubleValue() : 0.0;
double baseQuantity = (weight > 0) ? weight : 1.0;
double wastage = product.getWastagePercentage() != null ? product.getWastagePercentage() : 0.0;
double purity = product.getPurityFactor() != null ? product.getPurityFactor() : 1.0;
double materialQuantity = baseQuantity + (baseQuantity * (wastage / 100.0));
double materialCost = materialQuantity * dailyRate * purity;
double makingCharges = product.getMakingCharges() != null ? product.getMakingCharges() : 0.0;
String makingType = product.getMakingChargesType() != null ? product.getMakingChargesType() : "FLAT";
double making = 0.0;
if ("FLAT".equals(makingType)) {
making = makingCharges;
} else if ("PER_UNIT".equals(makingType)) {
making = makingCharges * baseQuantity;
} else if ("PERCENTAGE".equals(makingType)) {
making = materialCost * (makingCharges / 100.0);
}
double newPrice = materialCost + making;
product.setSellingPrice(BigDecimal.valueOf(newPrice));
product.setUpdatedAt(LocalDateTime.now());
return productRepository.save(product);
});
}
public Mono<InventoryMovement> adjustStock(Long userId, Long productId, InventoryMovement movement) {
if (movement.getItems() == null || movement.getItems().isEmpty()) {
return Mono.error(new IllegalArgumentException("Movement items are required"));
}
movement.setUserId(userId);
movement.setCreatedAt(LocalDateTime.now());
Mono<Long> locationIdMono = movement.getLocationId() != null
? Mono.just(movement.getLocationId())
: inventoryLocationRepository.findByUserId(userId)
.next()
.map(InventoryLocation::getId)
.switchIfEmpty(inventoryLocationRepository.save(InventoryLocation.builder()
.userId(userId)
.name("Main Store")
.isPrimary(true)
.createdAt(LocalDateTime.now())
.build())
.map(InventoryLocation::getId));
return productRepository.findById(productId)
.filter(p -> p.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Product not found or access denied")))
.flatMap(product -> locationIdMono.flatMap(locId -> {
movement.setLocationId(locId);
return inventoryBalanceRepository.findByProductIdAndLocationId(productId, locId)
.defaultIfEmpty(InventoryBalance.builder()
.productId(productId)
.locationId(locId)
.quantity(BigDecimal.ZERO)
.lastUpdated(LocalDateTime.now())
.build());
}))
.flatMap(balance -> {
BigDecimal qtyChange = movement.getItems().get(0).getQuantity();
if ("REDUCTION".equals(movement.getType()) || "DAMAGE".equals(movement.getType())) {
qtyChange = qtyChange.negate();
}
final BigDecimal finalQtyChange = qtyChange;
return businessFeatureRepository.findByUserId(userId)
.map(f -> f.getBomReductionStrategy() != null ? f.getBomReductionStrategy() : "COMPONENTS_ONLY")
.defaultIfEmpty("COMPONENTS_ONLY")
.flatMap(strategy -> {
Mono<InventoryBalance> parentProcess = Mono.just(balance);
if (!"COMPONENTS_ONLY".equals(strategy) || finalQtyChange.compareTo(BigDecimal.ZERO) >= 0 || !"REDUCTION".equals(movement.getType())) {
balance.setQuantity(balance.getQuantity().add(finalQtyChange));
balance.setLastUpdated(LocalDateTime.now());
parentProcess = inventoryBalanceRepository.save(balance);
}
return parentProcess;
})
.flatMap(savedBalance -> inventoryMovementRepository.save(movement))
.flatMap(savedMovement -> {
InventoryMovementItem item = movement.getItems().get(0);
item.setMovementId(savedMovement.getId());
item.setProductId(productId);
item.setCreatedAt(LocalDateTime.now());
return inventoryMovementItemRepository.save(item)
.flatMap(savedItem -> {
java.util.List<InventoryMovementItem> items = new java.util.ArrayList<>();
items.add(savedItem);
savedMovement.setItems(items);
if (finalQtyChange.compareTo(BigDecimal.ZERO) < 0 && "REDUCTION".equals(movement.getType())) {
return reduceBomComponentsWithItems(userId, productId, savedMovement.getId(), movement.getLocationId(), finalQtyChange)
.map(componentItems -> {
savedMovement.getItems().addAll(componentItems);
return savedMovement;
});
}
return Mono.just(savedMovement);
});
});
});
}
private Mono<java.util.List<InventoryMovementItem>> reduceBomComponentsWithItems(Long userId, Long parentProductId, Long movementId, Long locationId, BigDecimal parentQtyChange) {
return productBomRepository.findByParentProductId(parentProductId)
.flatMap(bomItem -> {
BigDecimal componentQtyChange = bomItem.getQuantity().multiply(parentQtyChange);
return inventoryBalanceRepository.findByProductIdAndLocationId(bomItem.getComponentProductId(), locationId)
.defaultIfEmpty(InventoryBalance.builder()
.productId(bomItem.getComponentProductId())
.locationId(locationId)
.quantity(BigDecimal.ZERO)
.lastUpdated(LocalDateTime.now())
.build())
.flatMap(balance -> {
balance.setQuantity(balance.getQuantity().add(componentQtyChange));
balance.setLastUpdated(LocalDateTime.now());
return inventoryBalanceRepository.save(balance);
})
.flatMap(savedBalance -> {
InventoryMovementItem compItem = new InventoryMovementItem();
compItem.setMovementId(movementId);
compItem.setProductId(bomItem.getComponentProductId());
compItem.setQuantity(componentQtyChange.abs());
compItem.setCreatedAt(LocalDateTime.now());
return inventoryMovementItemRepository.save(compItem);
});
})
.collectList();
}
public Flux<InventoryMovement> getProductStockLedger(Long userId, Long productId) {
// Find all movement items for the given product, then fetch their parent movements
return inventoryMovementItemRepository.findAll()
.filter(item -> productId.equals(item.getProductId()))
.flatMap(item -> inventoryMovementRepository.findById(item.getMovementId())
.map(movement -> {
movement.setItems(java.util.Collections.singletonList(item));
return movement;
})
)
.filter(movement -> userId.equals(movement.getUserId()))
.sort((m1, m2) -> m2.getCreatedAt().compareTo(m1.getCreatedAt())); // Descending order
}
public Flux<ProductBom> getProductBom(Long parentProductId) {
return productBomRepository.findByParentProductId(parentProductId);
}
public Mono<ProductBom> addOrUpdateBomItem(Long userId, ProductBom bomItem) {
return productRepository.findById(bomItem.getParentProductId())
.filter(p -> p.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Parent product not found or access denied")))
.flatMap(p -> {
if (bomItem.getId() != null) {
return productBomRepository.findById(bomItem.getId())
.flatMap(existing -> {
existing.setComponentProductId(bomItem.getComponentProductId());
existing.setQuantity(bomItem.getQuantity());
return productBomRepository.save(existing);
});
} else {
bomItem.setCreatedAt(LocalDateTime.now());
return productBomRepository.save(bomItem);
}
});
}
public Mono<Void> deleteBomItem(Long userId, Long bomId) {
return productBomRepository.findById(bomId)
.flatMap(bom -> productRepository.findById(bom.getParentProductId())
.filter(p -> p.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Access denied")))
.flatMap(p -> productBomRepository.delete(bom)));
}
}

View File

@@ -0,0 +1,47 @@
package com.kifi.api.service.inventory;
import com.kifi.api.entity.inventory.UnitOfMeasure;
import com.kifi.api.repository.inventory.UnitOfMeasureRepository;
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 UnitOfMeasureService {
private final UnitOfMeasureRepository uomRepository;
public Flux<UnitOfMeasure> getUomsByUserId(Long userId) {
return uomRepository.findByUserId(userId);
}
public Mono<UnitOfMeasure> getUomById(Long id, Long userId) {
return uomRepository.findById(id)
.filter(uom -> uom.getUserId().equals(userId));
}
public Mono<UnitOfMeasure> createUom(UnitOfMeasure uom) {
uom.setCreatedAt(LocalDateTime.now());
return uomRepository.save(uom);
}
public Mono<UnitOfMeasure> updateUom(Long id, UnitOfMeasure updatedUom, Long userId) {
return uomRepository.findById(id)
.filter(uom -> uom.getUserId().equals(userId))
.flatMap(existingUom -> {
existingUom.setName(updatedUom.getName());
existingUom.setAbbreviation(updatedUom.getAbbreviation());
return uomRepository.save(existingUom);
});
}
public Mono<Void> deleteUom(Long id, Long userId) {
return uomRepository.findById(id)
.filter(uom -> uom.getUserId().equals(userId))
.flatMap(uomRepository::delete);
}
}

View File

@@ -0,0 +1,239 @@
package com.kifi.api.service.invoice;
import com.kifi.api.entity.invoice.Invoice;
import com.kifi.api.entity.invoice.InvoiceItem;
import com.kifi.api.entity.invoice.InvoicePayment;
import com.kifi.api.repository.business.BusinessFeatureRepository;
import com.kifi.api.repository.invoice.InvoiceItemRepository;
import com.kifi.api.repository.invoice.InvoicePaymentRepository;
import com.kifi.api.repository.invoice.InvoiceRepository;
import com.kifi.api.service.inventory.ProductService;
import com.kifi.api.entity.inventory.InventoryMovement;
import com.kifi.api.entity.inventory.InventoryMovementItem;
import com.kifi.api.entity.Transaction;
import com.kifi.api.repository.TransactionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceItemRepository invoiceItemRepository;
private final InvoicePaymentRepository invoicePaymentRepository;
private final BusinessFeatureRepository businessFeatureRepository;
private final ProductService productService;
private final com.kifi.api.service.TransactionService transactionService;
public Flux<Invoice> getInvoices(Long userId) {
return invoiceRepository.findByUserId(userId)
.flatMap(invoice -> Mono.zip(
invoiceItemRepository.findByInvoiceId(invoice.getId()).collectList(),
invoicePaymentRepository.findByInvoiceId(invoice.getId()).collectList()
).map(tuple -> {
invoice.setItems(tuple.getT1());
invoice.setPayments(tuple.getT2());
return invoice;
}));
}
public Mono<Invoice> getInvoiceById(Long id, Long userId) {
return invoiceRepository.findById(id)
.filter(inv -> inv.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Invoice not found or unauthorized")))
.flatMap(invoice -> invoiceItemRepository.findByInvoiceId(invoice.getId())
.collectList()
.map(items -> {
invoice.setItems(items);
return invoice;
}));
}
public Mono<Invoice> createInvoice(Long userId, Invoice invoice) {
invoice.setUserId(userId);
invoice.setCreatedAt(LocalDateTime.now());
invoice.setUpdatedAt(LocalDateTime.now());
if (invoice.getStatus() == null || "DRAFT".equals(invoice.getStatus())) {
java.math.BigDecimal amountPaid = invoice.getAmountPaid() != null ? invoice.getAmountPaid() : java.math.BigDecimal.ZERO;
java.math.BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO;
if (amountPaid.compareTo(total) >= 0 && total.compareTo(java.math.BigDecimal.ZERO) > 0) {
invoice.setStatus("PAID");
} else if (amountPaid.compareTo(java.math.BigDecimal.ZERO) > 0) {
invoice.setStatus("PARTIAL");
} else {
invoice.setStatus("DRAFT");
}
}
return invoiceRepository.save(invoice)
.flatMap(savedInvoice -> {
Mono<Invoice> itemsMono = Mono.just(savedInvoice);
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
itemsMono = Flux.fromIterable(invoice.getItems())
.flatMap(item -> {
item.setInvoiceId(savedInvoice.getId());
return invoiceItemRepository.save(item);
})
.collectList()
.map(savedItems -> {
savedInvoice.setItems(savedItems);
return savedInvoice;
});
}
return itemsMono;
})
.flatMap(savedInvoice -> {
if (!"DRAFT".equals(savedInvoice.getStatus())) {
return processStockDeduction(savedInvoice, userId);
}
return Mono.just(savedInvoice);
})
.flatMap(savedInvoice -> {
if (invoice.getAmountPaid() != null && invoice.getAmountPaid().compareTo(java.math.BigDecimal.ZERO) > 0) {
InvoicePayment payment = InvoicePayment.builder()
.invoiceId(savedInvoice.getId())
.amount(invoice.getAmountPaid())
.paymentDate(java.time.LocalDate.now())
.createdAt(LocalDateTime.now())
.paymentMethod(invoice.getPaymentMethod() != null ? invoice.getPaymentMethod() : "Cash")
.walletId(invoice.getPaymentWalletId())
.build();
return invoicePaymentRepository.save(payment).flatMap(savedPayment -> {
if (savedPayment.getWalletId() != null) {
Transaction transaction = Transaction.builder()
.userId(userId)
.toWalletId(savedPayment.getWalletId())
.type("INCOME")
.amount(savedPayment.getAmount())
.date(savedPayment.getPaymentDate())
.description("Initial payment for Invoice #" + savedInvoice.getInvoiceNumber())
.notes(savedPayment.getPaymentMethod())
.createdAt(LocalDateTime.now())
.build();
return transactionService.addTransaction(userId, transaction).thenReturn(savedInvoice);
}
return Mono.just(savedInvoice);
});
}
return Mono.just(savedInvoice);
});
}
public Mono<Invoice> processStockDeduction(Invoice invoice, Long userId) {
return businessFeatureRepository.findByUserId(userId)
.map(feature -> feature.getStockDeductionOnInvoice() != null ? feature.getStockDeductionOnInvoice() : true)
.defaultIfEmpty(true)
.flatMap(shouldDeduct -> {
if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) {
return Flux.fromIterable(invoice.getItems())
.concatMap(item -> {
if (item.getProductId() != null) {
InventoryMovement movement = InventoryMovement.builder()
.userId(userId)
.type("REDUCTION")
.notes("Sales Invoice " + invoice.getInvoiceNumber())
.createdAt(LocalDateTime.now())
.build();
InventoryMovementItem movementItem = new InventoryMovementItem();
movementItem.setProductId(item.getProductId());
movementItem.setQuantity(item.getQuantity());
movement.setItems(java.util.Collections.singletonList(movementItem));
return productService.adjustStock(userId, item.getProductId(), movement);
}
return Mono.just(item);
})
.then(Mono.just(invoice));
}
return Mono.just(invoice);
});
}
public Mono<Invoice> finalizeInvoice(Long invoiceId, Long userId) {
return getInvoiceById(invoiceId, userId)
.flatMap(invoice -> {
if (!"DRAFT".equals(invoice.getStatus())) {
return Mono.error(new RuntimeException("Only DRAFT invoices can be finalized."));
}
return processStockDeduction(invoice, userId)
.flatMap(inv -> {
inv.setStatus("FINALIZED");
inv.setUpdatedAt(LocalDateTime.now());
return invoiceRepository.save(inv);
});
});
}
public Mono<InvoicePayment> addPaymentToInvoice(Long invoiceId, Long userId, InvoicePayment payment) {
return getInvoiceById(invoiceId, userId)
.flatMap(invoice -> {
String oldStatus = invoice.getStatus();
payment.setInvoiceId(invoiceId);
if (payment.getPaymentDate() == null) {
payment.setPaymentDate(java.time.LocalDate.now());
}
payment.setCreatedAt(LocalDateTime.now());
return invoicePaymentRepository.save(payment)
.flatMap(savedPayment -> {
// Update invoice amount_paid and status
java.math.BigDecimal currentPaid = invoice.getAmountPaid() != null ? invoice.getAmountPaid() : java.math.BigDecimal.ZERO;
java.math.BigDecimal newPaid = currentPaid.add(savedPayment.getAmount());
invoice.setAmountPaid(newPaid);
java.math.BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO;
if (newPaid.compareTo(total) >= 0) {
invoice.setStatus("PAID");
} else if (newPaid.compareTo(java.math.BigDecimal.ZERO) > 0) {
invoice.setStatus("PARTIAL");
}
return invoiceRepository.save(invoice)
.flatMap(inv -> {
Mono<Invoice> processMono = Mono.just(inv);
if (("DRAFT".equals(oldStatus) || oldStatus == null) && !"DRAFT".equals(inv.getStatus())) {
// Fetch items first if not present
if (inv.getItems() == null || inv.getItems().isEmpty()) {
processMono = invoiceItemRepository.findByInvoiceId(inv.getId()).collectList().flatMap(items -> {
inv.setItems(items);
return processStockDeduction(inv, userId);
});
} else {
processMono = processStockDeduction(inv, userId);
}
}
return processMono.flatMap(processedInv -> {
if (savedPayment.getWalletId() != null) {
Transaction transaction = Transaction.builder()
.userId(userId)
.toWalletId(savedPayment.getWalletId())
.type("INCOME")
.amount(savedPayment.getAmount())
.date(savedPayment.getPaymentDate())
.description("Payment for Invoice #" + invoice.getInvoiceNumber())
.notes(savedPayment.getPaymentMethod())
.createdAt(LocalDateTime.now())
.build();
return transactionService.addTransaction(userId, transaction).thenReturn(savedPayment);
}
return Mono.just(savedPayment);
});
});
});
});
}
public Flux<InvoicePayment> getPaymentsForInvoice(Long invoiceId, Long userId) {
return getInvoiceById(invoiceId, userId)
.flatMapMany(invoice -> invoicePaymentRepository.findByInvoiceId(invoiceId));
}
}

View File

@@ -145,17 +145,48 @@ CREATE TABLE IF NOT EXISTS business_profiles (
tax_number VARCHAR(100),
currency VARCHAR(10) DEFAULT 'INR',
tax_included_in_price BOOLEAN DEFAULT FALSE,
address TEXT,
state_id INTEGER,
contact_person VARCHAR(100),
contact_number VARCHAR(20),
email_id VARCHAR(100),
pan_number VARCHAR(20),
gstin VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
CREATE TABLE IF NOT EXISTS indian_states (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
gst_code VARCHAR(2) NOT NULL
);
-- Seed Indian States if empty
INSERT INTO indian_states (name, gst_code) VALUES
('Jammu & Kashmir', '01'), ('Himachal Pradesh', '02'), ('Punjab', '03'),
('Chandigarh', '04'), ('Uttarakhand', '05'), ('Haryana', '06'),
('Delhi', '07'), ('Rajasthan', '08'), ('Uttar Pradesh', '09'),
('Bihar', '10'), ('Sikkim', '11'), ('Arunachal Pradesh', '12'),
('Nagaland', '13'), ('Manipur', '14'), ('Mizoram', '15'),
('Tripura', '16'), ('Meghalaya', '17'), ('Assam', '18'),
('West Bengal', '19'), ('Jharkhand', '20'), ('Odisha', '21'),
('Chhattisgarh', '22'), ('Madhya Pradesh', '23'), ('Gujarat', '24'),
('Daman & Diu', '25'), ('Dadra & Nagar Haveli and Daman & Diu', '26'),
('Maharashtra', '27'), ('Karnataka', '29'), ('Goa', '30'),
('Lakshadweep', '31'), ('Kerala', '32'), ('Tamil Nadu', '33'),
('Puducherry', '34'), ('Andaman & Nicobar Islands', '35'),
('Telangana', '36'), ('Andhra Pradesh', '37'), ('Ladakh', '38')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS business_features (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
inventory_management BOOLEAN DEFAULT FALSE,
sales_management BOOLEAN DEFAULT FALSE,
multi_location BOOLEAN DEFAULT FALSE,
bom_reduction_strategy VARCHAR(50) DEFAULT 'COMPONENTS_ONLY',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
@@ -172,6 +203,16 @@ CREATE TABLE IF NOT EXISTS product_categories (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS category_rate_history (
id SERIAL PRIMARY KEY,
category_id INTEGER REFERENCES product_categories(id) ON DELETE CASCADE,
rate DECIMAL(15, 2) NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category_id, date)
);
CREATE TABLE IF NOT EXISTS units_of_measure (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
@@ -263,3 +304,63 @@ CREATE TABLE IF NOT EXISTS inventory_balances (
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, location_id)
);
-- SALES & INVOICES (Phase 2)
ALTER TABLE business_features ADD COLUMN IF NOT EXISTS stock_deduction_on_invoice BOOLEAN DEFAULT TRUE;
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
address TEXT,
gstin VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS invoices (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
customer_id INTEGER REFERENCES customers(id),
invoice_number VARCHAR(100) NOT NULL,
issue_date DATE NOT NULL,
due_date DATE,
subtotal DECIMAL(15,2) NOT NULL,
tax_total DECIMAL(15,2) DEFAULT 0.0,
discount_total DECIMAL(15,2) DEFAULT 0.0,
total_amount DECIMAL(15,2) NOT NULL,
status VARCHAR(50) DEFAULT 'DRAFT', -- DRAFT, SENT, PAID, PARTIAL, OVERDUE, CANCELLED
notes TEXT,
-- EMI tracking fields
is_emi BOOLEAN DEFAULT FALSE,
emi_amount DECIMAL(15,2),
emi_cycle VARCHAR(20), -- MONTHLY, WEEKLY
emi_start_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS invoice_items (
id SERIAL PRIMARY KEY,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id),
description VARCHAR(255),
quantity DECIMAL(10,3) NOT NULL,
unit_price DECIMAL(15,2) NOT NULL,
tax_rate DECIMAL(5,2) DEFAULT 0.0,
discount DECIMAL(15,2) DEFAULT 0.0,
total DECIMAL(15,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS invoice_payments (
id SERIAL PRIMARY KEY,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE,
amount DECIMAL(15,2) NOT NULL,
payment_date DATE NOT NULL,
emi_installment_number INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);