Pular para o conteúdo principal

Exemplos Práticos

Exemplos de integração com o SDK, baseados no app de exemplo (:sample).

Os métodos de I/O do PosPinpad são suspend — chame-os de dentro de um CoroutineScope (ex.: viewModelScope).

Inicialização Completa (ViewModel)​

Fluxo create → setEndpointProvider (opcional) → getTerminalConfig → register → terminalInitialization. A ativação (getTerminalConfig + register) é feita uma vez; a inicialização, a cada execução do app.

class PaymentViewModel : ViewModel() {

private lateinit var pospinpad: PosPinpad

fun initialize(context: Context) {
viewModelScope.launch {
pospinpad = PosPinpad.create(
context = context,
posPinpadCallback = paymentCallback // ver seção abaixo
)

// Opcional: ambientes admin (não-PCI) e financeiro (PCI) informados pela Tupi
pospinpad.setEndpointProvider(
EndpointProvider(
adminBaseUrl = BuildConfig.ADMIN_BASE_URL,
financialBaseUrl = BuildConfig.FINANCIAL_BASE_URL
)
)

val merchantId = armazenamento.merchantId ?: ativar() ?: return@launch

// Inicializa o terminal (carrega tabelas EMV) a cada execução
val init = pospinpad.terminalInitialization(
request = TerminalInitializationRequest(merchantId = merchantId)
)
if (!init.success) showError("Falha na inicialização. Tente novamente.")
}
}

// Primeira execução: ativa o terminal
private suspend fun ativar(): String? {
// Personalização do parceiro (define o registrationSchema)
pospinpad.getTerminalConfig(slug = PARTNER_SLUG)

// Registro CNPJ + Token (ids dos campos vêm do registrationSchema)
val registration = try {
pospinpad.register(
fields = mapOf("cnpj" to operatorCnpj, "token" to operatorToken),
partnerSlug = PARTNER_SLUG
)
} catch (e: Exception) {
showError("Falha de comunicação. Verifique a rede e tente novamente.")
return null
}

val merchantId = registration.merchantId
if (!registration.success || merchantId.isNullOrBlank()) {
showError(registration.error?.message ?: "Falha no registro do terminal.")
return null
}
armazenamento.merchantId = merchantId
return merchantId
}
}

Implementação dos Callbacks​

Os callbacks do fluxo de pagamento chegam na main thread. Os de impressão, tela secundária e erro HTTP chegam na thread de origem: aqui, setMessage/showError atualizam um StateFlow, que pode ser escrito de qualquer thread. Se for mexer em Views diretamente, use runOnUiThread.

