SPI Service
The SPI Service (apps/spi_service) processes real-time instant payments through the BACEN SPI (Sistema de Pagamentos Instantaneos) infrastructure. It runs on port 4002 and handles payment initiation, confirmation, return, and QR Code operations.
Responsibilities
- Payment initiation via ISO 20022 pacs.008 messages
- Payment confirmation processing (pacs.002)
- Payment return and reversal (pacs.004)
- QR Code generation (static and dynamic)
- Real-time position management
- Webhook delivery for payment status changes
- End-to-end ID generation and validation
Payment Flow
Outbound Payment (Sending)
┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────┐
│ Client │────>│ SPI Service│────>│ BACEN │────>│Recvr │
│ Request │ │ pacs.008 │ │ SPI │ │ PSP │
└──────────┘ └────────────┘ └──────────┘ └──────┘
│ │
│ pacs.002 │
│<─────────────────│
│ │
┌────▼────┐
│ Confirm │
│ & Notify│
└─────────┘Inbound Payment (Receiving)
┌──────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐
│Sender│────>│ BACEN │────>│ SPI Service│────>│ Account │
│ PSP │ │ SPI │ │ pacs.008 │ │ Credit │
└──────┘ └──────────┘ └────────────┘ └──────────┘
│
┌────▼────┐
│pacs.002 │
│ Response│
└─────────┘Payment Initiation
Creating a Payment
The SPI Service validates the payment request, resolves the destination via DICT, constructs the ISO 20022 message, signs it, and sends it to BACEN:
defmodule SpiService.Payments do
alias Shared.Repo
alias Shared.Schema.Transaction
def initiate_payment(params) do
with {:ok, validated} <- validate_payment(params),
{:ok, dest_info} <- resolve_destination(validated),
{:ok, transaction} <- create_transaction(validated, dest_info),
{:ok, message} <- build_pacs008(transaction),
{:ok, signed} <- sign_message(message),
{:ok, response} <- send_to_bacen(signed) do
update_transaction(transaction, :sent, response)
Shared.Nats.publish("pix.payment.initiated", transaction)
{:ok, transaction}
else
{:error, reason} -> handle_initiation_error(reason, params)
end
end
defp validate_payment(params) do
validations = [
&validate_amount/1,
&validate_idempotency/1,
&validate_debtor_account/1,
&validate_not_duplicate/1
]
Enum.reduce_while(validations, {:ok, params}, fn validator, {:ok, p} ->
case validator.(p) do
{:ok, p} -> {:cont, {:ok, p}}
{:error, _} = err -> {:halt, err}
end
end)
end
endPayment Validation Rules
| Rule | Description |
|---|---|
| Amount range | BRL 0.01 to BRL 999,999,999.99 |
| Idempotency | Duplicate end-to-end IDs rejected within 24h |
| Account status | Debtor account must be active and not blocked |
| Balance check | Sufficient available balance required |
| Rate limit | Per-account and per-institution limits enforced |
| Fraud check | Anti-fraud markers validated against DICT |
End-to-End ID
The end-to-end ID (E2EID) uniquely identifies each PIX payment. It follows the BACEN-defined format:
E{ISPB}{TIMESTAMP}{SEQUENCE}
Where:
- E = Fixed prefix
- ISPB = 8-digit participant ISPB
- TIMESTAMP = YYYYMMDDHHmm (creation timestamp)
- SEQUENCE = 11 alphanumeric charactersdefmodule SpiService.E2EId do
def generate(ispb) do
timestamp = Calendar.strftime(DateTime.utc_now(), "%Y%m%d%H%M")
sequence = :crypto.strong_rand_bytes(8) |> Base.encode32(padding: false) |> String.slice(0, 11)
"E#{ispb}#{timestamp}#{sequence}"
end
def parse(e2e_id) do
case e2e_id do
<<"E", ispb::binary-size(8), timestamp::binary-size(12), sequence::binary>> ->
{:ok, %{ispb: ispb, timestamp: parse_timestamp(timestamp), sequence: sequence}}
_ ->
{:error, :invalid_e2e_id}
end
end
endISO 20022 Messages
pacs.008 - Payment Initiation
The credit transfer message initiates a PIX payment:
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.09">
<FIToFICstmrCdtTrf>
<GrpHdr>
<MsgId>MSG20240115120000ABC</MsgId>
<CreDtTm>2024-01-15T12:00:00Z</CreDtTm>
<NbOfTxs>1</NbOfTxs>
<SttlmInf>
<SttlmMtd>CLRG</SttlmMtd>
</SttlmInf>
</GrpHdr>
<CdtTrfTxInf>
<PmtId>
<EndToEndId>E1234567820240115120012345678901</EndToEndId>
<TxId>TXN20240115ABC123</TxId>
</PmtId>
<IntrBkSttlmAmt Ccy="BRL">150.00</IntrBkSttlmAmt>
<ChrgBr>SLEV</ChrgBr>
<Dbtr>
<Nm>John Doe</Nm>
<Id><PrvtId><Othr><Id>12345678901</Id></Othr></PrvtId></Id>
</Dbtr>
<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>12345678</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>
<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>87654321</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>
<Cdtr>
<Nm>Jane Smith</Nm>
</Cdtr>
</CdtTrfTxInf>
</FIToFICstmrCdtTrf>
</Document>pacs.002 - Payment Status Report
Received from BACEN confirming payment acceptance or rejection:
defmodule SpiService.Handler.Pacs002 do
def handle(xml_message) do
with {:ok, parsed} <- parse_status_report(xml_message),
{:ok, transaction} <- find_transaction(parsed.original_e2e_id) do
case parsed.status do
"ACSP" -> confirm_payment(transaction) # Accepted Settlement in Process
"RJCT" -> reject_payment(transaction, parsed.reason) # Rejected
"ACCC" -> complete_payment(transaction) # Accepted Credit Completed
end
end
end
endpacs.004 - Payment Return
Used for payment reversals, including MED-triggered returns:
defmodule SpiService.Returns do
@return_reasons %{
"MD06" => :refund_request,
"FR01" => :fraud,
"AC03" => :invalid_creditor_account,
"AM09" => :wrong_amount,
"BE08" => :wrong_creditor
}
def initiate_return(original_e2e_id, reason, amount \\ nil) do
with {:ok, original} <- find_original_transaction(original_e2e_id),
amount <- amount || original.amount,
{:ok, return_msg} <- build_pacs004(original, reason, amount),
{:ok, signed} <- sign_message(return_msg),
{:ok, response} <- send_to_bacen(signed) do
create_return_record(original, reason, amount, response)
end
end
endQR Code Operations
Static QR Code
Static QR codes contain fixed payment information and can be reused:
defmodule SpiService.QrCode.Static do
def generate(params) do
payload = %{
"pixKey" => params.key,
"merchantName" => params.merchant_name,
"merchantCity" => params.merchant_city,
"amount" => params.amount, # optional for static
"description" => params.description
}
emv_string = encode_emv(payload)
qr_image = QRCode.create(emv_string) |> QRCode.render(:png)
{:ok, %{emv: emv_string, image: qr_image}}
end
endDynamic QR Code
Dynamic QR codes are single-use with a specific amount and expiration:
defmodule SpiService.QrCode.Dynamic do
def generate(params) do
# Create a payment location (loc) with BACEN
{:ok, loc} = create_payment_location(params)
payload = %{
"url" => loc.url,
"amount" => params.amount,
"expiration" => params.expires_in,
"payerRequest" => params.payer_message
}
emv_string = encode_emv(payload)
qr_image = QRCode.create(emv_string) |> QRCode.render(:png)
{:ok, %{emv: emv_string, image: qr_image, location: loc}}
end
endPosition Management
The SPI Service tracks the institution's real-time position (balance) with BACEN:
defmodule SpiService.PositionManager do
use GenServer
def init(_opts) do
{:ok, %{position: fetch_initial_position()}}
end
def handle_call({:check_balance, amount}, _from, state) do
available = state.position.available - state.position.reserved
result = if available >= amount, do: :ok, else: {:error, :insufficient_position}
{:reply, result, state}
end
def handle_cast({:debit, amount, e2e_id}, state) do
new_position = %{state.position |
available: state.position.available - amount,
reserved: state.position.reserved + amount,
pending: Map.put(state.position.pending, e2e_id, amount)
}
{:noreply, %{state | position: new_position}}
end
def handle_cast({:confirm, e2e_id}, state) do
amount = Map.get(state.position.pending, e2e_id, 0)
new_position = %{state.position |
reserved: state.position.reserved - amount,
pending: Map.delete(state.position.pending, e2e_id)
}
{:noreply, %{state | position: new_position}}
end
endWebhook Notifications
Payment status changes trigger webhook notifications to registered endpoints:
defmodule SpiService.Webhooks do
def notify(transaction, event) do
payload = %{
event: event,
e2e_id: transaction.end_to_end_id,
amount: transaction.amount,
status: transaction.status,
timestamp: DateTime.utc_now()
}
Enum.each(registered_webhooks(transaction.account_id), fn webhook ->
Task.Supervisor.start_child(SpiService.WebhookSupervisor, fn ->
deliver_with_retry(webhook.url, payload, max_retries: 5)
end)
end)
end
endTelemetry
| Metric | Type | Description |
|---|---|---|
spi.payment.initiated.count | Counter | Payments initiated |
spi.payment.confirmed.count | Counter | Payments confirmed |
spi.payment.rejected.count | Counter | Payments rejected |
spi.payment.returned.count | Counter | Payments returned |
spi.payment.duration | Histogram | End-to-end payment latency |
spi.bacen.request.duration | Histogram | BACEN SPI API call latency |
spi.position.available | Gauge | Current available position |
spi.qrcode.generated.count | Counter | QR codes generated |
spi.webhook.delivered.count | Counter | Webhooks delivered |