Uma interface, quatro parceiros, uma resposta só

Toda integração de parceiro começa igual: “é só um cliente HTTP, faço num service e pronto”. Aí entra o segundo parceiro. Depois o terceiro. E quando você percebe, o Controller virou um if/elseif gigante decidindo qual API chamar, qual campo ler, qual formato devolver. Cada parceiro novo é uma cirurgia de risco no código que já existe.

Eu vivo isso num projeto de cashback que conversa com quatro parceiros diferentes ao mesmo tempo (vou chamá-los aqui de FidelidadeA, FidelidadeB, FidelidadeC e FidelidadeD). Quatro APIs, quatro autenticações, quatro formatos de resposta — e um único frontend de PDV que não quer saber de nada disso. Ele manda um cupom e espera uma resposta só, sempre no mesmo shape.

A solução não tem nada de mágico. É interface + factory + um normalizador de saída, tudo amarrado pelo container do Laravel. Mas a diferença entre fazer isso e não fazer é a diferença entre adicionar um parceiro novo em uma tarde ou em uma sprint inteira. Bora destrinchar.

O problema real: cada ponta de entrada é um mundo

Pensa no que cada parceiro exige na prática:

  • FidelidadeA usa OAuth (client_credentials), devolve redeemables, tem PIN criptografado em AES que precisa ser descriptografado.
  • FidelidadeB separa resposta em discounts e products, tem redeemType, identifica cliente por uniqueId.
  • FidelidadeC trabalha com gift, percentual vs. valor fixo, lógica de cashback acumulado.
  • FidelidadeD tem balance, card_number, data de expiração em outro formato.

Cada um manda CPF num campo diferente, chama valor de um jeito, estrutura “o que o cliente pode usar” de outra forma. Se isso vaza pro resto do sistema, todo lugar que consome cashback precisa conhecer os quatro. É o famoso acoplamento que te persegue: muda a API de um parceiro e você reza pra não ter esquecido nenhum if.

O segredo é traçar uma linha. De um lado, o caos específico de cada parceiro. Do outro, o sistema que só fala uma língua. A interface é essa linha.

O contrato: uma interface que define a língua comum

namespace App\Cashback\V1\Contracts;

use App\Models\CashBackAuth;

interface CashbackInterface
{
    public function auth(): ?CashBackAuth;
    public function search(array $data): ?array;
    public function validate(array $data): ?array;
    public function confirm(array $data): ?array;
    public function refund(array $data): ?array;
    public function addCashback(array $data): ?array;
    public function benefits(array $data): ?array;
}

Sete métodos. É o ciclo de vida inteiro de um cashback: autentica, busca, valida, confirma, estorna. Não importa se por baixo é OAuth da FidelidadeA ou token simples da FidelidadeD — quem consome só conhece esses sete verbos.

Cada parceiro implementa o contrato do seu jeito:

class FidelidadeACashback implements CashbackInterface
{
    use NormalizeResultCashback;

    public function __construct(
        private CashBackConfig $cashBackConfig,
        private CashBackAuthRepositoryInterface $authRepository
    ) {}

    public function search(array $data): ?array
    {
        // ... chama a API do parceiro, com retry em timeout
        return $this->prepareResponseData($resultData, $data);
    }
    // confirm(), refund(), auth()... cada um com a regra da FidelidadeA
}

O FidelidadeBCashback, FidelidadeCCashback e FidelidadeDCashback seguem a mesma assinatura, cada um com a doideira da própria API encapsulada lá dentro. O caos não some — ele só fica trancado em uma classe, sem vazar.

A factory: traduzindo “tipo” em implementação

Beleza, mas quem decide se hoje é FidelidadeA ou FidelidadeB? Essa decisão tem que morar em um lugar só. Esse lugar é a factory:

class CashbackFactory
{
    public function __construct(
        private CashBackAuthRepositoryInterface $authRepository
    ) {}

    public function make(CashBackConfig $cashBackConfig): CashbackInterface
    {
        return match (strtolower($cashBackConfig->tipo)) {
            'fidelidade_a' => new FidelidadeACashback($cashBackConfig, $this->authRepository),
            'fidelidade_b' => new FidelidadeBCashback($cashBackConfig, $this->authRepository),
            'fidelidade_c' => new FidelidadeCCashback($cashBackConfig, $this->authRepository),
            'fidelidade_d' => new FidelidadeDCashback($cashBackConfig, $this->authRepository),
            default           => throw new InvalidArgumentException("Empresa '{$cashBackConfig->tipo}' não suportada."),
        };
    }
}

Repara que o retorno é CashbackInterface, não a classe concreta. Quem chama make() recebe “um cashback” e ponto — não sabe nem quer saber qual. E aquele default com exceção é ouro: parceiro não configurado falha na hora, alto e claro, em vez de devolver null silencioso que vira bug três camadas adiante.

O match do PHP 8 deixa isso lindo de ler. Adicionar parceiro? Uma linha nova. É o único ponto do sistema que conhece todos os tipos.

Onde o Laravel brilha: o container fazendo o trabalho sujo

Aqui é onde quem vem de Laravel sorri. No AppServiceProvider, você amarra os contratos às implementações:

