add test mode to checkout

This commit is contained in:
2026-01-20 10:41:32 -03:00
parent fde5c31ffc
commit 49e0c3333b
13 changed files with 847 additions and 493 deletions

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
from enum import Enum from enum import Enum
from functools import reduce from functools import reduce
from http import HTTPStatus from http import HTTPStatus
from typing import Any, Literal from typing import Any, Literal, NotRequired, TypedDict, cast
from uuid import uuid4 from uuid import uuid4
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
@@ -13,7 +13,7 @@ from aws_lambda_powertools.event_handler.exceptions import (
NotFoundError, NotFoundError,
) )
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
from layercake.extra_types import CnpjStr, CpfStr, CreditCard, NameStr from layercake.extra_types import CnpjStr, CpfStr, CreditCard, NameStr
from pydantic import ( from pydantic import (
UUID4, UUID4,
@@ -136,8 +136,8 @@ class Checkout(BaseModel):
@router.post('/') @router.post('/')
def checkout(payload: Checkout): def checkout(payload: Checkout):
now_ = now() now_ = now()
settings = _get_settings(str(payload.org_id or payload.user_id))
order_id = payload.id order_id = payload.id
org_id = payload.org_id
address = payload.address address = payload.address
credit_card = payload.credit_card credit_card = payload.credit_card
created_by = payload.created_by created_by = payload.created_by
@@ -148,7 +148,7 @@ def checkout(payload: Checkout):
installments = payload.installments installments = payload.installments
subtotal = _sum_items(items) subtotal = _sum_items(items)
due_date = ( due_date = (
_calc_due_date(now_, _get_due_days(org_id) if org_id else DUE_DAYS) _calc_due_date(now_, settings['due_days'])
if payment_method == 'BANK_SLIP' if payment_method == 'BANK_SLIP'
else now_ + timedelta(hours=1) else now_ + timedelta(hours=1)
) )
@@ -267,6 +267,16 @@ def checkout(payload: Checkout):
| enrollment.model_dump(exclude={'id'}) | enrollment.model_dump(exclude={'id'})
) )
if 'iugu_api_token' in settings:
transact.put(
item={
'id': order_id,
'sk': 'METADATA#TEST_MODE',
'iugu_api_token': settings['iugu_api_token'],
'created_at': now_,
}
)
return JSONResponse( return JSONResponse(
body={'id': order_id}, body={'id': order_id},
status_code=HTTPStatus.CREATED, status_code=HTTPStatus.CREATED,
@@ -319,18 +329,32 @@ def _calc_due_date(
return due_date return due_date
def _get_due_days( Settings = TypedDict(
org_id: str | UUID4, 'Settings',
default: int = DUE_DAYS, {
) -> int: 'due_days': int,
return int( 'iugu_api_token': NotRequired[str],
dyn.collection.get_item( },
KeyPair( )
pk=str(org_id),
sk=SortKey('METADATA#BILLING', path_spec='due_days'),
table_name=USER_TABLE, def _get_settings(id: str) -> Settings:
), r = dyn.collection.get_items(
raise_on_error=False, TransactKey(id, table_name=USER_TABLE)
default=default, + SortKey(
sk='METADATA#BILLING',
path_spec='due_days',
rename_key='due_days',
) )
+ SortKey(
'METADATA#TEST_MODE',
path_spec='iugu_api_token',
rename_key='iugu_api_token',
),
flatten_top=False,
) )
if 'due_days' not in r:
r['due_days'] = DUE_DAYS
return cast(Settings, r)

View File

@@ -82,7 +82,7 @@ def test_checkout_coupon(
'id': '99bb3b60-4ded-4a8e-937c-ba2d78ec6454', 'id': '99bb3b60-4ded-4a8e-937c-ba2d78ec6454',
'access_period': 365, 'access_period': 365,
}, },
'scheduled_for': '2026-01-20', 'scheduled_for': '2040-01-20',
'id': '1f0931ad-7dd4-4ca1-bce2-a2e89efa5b56', 'id': '1f0931ad-7dd4-4ca1-bce2-a2e89efa5b56',
'user': { 'user': {
'name': 'Maitê L Siqueira', 'name': 'Maitê L Siqueira',

View File

@@ -22,6 +22,8 @@
// Seeds for Org // Seeds for Org
{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "0", "name": "Banco do Brasil", "cnpj": "00000000000191"} {"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "0", "name": "Banco do Brasil", "cnpj": "00000000000191"}
{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "admins#15bacf02-1535-4bee-9022-19d106fd7518", "name": "Chester Bennington", "email": "chester@linkinpark.com"} {"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "admins#15bacf02-1535-4bee-9022-19d106fd7518", "name": "Chester Bennington", "email": "chester@linkinpark.com"}
{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "METADATA#BILLING", "due_days": "23"}
{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "METADATA#TEST_MODE", "iugu_api_token": "123"}
{"id": "orgmembers#f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "15bacf02-1535-4bee-9022-19d106fd7518"} {"id": "orgmembers#f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "15bacf02-1535-4bee-9022-19d106fd7518"}
// Seeds for Org // Seeds for Org

View File

@@ -148,12 +148,17 @@ type Order = Order_ & {
} }
export async function loader({ context, request, params }: Route.LoaderArgs) { export async function loader({ context, request, params }: Route.LoaderArgs) {
const order = (await req({ const r = await req({
url: `/orders/${params.id}`, url: `/orders/${params.id}`,
context, context,
request request
}).then((r) => r.json())) as Order })
if (!r.ok) {
throw new Response(null, { status: r.status })
}
const order = (await r.json()) as Order
return { order } return { order }
} }

View File

@@ -42,11 +42,11 @@ export const columns: ColumnDef<Org>[] = [
return ( return (
<div className="flex gap-2.5 items-center"> <div className="flex gap-2.5 items-center">
<div className="relative"> <div className="relative hidden lg:block">
{subscription_covered ? ( {subscription_covered ? (
<BadgeCheckIcon className="fill-blue-500 stroke-white absolute size-4 dark:size-3.5 -top-0 -right-0 z-2" /> <BadgeCheckIcon className="fill-blue-500 stroke-white absolute size-4 dark:size-3.5 -top-0 -right-0 z-2" />
) : null} ) : null}
<Avatar className="size-10 hidden lg:block"> <Avatar className="size-10">
<AvatarFallback className="border"> <AvatarFallback className="border">
{initials(name)} {initials(name)}
</AvatarFallback> </AvatarFallback>

View File

@@ -47,6 +47,12 @@ def _status_attr(status: str) -> StatusAttr | None:
return None return None
def _friendly_status(s: str) -> str:
if 'status' == 'EXTERNALLY_PAID':
return 'PAID'
return s
@app.post('/<order_id>/postback') @app.post('/<order_id>/postback')
@tracer.capture_method @tracer.capture_method
def postback(order_id: str): def postback(order_id: str):
@@ -73,7 +79,7 @@ def postback(order_id: str):
'#status_attr': status_attr.value, '#status_attr': status_attr.value,
}, },
expr_attr_values={ expr_attr_values={
':status': status, ':status': _friendly_status(status),
':now': now_, ':now': now_,
}, },
exc_cls=OrderNotFoundError, exc_cls=OrderNotFoundError,

View File

@@ -7,8 +7,7 @@ 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

@@ -8,6 +8,7 @@ from layercake.dateutils import now
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
KeyPair, KeyPair,
SortKey,
) )
from layercake.extra_types import CreditCard from layercake.extra_types import CreditCard
@@ -17,13 +18,6 @@ from iugu import Credentials, Iugu
logger = Logger(__name__) logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
iugu = Iugu(
Credentials(
IUGU_ACCOUNT_ID,
IUGU_API_TOKEN,
test_mode=IUGU_TEST_MODE,
)
)
@event_source(data_class=EventBridgeEvent) @event_source(data_class=EventBridgeEvent)
@@ -36,6 +30,26 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
credit_card = CreditCard(**new_image['credit_card']) credit_card = CreditCard(**new_image['credit_card'])
now_ = now() now_ = now()
api_token = dyn.collection.get_item(
KeyPair(
pk=order_id,
sk=SortKey(
'METADATA#TEST_MODE',
path_spec='iugu_api_token',
),
),
default=None,
raise_on_error=False,
)
test_mode = (api_token is not None) or IUGU_TEST_MODE
credentials = Credentials(
IUGU_ACCOUNT_ID,
# Note: `api_token` can be set from the database
api_token or IUGU_API_TOKEN,
test_mode=test_mode,
)
iugu = Iugu(credentials)
token = iugu.payment_token(credit_card) token = iugu.payment_token(credit_card)
charge = iugu.charge( charge = iugu.charge(
invoice_id=invoice_id, invoice_id=invoice_id,

View File

@@ -24,13 +24,6 @@ from iugu import Credentials, Iugu, Order
logger = Logger(__name__) logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
iugu = Iugu(
Credentials(
IUGU_ACCOUNT_ID,
IUGU_API_TOKEN,
test_mode=IUGU_TEST_MODE,
)
)
@event_source(data_class=EventBridgeEvent) @event_source(data_class=EventBridgeEvent)
@@ -43,14 +36,29 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
TransactKey(order_id) TransactKey(order_id)
+ SortKey('ADDRESS', rename_key='address') + 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'), + SortKey('CREDIT_CARD#PAYMENT_INTENT', rename_key='credit_card')
+ SortKey(
'METADATA#TEST_MODE',
rename_key='iugu_api_token',
path_spec='iugu_api_token',
),
flatten_top=False, flatten_top=False,
) )
api_token = r.get('iugu_api_token')
test_mode = (api_token is not None) or IUGU_TEST_MODE
credentials = Credentials(
IUGU_ACCOUNT_ID,
# Note: `api_token` can be set from the database
api_token or IUGU_API_TOKEN,
test_mode=test_mode,
)
payment_method = new_image['payment_method'] payment_method = new_image['payment_method']
is_pix = payment_method == 'PIX' is_pix = payment_method == 'PIX'
is_bank_slip = payment_method == 'BANK_SLIP' is_bank_slip = payment_method == 'BANK_SLIP'
iugu = Iugu(credentials)
invoice = iugu.create_invoice( invoice = iugu.create_invoice(
order=Order( order=Order(
address=r.get('address', {}), address=r.get('address', {}),
@@ -95,6 +103,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',
) )
if test_mode:
transact.put(
item={
'id': order_id,
'sk': 'SCHEDULE#SELF_DESTRUCTION',
'ttl': ttl(start_dt=now_, days=14),
'created_at': now_,
}
)
except Exception: except Exception:
pass pass

View File

@@ -237,6 +237,7 @@ class Iugu:
) )
r.raise_for_status() r.raise_for_status()
except requests.HTTPError as err: except requests.HTTPError as err:
logger.info('Response', err.response)
logger.exception(err) logger.exception(err)
raise raise
else: else:

View File

@@ -39,8 +39,7 @@ 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:
@@ -102,6 +101,7 @@ Resources:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.payments.charge_credit_card.lambda_handler Handler: events.payments.charge_credit_card.lambda_handler
Timeout: 12
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Policies: Policies:

View File

@@ -22,6 +22,7 @@
{"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"}
{"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": "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"}} {"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"}}
{"id": "121c1140-779d-4664-8d99-4a006a22f547", "sk": "METADATA#TEST_MODE", "iugu_api_token": "123"}
// User data // User data
{"id": "5OxmMjL-ujoR5IMGegQz", "sk": "0", "name": "Sérgio R Siqueira"} {"id": "5OxmMjL-ujoR5IMGegQz", "sk": "0", "name": "Sérgio R Siqueira"}

1187
orders-events/uv.lock generated

File diff suppressed because it is too large Load Diff