OpenPix (assinaturas e planos)¶
Esta receita cobre cobrança recorrente: mensalidade, plano anual, Pix Automático. Ela continua de onde a receita de cobrança avulsa parou — a configuração, a arquitetura em camadas e a regra "o webhook avisa, a API confirma" são as mesmas, e não se repetem aqui.
A primeira coisa a entender: plano é seu, assinatura é deles¶
A OpenPix não tem recurso de plano. Não existe POST /api/v1/plan, não
existe catálogo do lado do fornecedor. O que existe é subscription: um
acordo com um cliente, por um valor, em uma frequência.
Isso decide a sua modelagem:
| Conceito | Onde vive | Por quê |
|---|---|---|
| Plano ("Pro, R$ 49,90/mês") | Seu banco | É catálogo, preço e regra de negócio — nada disso a OpenPix conhece |
| Assinatura (Ana no Pro) | Seu banco e a OpenPix | Você guarda o vínculo e o estado; eles geram as cobranças |
| Cobrança do ciclo | A OpenPix gera | Cada período vira uma Charge comum, com o mesmo webhook |
plans (seu) subscriptions (seu) OpenPix
┌──────────────┐ ┌───────────────────┐ ┌──────────────┐
│ id │◄───────────│ plan_id │ │ subscription │
│ name "Pro" │ │ user_id │ │ correlationID│
│ value 4990 │ │ correlation_id ──┼───────────►│ globalID │
│ frequency │ │ status │ │ ... │
└──────────────┘ └───────────────────┘ └──────────────┘
O correlationID da assinatura é a sua chave: use o id da linha em
subscriptions, não o id do plano — o mesmo plano tem milhares de assinantes.
Criando a assinatura¶
from tempest_fastapi_sdk.integrations.payment.openpix import (
OpenPixClient,
Subscription,
SubscriptionFrequency,
SubscriptionPayload,
SubscriptionPayloadCustomer,
SubscriptionPayloadType,
reais_to_cents,
)
class SubscriptionService:
"""Regras de assinatura recorrente."""
def __init__(self, client: OpenPixClient) -> None:
"""Guarda o cliente gerado.
Args:
client (OpenPixClient): O cliente da OpenPix.
"""
self.client: OpenPixClient = client
async def subscribe(
self,
*,
reference: str,
plan_name: str,
amount_brl: str,
customer_name: str,
customer_email: str,
customer_tax_id: str,
charge_day: int,
) -> Subscription:
"""Assina um plano para um cliente.
Args:
reference (str): Id da assinatura no seu banco (`correlationID`).
plan_name (str): Nome do plano, que o pagador vê na cobrança.
amount_brl (str): Valor do ciclo, em reais.
customer_name (str): Nome do assinante.
customer_email (str): E-mail do assinante.
customer_tax_id (str): CPF ou CNPJ do assinante.
charge_day (int): Dia do mês em que a cobrança é gerada.
Returns:
Subscription: A assinatura criada.
Raises:
ValueError: Se a resposta vier sem a assinatura.
"""
response = await self.client.post_api_v1_subscriptions(
body=SubscriptionPayload(
correlation_id=reference,
name=plan_name,
value=reais_to_cents(amount_brl),
type=SubscriptionPayloadType.RECURRENT,
frequency=SubscriptionFrequency.MONTHLY,
day_generate_charge=charge_day,
day_due=5,
customer=SubscriptionPayloadCustomer(
name=customer_name,
email=customer_email,
tax_id=customer_tax_id,
),
)
)
if response.subscription is None:
raise ValueError(f"OpenPix não devolveu a assinatura {reference}")
return response.subscription
O corpo que sai no fio (medido, com MockTransport):
{
"customer": {"name": "Ana", "email": "ana@example.com", "taxID": "11111111111"},
"value": 4990.0,
"name": "Plano Pro",
"dayGenerateCharge": 10.0,
"frequency": "MONTHLY",
"type": "RECURRENT",
"dayDue": 5.0,
"correlationID": "assinatura-1",
"additionalInfo": []
}
E a resposta traz o payment_link_url — a página onde o assinante paga cada
ciclo — junto do global_id que a OpenPix usa internamente.
Os números saem como float, e isso é a especificação
value, dayGenerateCharge e dayDue são type: number na spec, então
o modelo gerado os serializa como 4990.0, 10.0 e 5.0. É JSON válido
e o mesmo valor — mas se você comparar corpos byte a byte em teste, é isso
que vai ver. Do lado da leitura, to_cents desfaz o float para centavo
inteiro.
Os campos que decidem o comportamento¶
| Campo | O que faz |
|---|---|
frequency |
Intervalo entre ciclos: WEEKLY, MONTHLY, BIMONTHLY, QUARTERLY, SEMIANNUALLY, ANNUALLY. Omitido, vira MONTHLY |
day_generate_charge |
Dia do mês em que a cobrança do ciclo nasce |
day_due |
Quantos dias depois disso ela vence |
installment_count |
Número total de ciclos. Sem ele, a assinatura não tem fim |
charge_type |
Como cada cobrança é emitida: DYNAMIC (Pix comum), OVERDUE (com juros e multa) ou BOLETO |
type |
RECURRENT ou PIX_RECURRING — a diferença está logo abaixo |
installment_count é o que separa assinatura de parcelamento
Sem ele você tem mensalidade: cobra até alguém cancelar. Com
installment_count=12, você tem um parcelado em 12 vezes que se encerra
sozinho — e installments_count na resposta volta None justamente
quando a assinatura é aberta.
RECURRENT ou PIX_RECURRING: a escolha que muda o produto¶
from tempest_fastapi_sdk.integrations.payment.openpix import SubscriptionPayloadType
manual = SubscriptionPayloadType.RECURRENT
automatic = SubscriptionPayloadType.PIX_RECURRING
RECURRENT |
PIX_RECURRING (Pix Automático) |
|
|---|---|---|
| Como o dinheiro sai | O assinante paga cada cobrança | Debitado da conta dele, sem ação |
| Autorização | Nenhuma, é uma cobrança por ciclo | O pagador autoriza uma vez, no banco dele |
| Inadimplência | Cobrança expira | O banco tenta de novo, conforme a retryPolicy |
| Frequências | As seis | Sem BIMONTHLY — o Banco Central não permite |
| Eventos de webhook | OPENPIX:CHARGE_* |
PIX_AUTOMATIC_*, sem o prefixo OPENPIX: |
O Pix Automático leva opções próprias:
from tempest_fastapi_sdk.integrations.payment.openpix import (
SubscriptionFrequency,
SubscriptionPayload,
SubscriptionPayloadCustomer,
SubscriptionPayloadPixRecurringOptions,
SubscriptionPayloadType,
)
payload = SubscriptionPayload(
correlation_id="assinatura-2",
name="Plano Pro",
value=4990,
type=SubscriptionPayloadType.PIX_RECURRING,
frequency=SubscriptionFrequency.MONTHLY,
customer=SubscriptionPayloadCustomer(name="Ana", tax_id="11111111111"),
pix_recurring_options=SubscriptionPayloadPixRecurringOptions(
minimum_value=1000,
),
)
Os eventos do Pix Automático não têm o prefixo OPENPIX:
Se o seu handler filtra por event_name.startswith("OPENPIX:"), ele
descarta a família inteira em silêncio. Compare com os membros de
OpenPixEvent, não com strings:
OpenPixEvent.PIX_AUTOMATIC_APPROVED.value == "PIX_AUTOMATIC_APPROVED".
Recebendo o dinheiro de cada ciclo¶
Aqui não há API nova: cada ciclo vira uma cobrança comum, com o mesmo
OPENPIX:CHARGE_COMPLETED da receita de cobrança avulsa. O que muda é que a
cobrança carrega a assinatura.
from tempest_fastapi_sdk.integrations.payment.openpix import Charge, OpenPixEvent
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixWebhookEvent
def subscription_of(event: OpenPixWebhookEvent) -> str | None:
"""Descobre a que assinatura uma cobrança paga pertence.
Args:
event (OpenPixWebhookEvent): A entrega verificada.
Returns:
str | None: O `correlationID` da assinatura, ou `None` quando a
cobrança é avulsa.
"""
if event.event is not OpenPixEvent.CHARGE_COMPLETED:
return None
charge = Charge.model_validate(event.payload["charge"])
if charge.subscription is None:
return None
return charge.subscription.correlation_id
Uma cobrança sem subscription é avulsa — trate pelo caminho da outra
receita. Com subscription, o que você está recebendo é a mensalidade: marque
o ciclo como pago e empurre a data de renovação.
Para listar o histórico de uma assinatura:
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixClient
async def charges_of(client: OpenPixClient, reference: str) -> list[str]:
"""Lista as cobranças geradas por uma assinatura.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` da assinatura.
Returns:
O status de cada cobrança do ciclo, da mais antiga à mais recente.
"""
response = await client.get_api_v1_charge(subscription=reference)
return [str(charge.status) for charge in response.charges]
Ciclo de vida¶
from tempest_fastapi_sdk.integrations.payment.openpix import (
Installment,
OpenPixClient,
Subscription,
)
async def read(client: OpenPixClient, reference: str) -> Subscription | None:
"""Lê o estado atual de uma assinatura.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` ou o `globalID` da assinatura.
Returns:
Subscription | None: A assinatura, ou `None` se não existir.
"""
response = await client.get_api_v1_subscriptions_by_id(id=reference)
return response.subscription
async def installments(client: OpenPixClient, global_id: str) -> list[Installment]:
"""Lista as parcelas já geradas.
Args:
client (OpenPixClient): O cliente da OpenPix.
global_id (str): O `globalID` da assinatura — este endpoint **não**
aceita o `correlationID`.
Returns:
As parcelas, com número, valor, status e data de geração.
"""
response = await client.get_api_v1_subscriptions_by_id_installments(id=global_id)
return response.installments
async def cancel(client: OpenPixClient, reference: str) -> None:
"""Encerra a assinatura.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` ou o `globalID` da assinatura.
"""
await client.put_api_v1_subscriptions_by_id_cancel(id=reference)
installments pede o globalID, os outros aceitam os dois
Está na própria especificação: get_api_v1_subscriptions_by_id e
put_api_v1_subscriptions_by_id_cancel documentam "the globalID or
correlationID", enquanto o de parcelas documenta "the globalID". Guarde
o global_id da resposta de criação no seu banco — sem ele, você precisa
de uma leitura extra só para listar parcelas.
Mudar o valor: a operação que a especificação deixou incompleta¶
PUT /api/v1/subscriptions/{id}/value existe e serve para reajustar as
próximas parcelas de uma assinatura de Pix Automático com valor dinâmico. Mas
a especificação não declara corpo nenhum para ela — conferido no
vendor/openpix-openapi.yaml: a operação tem só o parâmetro de path. O
cliente gerado reflete isso fielmente:
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixClient
async def bump(client: OpenPixClient, reference: str) -> None:
"""Chama o endpoint exatamente como a especificação o descreve.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` da assinatura.
"""
await client.put_api_v1_subscriptions_by_id_value(id=reference)
Se a sua conta precisa mandar o valor novo, envie pelo HTTPClient — é o
mesmo transporte, com os mesmos headers, retry e circuit breaker:
from tempest_fastapi_sdk import HTTPClient
async def bump_to(http: HTTPClient, reference: str, cents: int) -> None:
"""Reajusta o valor das próximas parcelas.
Args:
http (HTTPClient): O transporte já autenticado.
reference (str): O `correlationID` da assinatura.
cents (int): O novo valor, em centavos.
"""
response = await http.request(
"PUT",
f"/api/v1/subscriptions/{reference}/value",
json={"value": cents},
)
response.raise_for_status()
O gerador não inventa o que a especificação não diz — se ele adivinhasse um corpo, você descobriria o erro em produção, não aqui.
O estado que fica do seu lado¶
A OpenPix sabe se a cobrança do ciclo foi paga. Ela não sabe se o seu usuário tem acesso. Essa máquina de estados é sua:
cobrança do ciclo paga
┌────────┐ ─────────────────────────► ┌────────┐
│ criada │ │ ativa │
└────────┘ ◄───────────────────────── └────────┘
│ cobrança do próximo ciclo │
│ │ ciclo venceu sem pagamento
│ ▼
│ ┌──────────────┐
│ │ inadimplente │
│ └──────────────┘
│ cancelamento │
└──────────────► ┌───────────┐ ◄──────┘
│ cancelada │
└───────────┘
Duas regras que evitam o bug clássico de assinatura:
- Acesso vence por data, não por evento. Guarde
access_untile empurre a data quando o ciclo é pago. Se você guardar só um booleanois_active, um webhook perdido deixa o usuário sem acesso mesmo tendo pagado — ou com acesso eterno depois de cancelar. - Reconcilie por parcela. O job periódico compara as parcelas da OpenPix com os ciclos que você registrou; o que estiver pago lá e aberto aqui é webhook perdido.
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixClient
async def unpaid_cycles(client: OpenPixClient, global_id: str) -> list[float]:
"""Lista os números das parcelas que ainda não foram pagas.
Args:
client (OpenPixClient): O cliente da OpenPix.
global_id (str): O `globalID` da assinatura.
Returns:
Os `installment_number` das parcelas em aberto.
"""
response = await client.get_api_v1_subscriptions_by_id_installments(id=global_id)
return [
parcel.installment_number or 0.0
for parcel in response.installments
if parcel.status != "COMPLETED"
]
Recapitulando¶
- A OpenPix não tem planos. O catálogo é seu; a assinatura é o vínculo de um cliente com um valor e uma frequência.
correlationIDda assinatura é a linha do seu banco, não o plano. Guarde também oglobal_idque volta na criação — o endpoint de parcelas só aceita ele.RECURRENTcobra,PIX_RECURRINGdebita. A segunda opção tem frequências restritas pelo Banco Central e eventos de webhook sem o prefixoOPENPIX:.- Cada ciclo é uma cobrança comum — mesmo webhook, mesma conferência pela
API, e
charge.subscriptioné o que diz de qual assinatura ela veio. put_api_v1_subscriptions_by_id_valuenão tem corpo na especificação. Mande peloHTTPClientse a sua conta precisar.- O acesso do usuário vence por data, empurrada a cada ciclo pago, e é reconciliado pela lista de parcelas.