// Repository bindings
$this->app->bind(CashBackAuthRepositoryInterface::class, CashBackAuthRepository::class);

// Service bindings
$this->app->bind(SearchCupomServiceInterface::class, SearchCupomService::class);

// Factory
$this->app->bind('App\Cashback\V1\CashbackFactory', CashbackFactory::class);

Olha o detalhe esperto: a CashbackFactory depende de CashBackAuthRepositoryInterface no construtor. Você nunca instancia o repository na mão — o container resolve sozinho quando pede a factory. É a injeção de dependência trabalhando de graça pra você.

E no controller, a coisa fica fluida:

protected function buildSearchCupomUseCase(CashBackConfig $cashbackConfig, string $token): SearchCupomUseCase
{
    $cashbackRepository = app()
        ->make('App\Cashback\V1\CashbackFactory')
        ->make($cashbackConfig);

    $searchCupomService = app()->make(SearchCupomServiceInterface::class, [
        'repository' => $cashbackRepository,
        'token'      => $token,
    ]);

    return new SearchCupomUseCase($searchCupomService);
}

O service recebe um CashbackInterface via repository e processa. Ele não tem ideia se é FidelidadeA ou FidelidadeB — só chama search(). O parâmetro tipo veio da config do lojista no banco; o resto se monta sozinho. Esse é o ponto que vale gravar: a ponta de entrada (o tipo do parceiro) é dado, não é código. Trocar de parceiro é mudar uma linha numa tabela, não fazer deploy.

A saída normalizada: o NormalizeResultCashback

Falta a outra ponta. As quatro APIs respondem em formatos diferentes, mas o PDV precisa de um shape só. É aí que entra a trait que cada implementação usa:

public function normalizeCashbackResult(array $normalizedData, $originalData): array
{
    $tipo = $normalizedData['tipo'] ?? 'unknown';
    $data = array_merge($custom, $normalizedData, $originalData);

    return [
        'original_data'   => $myOriginalData, // resposta crua, pra debug e auditoria
        'normalized_data' => match ($tipo) {
            'fidelidade_a' => $this->normalizeFidelidadeAResult($data),
            'fidelidade_b' => $this->normalizeFidelidadeBResult($data),
            'fidelidade_c' => $this->normalizeFidelidadeCResult($data),
            'fidelidade_d' => $this->normalizeFidelidadeDResult($data),
            default        => $this->normalizeDefaultResult($data),
        },
        'type'            => $tipo,
        'processed_at'    => now()->toISOString(),
    ];
}

Cada normalizeXResult() pega a bagunça do parceiro e devolve sempre as mesmas chaves: customer_data, balance, redeemables, provider_data, card_data, store_data. O normalizeFidelidadeAResult, por exemplo, ainda descriptografa o PIN AES antes de montar a saída — toda a sujeira mora aqui, isolada.

Dois detalhes que valem por si só:

  1. original_data sempre junto. Você normaliza pra fora, mas guarda o cru. Quando o parceiro jura que mandou o valor certo e o cliente diz que não recebeu, você tem a resposta original pra provar quem está certo. Em integração de pagamento isso não é luxo, é sobrevivência.
  2. default no match. Parceiro novo que ainda não tem normalizador não quebra a aplicação — cai num formato padrão. Degrada com elegância em vez de explodir.

Os ganhos, sem enrolação

Vale a pena listar o que essa estrutura compra na prática:

  • Parceiro novo = um arquivo + duas linhas. Cria a classe implements CashbackInterface, adiciona um case na factory e um normalizeXResult(). Zero alteração no que já roda.
  • O resto do sistema é imutável. Controllers, services e use cases falam com a interface. Mudou a API da FidelidadeA? O estrago fica dentro de FidelidadeACashback.
  • Testável de verdade. Como tudo depende de CashbackInterface, você dá mock nela e testa o fluxo sem bater em nenhuma API externa. (Tem tests/Unit/NormalizeResultCashbackTest.php justamente garantindo a saída.)
  • Onboarding mais rápido. Dev novo entende “tem um contrato, tem implementações, tem uma factory” em cinco minutos. Não precisa ler 2 mil linhas de if.
  • Saída previsível. Quem consome a API confia que normalized_data é sempre igual. Contrato de saída estável é o que deixa o frontend dormir tranquilo.

Não tem padrão exótico aqui. É interface (o contrato), factory (a escolha em um lugar só) e um normalizador (a saída única), costurados pelo container do Laravel. A graça é o que isso elimina: o acoplamento entre “de onde o dado vem” e “como o sistema usa o dado”.

Quando você tem várias pontas de entrada possíveis — parceiros de pagamento, gateways, provedores de qualquer coisa —, o reflexo certo é desenhar o contrato primeiro e empurrar a bagunça pra dentro das implementações. O dia em que chegar o quinto parceiro, você vai agradecer: vai ser uma tarde, não uma sprint. E o melhor, sem medo de quebrar o que já está em produção.

A pergunta que eu sempre faço antes de codar uma integração: “se amanhã aparecer mais um desses, quanto do meu código eu vou ter que mexer?”. Se a resposta for “muito”, a abstração ainda não está no lugar.