Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci-cd.yml

This file was deleted.

31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run pytest
# moto mocks DynamoDB — no AWS credentials required
run: PYTHONPATH=. pytest tests/test_exclusao.py -v
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,31 @@ API serverless para solicitação de exclusão de cliente com geração de QR Co
## Architecture Decision Records

- [ADR-001 — Unificar exclusão Pix (CDK + Lambda)](docs/adr/ADR-001-unificar-exclusao-pix.md) — **Accepted** (CAB 2026-09-20). Este repo é o canônico; `aws-api-gateway-cdk` será arquivado após migrar o stack. Access pattern Dynamo: pointer `LATEST`. Auth (PR3): Cognito JWT.

---

## DynamoDB — ponteiro LATEST

Tabela: PK `cliente_id`, SK `request_id`.

Cada solicitação grava **dois** itens (histórico depois ponteiro):

| SK | Papel |
|----|--------|
| UUID da solicitação | Histórico daquele pedido |
| `LATEST` | Ponteiro do estado atual (`active_request_id`, `status`, …) |

- **Dedup / status** leem só `LATEST` (antes o código gravava UUID e lia SK=`LATEST` — nunca achava).
- **Confirmar pagamento** atualiza histórico e depois `LATEST` (condition em `active_request_id`).
- Writes ordenados (`put`/`update`) de propósito neste PR — TransactWrite fica pra um follow-up se precisar atomicidade forte.

### Testes

```bash
pip install -r requirements.txt
PYTHONPATH=. pytest tests/test_exclusao.py -v
```

Usa **moto** (sem AWS real). O pacote `lambda/` importa via `importlib` (nome reservado).

CI (GitHub Actions): mesmo comando em Python 3.12 em todo PR/`push` na `main` — sem credenciais AWS no runner.
214 changes: 161 additions & 53 deletions lambda/app.py
Original file line number Diff line number Diff line change
@@ -1,108 +1,216 @@
from aws_lambda_powertools import Logger, Tracer, Metrics
from aws_lambda_powertools.event_handler import APIGatewayHttpResolver
from aws_lambda_powertools.utilities.typing import LambdaContext
from aws_lambda_powertools.utilities.idempotency import idempotent
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent
import boto3
import json
import uuid
import os
from datetime import datetime, timedelta
from decimal import Decimal

logger = Logger(service="exclusao-cliente-lambda")
tracer = Tracer()
metrics = Metrics(namespace="ExclusaoCliente")
app = APIGatewayHttpResolver()

TABLE_NAME = os.environ.get('TABLE_NAME', 'ExclusaoClientes')
PIX_FEE = float(os.environ.get('PIX_FEE', '50.00'))
TABLE_NAME = os.environ.get("TABLE_NAME", "ExclusaoClientes")
PIX_FEE = Decimal(str(os.environ.get("PIX_FEE", "50.00")))
LATEST_SK = "LATEST"

dynamodb = boto3.resource('dynamodb')
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(TABLE_NAME)

@idempotent
@app.post('/solicitar-exclusao-cliente')

def _get_latest(cliente_id: str) -> dict | None:
"""Read the LATEST pointer row for a cliente."""
response = table.get_item(Key={"cliente_id": cliente_id, "request_id": LATEST_SK})
return response.get("Item")


def _put_solicitacao_with_latest(
*,
cliente_id: str,
request_id: str,
motivo: str,
qr_code_data: str,
fee_amount: Decimal,
created_at: str,
expires_at: str,
) -> None:
"""Write history (SK=uuid) then LATEST pointer (SK=LATEST).

Ordered puts keep the PR small and moto-friendly. TransactWrite can
replace this later if we need stronger atomicity.
"""
history = {
"cliente_id": cliente_id,
"request_id": request_id,
"status": "PENDING_PAYMENT",
"motivo": motivo,
"qr_code_data": qr_code_data,
"fee_amount": fee_amount,
"created_at": created_at,
"expires_at": expires_at,
}
latest = {
"cliente_id": cliente_id,
"request_id": LATEST_SK,
"active_request_id": request_id,
"status": "PENDING_PAYMENT",
"motivo": motivo,
"qr_code_data": qr_code_data,
"fee_amount": fee_amount,
"created_at": created_at,
"expires_at": expires_at,
}
table.put_item(Item=history)
table.put_item(Item=latest)


