Volver al índice
src/main/java/ar/com/companeros/decision/BudgetLedger.java
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;
}
}