private val paymentCallback = object : PosPinpadCallback {

// ---- Cartão / PIN ----
override fun onStartGetCard() {
setMessage("Aproxime, insira ou passe o cartão")
}

override fun onStartGetPinCoordinates(setPinCoordinates: (List<String>) -> Unit) {
// Exibe a tela de PIN; a UI mede os botões e devolve as coordenadas em até 5 s
showPinScreen { coordinates -> setPinCoordinates(coordinates) }
}

override fun onPinEntry(pinMask: String, message: String, amount: Long) {
updatePinMask(pinMask)
}

override fun onPinError(remainingAttempts: Int, message: String) {
setMessage("$message — tentativas restantes: $remainingAttempts")
}

// ---- Resultado ----
override fun onTransactionCompleted(result: PaymentResult) {
when (result.status) {
PaymentResult.PaymentStatus.APPROVED -> handleApproved(result)
PaymentResult.PaymentStatus.DECLINED -> showError(result.displayMessage ?: "Negado")
PaymentResult.PaymentStatus.ERROR -> showError(result.displayMessage ?: "Erro")
}
// Cartão de chip: retirar também em negadas e erros
if (result.requiresCardRemoval) {
setMessage("Retire o cartão")
viewModelScope.launch { pospinpad.removeCard() }
}
}

// ---- Inputs ----
override fun onRequestUserInput(
inputCode: InputCode, minLength: Int, maxLength: Int,
onTimeout: (() -> Unit) -> Unit, onInputReceived: (String?) -> Unit
) {
onTimeout { dismissInputDialog() }
val title = when (inputCode) {
InputCode.CVV -> "Digite o CVV"
InputCode.LAST_4_DIGITS -> "Últimos 4 dígitos"
}
showInputDialog(title, minLength, maxLength) { value -> onInputReceived(value) } // null cancela
}

override fun onRequestMenuSelection(
title: String, options: List<String>,
onTimeout: (() -> Unit) -> Unit, onOptionSelected: (Int?) -> Unit
) {
onTimeout { dismissMenu() }
showMenu(title, options) { index -> onOptionSelected(index) }
}

// ---- PIX ----
override fun onPixQrCodeGenerated(qrCode: String, transactionId: String, amount: Long, expiresInSeconds: Int?) {
showPixQrCode(qrCode, expiresInSeconds)
// Aprovado chega em onTransactionCompleted; expirado/inválido em onAppError(PP_CANCEL)
}

// ---- Estorno ----
override fun onRefundTransactionsAvailable(
transactions: List<TransactionSummary>, cardLastDigits: String,
cardBrand: String, cardBin: String,
confirmRefund: (Int?, Long?) -> Unit
) {
if (transactions.size == 1) { confirmRefund(0, null); return }
showSelection("Estornar (**** $cardLastDigits)", transactions.map { it.id }) { index ->
confirmRefund(index, null) // null = saldo total; index null cancela
}
}

override fun onRefundCompleted(result: PaymentResult) {
setMessage("Estorno concluído: ${result.receipt?.transactionId}")
}

override fun onRefundError(errorCode: String, message: String, cardLastDigits: String, cardBrand: String, cardBin: String) {
showError("Erro no estorno [$errorCode]: $message")
}

// ---- Pré-autorização ----
override fun onPreAuthTransactionsAvailable(
transactions: List<TransactionSummary>, cardLastDigits: String,
cardBrand: String, cardBin: String,
confirmPreAuth: (Int?, Long?) -> Unit
) {
if (transactions.size == 1) { confirmPreAuth(0, null); return }
showSelection("Pré-autorizações (**** $cardLastDigits)", transactions.map { it.id }) { index ->
confirmPreAuth(index, null) // null = valor original
}
}

// ---- Impressão / Tabelas (impressão não chega na main thread) ----
override fun onPrintCompleted() { setMessage("Comprovante impresso") }
override fun onNoHasPaper() { setMessage("Sem papel") }
override fun onNotifyPrinterError(code: Int, msg: String) { showError("Impressora $code: $msg") }
override fun onTablesLoaded(ok: Boolean) { if (!ok) showError("Erro ao carregar tabelas") }

override fun onUiNotify(text: String, type: NotificationType) { setMessage(text) }

// ---- Erros (os HTTP não chegam na main thread) ----
override fun onAppError(error: SdkErrorCode, message: String) {
when (error) {
SdkErrorCode.PP_CANCEL -> setMessage("Operação cancelada")
SdkErrorCode.PP_TIMEOUT -> setMessage("Tempo esgotado")
else -> showError("${error.name} (${error.code}): $message")
}
}
override fun onNetworkConnectionError() { showError("Sem conexão") }
override fun onClientError() { showError("Erro de requisição") }
override fun onUnauthorizedError() { navigateToLogin() }
override fun onSessionExpired() { navigateToLogin() }
override fun onNotFoundError() { showError("Não encontrado") }
override fun onServerError() { showError("Erro no servidor") }
override fun onGenericError() { showError("Erro desconhecido") }
}

Pagamentos​

fun processTransaction(amount: Long, paymentMethod: PaymentMethod?, transactionType: TransactionType) {
viewModelScope.launch {
when (transactionType) {
TransactionType.AUTHORIZATION ->
pospinpad.createPayment(
type = TransactionType.AUTHORIZATION,
amount = amount,
paymentMethod = paymentMethod,
installments = 1
)

TransactionType.PRE_AUTHORIZE ->
pospinpad.createPayment(
type = TransactionType.PRE_AUTHORIZE,
amount = amount,
paymentMethod = PaymentMethod.CREDIT,
installments = 1
)

TransactionType.VOID -> pospinpad.startRefundFlow()
TransactionType.CONFIRM_PRE_AUTHORIZED -> pospinpad.startPreAuthFlow()
else -> { /* sem ação */ }
}
}
}

Crédito parcelado​

suspend fun creditoParcelado(valor: Long, parcelas: Int, comJuros: Boolean) {
pospinpad.createPayment(
type = TransactionType.AUTHORIZATION,
amount = valor, // ex.: 30000L = R$ 300,00
paymentMethod = PaymentMethod.CREDIT,
installments = parcelas,
installmentsInterest = comJuros
)
}

Voucher​

// Mostre a opção só se a carga habilitar voucher e tiver produtos voucher
if (pospinpad.getAvailableProducts(PaymentMethod.VOUCHER).isNotEmpty()) {
viewModelScope.launch {
pospinpad.createPayment(
type = TransactionType.AUTHORIZATION,
amount = 4200L,
paymentMethod = PaymentMethod.VOUCHER,
installments = 1 // voucher é sempre à vista: o SDK ignora parcelas e juros
)
}
}

