add iugu postback

This commit is contained in:
2026-01-12 23:30:52 -03:00
parent 29b8305713
commit ad23e9aa51
25 changed files with 191 additions and 29 deletions

View File

@@ -145,7 +145,7 @@ def dynamodb_persistence_layer(dynamodb_client):
@pytest.fixture() @pytest.fixture()
def seeds(dynamodb_client): def dynamodb_seeds(dynamodb_client):
from layercake.dynamodb import serialize from layercake.dynamodb import serialize
with open('tests/seeds.jsonl', 'rb') as fp: with open('tests/seeds.jsonl', 'rb') as fp:

View File

@@ -5,7 +5,7 @@ import events.send_reminder_emails as app
def test_reminder_access_period_before_30_days( def test_reminder_access_period_before_30_days(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -4,7 +4,7 @@ import events.send_reminder_emails as app
def test_reminder_cert_expiration_before_30_days( def test_reminder_cert_expiration_before_30_days(
seeds, dynamodb_seeds,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
event = { event = {

View File

@@ -4,7 +4,7 @@ import events.send_reminder_emails as app
def test_reminder_no_access_after_3_days( def test_reminder_no_access_after_3_days(
seeds, dynamodb_seeds,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
event = { event = {

View File

@@ -4,7 +4,7 @@ import events.send_reminder_emails as app
def test_reminder_no_activity_after_7_days( def test_reminder_no_activity_after_7_days(
seeds, dynamodb_seeds,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
event = { event = {

View File

@@ -12,7 +12,7 @@ import events.reporting.append_cert as app
def test_append_cert( def test_append_cert(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
@@ -65,7 +65,7 @@ def test_append_cert(
def test_report_exists( def test_report_exists(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -9,7 +9,7 @@ import events.reporting.send_report_email as app
def test_send_report_email( def test_send_report_email(
monkeypatch, monkeypatch,
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -8,7 +8,7 @@ import events.stopgap.patch_course_metadata as app
def test_enroll( def test_enroll(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -5,7 +5,7 @@ import events.stopgap.patch_konviva as app
def test_patch_konviva( def test_patch_konviva(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -5,7 +5,7 @@ import events.allocate_slots as app
def test_allocate_slots( def test_allocate_slots(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -5,7 +5,7 @@ import events.enroll as app
def test_enroll( def test_enroll(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -5,7 +5,7 @@ import events.issue_cert as app
def test_issue_cert( def test_issue_cert(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
@@ -44,7 +44,7 @@ def test_issue_cert(
def test_non_exp_interval( def test_non_exp_interval(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -5,7 +5,7 @@ import events.reenroll_if_failed as app
def test_reenroll_custom_dedup_window( def test_reenroll_custom_dedup_window(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -8,7 +8,7 @@ import events.schedule_reminders as app
def test_schedule_reminders( def test_schedule_reminders(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -9,7 +9,7 @@ import events.set_access_expired as app
def test_set_access_expired( def test_set_access_expired(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -9,7 +9,7 @@ import events.set_cert_expired as app
def test_set_cert_expired( def test_set_cert_expired(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
@@ -34,7 +34,7 @@ def test_set_cert_expired(
def test_existing_issued_cert( def test_existing_issued_cert(
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):

View File

@@ -16,7 +16,7 @@ body = {
def test_postback( def test_postback(
app, app,
seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy, http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, lambda_context: LambdaContext,

View File

@@ -576,7 +576,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.11.4" version = "0.12.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -1,5 +1,6 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
from urllib.parse import parse_qsl
from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.event_handler.api_gateway import ( from aws_lambda_powertools.event_handler.api_gateway import (
@@ -8,7 +9,8 @@ from aws_lambda_powertools.event_handler.api_gateway import (
) )
from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ORDER_TABLE from config import ORDER_TABLE
@@ -19,9 +21,34 @@ app = APIGatewayHttpResolver(enable_validation=True)
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@app.post('/postback/<order_id>') @app.post('/<order_id>/postback')
@tracer.capture_method @tracer.capture_method
def postback(order_id: str): 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':
return Response(status_code=HTTPStatus.NO_CONTENT)
try:
dyn.update_item(
key=KeyPair(order_id, '0'),
update_expr='SET #status = :status, \
updated_at = :now',
cond_expr='attribute_exists(sk)',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':status': status,
':now': now(),
},
)
except Exception:
return Response(status_code=HTTPStatus.NOT_FOUND)
else:
return Response(status_code=HTTPStatus.NO_CONTENT) return Response(status_code=HTTPStatus.NO_CONTENT)

View File

@@ -7,7 +7,8 @@ ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
IUGU_ACCOUNT_ID: str = 'AF01CF1B3451459F92666F10589278EE' IUGU_ACCOUNT_ID: str = 'AF01CF1B3451459F92666F10589278EE'
IUGU_API_TOKEN: str = os.getenv('IUGU_API_TOKEN') # type: ignore IUGU_API_TOKEN: str = os.getenv('IUGU_API_TOKEN') # type: ignore
IUGU_TEST_MODE: bool = os.getenv('AWS_LAMBDA_FUNCTION_NAME') is None # IUGU_TEST_MODE: bool = os.getenv('AWS_LAMBDA_FUNCTION_NAME') is None
IUGU_TEST_MODE: bool = True
IUGU_POSTBACK_URL = 'https://zjg09ppxq8.execute-api.sa-east-1.amazonaws.com' IUGU_POSTBACK_URL = 'https://zjg09ppxq8.execute-api.sa-east-1.amazonaws.com'
HTTP_CONNECT_TIMEOUT = int(os.environ.get('HTTP_CONNECT_TIMEOUT', 1)) HTTP_CONNECT_TIMEOUT = int(os.environ.get('HTTP_CONNECT_TIMEOUT', 1))

View File

@@ -55,7 +55,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
items=r.get('items', []), items=r.get('items', []),
**new_image, **new_image,
), ),
postback_url=f'{IUGU_POSTBACK_URL}/postback/{order_id}', postback_url=f'{IUGU_POSTBACK_URL}/{order_id}/postback',
) )
try: try:

View File

@@ -39,7 +39,8 @@ Globals:
ENROLLMENT_TABLE: !Ref EnrollmentTable ENROLLMENT_TABLE: !Ref EnrollmentTable
COURSE_TABLE: !Ref CourseTable COURSE_TABLE: !Ref CourseTable
BUCKET_NAME: !Ref BucketName BUCKET_NAME: !Ref BucketName
IUGU_API_TOKEN: '{{resolve:ssm:/saladeaula/iugu_api_token}}' # IUGU_API_TOKEN: '{{resolve:ssm:/saladeaula/iugu_api_token}}'
IUGU_API_TOKEN: 419BEF0AD0B4EC180AEF80281BBF3A1CBBCC0EC45C8AE200D8A53ACC994DE639
Resources: Resources:
EventLog: EventLog:
@@ -64,7 +65,6 @@ Resources:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: app.lambda_handler Handler: app.lambda_handler
Timeout: 12
LoggingConfig: LoggingConfig:
LogGroup: !Ref HttpLog LogGroup: !Ref HttpLog
Policies: Policies:
@@ -74,7 +74,7 @@ Resources:
Post: Post:
Type: HttpApi Type: HttpApi
Properties: Properties:
Path: / Path: /{id}/postback
Method: POST Method: POST
ApiId: !Ref HttpApi ApiId: !Ref HttpApi

View File

@@ -1,5 +1,9 @@
import base64
import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from http import HTTPMethod
from urllib.parse import urlencode
import jsonlines import jsonlines
import pytest import pytest
@@ -35,6 +39,87 @@ def lambda_context() -> LambdaContext:
return LambdaContext() return LambdaContext()
class HttpApiProxy:
def __call__(
self,
raw_path: str,
method: str = HTTPMethod.GET,
body: dict | str | None = None,
*,
headers: dict = {},
cookies: list[str] = [],
query_string_parameters: dict = {},
is_base64_encoded: bool = True,
**kwargs,
) -> dict:
if isinstance(body, dict):
body = json.dumps(body)
if is_base64_encoded and body:
body = _base64_encode(body)
return {
'version': '2.0',
'routeKey': '$default',
'rawPath': raw_path,
'rawQueryString': urlencode(query_string_parameters),
'cookies': cookies,
'headers': headers,
'queryStringParameters': query_string_parameters,
'requestContext': {
'accountId': '123456789012',
'apiId': 'api-id',
'authorizer': {
'jwt': {
'claims': {
'aud': '1db63660-063d-4280-b2ea-388aca4a9459',
'client_id': '1db63660-063d-4280-b2ea-388aca4a9459',
'email': 'sergio@somosbeta.com.br',
'email_verified': 'true',
'exp': '1765205975',
'iat': '1765202375',
'iss': 'https://id.saladeaula.digital',
'jti': 'Fbbyvwwze3npdEgs',
'name': 'Sérgio R Siqueira',
'scope': 'openid profile email offline_access apps:admin',
'sub': '5OxmMjL-ujoR5IMGegQz',
},
'scopes': None,
}
},
'domainName': 'id.execute-api.us-east-1.amazonaws.com',
'domainPrefix': 'id',
'http': {
'method': str(method),
'path': raw_path,
'protocol': 'HTTP/1.1',
'sourceIp': '192.168.0.1/32',
'userAgent': 'agent',
},
'requestId': 'id',
'routeKey': '$default',
'stage': '$default',
'time': '12/Mar/2020:19:03:58 +0000',
'timeEpoch': 1583348638390,
},
'body': body,
'pathParameters': {'parameter1': 'value1'},
'isBase64Encoded': is_base64_encoded,
'stageVariables': {'stageVariable1': 'value1', 'stageVariable2': 'value2'},
}
def _base64_encode(s: str) -> str | None:
if not s:
return None
return base64.b64encode(s.encode()).decode()
@pytest.fixture
def http_api_proxy():
return HttpApiProxy()
@pytest.fixture @pytest.fixture
def dynamodb_client(): def dynamodb_client():
from boto3clients import dynamodb_client as client from boto3clients import dynamodb_client as client
@@ -74,3 +159,10 @@ def dynamodb_seeds(dynamodb_persistence_layer):
for line in reader.iter(type=dict, skip_invalid=True): for line in reader.iter(type=dict, skip_invalid=True):
dynamodb_persistence_layer.put_item(item=line) dynamodb_persistence_layer.put_item(item=line)
@pytest.fixture
def app():
import app
return app

View File

@@ -15,6 +15,7 @@
{"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ADDRESS", "city": "São José", "postcode": "88101001", "state": "SC", "created_at": "2026-01-07T19:09:54.193859-03:00", "address1": "Avenida Presidente Kennedy" "address2": "", "neighborhood": "Campinas"} {"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ADDRESS", "city": "São José", "postcode": "88101001", "state": "SC", "created_at": "2026-01-07T19:09:54.193859-03:00", "address1": "Avenida Presidente Kennedy" "address2": "", "neighborhood": "Campinas"}
// Seeds for Iugu // Seeds for Iugu
// file: tests/test_app.py
// file: tests/events/payments/test_create_invoice.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": "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": "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"}

View File

@@ -0,0 +1,41 @@
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from .conftest import HttpApiProxy, LambdaContext
order_id = '121c1140-779d-4664-8d99-4a006a22f547'
def test_postback(
app,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
# This data was added from seeds
r = app.lambda_handler(
http_api_proxy(
raw_path=f'/{order_id}/postback',
method=HTTPMethod.POST,
body=(
'event=invoice.status_changed&'
'data[id]=RDBBACE5DE174554BA2C836E96D751AA&'
'data[status]=paid&'
'data[account_id]=AF01CF1B3451459F92666F10589278EE&'
'data[payment_method]=iugu_credit_card&'
'data[paid_at]=2022-10-17T18:21:55-03:00&'
'data[paid_cents]=10255&'
'data[order_id]=cPqkdJUeqqCB6WATsSWnsZ'
),
is_base64_encoded=True,
headers={'content-type': 'application/x-www-form-urlencoded'},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.NO_CONTENT
order = dynamodb_persistence_layer.get_item(KeyPair(order_id, '0'))
assert order['status'] == 'PAID'