Baixar o estoque só depois do pagamento aprovado reabre a corrida em outro lugar: o cliente preenche o cartão, o gateway aprova e só então o sistema descobre que a unidade acabou, com o dinheiro já capturado. Baixar antes, sem prazo, cria o problema oposto: carrinhos abandonados e pagamentos recusados prendem unidades que nunca serão vendidas, e a promoção termina com produto parado e página mostrando esgotado. A solução é separar o saldo em dois números, disponível e reservado, e dar prazo à reserva.
- Ao iniciar o pagamento, o UPDATE condicional move a quantidade de disponível para reservado e grava uma linha em reservas com expira_em. Essa é a única etapa que disputa a unidade.
- Com o pagamento aprovado, a reserva vira confirmada e a quantidade sai do reservado. Não há mais disputa, porque a unidade já é do pedido.
- Se o prazo vence sem pagamento, um job marca a reserva como liberada e devolve a quantidade ao disponível.
- Pagamento recusado ou carrinho cancelado pelo cliente liberam a reserva na hora, pelo mesmo caminho do job, sem esperar o prazo.
import pg from 'pg';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20 });
export class EstoqueInsuficiente extends Error {
constructor(produtoId) {
super('estoque insuficiente para o produto ' + produtoId);
this.name = 'EstoqueInsuficiente';
this.produtoId = produtoId;
}
}
const RESERVA_TTL = '15 minutes';
// Soma itens repetidos e ordena por produto_id. Dois pedidos com os mesmos
// produtos travam as linhas de estoque na mesma ordem e nao entram em deadlock.
function normalizar(itens) {
const soma = new Map();
for (const { produtoId, quantidade } of itens) {
if (!Number.isInteger(quantidade) || quantidade <= 0) {
throw new RangeError('quantidade invalida para o produto ' + produtoId);
}
soma.set(produtoId, (soma.get(produtoId) || 0) + quantidade);
}
return [...soma.entries()]
.map(([produtoId, quantidade]) => ({ produtoId, quantidade }))
.sort((a, b) => a.produtoId - b.produtoId);
}
// Reserva todos os itens do pedido ou nenhum. A transacao nao chama nada
// externo: quanto menos tempo a trava da linha fica presa, mais vendas por
// segundo o produto mais disputado aguenta.
export async function reservarPedido(pedidoId, itens) {
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const { produtoId, quantidade } of normalizar(itens)) {
const { rowCount } = await client.query(
'UPDATE estoque SET disponivel = disponivel - $2, reservado = reservado + $2 ' +
'WHERE produto_id = $1 AND disponivel >= $2',
[produtoId, quantidade],
);
if (rowCount === 0) throw new EstoqueInsuficiente(produtoId);
await client.query(
'INSERT INTO reservas (pedido_id, produto_id, quantidade, expira_em) ' +
'VALUES ($1, $2, $3, now() + $4::interval)',
[pedidoId, produtoId, quantidade, RESERVA_TTL],
);
}
await client.query('COMMIT');
return { jaReservado: false };
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
// Retentativa do mesmo pedido: a UNIQUE (pedido_id, produto_id) falha,
// o ROLLBACK devolve o que esta tentativa baixou e a reserva original fica.
if (err.code === '23505') return { jaReservado: true };
throw err;
} finally {
client.release();
}
}
// Pagamento aprovado: a reserva vira venda e a unidade sai do reservado.
// Devolve os itens confirmados; se vier menos do que o pedido tem, parte da
// reserva expirou antes do pagamento e o chamador precisa reservar de novo
// ou estornar.
export async function confirmarPedido(pedidoId) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
"UPDATE reservas SET status = 'confirmada' " +
"WHERE pedido_id = $1 AND status = 'ativa' " +
'RETURNING produto_id, quantidade',
[pedidoId],
);
rows.sort((a, b) => Number(a.produto_id) - Number(b.produto_id));
for (const r of rows) {
await client.query(
'UPDATE estoque SET reservado = reservado - $2 WHERE produto_id = $1',
[r.produto_id, r.quantidade],
);
}
await client.query('COMMIT');
return rows;
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}
Três detalhes fazem esse código aguentar produção. A restrição UNIQUE (pedido_id, produto_id) torna a reserva idempotente: se o cliente clica duas vezes ou o front repete a requisição por timeout, a segunda tentativa falha na inserção, o ROLLBACK devolve o que ela tinha baixado e a função responde que o pedido já estava reservado. A transação não faz nenhuma chamada externa, porque cada milissegundo com a linha travada é um milissegundo em que ninguém mais compra aquele produto. E confirmarPedido só confirma reservas ainda ativas, então a corrida entre o pagamento que chega e o job que expira tem um vencedor só, decidido pela trava da linha em reservas.
-- Job a cada 30 segundos: devolve ao disponivel o que foi reservado e nao pago.
-- SKIP LOCKED pula reservas que um confirmarPedido esta travando agora; a
-- condicao status = 'ativa' garante que so um dos dois vence.
-- Se o banco abortar a execucao por deadlock (40P01), o job repete no ciclo seguinte.
WITH expiradas AS (
UPDATE reservas
SET status = 'liberada'
WHERE id IN (
SELECT id
FROM reservas
WHERE status = 'ativa'
AND expira_em < now()
ORDER BY expira_em
LIMIT 500
FOR UPDATE SKIP LOCKED
)
RETURNING produto_id, quantidade
),
por_produto AS (
SELECT produto_id, sum(quantidade)::int AS qtd
FROM expiradas
GROUP BY produto_id
)
UPDATE estoque e
SET disponivel = e.disponivel + p.qtd,
reservado = e.reservado - p.qtd
FROM por_produto p
WHERE e.produto_id = p.produto_id;
O prazo da reserva é uma decisão de produto com consequência técnica. Quinze minutos cobrem o pagamento por cartão e a maior parte dos Pix. Boleto não cabe nesse modelo, porque o pagamento pode levar dias: nesse caso, ou a reserva tem prazo longo e o produto disputado fica preso, ou o boleto não reserva e o pedido é confirmado apenas se houver saldo na compensação, com estorno automático quando não houver. Quando confirmarPedido devolve menos itens do que o pedido tem, é esse o caminho: tentar reservar de novo o que expirou e, se não houver saldo, estornar e avisar o cliente antes que ele descubra pela falta de entrega.