From da10a36a1d73b5219685f4ef1f0684326b8556e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Tue, 13 Jan 2026 03:18:05 -0300 Subject: [PATCH] add support to milti transaction --- .../app/routes/orders/checkout.py | 6 +- orders-events/app/app.py | 60 ++++++++++++--- .../app/events/payments/charge_credit_card.py | 73 ++++++++++++++++++- .../app/events/payments/create_invoice.py | 55 +++++++++----- orders-events/app/iugu.py | 3 + orders-events/template.yaml | 16 +++- .../payments/test_charge_credit_card.py | 48 ++++++++++++ .../events/payments/test_create_invoice.py | 36 ++++++++- ...dit.json => iugu_invoice_credit_card.json} | 0 orders-events/tests/seeds.jsonl | 3 +- orders-events/tests/test_iugu.py | 43 +++++------ 11 files changed, 287 insertions(+), 56 deletions(-) create mode 100644 orders-events/tests/events/payments/test_charge_credit_card.py rename orders-events/tests/samples/{iugu_invoice_credit.json => iugu_invoice_credit_card.json} (100%) diff --git a/api.saladeaula.digital/app/routes/orders/checkout.py b/api.saladeaula.digital/app/routes/orders/checkout.py index e5393be..950c977 100644 --- a/api.saladeaula.digital/app/routes/orders/checkout.py +++ b/api.saladeaula.digital/app/routes/orders/checkout.py @@ -40,7 +40,7 @@ class PaymentMethod(str, Enum): PIX = 'PIX' CREDIT_CARD = 'CREDIT_CARD' BANK_SLIP = 'BANK_SLIP' - MANUAL = 'MANUAL' + # MANUAL = 'MANUAL' class User(BaseModel): @@ -213,8 +213,8 @@ def checkout(payload: Checkout): ) transact.put( item={ - 'id': 'TRANSACTION', - 'sk': order_id, + 'id': order_id, + 'sk': 'CREDIT_CARD#PAYMENT_INTENT', 'ttl': ttl(start_dt=now_, minutes=5), 'created_at': now_, } diff --git a/orders-events/app/app.py b/orders-events/app/app.py index d3ddc75..b7727d1 100644 --- a/orders-events/app/app.py +++ b/orders-events/app/app.py @@ -1,3 +1,4 @@ +from enum import Enum from http import HTTPStatus from typing import Any from urllib.parse import parse_qsl @@ -7,6 +8,7 @@ from aws_lambda_powertools.event_handler.api_gateway import ( APIGatewayHttpResolver, Response, ) +from aws_lambda_powertools.event_handler.exceptions import NotFoundError from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dateutils import now @@ -21,35 +23,73 @@ app = APIGatewayHttpResolver(enable_validation=True) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) +class OrderNotFoundError(NotFoundError): ... + + +class InvoiceNotFoundError(NotFoundError): ... + + +class StatusAttr(Enum): + PAID = 'paid_at' + CANCELED = 'canceled_at' + REFUNDED = 'refunded_at' + EXPIRED = 'expired_at' + EXTERNALLY_PAID = 'paid_at' + + +def _status_attr(status: str) -> StatusAttr | None: + try: + return StatusAttr[status] + except KeyError: + return None + + @app.post('//postback') @tracer.capture_method def postback(order_id: str): decoded_body = dict(parse_qsl(app.current_event.decoded_body)) logger.info('IUGU Postback', decoded_body=decoded_body) - event = decoded_body['event'] - status = decoded_body['data[status]'].upper() - if event != 'invoice.status_changed': + now_ = now() + event = decoded_body['event'] + status = decoded_body.get('data[status]', '').upper() + status_attr = _status_attr(status) + + if event != 'invoice.status_changed' or not status_attr: return Response(status_code=HTTPStatus.NO_CONTENT) - try: - dyn.update_item( + with dyn.transact_writer() as transact: + transact.update( key=KeyPair(order_id, '0'), update_expr='SET #status = :status, \ + #status_attr = :now, \ updated_at = :now', cond_expr='attribute_exists(sk)', expr_attr_names={ '#status': 'status', + '#status_attr': status_attr.value, }, expr_attr_values={ ':status': status, - ':now': now(), + ':now': now_, }, + exc_cls=OrderNotFoundError, ) - except Exception: - return Response(status_code=HTTPStatus.NOT_FOUND) - else: - return Response(status_code=HTTPStatus.NO_CONTENT) + + if status == 'EXTERNALLY_PAID': + transact.update( + key=KeyPair(order_id, 'INVOICE'), + cond_expr='attribute_exists(sk)', + update_expr='SET externally_paid = :true, \ + updated_at = :now', + expr_attr_values={ + ':true': True, + ':now': now_, + }, + exc_cls=InvoiceNotFoundError, + ) + + return Response(status_code=HTTPStatus.NO_CONTENT) @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) diff --git a/orders-events/app/events/payments/charge_credit_card.py b/orders-events/app/events/payments/charge_credit_card.py index 62f8cee..73c8ee3 100644 --- a/orders-events/app/events/payments/charge_credit_card.py +++ b/orders-events/app/events/payments/charge_credit_card.py @@ -4,9 +4,12 @@ from aws_lambda_powertools.utilities.data_classes import ( event_source, ) from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dateutils import now from layercake.dynamodb import ( DynamoDBPersistenceLayer, + KeyPair, ) +from layercake.extra_types import CreditCard from boto3clients import dynamodb_client from config import IUGU_ACCOUNT_ID, IUGU_API_TOKEN, IUGU_TEST_MODE, ORDER_TABLE @@ -25,4 +28,72 @@ iugu = Iugu( @event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: ... +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + new_image = event.detail['new_image'] + order_id = new_image['id'] + invoice_id = new_image['invoice_id'] + installments = new_image['installments'] + credit_card = CreditCard(**new_image['credit_card']) + now_ = now() + + token = iugu.payment_token(credit_card) + charge = iugu.charge( + invoice_id=invoice_id, + token=token['id'], + installments=installments, + ) + + with dyn.transact_writer() as transact: + transact.delete(key=KeyPair(order_id, 'TRANSACTION')) + transact.update( + key=KeyPair(order_id, 'TRANSACTION#STATS'), + update_expr='SET #count = if_not_exists(#count, :zero) + :one, \ + updated_at = :now', + expr_attr_names={ + '#count': 'payment_attempts', + }, + expr_attr_values={ + ':zero': 0, + ':one': 1, + ':now': now(), + }, + ) + + if charge['success'] is True: + transact.update( + key=KeyPair(order_id, '0'), + update_expr='SET #status = :status, \ + paid_at = :now, \ + updated_at = :now', + expr_attr_names={ + '#status': 'status', + }, + expr_attr_values={ + ':status': 'PAID', + ':now': now_, + }, + cond_expr='attribute_exists(sk)', + ) + transact.put( + item={ + 'id': order_id, + 'sk': f'TRANSACTION#ATTEMPTS#{now_.isoformat()}', + 'brand': credit_card.brand, + 'last4': credit_card.last4, + 'status': 'SUCCEEDED', + 'transaction': charge, + }, + ) + else: + transact.put( + item={ + 'id': order_id, + 'sk': f'TRANSACTION#ATTEMPTS#{now_.isoformat()}', + 'brand': credit_card.brand, + 'last4': credit_card.last4, + 'status': 'FAILED', + 'transaction': charge, + }, + ) + + return charge['success'] diff --git a/orders-events/app/events/payments/create_invoice.py b/orders-events/app/events/payments/create_invoice.py index bf0ea5a..a14ae4d 100644 --- a/orders-events/app/events/payments/create_invoice.py +++ b/orders-events/app/events/payments/create_invoice.py @@ -4,9 +4,10 @@ from aws_lambda_powertools.utilities.data_classes import ( event_source, ) from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dateutils import now +from layercake.dateutils import now, ttl from layercake.dynamodb import ( DynamoDBPersistenceLayer, + KeyPair, SortKey, TransactKey, ) @@ -41,7 +42,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: r = dyn.collection.get_items( TransactKey(order_id) + SortKey('ADDRESS', rename_key='address') - + SortKey('ITEMS', path_spec='items', rename_key='items'), + + SortKey('ITEMS', path_spec='items', rename_key='items') + + SortKey('CREDIT_CARD#PAYMENT_INTENT', rename_key='credit_card'), flatten_top=False, ) @@ -59,21 +61,40 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: ) try: - dyn.put_item( - item={ - 'id': order_id, - 'sk': 'INVOICE', - 'payment_method': payment_method, - 'secure_id': invoice['secure_id'], - 'secure_url': invoice['secure_url'], - 'created_at': now_, - # Uncomment this when adding for multiple payment providers - # 'payment_provider': 'iugu', - } - | ({'bank_slip': invoice['bank_slip']} if is_bank_slip else {}) - | ({'pix': invoice['pix']} if is_pix else {}), - cond_expr='attribute_not_exists(sk)', - ) + with dyn.transact_writer() as transact: + transact.put( + item={ + 'id': order_id, + 'sk': 'INVOICE', + 'payment_method': payment_method, + 'secure_id': invoice['secure_id'], + 'secure_url': invoice['secure_url'], + 'created_at': now_, + # Uncomment this when adding for multiple payment providers + # 'payment_provider': 'iugu', + } + | ({'bank_slip': invoice['bank_slip']} if is_bank_slip else {}) + | ({'pix': invoice['pix']} if is_pix else {}), + cond_expr='attribute_not_exists(sk)', + ) + + if 'credit_card' in r: + transact.delete( + key=KeyPair(order_id, 'CREDIT_CARD#PAYMENT_INTENT'), + ) + transact.put( + item={ + 'id': order_id, + 'sk': 'TRANSACTION', + 'invoice_id': invoice['secure_id'], + 'credit_card': r['credit_card'], + 'installments': int(new_image.get('installments', 1)), + 'ttl': ttl(start_dt=now_, minutes=5), + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + except Exception: pass diff --git a/orders-events/app/iugu.py b/orders-events/app/iugu.py index 6b4568a..0b518cf 100644 --- a/orders-events/app/iugu.py +++ b/orders-events/app/iugu.py @@ -71,6 +71,7 @@ class Order(BaseModel): address: Address items: tuple[Item, ...] payment_method: PaymentMethod + discount: Decimal = Decimal('0') cpf: str | None = None cnpj: str | None = None @@ -127,6 +128,7 @@ class Iugu: } for item in order.items ] + payload = { 'order_id': order.id, 'external_reference': order.id, @@ -135,6 +137,7 @@ class Iugu: 'email': order.email, 'payable_with': order.payment_method.lower(), 'notification_url': postback_url.geturl(), + 'discount_cents': int(order.discount * -100), 'payer': { 'name': order.name, 'email': order.email, diff --git a/orders-events/template.yaml b/orders-events/template.yaml index 52f7ae4..dd1cab8 100644 --- a/orders-events/template.yaml +++ b/orders-events/template.yaml @@ -116,8 +116,20 @@ Resources: detail-type: [INSERT] detail: new_image: - sk: ['INVOICE'] - payment_method: ['CREDIT_CARD'] + sk: ['TRANSACTION'] + invoice_id: + - exists: true + credit_card: + holder_name: + - exists: true + number: + - exists: true + exp_month: + - exists: true + exp_year: + - exists: true + cvv: + - exists: true EventBillingAppendEnrollmentFunction: Type: AWS::Serverless::Function diff --git a/orders-events/tests/events/payments/test_charge_credit_card.py b/orders-events/tests/events/payments/test_charge_credit_card.py new file mode 100644 index 0000000..0b16a0f --- /dev/null +++ b/orders-events/tests/events/payments/test_charge_credit_card.py @@ -0,0 +1,48 @@ +import requests +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer + +import events.payments.charge_credit_card as app + +from ...test_iugu import MockResponse + +event = { + 'detail': { + 'new_image': { + 'credit_card': { + 'number': '4242424242424242', + 'cvv': '123', + 'created_at': '2026-01-13T02:33:57.088176-03:00', + 'exp_month': '03', + 'exp_year': '2027', + 'ttl': 1768282737, + 'holder_name': 'Sergio R Siqueira', + }, + 'created_at': '2026-01-13T02:34:00.065619-03:00', + 'invoice_id': '493dd32a-b646-40b2-a307-0fcb3d42d954-2097', + 'installments': 12, + 'ttl': 1768282740, + 'id': '121c1140-779d-4664-8d99-4a006a22f547', + 'sk': 'TRANSACTION', + } + } +} + + +def test_charge_credit_card( + monkeypatch, + dynamodb_seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + monkeypatch.setattr( + requests, + 'post', + lambda url, *args, **kwargs: MockResponse( + 'tests/samples/iugu_payment_token.json' + if 'payment_token' in url + else 'tests/samples/iugu_charge_paid.json' + ), + ) + + assert app.lambda_handler(event, lambda_context) # type: ignore diff --git a/orders-events/tests/events/payments/test_create_invoice.py b/orders-events/tests/events/payments/test_create_invoice.py index 210a139..bc2dcc0 100644 --- a/orders-events/tests/events/payments/test_create_invoice.py +++ b/orders-events/tests/events/payments/test_create_invoice.py @@ -2,7 +2,7 @@ from decimal import Decimal import requests from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey import events.payments.create_invoice as app @@ -89,3 +89,37 @@ def test_create_bank_slip_pix( invoice['pix']['qrcode_text'] == 'http://faturas.iugu.com/iugu_pix/970cb579-c396-4e59-a323-ce61ae04f7bc-196c/test/pay' ) + + +def test_create_credit_card( + monkeypatch, + dynamodb_seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + monkeypatch.setattr( + requests, + 'post', + lambda *args, **kwargs: MockResponse( + 'tests/samples/iugu_invoice_credit_card.json' + ), + ) + + assert app.lambda_handler(_event('CREDIT_CARD'), lambda_context) # type: ignore + + invoice = dynamodb_persistence_layer.collection.get_items( + TransactKey(order_id) + + SortKey('0') + + SortKey('INVOICE') + + SortKey('TRANSACTION') + ) + print(invoice) + + # assert ( + # invoice['pix']['qrcode_text'] + # == 'http://faturas.iugu.com/iugu_pix/970cb579-c396-4e59-a323-ce61ae04f7bc-196c/test/pay' + # ) + # assert ( + # invoice['pix']['qrcode_text'] + # == 'http://faturas.iugu.com/iugu_pix/970cb579-c396-4e59-a323-ce61ae04f7bc-196c/test/pay' + # ) diff --git a/orders-events/tests/samples/iugu_invoice_credit.json b/orders-events/tests/samples/iugu_invoice_credit_card.json similarity index 100% rename from orders-events/tests/samples/iugu_invoice_credit.json rename to orders-events/tests/samples/iugu_invoice_credit_card.json diff --git a/orders-events/tests/seeds.jsonl b/orders-events/tests/seeds.jsonl index d88daa0..244936c 100644 --- a/orders-events/tests/seeds.jsonl +++ b/orders-events/tests/seeds.jsonl @@ -16,11 +16,12 @@ // Seeds for Iugu // file: tests/test_app.py +// file: tests/events/payments/test_charge_credit_card.py // file: tests/events/payments/test_create_invoice.py {"id": "121c1140-779d-4664-8d99-4a006a22f547", "sk": "0", "total": "267.3", "name": "Beta Educação", "payment_method": "BANK_SLIP", "create_date": "2026-01-07T19:07:49.272967-03:00", "due_date": "2026-01-12T00:35:44.897447-03:00", "coupon": "10OFF", "discount": "-29.7", "updated_at": "2026-01-07T19:07:51.512605-03:00", "tenant_id": "cJtK9SsnJhKPyxESe7g3DG", "email": "org+15608435000190@users.noreply.saladeaula.digital", "org_id": "cJtK9SsnJhKPyxESe7g3DG", "cnpj": "15608435000190", "status": "PENDING", "subtotal": 297} {"id": "121c1140-779d-4664-8d99-4a006a22f547", "sk": "ADDRESS", "city": "São José", "neighborhood": "Campinas", "address2": "", "postcode": "88101001", "state": "SC", "address1": "Avenida Presidente Kennedy", "created_at": "2026-01-07T19:07:49.272967-03:00"} {"id": "121c1140-779d-4664-8d99-4a006a22f547", "sk": "ITEMS", "items": [{"name": "CIPA Grau de Risco 2", "id": "99bb3b60-4ded-4a8e-937c-ba2d78ec6454", "quantity": 3, "unit_price": 99}], "created_at": "2026-01-07T19:07:49.272967-03:00"} - +{"id": "121c1140-779d-4664-8d99-4a006a22f547", "sk": "CREDIT_CARD#PAYMENT_INTENT", "credit_card": {"number": "4111111111111111","cvv": "123","created_at": "2026-01-13T02:33:57.088176-03:00","exp_month": "03","exp_year": "2027","ttl": 1768282737,"holder_name": "Sergio R Siqueira"}} // User data {"id": "5OxmMjL-ujoR5IMGegQz", "sk": "0", "name": "Sérgio R Siqueira"} diff --git a/orders-events/tests/test_iugu.py b/orders-events/tests/test_iugu.py index 5fb996b..e34a026 100644 --- a/orders-events/tests/test_iugu.py +++ b/orders-events/tests/test_iugu.py @@ -35,6 +35,7 @@ event = { 'unit_price': 100, }, ], + 'discount': -10, } @@ -53,27 +54,6 @@ class MockResponse: def raise_for_status(): ... -def test_create_invoice_pix(monkeypatch): - monkeypatch.setattr( - requests, - 'post', - lambda *args, **kwargs: MockResponse('tests/samples/iugu_invoice_pix.json'), - ) - - order = Order( - id=str(uuid4()), - payment_method='PIX', # type: ignore - **event, - ) - invoice = iugu.create_invoice(order, postback_url='http://localhost') - - assert invoice['id'] == '970CB579C3964E59A323CE61AE04F7BC' - assert ( - invoice['pix']['qrcode_text'] - == 'http://faturas.iugu.com/iugu_pix/970cb579-c396-4e59-a323-ce61ae04f7bc-196c/test/pay' - ) - - def test_create_invoice_bank_slip(monkeypatch): monkeypatch.setattr( requests, @@ -97,6 +77,27 @@ def test_create_invoice_bank_slip(monkeypatch): ) +def test_create_invoice_pix(monkeypatch): + monkeypatch.setattr( + requests, + 'post', + lambda *args, **kwargs: MockResponse('tests/samples/iugu_invoice_pix.json'), + ) + + order = Order( + id=str(uuid4()), + payment_method='PIX', # type: ignore + **event, + ) + invoice = iugu.create_invoice(order, postback_url='http://localhost') + + assert invoice['id'] == '970CB579C3964E59A323CE61AE04F7BC' + assert ( + invoice['pix']['qrcode_text'] + == 'http://faturas.iugu.com/iugu_pix/970cb579-c396-4e59-a323-ce61ae04f7bc-196c/test/pay' + ) + + def test_payment_token(monkeypatch): monkeypatch.setattr( requests,