feat: let production select Mercado Pago and acknowledge simulated notifications

The production compose hard-coded the fake payment adapter; it now takes
PAYMENT_ADAPTER and the MP_* settings from the stack's environment, so the
sandbox can run with test credentials. A signed notification about a payment
Mercado Pago does not have, such as the panel's "Simular notificação", is
acknowledged instead of answering 500 and being retried; any other lookup
failure still raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-28 10:41:18 -03:00
parent 60b7336b37
commit bd56170248
4 changed files with 40 additions and 5 deletions

View File

@@ -134,7 +134,15 @@ class MercadoPagoPayment:
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '') payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
if not payment_id.isdigit(): if not payment_id.isdigit():
return None return None
payment = self.lookup(payment_id) try:
payment = self.lookup(payment_id)
except httpx.HTTPStatusError as error:
# Signed by Mercado Pago but about no payment of ours, such as the
# panel's "Simular notificação". Anything else is raised so that a
# real notification is retried.
if error.response.status_code == 404:
return None
raise
return event_from_payment(payment, notification_id=str(data.get('id', ''))) return event_from_payment(payment, notification_id=str(data.get('id', '')))

View File

@@ -27,6 +27,10 @@ POSTGRES_VOLUME=TBD
OPERATOR_EMAIL=TBD OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD PAYMENT_ADAPTER=TBD
# With PAYMENT_ADAPTER=mercadopago. MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET are
# secrets: enter them in Portainer only, never in this file.
MP_NOTIFICATION_URL=https://<SITE_DOMAIN>/api/payments/webhook
MP_PUBLIC_KEY=
FREIGHT_ADAPTER=TBD FREIGHT_ADAPTER=TBD
TINY_ADAPTER=TBD TINY_ADAPTER=TBD
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The # Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The

View File

@@ -19,10 +19,20 @@ x-app-environment: &app-environment
# this value is configured. # this value is configured.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-} OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake # fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
# Optional: without it the webhook verifies nothing and therefore accepts # and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
# nothing, which is the correct state until a provider is connected. Set it # credentials (TEST-...) until the sandbox flows have passed. The card form
# when the provider is configured, never to a value anyone could guess. # appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too.
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
# The "assinatura secreta" from the webhook settings in Mercado Pago.
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
# https://<SITE_DOMAIN>/api/payments/webhook, sent with every payment.
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
# The fake adapter's secret. Optional: without it the webhook verifies
# nothing and therefore accepts nothing, which is the correct state until a
# provider is connected. Never set it to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
FREIGHT_ADAPTER: fake FREIGHT_ADAPTER: fake
# Order creation in Tiny stays off until it has been tested against the # Order creation in Tiny stays off until it has been tested against the

View File

@@ -36,6 +36,10 @@ class MercadoPagoTests(unittest.TestCase):
self.requests.append(request) self.requests.append(request)
if request.method == 'GET': if request.method == 'GET':
payment_id = request.url.path.rsplit('/', 1)[-1] payment_id = request.url.path.rsplit('/', 1)[-1]
if payment_id == '500':
return httpx.Response(500, json={'message': 'internal_error'})
if payment_id not in self.payments:
return httpx.Response(404, json={'message': 'Payment not found'})
return httpx.Response(200, json=self.payments[payment_id]) return httpx.Response(200, json=self.payments[payment_id])
body = json.loads(request.content) body = json.loads(request.content)
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer', payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
@@ -78,6 +82,15 @@ class MercadoPagoTests(unittest.TestCase):
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token') self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode())) self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
def test_notification_for_an_unknown_payment_is_acknowledged(self):
# The panel's "Simular notificação" sends a payment id that does not
# exist. Raising would answer 500 and Mercado Pago would retry for ever.
body = json.dumps({'id': 43, 'type': 'payment', 'data': {'id': '123456'}}).encode()
self.assertIsNone(self.mp.parse(body, {'data.id': '123456', 'type': 'payment'}))
# Any other failure still raises, so a real notification is retried.
with self.assertRaises(httpx.HTTPStatusError):
self.mp.parse(json.dumps({'type': 'payment', 'data': {'id': '500'}}).encode(), {'data.id': '500'})
def test_amounts_outside_brl_centavos_are_not_trusted(self): def test_amounts_outside_brl_centavos_are_not_trusted(self):
for payment in ({'currency_id': 'USD', 'transaction_amount': 10}, for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
{'currency_id': 'BRL', 'transaction_amount': 10.001}, {'currency_id': 'BRL', 'transaction_amount': 10.001},