O saldo, quando a adquirente informa, vem em result.voucherBalance, inclusive em vendas negadas.

Venda com metadados​

suspend fun vendaDoPedido(valor: Long, pedido: String) {
pospinpad.createPayment(
type = TransactionType.AUTHORIZATION,
amount = valor,
paymentMethod = PaymentMethod.CREDIT,
installments = 1,
metadata = TransactionMetadata(mapOf("pedido" to pedido))
)
}

PIX​

private var pixJob: Job? = null

fun cobrarPix(valor: Long) {
pixJob = viewModelScope.launch {
pospinpad.createPixPayment(amount = valor)
// QR Code chega em onPixQrCodeGenerated(...)
}
}

// Botão "Cancelar" na tela do QR Code (cancelPayment() não interrompe o PIX)
fun cancelarPix() {
pixJob?.cancel()
}

Capturar pré-autorização específica​

pospinpad.startPreAuthFlow(preAuthId = summary.id) // null lista todas as do cartão
// Se o id não for deste cartão: onAppError(SdkErrorCode.PRE_AUTH_NOT_FOUND_FOR_CARD)

Estorno autorizado pelo Supervisor​

viewModelScope.launch {
// verify faz derivação de chave: rode fora da main thread
val supervisor = withContext(Dispatchers.IO) {
pospinpad.localUsers.verify(senhaDigitada, LocalUserProfile.SUPERVISOR)
}
if (supervisor != null) {
pospinpad.startRefundFlow(operatorTaxId = supervisor.cpf, paymentMethod = PaymentMethod.CREDIT)
}
}

Validar limites antes do pagamento​

Campos com 0 significam "sem limite": compare só quando o limite for maior que zero.

fun validarVenda(valor: Long, parcelas: Int): String? {
val limites = pospinpad.getTransactionLimits(
TransactionType.AUTHORIZATION, PaymentMethod.CREDIT, parcelas
) ?: return null // sem limite configurado

return when {
limites.minTransactionValueCents > 0 && valor < limites.minTransactionValueCents -> "Valor abaixo do mínimo"
limites.maxTransactionValueCents > 0 && valor > limites.maxTransactionValueCents -> "Valor acima do máximo para ${limites.transactionName}"
limites.maxInstallments > 0 && parcelas > limites.maxInstallments -> "Máximo de ${limites.maxInstallments} parcelas"
limites.minInstallmentValueCents > 0 && valor / parcelas < limites.minInstallmentValueCents -> "Parcela abaixo do mínimo"
else -> null
}
}

Consulta de Transações​

suspend fun listarVendasDoDia() {
val base = QueryFilterBuilder()
.add(FilterField.STATUS, FilterOperator.EQ, "approved")
.add(FilterField.CREATED_AT, FilterOperator.GE, hojeIso())

val response = pospinpad.getTransactionSummaries(base.copyOf().pagination(limit = 20, preloads = true))
val totais = pospinpad.getTransactionStats(base.copyOf())

response.data.forEach { tx ->
Log.d("YBY", "${tx.id} ${tx.amountAsDouble} ${tx.cardBrand} ${tx.status} NSU=${tx.displayNsu}")
}
Log.d("YBY", "Total aprovado: ${totais.approvedAmount.toCents()}")

// Próxima página
response.metadata.nextCursor?.let { cursor ->
pospinpad.getTransactionSummaries(base.copyOf().pagination(cursor = cursor, limit = 20, preloads = true))
}
}

Configuração de Coordenadas do PIN (Compose)​

A tela de senha mede a posição de cada botão com onGloballyPositioned + positionInWindow() e monta a lista na ordem esperada pelo SDK ([0-8]=1–9, [9]=0, [10]=CANCELAR, [11]=OK, [12]=backspace). Entregue as coordenadas em até 5 segundos; o SDK as guarda durante o processo, então mantenha o teclado sempre na mesma posição.

@Composable
fun PinInputScreen(
amount: String,
pin: String,
onCoordinatesCaptured: (List<String>) -> Unit
) {
val buttonCoordinates = remember { mutableStateMapOf<String, String>() }
val rows = remember {
listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("<", "0", "X"), // < = backspace, X = cancelar
listOf("OK")
)
}

Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Valor: $amount", fontSize = 32.sp)
Text("*".repeat(pin.length).ifEmpty { "Digite o PIN" }, fontSize = 64.sp)

