Carteira e saque Pix¶
Plataforma que recebe por Pix e repassa para alguém — motorista, artista, vendedor — precisa de três coisas: um saldo por pessoa, o histórico de cada centavo que entrou e saiu, e um botão de saque. Parece pouco código, e é exatamente por isso que dá errado do mesmo jeito em todo produto:
- crédito perdido: ler o saldo em Python, somar e gravar de volta perde um dos créditos quando duas vendas do mesmo motorista liquidam ao mesmo tempo;
- saque em dobro: ler o saldo, chamar o Pix e só então zerar paga duas vezes quando a pessoa aperta o botão duas vezes;
- retenção furada: calcular "o disponível" antes de debitar deixa dois saques simultâneos levarem dinheiro que ainda devia ficar retido;
- dinheiro em
float:33.30 - 30.13vira3.169999999999998.
O módulo tempest_fastapi_sdk.wallet resolve as quatro uma vez. Todo valor
é centavo inteiro.
Os modelos¶
O saldo mora na linha do seu usuário, e cada movimento vira uma linha de extrato:
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseUserModel
from tempest_fastapi_sdk.wallet import BaseWalletEntryModel, WalletBalanceMixin
class UserModel(BaseUserModel, WalletBalanceMixin):
__tablename__ = "users"
class WalletEntryModel(BaseWalletEntryModel):
__tablename__ = "wallet_entries"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
WalletBalanceMixinacrescentawallet_cents(BIGINT, default0).BaseWalletEntryModeltrazkind,amount_cents(com sinal: positivo entra, negativo sai),balance_after_cents,available_at(quando o crédito pode ser sacado),description,reference_typeereference_id. Você só declara a FK do usuário e o nome da tabela.
RESTRICT, não CASCADE
Com CASCADE, apagar o usuário apaga o histórico financeiro junto. Com
RESTRICT, o banco recusa — desative ou anonimize o usuário em vez de
apagar. make_wallet_entry_model() (para teste e script) usa
RESTRICT por padrão.
Creditar, com e sem retenção¶
import asyncio
from datetime import timedelta
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import AsyncDatabaseManager, BaseRepository, BaseUserModel
from tempest_fastapi_sdk.wallet import (
BaseWalletEntryModel,
WalletBalanceMixin,
WalletService,
)
class UserModel(BaseUserModel, WalletBalanceMixin):
__tablename__ = "users"
class WalletEntryModel(BaseWalletEntryModel):
__tablename__ = "wallet_entries"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
def wallet_service(session: AsyncSession) -> WalletService:
return WalletService(
balances=BaseRepository(session, model=UserModel),
entries=BaseRepository(session, model=WalletEntryModel),
)
async def main() -> None:
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.connect()
await db.create_tables()
async with db.get_session_context() as session:
user = UserModel(email="motorista@example.com", hashed_password="x")
session.add(user)
await session.commit()
service = wallet_service(session)
await service.credit(user.id, 9_320, kind="TICKET_SALE")
await service.credit(
user.id, 4_500, kind="TICKET_SALE", hold=timedelta(hours=48)
)
balance = await service.balance(user.id)
print(balance.total_cents, balance.held_cents, balance.available_cents)
await db.disconnect()
asyncio.run(main())
Saída:
WalletServicerecebe dois repositórios na mesma sessão: o do dono do saldo e o do extrato. Sessões diferentes são recusadas no construtor, porque o saldo e a linha de extrato precisam entrar no mesmo commit.credit()faz um únicoUPDATE ... SET wallet_cents = wallet_cents + :n RETURNING, e grava a linha de extrato na mesma transação. Nada é lido em Python antes.hold=timedelta(hours=48)gravaavailable_at48 horas à frente. O crédito conta no total, mas não no disponível, até lá.kindé vocabulário seu ("TICKET_SALE","ALO_SALE"). Os que o service escreve sozinho estão emWalletEntryKind.
Medido com concorrência de verdade
tests/wallet/test_wallet_live.py roda 50 tarefas simultâneas, cada
uma na própria sessão, contra Postgres num container. 50 créditos de
100 terminam em 5000, sempre; e o teste de controle, rodando no
mesmo cenário a versão ingênua (ler, somar, gravar), termina abaixo de
5000. Medido em postgres:16-alpine: 5 execuções seguidas de
pytest tests/wallet/test_wallet_live.py -m docker, os 4 testes
passando nas cinco.
Sacar¶
O saque é a parte com mais jeito de dar errado, então a ordem é fixa:
- debita primeiro, só do disponível — a retenção faz parte do
WHEREdoUPDATE, então dois saques simultâneos não passam os dois; - chama o provedor depois do commit do débito;
- devolve o dinheiro só se o provedor recusou de forma definitiva.
import asyncio
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseRepository,
BaseUserModel,
PayoutRejectedException,
)
from tempest_fastapi_sdk import PixKeyType
from tempest_fastapi_sdk.testing.fakes import FakePayoutProvider
from tempest_fastapi_sdk.wallet import (
BaseWalletEntryModel,
WalletBalanceMixin,
WalletService,
)
class UserModel(BaseUserModel, WalletBalanceMixin):
__tablename__ = "users"
class WalletEntryModel(BaseWalletEntryModel):
__tablename__ = "wallet_entries"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
def wallet_service(session: AsyncSession) -> WalletService:
return WalletService(
balances=BaseRepository(session, model=UserModel),
entries=BaseRepository(session, model=WalletEntryModel),
)
async def main() -> None:
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.connect()
await db.create_tables()
async with db.get_session_context() as session:
user = UserModel(email="motorista@example.com", hashed_password="x")
session.add(user)
await session.commit()
service = wallet_service(session)
await service.credit(user.id, 9_320, kind="TICKET_SALE")
payout = FakePayoutProvider()
payout.fail_next(PayoutRejectedException("chave Pix inexistente"))
try:
await service.withdraw(
user.id,
payout=payout,
pix_key="motorista@example.com",
pix_key_type=PixKeyType.EMAIL,
)
except PayoutRejectedException:
print("recusado:", (await service.balance(user.id)).available_cents)
result = await service.withdraw(
user.id,
payout=payout,
pix_key="motorista@example.com",
pix_key_type=PixKeyType.EMAIL,
)
print("pago:", result.entry.amount_cents, result.payout.status)
print("saldo:", (await service.balance(user.id)).total_cents)
page = await service.statement(user.id)
print([entry.kind for entry in page["items"]])
await db.disconnect()
asyncio.run(main())
Saída:
recusado: 9320
pago: -9320 confirmed
saldo: 0
['WITHDRAW', 'WITHDRAW_REFUND', 'WITHDRAW', 'TICKET_SALE']
A primeira tentativa foi recusada: o débito saiu, o provedor recusou, e o
WITHDRAW_REFUND devolveu o valor. A segunda pagou. O extrato mostra as
quatro linhas, da mais nova para a mais antiga.
O que acontece em cada desfecho:
| O provedor... | Exceção | O débito |
|---|---|---|
| aceitou | — (devolve WithdrawalSchema) |
fica |
| recusou de forma definitiva | PayoutRejectedException (502) |
volta (WITHDRAW_REFUND) |
| deu timeout, caiu a conexão, respondeu algo ilegível | PayoutUncertainException (502) |
fica, com log CRITICAL |
| — não havia disponível | InsufficientBalanceException (409) |
nada foi debitado |
Por que o débito fica no caso incerto
Num timeout, o Pix pode ter saído. Devolver o saldo ali deixa a pessoa
sacar de novo o mesmo dinheiro. O log CRITICAL traz o
correlation_id: a conciliação é conferir esse id no provedor e, se o
Pix não saiu, creditar de volta com credit(..., kind="WITHDRAW_REFUND").
A chave Pix vem do perfil
withdraw() recebe pix_key de quem chama. Leia do cadastro do usuário,
nunca do corpo da requisição — senão qualquer um saca para a própria
chave a partir da carteira de outra pessoa. O router abaixo já faz assim.
Sacar pela OpenPix¶
Em produção, troque o fake por OpenPixPayoutProvider:
from tempest_fastapi_sdk import HTTPClient, RetryPolicy
from tempest_fastapi_sdk.integrations.payment.adapters import OpenPixPayoutProvider
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixEnvironment
http: HTTPClient = HTTPClient(
base_url=OpenPixEnvironment.SANDBOX.base_url,
default_headers={"Authorization": "<seu AppID>"},
retry_policy=RetryPolicy(max_attempts=1),
)
payout: OpenPixPayoutProvider = OpenPixPayoutProvider(http)
- O adapter manda
autoApprove: true: cria e aprova o pagamento numa chamada só, sem a janela "criado, ainda não aprovado". - HTTP 4xx, ou pagamento
DENIED/FAILED, viramPayoutRejectedException.CONFIRMEDviraPayoutStatus.CONFIRMED.CREATED,APPROVEDou um estado que o adapter não conhece viramPayoutStatus.PENDING, com o valor original emprovider_status— a liquidação chega depois pelos webhooksOPENPIX:MOVEMENT_*.
RetryPolicy(max_attempts=1) é obrigatório, e o adapter confere
O HTTPClient refaz qualquer método em 429, 5xx e timeout de
leitura. Medido com a política padrão: um POST respondido 500 sai
três vezes. Num saque, o primeiro POST pode ter criado o pagamento, e
o segundo receberia uma resposta que o adapter leria como recusa — a
carteira devolveria dinheiro que já saiu. Por isso
OpenPixPayoutProvider levanta ValueError no construtor se o
HTTPClient puder refazer a requisição.
Taxa e divisão¶
openpix_fee_cents() calcula a taxa da OpenPix, e split_net() divide o
total entre plataforma, gateway e quem recebe:
from tempest_fastapi_sdk.wallet import openpix_fee_cents, split_net
total = 10_000
driver = split_net(
total,
platform_bps=500,
gateway_fee_cents=openpix_fee_cents(total),
residual_recipient="driver",
)
print(driver.platform_cents, driver.gateway_fee_cents, dict(driver.shares))
producer = split_net(
total,
platform_bps=1_500,
gateway_fee_cents=openpix_fee_cents(total),
residual_recipient="producer",
shares_bps={"interlocutor": 2_000},
)
print(producer.platform_cents, producer.net_cents, dict(producer.shares))
Saída:
- Percentual é basis point:
500são 5 %,10_000são 100 %. Tudo em divisão inteira, arredondando para baixo. - A plataforma tira a parte dela do bruto; a taxa do gateway sai em
seguida; o que sobra é o líquido. Cada entrada de
shares_bpsleva a porcentagem dela do líquido, eresidual_recipientfica com o resto — então o centavo que o arredondamento sobra cai numa pessoa conhecida, em vez de sumir. Quando as taxas passam do total, o líquido é0. OPENPIX_FEE_TIERSé a tabela que os serviços Tempest usam hoje (até R$ 62,50: R$ 0,50; até R$ 625,00: 0,8 %; acima: R$ 5,00; mais R$ 1,00 fixo). Contrato diferente? Passe o seuOpenPixFeeTiersemtiers=.
Comparado com os serviços que já existiam
Medido em todo total de R$ 0,01 a R$ 2.000,00 (200 000 valores):
openpix_fee_cents e split_net (15 % de plataforma, interlocutores
sobre o líquido) dão exatamente o que o alofans-api calcula. Contra o
transport-backend, que arredonda em reais com round(), o motorista
recebe 0, 1 ou 2 centavos a mais aqui (87 928, 98 435 e 13 637 dos
200 000 totais) — o SDK arredonda a parte da plataforma sempre para
baixo.
Liquidar uma vez só¶
O webhook da cobrança chega mais de uma vez, e pode chegar junto com a
confirmação manual. claim_once() marca a linha só se a coluna ainda estiver
vazia:
from datetime import datetime
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
from tempest_fastapi_sdk.db.transaction import transaction
from tempest_fastapi_sdk.wallet import WalletService, claim_once
class OrderModel(BaseModel):
__tablename__ = "orders"
seller_id: Mapped[UUID] = mapped_column(nullable=False)
amount_cents: Mapped[int] = mapped_column(nullable=False)
credited_at: Mapped[datetime | None] = mapped_column(nullable=True)
async def settle(
session: AsyncSession, wallet: WalletService, order: OrderModel
) -> bool:
async with transaction(session):
if not await claim_once(session, OrderModel, order.id, "credited_at"):
return False
await wallet.credit(
order.seller_id,
order.amount_cents,
kind="SALE",
reference_type="order",
reference_id=str(order.id),
)
return True
A segunda chamada recebe False e não credita de novo. claim_once() não
faz commit: ele entra no mesmo bloco que o crédito, então uma falha no
crédito desfaz a marcação também.
O router¶
from collections.abc import AsyncIterator
from uuid import UUID
from fastapi import FastAPI
from sqlalchemy import ForeignKey
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseRepository,
BaseUserModel,
HTTPClient,
RetryPolicy,
)
from tempest_fastapi_sdk import PixKeyType
from tempest_fastapi_sdk.integrations.payment.adapters import OpenPixPayoutProvider
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixEnvironment
from tempest_fastapi_sdk.wallet import (
BaseWalletEntryModel,
PixDestinationSchema,
WalletBalanceMixin,
WalletService,
make_wallet_router,
)
class UserModel(BaseUserModel, WalletBalanceMixin):
__tablename__ = "users"
class WalletEntryModel(BaseWalletEntryModel):
__tablename__ = "wallet_entries"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
db = AsyncDatabaseManager("sqlite+aiosqlite:///./app.db")
payout = OpenPixPayoutProvider(
HTTPClient(
base_url=OpenPixEnvironment.SANDBOX.base_url,
default_headers={"Authorization": "<seu AppID>"},
retry_policy=RetryPolicy(max_attempts=1),
)
)
async def sessions() -> AsyncIterator[AsyncSession]:
async with db.get_session_context() as session:
yield session
def wallet_service(session: AsyncSession) -> WalletService:
return WalletService(
balances=BaseRepository(session, model=UserModel),
entries=BaseRepository(session, model=WalletEntryModel),
)
def current_user_id() -> UUID:
return UUID("00000000-0000-0000-0000-000000000001")
def pix_destination() -> PixDestinationSchema:
return PixDestinationSchema(
pix_key="motorista@example.com", pix_key_type=PixKeyType.EMAIL
)
def payout_provider() -> OpenPixPayoutProvider:
return payout
app = FastAPI()
app.include_router(
make_wallet_router(
service_factory=wallet_service,
session_factory=sessions,
current_user_id=current_user_id,
payout_provider=payout_provider,
pix_destination=pix_destination,
)
)
| Rota | O que faz |
|---|---|
GET /api/wallet/balance |
WalletBalanceSchema de quem está logado |
GET /api/wallet/statement?page=1&page_size=20 |
extrato paginado, mais novo primeiro |
POST /api/wallet/withdraw {"amount_cents": 500} |
saca (sem amount_cents, todo o disponível) |
current_user_id e pix_destination são os dois que você troca pelos
seus: o primeiro vem da sua autenticação; o segundo lê a chave do cadastro
do usuário e levanta erro quando não há chave cadastrada. Nenhuma rota
aceita chave Pix ou id de usuário no corpo.
Modelo que já tem coluna de saldo¶
Serviço que já guarda o saldo numa coluna wallet (em centavos) não precisa
do mixin. Aponte o service para ela:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel, BaseRepository
from tempest_fastapi_sdk.wallet import WalletService, make_wallet_entry_model
class LegacyUserModel(BaseModel):
__tablename__ = "legacy_users"
wallet: Mapped[int] = mapped_column(nullable=False, default=0)
LegacyEntryModel = make_wallet_entry_model(
user_table="legacy_users",
tablename="legacy_wallet_entries",
class_name="LegacyEntryModel",
)
def wallet_service(session: AsyncSession) -> WalletService:
return WalletService(
balances=BaseRepository(session, model=LegacyUserModel),
entries=BaseRepository(session, model=LegacyEntryModel),
balance_attribute="wallet",
)
Quem já tem saldo antes do extrato existir ganha uma primeira linha
WalletEntryKind.OPENING_BALANCE na migração, para o extrato fechar com o
saldo.
Recap¶
- O saldo é centavo inteiro na linha do usuário; cada movimento é uma linha de extrato na mesma transação.
- Todo movimento é um
UPDATEsó no banco: crédito não se perde e saque não paga duas vezes, medido com 50 tarefas simultâneas em Postgres. - A retenção faz parte do débito, não de uma leitura anterior.
- Saque debita antes, devolve só na recusa definitiva e guarda o débito quando o desfecho é incerto.
split_netem basis points nunca perde nem inventa centavo.- Para testar sem credencial, use
FakePayoutProvider(veja Fakes).