← Pagamentos e SEPA: decisões de projeto e operação
07 / 10 · 60 MIN

Oficina: contrato VOP e respostas ambíguas

Diagnosticar respostas incompatíveis e construir um consumidor limitado que conserva a diferença entre resultado funcional e erro técnico.

Fixar os documentos e o âmbito

Nesta oficina, o contrato de referência é a API VOP 1.1.1, com entrada em vigor em 20 de setembro de 2026. O rulebook aplicável tem a sua própria versão 1.1. Guardar ambos no registo de dependências: igualar os números faria perder a identidade do documento implementado. O código é um consumidor didático original de respostas inventadas. Não é um cliente completo, algoritmo de comparação ou implementação de segurança. ACCOUNT-A é um marcador local, não um IBAN. Antes de adaptar este exercício, identificar quais componentes ficam por implementar e quem aceita cada parte da integração.

Verificar o ramo esperado

Preparar duas famílias de fixtures: nome e identificador de pessoa coletiva. A resposta tem de corresponder ao ramo pedido. O ramo de nome admite MTCH, NMTC, CMTC e NOAP; o de identificação não admite CMTC. No exercício, misturar os dois campos provoca rejeição. Para CMTC, matchedName acompanha o resultado. O consumidor limita o nome a 140 caracteres e exige texto não vazio, sendo esta última verificação uma escolha local de usabilidade. Não verifica todo o conjunto de caracteres permitido. O aluno deve explicar precisamente o que o teste demonstrou, evitando chamar-lhe validação integral do schema.

Conservar a diferença entre erro e comparação

Uma equipa fictícia conta todas as respostas recebidas como verificações positivas. O primeiro passo de diagnóstico é separar o transporte do resultado. O contrato usa application/json para sucesso e application/problem+json para erro detalhado. O modelo exige type e code textuais no erro, sem implementar todas as restrições oficiais desses campos. Um erro 503 permanece API_ERROR; não é convertido em NMTC. A diferença muda o responsável pela investigação: indisponibilidade técnica não demonstra que o cliente escreveu um nome incorreto. O painel deve permitir contar falhas e resultados funcionais separadamente, conservando o universo de pedidos usado.

Recusar ambiguidades antes de interpretar

A fixture com dois membros partyNameMatch contém primeiro MTCH e depois NMTC. Uma biblioteca que conserva o último esconde o conflito original. O exercício usa object_pairs_hook para recusar membros repetidos antes de criar o objeto final e parse_constant para recusar NaN. A RFC recomenda nomes únicos; a política de rejeição é uma escolha explícita deste consumidor. Existe também um limite local de 2048 caracteres do texto, não de bytes. Estas verificações servem fixtures controladas e não provam resistência a entradas hostis. Introduzir uma falha de cada vez permite distinguir parsing, tipo do objeto e contrato funcional.

Desenhar o diagnóstico com o fornecedor

No caso da Oficina Aurora DR, o frontend promove CMTC sem nome a correspondência exata. Pedir ao fornecedor a fixture mínima, resultado esperado, resultado observado e ponto onde ocorre a transformação. Não preencher o campo em falta com o nome do pedido: isso fabricaria uma observação do respondente. Um teste negativo deve acompanhar a correção para impedir regressões. Distinguir também um identificador indisponível para o beneficiário de um tipo não suportado no diretório: não são a mesma condição. O laboratório não consulta esse diretório; esta dependência deve aparecer separadamente no plano de qualificação e na decisão de aceitação.

Executar e explicar a evidência

Reservar dez minutos para prever os resultados, vinte para executar e alterar fixtures, vinte para discutir as falhas e dez para preparar a passagem de informação. Copiar o programa completo abaixo e executar python3 run.py --output evidence.json numa pasta local. Usa apenas a biblioteca padrão. O ficheiro de evidência regista versão do intérprete, hash do programa e 40 verificações, incluindo a associação de pedidos da aula seguinte. Guardar também as previsões e divergências observadas. A execução demonstra estes casos locais; não demonstra uma ligação a PSP, consulta de conta real ou autorização de pagamento.

