Pular para conteúdo

Payment Mapping — Nayax → Saipos

TL;DR: Convertemos eventos de pagamento da Nayax em SaiposOrderDto, mapeando tipos/códigos de pagamento, normalizando valores, gerando IDs válidos, equilibrando somatórios e persistindo tudo antes de enfileirar o outbox job.


Escopo

  • Entrada: Payload de transação Nayax (ev)
  • Saída: SaiposOrderDto pronto para envio ao Saipos
  • Uso: Pipeline de integração (webhook Nayax → serviço → fila Saipos)
  • Arquivo: reference/payment-mapping.md

Tipos & Contratos

import { SaiposOrderDto } from '../dto/nayax.dto';

type SaiposType = 'ONLINE' | 'OFFLINE';
type SaiposCode = 'PARTNER_PAYMENT' | 'DIN' | 'DEB' | 'CARD' | 'CRE' | 'VALE' | 'OTHER';

interface SaiposPaymentOut {
  code: SaiposCode;
  amount: number;
  change_for: number;
  complement: string;
  type: SaiposType;
}

Tabela de Mapeamento (TENDER_MAP)

Nayax tenderType Saipos code type complement Observações
1 DIN OFFLINE Dinheiro
2 CRE OFFLINE "" Cartão genérico
3 OTHER OFFLINE "Cash Change"
8 DEB OFFLINE Débito
14 CRE OFFLINE Amex
16 CRE OFFLINE Visa
18 CRE OFFLINE Diners
28 CRE OFFLINE Mastercard
23 VALE OFFLINE Voucher
37 PARTNER_PAYMENT ONLINE "" Transferência
50 PARTNER_PAYMENT ONLINE "pix" Pix
4 null Rounding (ignorado)
5 null Cupom (tratado em desconto)
7 null Gorjeta (ignorada no total)

Heurística de Fallback

  • Texto contém cash/dinheiroDIN/OFFLINE
  • pix/online/qrPARTNER_PAYMENT/ONLINE (complement "pix" se houver “pix”)
  • debit/débito/immediateDEB/OFFLINE
  • credit/crédito/visa/master/amex/dinersCRE/OFFLINE
  • Caso contrário → OTHER/OFFLINE

Regras de ID & Display

  • order_id: máx 30 caracteres
  • display_id: máx 15 caracteres
  • Transação de teste (isTestTransaction ou storeCode === 'STORE1001'):
  • Sufixa -<epoch> curto
  • Trunca base conforme espaço disponível

Itens, Modificadores e Totais

  • Cada item:
  • integration_codeitemCode
  • desc_itemitemName
  • unit_priceprice ou amount
  • quantity ← número
  • choice_items a partir de modifiers:
    • integration_code, desc_item_choice, aditional_price, quantity
  • Gross dos itens = soma de (preço × qty) + modificadores
  • Desconto = soma absoluta de coupons[].couponSum
  • total_increase = total_amount − (itemsGrosstotal_discount) se positivo; senão 0
  • Arredondamento: função to2 (2 casas decimais)

Pagamentos & Balanceamento

  1. Constrói payment_types a partir de ev.payments aplicando mapPaymentByTender.
  2. Ignora pagamentos sem valor ou inválidos.
  3. Se vazio, injeta 1 linha OTHER/OFFLINE cobrindo total_amount.
  4. Balanceamento:
  5. Ajusta o último pagamento para fechar diferença (target - sumBefore).
  6. Se ficar negativo, redistribui consumindo anteriores.
  7. Corrige “poeira” de < 0.01.

Saída (SaiposOrderDto)

{
  "order_id": "5417-STORE1001-...-1761746662",
  "display_id": "123456789-17617",
  "cod_store": "COD_STORE_SAIPOS",
  "created_at": "2025-10-29T14:04:05Z",
  "notes": "Nayax pos=POS001 store=STORE1001",
  "total_increase": 45,
  "total_discount": 0,
  "total_amount": 100,
  "customer": {
    "id": "5417",
    "name": "Cliente",
    "phone": ""
  },
  "order_method": {
    "mode": "TICKET",
    "scheduled": false,
    "delivery_date_time": "2025-10-29T14:04:05Z"
  },
  "items": [
    {
      "integration_code": "ITEM001",
      "desc_item": "Test Item Name",
      "quantity": 1,
      "unit_price": 50,
      "notes": "",
      "choice_items": [
        {
          "integration_code": "MOD001",
          "desc_item_choice": "Extra Cheese",
          "aditional_price": 5,
          "quantity": 1,
          "notes": ""
        }
      ]
    }
  ],
  "payment_types": [
    {
      "code": "PARTNER_PAYMENT",
      "amount": 100,
      "change_for": 0,
      "type": "ONLINE",
      "complement": "pix"
    }
  ]
}