rows.forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
row.forEach { label ->
Surface(
modifier = Modifier
.weight(1f)
.height(100.dp)
.onGloballyPositioned { c ->
val pos = c.positionInWindow()
val left = pos.x.toInt(); val top = pos.y.toInt()
val right = left + c.size.width; val bottom = top + c.size.height
val key = when (label) {
"<" -> "BACKSPACE"
"X" -> "CANCELAR"
else -> label
}
buttonCoordinates[key] = "$left,$top,$right,$bottom"

// 13 chaves posicionadas → montar a lista na ordem do SDK
if (buttonCoordinates.size == 13) {
val list = ArrayList<String>()
for (i in 1..9) list.add(buttonCoordinates[i.toString()] ?: "")
list.add(buttonCoordinates["0"] ?: "") // [9] = 0
list.add(buttonCoordinates["CANCELAR"] ?: "") // [10] = CANCELAR
list.add(buttonCoordinates["OK"] ?: "") // [11] = OK
list.add(buttonCoordinates["BACKSPACE"] ?: "") // [12] = backspace
onCoordinatesCaptured(list)
}
}
) {
Box(contentAlignment = Alignment.Center) { Text(label, fontSize = 20.sp) }
}
}
}
}
}
}

Ligando à solicitação do SDK:

override fun onStartGetPinCoordinates(setPinCoordinates: (List<String>) -> Unit) {
showPinScreen(onCoordinatesCaptured = { coords -> setPinCoordinates(coords) })
}

Impressão do Comprovante​

// Align e Typeface são do SDK, não de android.graphics
import com.tupifintech.apossdk.sdk.api.model.Align
import com.tupifintech.apossdk.sdk.api.model.PrintAttributes
import com.tupifintech.apossdk.sdk.api.model.Typeface

private fun handleApproved(result: PaymentResult) {
val receipt = result.receipt ?: return

pospinpad.printText("COMPROVANTE DE VENDA",
PrintAttributes(align = Align.CENTER, typeface = Typeface.BOLD), notifyComplete = false)
pospinpad.printColumns(
listOf("Valor", formatCents(receipt.amount)),
listOf(PrintAttributes(weight = 1), PrintAttributes(align = Align.RIGHT, weight = 1)),
notifyComplete = false
)
pospinpad.printText("Cartão: ${receipt.cardBrand} ****${receipt.cardLastDigits}", PrintAttributes(), notifyComplete = false)
pospinpad.printText("Autorização: ${receipt.authCode}", PrintAttributes(), notifyComplete = false)
receipt.installments?.let { pospinpad.printText("Parcelas: ${it}x", PrintAttributes(), notifyComplete = false) }
receipt.acquirerName?.let { pospinpad.printText("Adquirente: $it", PrintAttributes(), notifyComplete = false) }

pospinpad.feedStep(1) // destaca a bobina; última instrução notifica onPrintCompleted
}

private fun formatCents(cents: Long): String =
"R$ %d,%02d".format(cents / 100, cents % 100)

Se preferir o comprovante pronto, imprima result.slip com pospinpad.print(result.slip!!).

Tela Secundária (OCTA 400)​

if (pospinpad.hasSecondaryDisplay()) {
pospinpad.initSecondaryDisplay() // → onSecondaryDisplayReady()
}

// após onSecondaryDisplayReady():
pospinpad.setSecondaryDisplayBrightness(80)
pospinpad.showMessageOnSecondaryDisplay("Bem-vindo!")

// no onDestroy:
pospinpad.releaseSecondaryDisplay()

Testes sem Hardware​

O artefato apossdk-debug-staging inclui um POS simulado, com respostas de exemplo. createFake não existe no artefato de produção, então a chamada precisa ficar num source set separado por build type, com a mesma função nos dois:

// app/src/debug/java/.../PinpadFactory.kt
import com.tupifintech.apossdk.sdk.api.createFake // extensão

fun criarPinpad(context: Context, callback: PosPinpadCallback): PosPinpad =
PosPinpad.createFake(context, callback)

// app/src/release/java/.../PinpadFactory.kt
fun criarPinpad(context: Context, callback: PosPinpadCallback): PosPinpad =
PosPinpad.create(context, callback)

Mais Recursos​

  • Código de referência: módulo :sample do projeto yby-android-mobile-pos-sdk (PaymentViewModel.kt, PinScreen.kt)
  • API PosPinpad — referência completa
  • Callbacks — todos os eventos
  • Modelos — estruturas de dados e códigos de erro