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;
    }
}