Funções-Chave

export function mapNayaxToSaipos(ev: any, cod_store_saipos: string): SaiposOrderDto
  • Normaliza datas com toISOString() sem milissegundos
  • Trata isTestTransaction/STORE1001
  • Monta itens/modificadores
  • Calcula total_discount, total_increase
  • Gera e balanceia payment_types

Ingestão & Persistência (NayaxService)

@Injectable()
export class NayaxService {
  async ingestAndEnqueue(dto: any, actor: { id: string; email: string } | null = null)
}

Regras de negócio críticas:

  • Auth obrigatória; ator precisa ter vínculo userRestaurant com role=OWNER
  • Idempotência por transactionKey
  • Teste (STORE1001/isTestTransaction) → transactionKey único com -Date.now()
  • Se já existir:
    • Teste → ignora duplicata
    • Produção → 400 BadRequest (“Transação já processada”)
  • Persistência:
  • nayaxEvent (cabeçalho + rawPayload)
  • nayaxItem + nayaxModifier + nayaxTaxInfo + nayaxPromotion
  • nayaxTaxInvoice
  • nayaxEmployee
  • nayaxPayment (com parsing: números, datas, maskLast4, etc.)
  • Outbox:
  • Cria outboxJob (action = CREATE se transactionType===1, senão CANCEL)
  • Trata P2002 (job já existe) com log informativo

Regras de Data & Hora

  • created_at/delivery_date_timetransactionDate em ISO (sem milissegundos)
  • transactionStartDateTime/transactionEndDateTime persistidos quando presentes
  • parseExpireDate aceita formatos: ISO, MM/YY, MM/YYYY, YYYY-MM

Edge Cases & Decisões de Design

  • Sem pagamentos → 1 linha OTHER/OFFLINE cobrindo 100% do valor
  • Cupom/Gorjeta/Rounding:
  • Cupom: entra no desconto
  • Gorjeta/Rounding: não alteram payment_types; rounding (tender 4) é ignorado
  • Poeira de centavos (< 0,01) corrigida no último pagamento
  • Telefone do cliente: apenas dígitos (replace(/\D/g, ''))
  • IDs truncados antes do sufixo de teste (quando necessário)

Exemplo de Uso (pseudo-pipeline)

// controller
@Post('/webhooks/nayax')
async receive(@Body() payload: any, @Req() req: any) {
  const actor = req.user; // precisa estar autenticado
  await this.nayaxService.ingestAndEnqueue(payload, actor);
  return { ok: true };
}

// worker (later)
const order = mapNayaxToSaipos(nayaxEvent.rawPayload, store.codStoreSaipos);
await saiposClient.createOrder(order);

Checklist de Qualidade

  • order_id ≤ 30 e display_id ≤ 15 (incluindo sufixo de teste)
  • payment_types soma == total_amount (±0,01 fixado)
  • total_increase coerente com gross − desconto
  • Nenhum pagamento com amount <= 0
  • isTestTransaction respeitado (duplicatas ignoradas)
  • Outbox criado uma única vez (idempotência)
  • phone somente dígitos
  • Datas ISO sem milissegundos

Troubleshooting

  • Valores não fecham → valide itemsGross, total_discount, total_amount; verifique se gorjetas/rounding vieram como itens e não em payments.
  • Pix virou OTHER → cheque se tenderType = 50 ou se o texto contém “pix/qr/online”.
  • Duplicata em produção → confirme transactionKey e transactionType.
  • Cartão sem bandeira → cai em CRE genérico (ok por design).

Changelog

  • v1.0.0: Mapeamento inicial, heurística de fallback, balanceamento de pagamentos, idempotência e outbox.

Referência Rápida (snippets)

mapPaymentByTender

function mapPaymentByTender(
  p: any,
): { code: SaiposCode; type: SaiposType; complement: string } | null
  • Retorna null para tenderTypes ignorados (4,5,7)
  • Aplica heurística textual quando necessário

Normalizador de Casas Decimais

function to2(n: number) {
  return Math.round((n + Number.EPSILON) * 100) / 100;
}