def _confirm_payment(cliente_id: str, request_id: str) -> None:
"""Mark history + LATEST as PAID when pointer matches request_id."""
ts = datetime.utcnow().isoformat()
table.update_item(
Key={"cliente_id": cliente_id, "request_id": request_id},
UpdateExpression="SET #status = :paid, updated_at = :ts",
ExpressionAttributeNames={"#status": "status"},
ExpressionAttributeValues={":paid": "PAID", ":ts": ts},
ConditionExpression="attribute_exists(cliente_id)",
)
table.update_item(
Key={"cliente_id": cliente_id, "request_id": LATEST_SK},
UpdateExpression="SET #status = :paid, updated_at = :ts",
ExpressionAttributeNames={"#status": "status"},
ExpressionAttributeValues={
":paid": "PAID",
":ts": ts,
":rid": request_id,
},
ConditionExpression="active_request_id = :rid",
)


def generate_pix_qr_code(cliente_id: str, amount: Decimal, request_id: str) -> str:
return f"pix-emv-stub://{cliente_id}/{amount}/{request_id}"


@app.post("/solicitar-exclusao-cliente")
@tracer.capture_method
def solicitar_exclusao(event: APIGatewayProxyEvent):
body = event.json_body or {}
cliente_id = body.get('cliente_id')
motivo = body.get('motivo', 'Não informado')
cliente_id = body.get("cliente_id")
motivo = body.get("motivo", "Não informado")

if not cliente_id:
return {'statusCode': 400, 'body': json.dumps({'error': 'cliente_id is required'})}
return {
"statusCode": 400,
"body": json.dumps({"error": "cliente_id is required"}),
}

existing = _get_latest(cliente_id)
if existing and existing.get("status") in ("PENDING_PAYMENT", "PAID"):
return {
"statusCode": 200,
"body": json.dumps(
{
"message": "Solicitação já existe",
"request_id": existing.get("active_request_id"),
"status": existing.get("status"),
}
),
}

request_id = str(uuid.uuid4())
timestamp = datetime.utcnow().isoformat()

try:
response = table.get_item(Key={'cliente_id': cliente_id, 'request_id': 'LATEST'})
if 'Item' in response:
existing = response['Item']
if existing.get('status') in ['PENDING_PAYMENT', 'PAID']:
return {'statusCode': 200, 'body': json.dumps({'message': 'Solicitação já existe', 'request_id': existing.get('request_id'), 'status': existing.get('status')})}
except Exception:
pass

expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
qr_code_data = generate_pix_qr_code(cliente_id, PIX_FEE, request_id)

item = {
'cliente_id': cliente_id,
'request_id': request_id,
'status': 'PENDING_PAYMENT',
'motivo': motivo,
'qr_code_data': qr_code_data,
'fee_amount': PIX_FEE,
'created_at': timestamp,
'expires_at': (datetime.utcnow() + timedelta(days=7)).isoformat()
}
table.put_item(Item=item)
_put_solicitacao_with_latest(
cliente_id=cliente_id,
request_id=request_id,
motivo=motivo,
qr_code_data=qr_code_data,
fee_amount=PIX_FEE,
created_at=timestamp,
expires_at=expires_at,
)

metrics.add_metric(name="exclusao_solicitada", unit="Count", value=1)
logger.info("Solicitação criada", extra={"cliente_id": cliente_id})

return {'statusCode': 201, 'body': json.dumps({'message': 'Solicitação registrada', 'request_id': request_id, 'qr_code': qr_code_data, 'fee': PIX_FEE})}
return {
"statusCode": 201,
"body": json.dumps(
{
"message": "Solicitação registrada",
"request_id": request_id,
"qr_code": qr_code_data,
"fee": float(PIX_FEE),
}
),
}

