from enum import Enum from http import HTTPStatus from typing import Any from urllib.parse import parse_qsl from aws_lambda_powertools import Logger, Tracer 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 from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.funcs import pick from boto3clients import dynamodb_client from config import ORDER_TABLE logger = Logger(__name__) tracer = Tracer() app = APIGatewayHttpResolver(enable_validation=True) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) class OrderNotFoundError(NotFoundError): ... class InvoiceNotFoundError(NotFoundError): ... class StatusTimestampAttr(Enum): # Post-migration (orders): uncomment the following 2 lines # PAID = 'paid_at' # EXTERNALLY_PAID = 'paid_at' # Post-migration (orders): remove the following 2 lines EXTERNALLY_PAID = 'payment_date' PAID = 'payment_date' CANCELED = 'canceled_at' REFUNDED = 'refunded_at' EXPIRED = 'expired_at' def _timestamp_attr_for_status(status: str) -> str | None: try: return StatusTimestampAttr[status].value except KeyError: return None def _friendly_status(status: str) -> str: if status == 'EXTERNALLY_PAID': return 'PAID' return status def _get_order_owner(order_id: str) -> dict: r = dyn.get_item(KeyPair(order_id, '0')) return pick(('user_id', 'org_id'), r) @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) now_ = now() event = decoded_body['event'] raw_status = decoded_body.get('data[status]', '').upper() status = _friendly_status(raw_status) timestamp_attr = _timestamp_attr_for_status(raw_status) if event != 'invoice.status_changed' or not timestamp_attr: logger.debug('Event not acceptable', order_id=order_id) return Response(status_code=HTTPStatus.NOT_ACCEPTABLE) with dyn.transact_writer() as transact: transact.update( key=KeyPair(order_id, '0'), update_expr='SET #status = :status, \ #ts_attr = :now, \ updated_at = :now', cond_expr='attribute_exists(sk)', expr_attr_names={ '#status': 'status', '#ts_attr': timestamp_attr, }, expr_attr_values={ ':status': status, ':now': now_, }, exc_cls=OrderNotFoundError, ) if raw_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, ) if status == 'PAID': try: dyn.put_item( item={ 'id': order_id, 'sk': 'FULFILLMENT', 'status': 'IN_PROGRESS', 'created_at': now_, **_get_order_owner(order_id), }, cond_expr='attribute_not_exists(sk)', ) except Exception: pass if status in ('CANCELED', 'REFUNDED'): try: with dyn.transact_writer() as transact: transact.condition( key=KeyPair(order_id, 'FULFILLMENT'), cond_expr=( 'attribute_exists(sk) ' 'AND #status <> :in_progress ' 'AND #status <> :rollback' ), expr_attr_names={ '#status': 'status', }, expr_attr_values={ ':in_progress': 'IN_PROGRESS', ':rollback': 'ROLLBACK', }, ) transact.put( item={ 'id': order_id, 'sk': 'FULFILLMENT#ROLLBACK', 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', ) logger.debug('Fulfillment rollback event created', order_id=order_id) except Exception: logger.debug('Fulfillment rollback event already exists', order_id=order_id) return Response(status_code=HTTPStatus.NO_CONTENT) @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) @tracer.capture_lambda_handler def lambda_handler( event: dict[str, Any], context: LambdaContext, ) -> dict[str, Any]: return app.resolve(event, context)