facturas resuelve ese problema con una clave estable de tu aplicación y un
almacenamiento compartido, llamado store en la API. El SDK guarda ahí el
número reservado, el input y el resultado. Cuando repetís la misma operación
con la misma idempotencyKey, consulta esa reserva en vez de empezar otra.
Podés usar el Postgres o Redis que tu aplicación ya tiene. También hay
adaptadores para un directorio privado y para memoria. El SDK no instala una
base de datos ni un servicio externo.
Qué guarda cada opción
Con un store que provee
withLock, issue(), issueCreditNote() e
issueDebitNote() coordinan la numeración. Dos llamadas simultáneas para el
mismo punto de venta y tipo de comprobante reciben números consecutivos y cada
una escribe una sola vez. Sin withLock, otros procesos que usan ese punto de
venta quedan fuera de la coordinación.
Antes de reservar un número, el SDK revisa si la operación anterior quedó sin
resolver. Primero consulta ARCA. Si todavía no puede saber qué pasó, devuelve
indeterminate con lookup: { kind: "blocked", by: <clave>, byTaxId } sin
reservar ni emitir nada. byTaxId es el CUIT del emisor que reservó esa clave.
Conciliá la clave con recover() desde ese emisor y repetí la llamada.
El bloqueo se renueva mientras el proceso está activo. Si el proceso se cae, el
bloqueo vence, pero la reserva pendiente sigue frenando la secuencia. Así, otro
proceso puede continuar sin reutilizar a ciegas un número cuyo resultado todavía
no se conoce.
Postgres
Sirve cualquier Postgres, como Supabase, PlanetScale Postgres, Neon o un servidor propio. Usá el cliente que ya tiene tu aplicación, por ejemplopg o
postgres. La función de consulta recibe el SQL y sus parámetros, y puede
devolver una lista de filas o { rows }. Con postgres, adaptá
sql.unsafe(text, params). Creá la tabla por defecto una sola vez:
INSERT ... ON CONFLICT DO NOTHING RETURNING key. El
adaptador no crea la tabla. El bloqueo de secuencia es una fila más de esa tabla,
tomada con el mismo INSERT y liberada con un DELETE que verifica el dueño,
y una fila abandonada se recupera con un UPDATE condicional sobre
updated_at. No usa bloqueos de sesión, así que funciona detrás de PgBouncer en
modo transacción.
Redis
call usa ioredis SET key value NX. Si no, el adaptador usa
Upstash set(key, value, { nx: true }). Usá un Redis persistente, sin desalojo de
las claves de reserva: los registros no llevan TTL. El bloqueo de secuencia sí es
una clave con vencimiento, tomada con SET NX PX, renovada mientras se la
tiene y borrada solo si sigue siendo tuya. Necesita del en el cliente: sin
del, el adaptador no expone withLock y la secuencia no se coordina.
Archivos
rename. Los archivos quedan con modo 0600 y los
directorios
nuevos con 0700. El bloqueo de secuencia es un directorio .lock creado con
mkdir, con el dueño y el vencimiento adentro: coordina varios procesos sobre
el mismo volumen, no varios servidores sobre un NFS compartido.
Memoria
Implementar otro store
add tiene que devolver false de forma atómica sin cambiar un valor existente.
El withLock opcional coordina los refrescos de ticket WSAA y la secuencia del
punto de venta. Tiene que ser exclusivo entre procesos y soltarse siempre, aun
si el proceso muere. Si recibe options.signal y se aborta antes de tomar el
lock, tiene que dejar de esperar sin correr fn y lanzar ArcaLockTimeoutError
con reason: "aborted". Un store sin withLock conserva las reservas y la
recuperación, pero no coordina la secuencia entre procesos. Cuando pasás las dos
opciones, un wsaaSessionStore
explícito gana para los tickets.
Formato y vida de los registros
Formato y vida de los registros
Las claves usan
arca:v2:wsaa:{environment}:{service}:{fingerprint} y
arca:v1:attempt:{environment}:{taxId}:{idempotencyKey}. El registro de
ticket es un JSON { v: 2, iv, data, tag } cifrado con AES-256-GCM. Un
ticket vigente que una versión anterior dejó en claro bajo arca:v1:wsaa: se
vuelve a guardar cifrado en la primera lectura en vez de pedir otro, porque
ARCA rechaza un login nuevo hasta unos minutos después del anterior. La copia
en claro queda hasta que vence, para que los procesos de la versión anterior de
un despliegue mixto la sigan encontrando.
El withLock de los tickets sigue usando la clave
arca:v1:wsaa:, así que las dos versiones serializan sus logins. Los registros de
reserva contienen el hash del input, la operación, las coordenadas reservadas y
el input exacto enviado, y rejectedAt si ARCA rechazó todos sus envíos.
Contienen datos fiscales y de clientes. Restringí el
acceso y protegé los backups.arca:v1:settled:{environment}:{taxId}:{idempotencyKey} guarda un resultado
que tiene que sobrevivir al reintento, y se escribe una sola vez con add. Con
{ v: 2, kind: "conflict", number, found, settledAt }, otro comprobante ocupa
el número reservado. found es un VoucherSummary en centavos y fechas
YYYY-MM-DD. Un registro v: 1 anterior lo guardó en las unidades de ARCA y
se normaliza al leerlo. Una repetición con esa clave o un recover() devuelven el
mismo conflict sin consultar al proveedor. Con
{ v: 1, kind: "superseded", number, by, byTaxId, settledAt }, la barrera
probó que el número estaba vacío y se lo dio a la clave by, reservada bajo el
CUIT emisor byTaxId. Un registro sin byTaxId es de una versión anterior y
nombra una clave del mismo emisor. La clave vieja consulta el
número una vez y nunca reenvía. Devuelve indeterminate con
lookup: { kind: "superseded", by, byTaxId } si el número sigue vacío o si el
comprobante que hay ahí es de by o de una clave que a su vez se lo quitó a
by. Emití bajo una clave nueva. Devuelve conflict solo si alguna clave de
esa cadena anotó un conflicto en ese número, porque entonces un desconocido
llegó al número y el comprobante se atribuye a mano. Las autorizaciones no se
anotan porque ARCA es la fuente de verdad y cada repetición la consulta. Un
rechazo se marca en la reserva con rejectedAt solo si ARCA rechazó todos los
envíos de esa clave. Entonces esa clave nunca escribió el número, así que una
repetición o un recover() que encuentran un comprobante ahí devuelven
conflict, venga de otra clave, de un issue() sin clave o de otro sistema.
Si un envío anterior quedó sin respuesta, puede haber llegado, así que el
rechazo no se marca y la repetición compara el comprobante por sus datos
fiscales como antes. El conflict que encuentra el primer envío de una clave se
anota siempre, con o sin withLock. Sin withLock, un doble envío de la misma
clave puede dejar su propio CAE anotado como conflicto, que se concilia a
mano. Sin withLock, además, el rechazo de ese primer envío no se marca si ARCA
lo rechazó con un código que puede indicar un número ocupado, y el conflict
que encuentra una repetición o un recover() después de un rechazo se devuelve
pero no se anota,
porque otra llamada con la misma clave puede haber escrito el número, y la
llamada siguiente lo vuelve a consultar. Si para entonces el rechazo ya no está
marcado, porque el reenvío chocó con otro envío o ARCA lo rechazó con un código
que puede indicar un número ocupado, la clave compara el comprobante por sus
datos fiscales, como antes. Si el número sigue vacío, la repetición lo reenvía.
Si el store falla al anotar uno de los conflictos que sí se anotan, la llamada
lanza ArcaConfigurationError en vez de devolver un conflicto que un reintento
podría no volver a ver.Cada registro lleva su versión. v: 1 es una reserva de WSFE sin detalle.
v: 2 es una reserva de WSMTXCA o con detalle de ítems, y siempre nombra su
servicio. Un SDK que no conoce v: 2 no la reproduce, así que volver a una
versión anterior no reenvía por WSFE un comprobante que era de WSMTXCA.
Preservá las dos versiones.arca:v1:sequence:{environment}:{taxId}:{salesPoint}:{voucherType} guarda la
última reserva reclamada en esa secuencia y si ARCA ya informó su desenlace. El
taxId de la clave es el del contribuyente dueño de la numeración, que puede
ser un representado. El registro nombra la reserva con su key y el CUIT del
emisor que la creó en issuerTaxId, porque varios emisores con certificados
distintos pueden representar al mismo contribuyente y compartir el store. La
barrera consulta esa reserva con su CUIT representado o, si no lo tiene, con el
del dueño de la secuencia. Se escribe antes que la reserva que nombra.
arca:v1:lock:sequence:... es la clave del bloqueo. Los dos son registros de
coordinación, no evidencia fiscal. Se reescriben en cada reclamo y se pueden
borrar si hacen falta, siempre que ninguna reserva quede sin conciliar. Repetir
una clave con otro input, otra
operación, otro servicio u otro number explícito es una incompatibilidad de
idempotencia y lanza ARCA_INPUT_IDEMPOTENCY_MISMATCH.No podés borrar, vencer ni reescribir los registros de reserva. El SDK los
crea y solo reescribe rejectedAt: lo borra antes de reenviar el número y lo
vuelve a escribir solo si ARCA rechaza también ese envío. Siempre consulta a
ARCA en una repetición. Borrar una reserva puede hacer que un reintento posterior emita
otra factura. Las versiones anteriores ignoran rejectedAt y comparan el
comprobante por sus datos fiscales, así que actualizá todos los procesos que
comparten un store.