def generate_pix_qr_code(cliente_id: str, amount: float, request_id: str) -> str:
return f"pix-emv-stub://{cliente_id}/{amount}/{request_id}"

@app.get('/health')
@app.get("/health")
@tracer.capture_method
def health_check():
try:
table.meta.client.describe_table(TableName=TABLE_NAME)
return {'statusCode': 200, 'body': json.dumps({'status': 'healthy'})}
return {"statusCode": 200, "body": json.dumps({"status": "healthy"})}
except Exception as e:
return {'statusCode': 503, 'body': json.dumps({'status': 'unhealthy', 'error': str(e)})}
return {
"statusCode": 503,
"body": json.dumps({"status": "unhealthy", "error": str(e)}),
}

@app.get('/status-exclusao/{cliente_id}')

@app.get("/status-exclusao/{cliente_id}")
@tracer.capture_method
def get_status(cliente_id: str):
try:
response = table.get_item(Key={'cliente_id': cliente_id, 'request_id': 'LATEST'})
if 'Item' in response:
item = response['Item']
return {'statusCode': 200, 'body': json.dumps({'cliente_id': cliente_id, 'status': item.get('status'), 'request_id': item.get('request_id')})}
return {'statusCode': 404, 'body': json.dumps({'error': 'Não encontrado'})}
item = _get_latest(cliente_id)
if item:
return {
"statusCode": 200,
"body": json.dumps(
{
"cliente_id": cliente_id,
"status": item.get("status"),
"request_id": item.get("active_request_id"),
}
),
}
return {"statusCode": 404, "body": json.dumps({"error": "Não encontrado"})}
except Exception as e:
return {'statusCode': 500, 'body': json.dumps({'error': str(e)})}
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}


@app.post('/confirmar-pagamento')
@app.post("/confirmar-pagamento")
@tracer.capture_method
def confirmar_pagamento(event: APIGatewayProxyEvent):
body = event.json_body or {}
cliente_id = body.get("cliente_id")
request_id = body.get("request_id")
if not cliente_id or not request_id:
return {
"statusCode": 400,
"body": json.dumps(
{"error": "cliente_id and request_id are required"}
),
}
try:
table.update_item(
Key={'cliente_id': body.get('cliente_id'), 'request_id': body.get('request_id')},
UpdateExpression='SET #status = :paid, updated_at = :ts',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={':paid': 'PAID', ':ts': datetime.utcnow().isoformat()}
)
_confirm_payment(cliente_id, request_id)
metrics.add_metric(name="exclusao_paga", unit="Count", value=1)
return {'statusCode': 200, 'body': json.dumps({'message': 'Pagamento confirmado'})}
return {
"statusCode": 200,
"body": json.dumps({"message": "Pagamento confirmado"}),
}
except Exception as e:
return {'statusCode': 500, 'body': json.dumps({'error': str(e)})}
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}


@logger.inject_lambda_context(correlation_id_path="requestContext.requestId")
@tracer.capture_lambda_handler
def lambda_handler(event: dict, context: LambdaContext) -> dict:
return app.resolve(event, context)
return app.resolve(event, context)
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
aws-lambda-powertools[all]
boto3
qrcode # for real QR if needed

# Local / CI testing
pytest>=8.0.0
moto[dynamodb]>=5.0.0
23 changes: 1 addition & 22 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,22 +1 @@
import json
import pytest
from unittest.mock import patch, MagicMock
from lambda.app import app, hello, save_item

mock_table = MagicMock()
mock_table.put_item.return_value = {}

@pytest.fixture
def mock_dynamodb():
with patch('lambda.app.table', mock_table):
yield mock_table

def test_hello_default():
result = hello()
assert result['message'] == 'Hello World from Lambda!'

def test_save_item_success(mock_dynamodb):
item = {'id': '123', 'name': 'Test'}
result = save_item(item)
assert result['status'] == 'saved'
mock_dynamodb.put_item.assert_called_once_with(Item=item)
"""Legacy stub tests removed — see tests/test_exclusao.py (moto + LATEST)."""
Loading
Loading