= Напреден развој на апликацијата = == Трансакции == === executeTrade (точка за влез) === '''Опис:''' Методот `executeTrade` во `PortfolioService` е централната точка за влез која ја повикува `TradeRequestController` при одобрување на барање за тргување. Врши рутирање кон `buyStock` или `sellStock` врз основа на типот на трансакцијата. Целата операција е `@Transactional` - доколку кој било чекор не успее, се откажува целосно. {{{ @Transactional public void executeTrade(TradeRequest tr) { BigDecimal price = BigDecimal.valueOf(tr.getPricePerUnit()); Portfolio portfolio = tr.getPortfolio(); if ("BUY".equalsIgnoreCase(tr.getType())) { buyStock(portfolio.getId(), tr.getStockSymbol(), tr.getQuantity(), price); } else if ("SELL".equalsIgnoreCase(tr.getType())) { sellStock(portfolio.getId(), tr.getStockSymbol(), tr.getQuantity(), price); } else { throw new RuntimeException("Unknown trade type: " + tr.getType()); } } }}} === Купување на акција (buyStock) === '''Опис:''' Методот `buyStock` во `PortfolioService` извршува повеќе зависни операции: * Проверка дека портфолиото има доволно баланс за купување * Намалување на балансот на портфолиото за вкупната вредност на купените акции * Ажурирање на постоечко холдинг (или креирање на нов) со новата количина и пресметана weighted average цена * Зачувување на трансакцискиот запис Доколку кој било чекор не успее (на пр. акцијата не постои, портфолиото не е пронајдено, недоволен баланс), целата операција се откажува и балансот останува непроменет. {{{ @Transactional public void buyStock(Long portfolioId, String stockSymbol, int quantity, BigDecimal pricePerUnit) { Portfolio portfolio = portfolioRepository.findById(portfolioId) .orElseThrow(() -> new RuntimeException("Portfolio not found")); BigDecimal totalCost = pricePerUnit.multiply(BigDecimal.valueOf(quantity)); if (portfolio.getBalance().compareTo(totalCost) < 0) { throw new RuntimeException("Insufficient balance to buy stock"); } portfolio.setBalance(portfolio.getBalance().subtract(totalCost)); Stock stock = stockRepository.findBySymbol(stockSymbol) .orElseThrow(() -> new RuntimeException("Stock not found: " + stockSymbol)); PortfolioHolding holding = holdingRepository .findByPortfolioIdAndStock_Symbol(portfolioId, stockSymbol) .orElse(PortfolioHolding.builder() .portfolio(portfolio) .stock(stock) .quantity(0) .avgPrice(BigDecimal.ZERO) .build()); // Recalculate weighted average price BigDecimal currentTotalValue = holding.getAvgPrice().multiply(BigDecimal.valueOf(holding.getQuantity())); BigDecimal newTotalValue = currentTotalValue.add(totalCost); int newQuantity = holding.getQuantity() + quantity; BigDecimal newAvgPrice = newTotalValue.divide(BigDecimal.valueOf(newQuantity), RoundingMode.HALF_UP); holding.setQuantity(newQuantity); holding.setAvgPrice(newAvgPrice); holdingRepository.save(holding); portfolioRepository.save(portfolio); // Save transaction Transaction transaction = new Transaction(); transaction.setUser(portfolio.getUser()); transaction.setStock(stock); transaction.setType("BUY"); transaction.setQuantity(quantity); transaction.setPrice(pricePerUnit.doubleValue()); transaction.setTimestamp(LocalDateTime.now()); transactionRepository.save(transaction); } }}} === Продавање на акција (sellStock) === '''Опис:''' Методот `sellStock` во `PortfolioService`: * Проверка дека корисникот поседува доволно акции * Намалување на количината во холдингот (или бришење ако достигне 0) * Зголемување на балансот за приходот од продажбата * Зачувување на трансакцискиот запис {{{ @Transactional public void sellStock(Long portfolioId, String stockSymbol, int quantity, BigDecimal pricePerUnit) { Portfolio portfolio = portfolioRepository.findById(portfolioId) .orElseThrow(() -> new RuntimeException("Portfolio not found")); PortfolioHolding holding = holdingRepository .findByPortfolioIdAndStock_Symbol(portfolioId, stockSymbol) .orElseThrow(() -> new RuntimeException("Stock not found in portfolio")); if (holding.getQuantity() < quantity) { throw new RuntimeException("Not enough shares to sell"); } if (pricePerUnit == null) { // fallback: get latest price from stock Stock stock = stockRepository.findBySymbol(stockSymbol) .orElseThrow(() -> new RuntimeException("Stock not found: " + stockSymbol)); pricePerUnit = BigDecimal.valueOf(stock.getCurrentPrice()); } // gain from the sale made BigDecimal totalGain = pricePerUnit.multiply(BigDecimal.valueOf(quantity)); holding.setQuantity(holding.getQuantity() - quantity); // if holding reaches zero - remove it from database if (holding.getQuantity() == 0) { holdingRepository.delete(holding); } else { holdingRepository.save(holding); } portfolio.setBalance(portfolio.getBalance().add(totalGain)); portfolioRepository.save(portfolio); // Save transaction Stock stock = stockRepository.findBySymbol(stockSymbol) .orElseThrow(() -> new RuntimeException("Stock not found: " + stockSymbol)); Transaction transaction = new Transaction(); transaction.setUser(portfolio.getUser()); transaction.setStock(stock); transaction.setType("SELL"); transaction.setQuantity(quantity); transaction.setPrice(pricePerUnit.doubleValue()); transaction.setTimestamp(LocalDateTime.now()); transactionRepository.save(transaction); } }}} === Поврзување на Google OAuth акаунт (confirmLink) === '''Опис:''' Методот `confirmLink` во `AuthLinkController` поврзува Google OAuth акаунт со постоечки интерен акаунт. Операцијата вклучува: * Верификација и читање на pending OAuth токенот * Автентикација на интерните credentials на корисникот * Додавање на `GOOGLE` провајдер во множеството на auth провајдери на корисникот * Зачувување на корисникот * Бришење на pending токенот {{{ @PostMapping("/confirm") @Transactional public ResponseEntity confirmLink(@RequestBody Map body) { String pendingToken = body.get("pendingToken"); String usernameOrEmail = body.get("username"); String password = body.get("password"); if (pendingToken == null || usernameOrEmail == null || password == null) { return ResponseEntity.badRequest().body("missing fields"); } PendingLink pending = pendingLinkRepository.findByToken(pendingToken).orElse(null); if (pending == null || pending.getExpiresAt().isBefore(Instant.now())) { return ResponseEntity.status(410).body("pending token invalid or expired"); } // auth internal credentials try { Authentication auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(usernameOrEmail, password) ); } catch (Exception ex) { return ResponseEntity.status(401).body("Invalid internal credentials"); } // load user User user = userRepository.findByUsername(usernameOrEmail) .orElseGet(() -> userRepository.findByEmail(usernameOrEmail).orElse(null)); if (user == null) { return ResponseEntity.status(404).body("User not found"); } // verufy // if (!user.getEmail().equalsIgnoreCase(pending.getEmail())) { // return ResponseEntity.status(403).body("Pending link does not match authenticated user"); // } if (!pending.getUser().getId().equals(user.getId())) { return ResponseEntity.status(403).body("Pending link does not belong to user"); } Set providers = user.getAuthProviders(); providers.add(AuthProvider.GOOGLE); user.setAuthProviders(providers); userRepository.save(user); // delete pending pendingLinkRepository.deleteByToken(pendingToken); // generate token String jwt = userService.generateToken(user); Map resp = new HashMap<>(); resp.put("token", jwt); resp.put("message", "Google account linked and logged in"); return ResponseEntity.ok(resp); } }}} ---- == Database Connection Pooling == Spring Boot автоматски го вклучува **HikariCP** connection pool-от. === Конфигурација === Тековната конфигурација во `application.yml` го користи HikariCP со стандардните вредности: {{{ # application.yml spring: datasource: url: jdbc:postgresql://localhost:9999/db_202526z_va_prj_tradingmk username: db_202526z_va_prj_tradingmk_owner password: •••••••• jpa: hibernate: ddl-auto: create-drop show-sql: true properties: hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect }}} === Пример на користење на конекција од pool-от === Секој повик кон repository методите автоматски зема конекција од HikariCP pool-от и ја враќа по завршување. Следниот пример го прикажува текот при `buyStock`: {{{ // При повик на buyStock(), HikariCP управува со конекциите: @Transactional // <-- Spring отвора трансакција, HikariCP дава конекција public void buyStock(...) { portfolioRepository.findById(...) // конекција од pool-от се користи stockRepository.findBySymbol(...) // истата конекција holdingRepository.save(...) // истата конекција portfolioRepository.save(...) // истата конекција transactionRepository.save(...) // истата конекција } // <-- COMMIT, конекцијата се враќа во pool-от }}} Без connection pooling, секој од овие повици би отворал и затворал посебна TCP конекција кон PostgreSQL - операција која трае ~50-100ms. Со HikariCP, конекцијата е веќе отворена и чека во pool-от.