"""Original DR VOP response-consumer and draft-binding exercise. Python 3.13.
python3 run.py --output evidence.json
In-script fixtures only. No actual IBAN, account lookup, matching algorithm,
HTTP request, payment, authentication, full schema or scheme conformance.
Registry, draft revision, binding and decision labels are local DR rules.
"""
import argparse
import hashlib
import json
from pathlib import Path
import sys

checks=[]
def check(name,actual,expected):
    assert actual==expected,(name,actual,expected)
    checks.append(dict(name=name,actual=actual,expected=expected,passed=True))
def rejection(fn):
    try:fn()
    except ValueError as e:return str(e)
    raise AssertionError('Expected rejection')
def pairs_unique(pairs):
    obj={}
    for k,v in pairs:
        if k in obj:raise ValueError('duplicate JSON member')
        obj[k]=v
    return obj
def invalid_constant(value):raise ValueError('non JSON constant')
def parse(body):
    if len(body)>2048:raise ValueError('local size limit')
    value=json.loads(body,object_pairs_hook=pairs_unique,parse_constant=invalid_constant)
    if not isinstance(value,dict):raise ValueError('expected object')
    return value

def consume(mode,http,media,body):
    if mode not in {'name','id'}:raise ValueError('unknown local mode')
    value=parse(body)
    media=media.split(';')[0].strip().lower()
    if http!=200:
        if media!='application/problem+json':raise ValueError('error media type')
        if not all(isinstance(value.get(k),str) and value[k] for k in ('type','code')):raise ValueError('incomplete problem')
        return dict(result='API_ERROR',displayName=None)
    if media!='application/json':raise ValueError('success media type')
    key='partyNameMatch' if mode=='name' else 'partyIdMatch'
    other='partyIdMatch' if mode=='name' else 'partyNameMatch'
    if key not in value or other in value:raise ValueError('response branch')
    result=value[key]
    allowed={'MTCH','NMTC','NOAP'}|({'CMTC'} if mode=='name' else set())
    if not isinstance(result,str) or result not in allowed:raise ValueError('result code')
    name=value.get('matchedName')
    if result=='CMTC':
        # Nonblank requirement is a local usability check; full character set omitted.
        if not isinstance(name,str) or not name.strip() or len(name)>140:raise ValueError('close match name')
    elif 'matchedName' in value:raise ValueError('unexpected matched name')
    return dict(result=result,displayName=name)

def signature(draft):
    return tuple(draft[k] for k in ('revision','environment','mode','account','party'))
def register(registry,request_id,draft):
    snap=signature(draft)
    if request_id in registry and registry[request_id]!=snap:raise ValueError('request identity conflict')
    registry[request_id]=snap
def applicable(registry,request_id,current):
    return request_id in registry and registry[request_id]==signature(current)
def within_vop_limit(sent_ms,received_ms):
    elapsed=received_ms-sent_ms
    if elapsed<0:raise ValueError('invalid elapsed time')
    return elapsed<=5000

