package ar.com.companeros.decision; import com.google.gson.JsonObject; import java.io.IOException; import java.math.BigDecimal; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; /** Presupuesto del operador, fuera del mundo: incluye solicitudes todavía sin liquidar. */ public final class BudgetLedger implements AutoCloseable { private final Path file; private BigDecimal committed = BigDecimal.ZERO; private FileChannel lockChannel; private FileLock lock; private BudgetLedger(Path file) { this.file = file; } public static BudgetLedger memory() { return new BudgetLedger(null); } public static BudgetLedger file(Path file) { return new BudgetLedger(file.toAbsolutePath().normalize()); } // Leer al arrancar. Una reserva que sobrevivió a un cierre inesperado cuenta como gasto posible. public synchronized BigDecimal load() throws IOException { if (file == null) return committed; checkPath(); if (lock == null) { Path lockFile = file.resolveSibling("budget.lock"); if (Files.isSymbolicLink(lockFile)) throw new IOException("Archivo de bloqueo inválido"); lockChannel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); try { lock = lockChannel.tryLock(); } catch (RuntimeException failure) { close(); throw new IOException("Presupuesto ya utilizado", failure); } if (lock == null) { close(); throw new IOException("Presupuesto ya utilizado"); } } if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return committed; if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 16_384) throw new IOException("Registro de presupuesto inválido"); try { JsonObject data = DecisionCodec.strictObject(Files.readString(file, StandardCharsets.UTF_8), 16_384); if (!data.keySet().equals(java.util.Set.of("schemaVersion", "committedUsd")) || data.get("schemaVersion").getAsInt() != 1) throw new IllegalArgumentException("Formato inválido"); committed = checked(data.get("committedUsd").getAsBigDecimal()); return committed; } catch (RuntimeException failure) { throw new IOException("Registro de presupuesto inválido", failure); } } // Reservar ANTES de llamar a la API. Reemplazo atómico y escritura confirmada en disco. public synchronized void save(BigDecimal value) throws IOException { value = checked(value); if (file == null) { committed = value; return; } checkPath(); if (lock == null || !lock.isValid()) throw new IOException("Presupuesto sin bloqueo"); JsonObject data = new JsonObject(); data.addProperty("schemaVersion", 1); data.addProperty("committedUsd", value); Path temporary = Files.createTempFile(file.getParent(), "budget-", ".tmp"); try { Files.writeString(temporary, data + System.lineSeparator(), StandardCharsets.UTF_8); try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE)) { output.force(true); } try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } committed = value; } finally { Files.deleteIfExists(temporary); } } // Las rutas pertenecen al servicio; ninguna herramienta del bot recibe acceso a estos archivos. private void checkPath() throws IOException { Files.createDirectories(file.getParent()); if (Files.isSymbolicLink(file.getParent()) || Files.isSymbolicLink(file)) throw new IOException("Ruta de presupuesto inválida"); } private static BigDecimal checked(BigDecimal value) { if (value == null || value.signum() < 0 || value.precision() > 20 || Math.abs((long) value.scale()) > 12) throw new IllegalArgumentException("Importe inválido"); return value; } @Override public synchronized void close() { try { if (lock != null) lock.release(); } catch (IOException ignored) { } try { if (lockChannel != null) lockChannel.close(); } catch (IOException ignored) { } lock = null; lockChannel = null; } }