| | 1 | = Напреден развој на апликацијата = |
| | 2 | |
| | 3 | == Трансакции == |
| | 4 | |
| | 5 | === Купување на акција (buyStock) === |
| | 6 | '''Опис:''' Методот `buyStock` во `PortfolioService` извршува повеќе зависни операции: |
| | 7 | * Намалување на балансот на портфолиото за вкупната вредност на купените акции |
| | 8 | * Ажурирање на постоечко холдинг (или креирање на нов) со новата количина и пресметана weighted average цена |
| | 9 | * Зачувување на трансакцискиот запис |
| | 10 | |
| | 11 | Доколку кој било чекор не успее (на пр. акцијата не постои, портфолиото не е пронајдено), целата операција се откажува и балансот останува непроменет. |
| | 12 | {{{ |
| | 13 | @Transactional |
| | 14 | public void buyStock(Long portfolioId, String stockSymbol, int quantity, BigDecimal pricePerUnit) { |
| | 15 | Portfolio portfolio = portfolioRepository.findById(portfolioId) |
| | 16 | .orElseThrow(() -> new RuntimeException("Portfolio not found")); |
| | 17 | |
| | 18 | |
| | 19 | //multiply for how much stocks are bught |
| | 20 | BigDecimal totalCost = pricePerUnit.multiply(BigDecimal.valueOf(quantity)); |
| | 21 | |
| | 22 | // if (portfolio.getBalance().compareTo(totalCost) < 0) { |
| | 23 | // throw new RuntimeException("not enough balance to buy stock"); |
| | 24 | // } |
| | 25 | //TODO - > BRING BACK , JUST FOR TESTING !! |
| | 26 | |
| | 27 | portfolio.setBalance(portfolio.getBalance().subtract(totalCost)); |
| | 28 | |
| | 29 | Stock stock1 = stockRepository.findBySymbol(stockSymbol) |
| | 30 | .orElseThrow(() -> new RuntimeException("Stock not found")); |
| | 31 | |
| | 32 | PortfolioHolding holding = holdingRepository |
| | 33 | .findByPortfolioIdAndStock_Symbol(portfolioId, stockSymbol) |
| | 34 | .orElse(PortfolioHolding.builder() |
| | 35 | .portfolio(portfolio) |
| | 36 | .stock(stock1) |
| | 37 | .quantity(0) |
| | 38 | .avgPrice(BigDecimal.ZERO) |
| | 39 | .build()); |
| | 40 | |
| | 41 | // avg price bought |
| | 42 | // alkaloid 2 x 22.000 + alkaloid 3x 28.000 average od ova |
| | 43 | BigDecimal currentTotalValue = holding.getAvgPrice().multiply(BigDecimal.valueOf(holding.getQuantity())); |
| | 44 | BigDecimal newTotalValue = currentTotalValue.add(totalCost); |
| | 45 | int newQuantity = holding.getQuantity() + quantity; |
| | 46 | BigDecimal newAvgPrice = newTotalValue.divide(BigDecimal.valueOf(newQuantity), BigDecimal.ROUND_HALF_UP); |
| | 47 | |
| | 48 | holding.setQuantity(newQuantity); |
| | 49 | holding.setAvgPrice(newAvgPrice); |
| | 50 | |
| | 51 | holdingRepository.save(holding); |
| | 52 | portfolioRepository.save(portfolio); |
| | 53 | |
| | 54 | |
| | 55 | //sava a transaction |
| | 56 | Transaction transaction = new Transaction(); |
| | 57 | transaction.setUser(portfolio.getUser()); |
| | 58 | |
| | 59 | Stock stock = stockRepository.findBySymbol(stockSymbol) |
| | 60 | .orElseThrow(() -> new RuntimeException("stock not found: " + stockSymbol)); |
| | 61 | transaction.setStock(stock); |
| | 62 | /* transaction.setStock(stockRepository.findBySymbol(stockSymbol) |
| | 63 | .orElseThrow(() -> new RuntimeException("Stock not found")));*/ |
| | 64 | transaction.setType("BUY"); |
| | 65 | transaction.setQuantity(quantity); |
| | 66 | transaction.setPrice(pricePerUnit.doubleValue()); |
| | 67 | transaction.setTimestamp(LocalDateTime.now()); |
| | 68 | |
| | 69 | transactionRepository.save(transaction); |
| | 70 | } |
| | 71 | }}} |
| | 72 | |
| | 73 | |
| | 74 | === Продавање на акција (sellStock) === |
| | 75 | |
| | 76 | '''Опис:''' Методот `sellStock`: |
| | 77 | * Проверка дека корисникот поседува доволно акции |
| | 78 | * Намалување на количината во холдингот (или бришење ако достигне 0) |
| | 79 | * Зголемување на балансот за приходот од продажбата |
| | 80 | * Зачувување на трансакцискиот запис |
| | 81 | |
| | 82 | {{{ |
| | 83 | @Transactional |
| | 84 | public void sellStock(Long portfolioId, String stockSymbol, int quantity, BigDecimal pricePerUnit) { |
| | 85 | Portfolio portfolio = portfolioRepository.findById(portfolioId) |
| | 86 | .orElseThrow(() -> new RuntimeException("portfolio not found")); |
| | 87 | |
| | 88 | PortfolioHolding holding = holdingRepository |
| | 89 | .findByPortfolioIdAndStock_Symbol(portfolioId, stockSymbol) |
| | 90 | .orElseThrow(() -> new RuntimeException("stock not found in portfolio")); |
| | 91 | |
| | 92 | |
| | 93 | // checks |
| | 94 | if (holding.getQuantity() < quantity) { |
| | 95 | throw new RuntimeException("not enough shares to sell"); |
| | 96 | } |
| | 97 | |
| | 98 | if (pricePerUnit == null) { |
| | 99 | // fallback: get latest price from stock history or live API |
| | 100 | Stock stock = stockRepository.findBySymbol(stockSymbol) |
| | 101 | .orElseThrow(() -> new RuntimeException("stock not found: " + stockSymbol)); |
| | 102 | pricePerUnit = BigDecimal.valueOf(stock.getCurrentPrice()); |
| | 103 | } |
| | 104 | |
| | 105 | // gain from the sale made |
| | 106 | BigDecimal totalGain = pricePerUnit.multiply(BigDecimal.valueOf(quantity)); |
| | 107 | |
| | 108 | |
| | 109 | holding.setQuantity(holding.getQuantity() - quantity); |
| | 110 | |
| | 111 | // if holding is zero ==== remove it from database |
| | 112 | if (holding.getQuantity() == 0) { |
| | 113 | holdingRepository.delete(holding); |
| | 114 | } else { |
| | 115 | holdingRepository.save(holding); |
| | 116 | } |
| | 117 | |
| | 118 | // update |
| | 119 | portfolio.setBalance(portfolio.getBalance().add(totalGain)); |
| | 120 | portfolioRepository.save(portfolio); |
| | 121 | |
| | 122 | |
| | 123 | Stock stock = stockRepository.findBySymbol(stockSymbol) |
| | 124 | .orElseThrow(() -> new RuntimeException("stock not found: " + stockSymbol)); |
| | 125 | |
| | 126 | Transaction transaction = new Transaction(); |
| | 127 | transaction.setUser(portfolio.getUser()); |
| | 128 | transaction.setStock(stock); |
| | 129 | transaction.setType("SELL"); |
| | 130 | transaction.setQuantity(quantity); |
| | 131 | transaction.setPrice(pricePerUnit.doubleValue()); |
| | 132 | transaction.setTimestamp(LocalDateTime.now()); |
| | 133 | |
| | 134 | transactionRepository.save(transaction); |
| | 135 | |
| | 136 | System.out.println("saved sell"); |
| | 137 | } |
| | 138 | }}} |
| | 139 | |
| | 140 | === Поврзување на Google OAuth акаунт (confirmLink) === |
| | 141 | |
| | 142 | '''Опис:''' Методот `confirmLink` во `AuthLinkController` поврзува Google OAuth акаунт со постоечки интерен акаунт. Операцијата вклучува: |
| | 143 | * Верификација и читање на pending OAuth токенот |
| | 144 | * Автентикација на интерните credentials на корисникот |
| | 145 | * Додавање на `GOOGLE` провајдер во множеството на auth провајдери на корисникот |
| | 146 | * Зачувување на корисникот |
| | 147 | * Бришење на pending токенот |
| | 148 | |
| | 149 | {{{ |
| | 150 | @PostMapping("/confirm") |
| | 151 | @Transactional |
| | 152 | public ResponseEntity<?> confirmLink(@RequestBody Map<String, String> body) { |
| | 153 | String pendingToken = body.get("pendingToken"); |
| | 154 | String usernameOrEmail = body.get("username"); |
| | 155 | String password = body.get("password"); |
| | 156 | |
| | 157 | if (pendingToken == null || usernameOrEmail == null || password == null) { |
| | 158 | return ResponseEntity.badRequest().body("missing fields"); |
| | 159 | } |
| | 160 | |
| | 161 | PendingLink pending = pendingLinkRepository.findByToken(pendingToken).orElse(null); |
| | 162 | if (pending == null || pending.getExpiresAt().isBefore(Instant.now())) { |
| | 163 | return ResponseEntity.status(410).body("pending token invalid or expired"); |
| | 164 | } |
| | 165 | |
| | 166 | // auth internal credentials |
| | 167 | try { |
| | 168 | Authentication auth = authenticationManager.authenticate( |
| | 169 | new UsernamePasswordAuthenticationToken(usernameOrEmail, password) |
| | 170 | ); |
| | 171 | } catch (Exception ex) { |
| | 172 | return ResponseEntity.status(401).body("Invalid internal credentials"); |
| | 173 | } |
| | 174 | |
| | 175 | // load user |
| | 176 | User user = userRepository.findByUsername(usernameOrEmail) |
| | 177 | .orElseGet(() -> userRepository.findByEmail(usernameOrEmail).orElse(null)); |
| | 178 | if (user == null) { |
| | 179 | return ResponseEntity.status(404).body("User not found"); |
| | 180 | } |
| | 181 | |
| | 182 | // verufy |
| | 183 | // if (!user.getEmail().equalsIgnoreCase(pending.getEmail())) { |
| | 184 | // return ResponseEntity.status(403).body("Pending link does not match authenticated user"); |
| | 185 | // } |
| | 186 | if (!pending.getUser().getId().equals(user.getId())) { |
| | 187 | return ResponseEntity.status(403).body("Pending link does not belong to user"); |
| | 188 | } |
| | 189 | |
| | 190 | |
| | 191 | |
| | 192 | Set<AuthProvider> providers = user.getAuthProviders(); |
| | 193 | providers.add(AuthProvider.GOOGLE); |
| | 194 | user.setAuthProviders(providers); |
| | 195 | userRepository.save(user); |
| | 196 | |
| | 197 | // delete pending |
| | 198 | pendingLinkRepository.deleteByToken(pendingToken); |
| | 199 | |
| | 200 | // generate token |
| | 201 | String jwt = userService.generateToken(user); |
| | 202 | |
| | 203 | Map<String, Object> resp = new HashMap<>(); |
| | 204 | resp.put("token", jwt); |
| | 205 | resp.put("message", "Google account linked and logged in"); |
| | 206 | |
| | 207 | return ResponseEntity.ok(resp); |
| | 208 | } |
| | 209 | }}} |
| | 210 | |
| | 211 | |
| | 212 | ---- |
| | 213 | |
| | 214 | == Database Connection Pooling == |
| | 215 | |
| | 216 | Spring Boot автоматски го вклучува **HikariCP** connection pool-от. |
| | 217 | |
| | 218 | === Конфигурација === |
| | 219 | |
| | 220 | Тековната конфигурација во `application.yml` го користи HikariCP со стандардните вредности: |
| | 221 | |
| | 222 | {{{ |
| | 223 | # application.yml |
| | 224 | spring: |
| | 225 | datasource: |
| | 226 | url: jdbc:postgresql://localhost:9999/db_202526z_va_prj_tradingmk |
| | 227 | username: db_202526z_va_prj_tradingmk_owner |
| | 228 | password: •••••••• |
| | 229 | jpa: |
| | 230 | hibernate: |
| | 231 | ddl-auto: create-drop |
| | 232 | show-sql: true |
| | 233 | properties: |
| | 234 | hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect |
| | 235 | }}} |
| | 236 | |
| | 237 | |
| | 238 | === Пример на користење на конекција од pool-от === |
| | 239 | |
| | 240 | Секој повик кон repository методите автоматски зема конекција од HikariCP pool-от и ја враќа по завршување. Следниот пример го прикажува текот при `buyStock`: |
| | 241 | |
| | 242 | {{{ |
| | 243 | // При повик на buyStock(), HikariCP управува со конекциите: |
| | 244 | |
| | 245 | @Transactional // <-- Spring отвора трансакција, HikariCP дава конекција |
| | 246 | public void buyStock(...) { |
| | 247 | portfolioRepository.findById(...) // конекција од pool-от се користи |
| | 248 | stockRepository.findBySymbol(...) // истата конекција |
| | 249 | holdingRepository.save(...) // истата конекција |
| | 250 | portfolioRepository.save(...) // истата конекција |
| | 251 | transactionRepository.save(...) // истата конекција |
| | 252 | } // <-- COMMIT, конекцијата се враќа во pool-от |
| | 253 | }}} |
| | 254 | |
| | 255 | Без connection pooling, секој од овие повици би отворал и затворал посебна TCP конекција кон PostgreSQL - операција која трае ~50-100ms. Со HikariCP, конекцијата е веќе отворена и чека во pool-от. |