def main():
    def response(mode='name',http=200,media='application/json',**value):
        return consume(mode,http,media,json.dumps(value))
    for code in ('MTCH','NMTC','NOAP'):
        check('name '+code,response(partyNameMatch=code),dict(result=code,displayName=None))
    check('name close match',response(partyNameMatch='CMTC',matchedName='Oficina Aurora DR'),dict(result='CMTC',displayName='Oficina Aurora DR'))
    for code in ('MTCH','NMTC','NOAP'):
        check('id '+code,response(mode='id',partyIdMatch=code),dict(result=code,displayName=None))
    check('id close match rejected',rejection(lambda:response(mode='id',partyIdMatch='CMTC')),'result code')
    check('wrong branch rejected',rejection(lambda:response(partyIdMatch='MTCH')),'response branch')
    check('both branches rejected',rejection(lambda:response(partyNameMatch='MTCH',partyIdMatch='MTCH')),'response branch')
    check('missing close match name',rejection(lambda:response(partyNameMatch='CMTC')),'close match name')
    check('blank close match name',rejection(lambda:response(partyNameMatch='CMTC',matchedName=' ')),'close match name')
    check('long close match name',rejection(lambda:response(partyNameMatch='CMTC',matchedName='A'*141)),'close match name')
    check('name without close match rejected',rejection(lambda:response(partyNameMatch='MTCH',matchedName='Oficina Aurora DR')),'unexpected matched name')
    check('unknown code rejected',rejection(lambda:response(partyNameMatch='SUCCESS')),'result code')
    check('numeric code rejected',rejection(lambda:response(partyNameMatch=1)),'result code')
    check('duplicate JSON member rejected',rejection(lambda:consume('name',200,'application/json','{"partyNameMatch":"MTCH","partyNameMatch":"NMTC"}')),'duplicate JSON member')
    check('NaN rejected',rejection(lambda:consume('name',200,'application/json','{"partyNameMatch":NaN}')),'non JSON constant')
    check('array rejected',rejection(lambda:consume('name',200,'application/json','[]')),'expected object')
    check('local size bound rejected',rejection(lambda:parse(' '*2049)),'local size limit')
    check('media parameter accepted',response(media='application/json; charset=utf-8',partyNameMatch='MTCH')['result'],'MTCH')
    check('HTML success rejected',rejection(lambda:response(media='text/html',partyNameMatch='MTCH')),'success media type')
    problem=response(http=400,media='application/problem+json',type='urn:dr:training:invalid-request',code='DR-FIXTURE')
    check('problem remains API error',problem,dict(result='API_ERROR',displayName=None))
    check('wrong error media rejected',rejection(lambda:response(http=400,type='urn:dr:training:error',code='DR-FIXTURE')),'error media type')
    check('incomplete problem rejected',rejection(lambda:response(http=503,media='application/problem+json',type='urn:dr:training:error')),'incomplete problem')
    draft=dict(revision=1,environment='DR-TEST',mode='name',account='ACCOUNT-A',party='Oficina Aurora DR')
    registry={};register(registry,'DR-Q1',draft)
    check('response bound to unchanged draft',applicable(registry,'DR-Q1',draft),True)
    for key,value in [('revision',2),('environment','DR-OTHER'),('mode','id'),('account','ACCOUNT-B'),('party','Atelier Boreal DR')]:
        check('changed '+key+' invalidates observation',applicable(registry,'DR-Q1',{**draft,key:value}),False)
    check('unknown request isolated',applicable(registry,'DR-Q9',draft),False)
    register(registry,'DR-Q1',draft)
    check('same local registration unchanged',len(registry),1)
    check('identity conflict rejected',rejection(lambda:register(registry,'DR-Q1',{**draft,'account':'ACCOUNT-B'})),'request identity conflict')
    current={**draft,'revision':2,'account':'ACCOUNT-B'};register(registry,'DR-Q2',current)
    check('new response applies',applicable(registry,'DR-Q2',current),True)
    check('late old response stays stale',applicable(registry,'DR-Q1',current),False)
    check('within five seconds',within_vop_limit(1000,5999),True)
    check('at five-second boundary',within_vop_limit(1000,6000),True)
    check('beyond five-second boundary',within_vop_limit(1000,6001),False)
    check('negative duration rejected',rejection(lambda:within_vop_limit(1000,999)),'invalid elapsed time')
    output=dict(scope='Original bounded response-consumer and draft-binding fixtures; no actual matching, VOP API, full conformance, payment authorization or execution.',python=sys.version.split()[0],runnerSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),passed=len(checks),checks=checks)
    parser=argparse.ArgumentParser();parser.add_argument('--output',required=True)
    Path(parser.parse_args().output).write_text(json.dumps(output,indent=2)+'\n');print(json.dumps({'passed':len(checks),'scope':output['scope']}))
if __name__=='__main__':main()
NA PRÁTICA

Caso: o fornecedor devolve os dois ramos na mesma resposta e a interface escolhe o positivo. O ensaio deve recusar a mistura e conservar o pedido que a originou.

Armadilhas comuns

Aceitar JSON como prova de conformidade; transformar erro em no match; preencher nomes ausentes; confundir fixtures com uma integração qualificada.

Tópicos relacionados: Prazos, confirmações e resultados tardios · Mandatos, Core e B2B · Alterações e prontidão operacional

Leva esta ideia contigo

Uma resposta utilizável exige interpretar o ramo, o código e os campos no contexto correto, conservando as falhas que o consumidor realmente observou.

Criar conta

Referência: EPC VOP Inter-PSP API specifications 1.1.1 · DR Payments and SEPA professional assessment2026.10