Skip to content

Commit

Permalink
Reconciliation workers (#421)
Browse files Browse the repository at this point in the history
* add some tests with the new order query endpoint

* retry send order tests pass

* test check deposits

* tests

* test

* fix

* version

* rename validator property

* raises exception on state = None

* comments

* missing test
  • Loading branch information
felipao-mx authored Nov 1, 2023
1 parent 0910441 commit 3c8c204
Show file tree
Hide file tree
Showing 28 changed files with 1,276 additions and 345 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ coverage.xml
/.python-version

prueba-cert.pem

htmlcov/
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ newrelic==6.2.0.156
pandas==1.2.4
python-hosts==1.0.1
sentry-sdk==1.14.0
stpmex==3.11.2
stpmex==3.13.1
importlib-metadata==4.13.0
44 changes: 11 additions & 33 deletions speid/commands/spei.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@
import pytz
from mongoengine import DoesNotExist
from stpmex.business_days import get_next_business_day
from stpmex.types import Estado as StpEstado

from speid import app
from speid.helpers.callback_helper import set_status_transaction
from speid.helpers.transaction_helper import process_incoming_transaction
from speid.helpers.transaction_helper import (
process_incoming_transaction,
stp_model_to_dict,
)
from speid.models import Event, Transaction
from speid.models.transaction import (
REFUNDS_PAYMENTS_TYPES,
STP_VALID_DEPOSITS_STATUSES,
)
from speid.processors import stpmex_client
from speid.types import Estado, EventType

ESTADOS_DEPOSITOS_VALIDOS = {
StpEstado.confirmada,
StpEstado.liquidada,
StpEstado.traspaso_liquidado,
}

TIPOS_PAGO_DEVOLUCION = {0, 16, 17, 18, 23, 24}


@app.cli.group('speid')
def speid_group():
Expand Down Expand Up @@ -87,11 +85,11 @@ def reconciliate_deposits(
# Se ignora los tipos pago devolución debido a que
# el estado de estas operaciones se envían
# al webhook `POST /orden_events`
if recibida.tipoPago in TIPOS_PAGO_DEVOLUCION:
if recibida.tipoPago in REFUNDS_PAYMENTS_TYPES:
no_procesadas.append(recibida.claveRastreo)
continue

if recibida.estado not in ESTADOS_DEPOSITOS_VALIDOS:
if recibida.estado not in STP_VALID_DEPOSITS_STATUSES:
no_procesadas.append(recibida.claveRastreo)
continue

Expand All @@ -105,27 +103,7 @@ def reconciliate_deposits(
# hace una conversión del modelo de respuesta de
# la función `consulta_recibidas` al modelo del evento que envía
# STP por el webhook en `POST /ordenes`
stp_request = dict(
Clave=recibida.idEF,
FechaOperacion=recibida.fechaOperacion.strftime('%Y%m%d'),
InstitucionOrdenante=recibida.institucionContraparte,
InstitucionBeneficiaria=recibida.institucionOperante,
ClaveRastreo=recibida.claveRastreo,
Monto=recibida.monto,
NombreOrdenante=recibida.nombreOrdenante,
TipoCuentaOrdenante=recibida.tipoCuentaOrdenante,
CuentaOrdenante=recibida.cuentaOrdenante,
RFCCurpOrdenante=recibida.rfcCurpOrdenante,
NombreBeneficiario=recibida.nombreBeneficiario,
TipoCuentaBeneficiario=recibida.tipoCuentaBeneficiario,
CuentaBeneficiario=recibida.cuentaBeneficiario,
RFCCurpBeneficiario=getattr(
recibida, 'rfcCurpBeneficiario', 'NA'
),
ConceptoPago=recibida.conceptoPago,
ReferenciaNumerica=recibida.referenciaNumerica,
Empresa=recibida.empresa,
)
stp_request = stp_model_to_dict(recibida)
click.echo(f'Depósito procesado: {recibida.claveRastreo}')
process_incoming_transaction(stp_request)
else:
Expand Down
13 changes: 13 additions & 0 deletions speid/exc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from dataclasses import dataclass


class OrderNotFoundException(ReferenceError):
pass

Expand All @@ -16,3 +19,13 @@ class ScheduleError(Exception):
"""

pass


@dataclass
class TransactionNeedManualReviewError(Exception):
"""
when a person should review the transaction status manually
"""

speid_id: str
error: str
23 changes: 23 additions & 0 deletions speid/helpers/transaction_helper.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from typing import Dict

from mongoengine import NotUniqueError
from sentry_sdk import capture_exception, capture_message
Expand Down Expand Up @@ -40,3 +41,25 @@ def process_incoming_transaction(incoming_transaction: dict) -> dict:
transaction.save()
capture_exception(e)
return r


def stp_model_to_dict(model) -> Dict:
return dict(
Clave=model.idEF,
FechaOperacion=model.fechaOperacion.strftime('%Y%m%d'),
InstitucionOrdenante=model.institucionContraparte,
InstitucionBeneficiaria=model.institucionOperante,
ClaveRastreo=model.claveRastreo,
Monto=model.monto,
NombreOrdenante=model.nombreOrdenante,
TipoCuentaOrdenante=model.tipoCuentaOrdenante,
CuentaOrdenante=model.cuentaOrdenante,
RFCCurpOrdenante=model.rfcCurpOrdenante,
NombreBeneficiario=model.nombreBeneficiario,
TipoCuentaBeneficiario=model.tipoCuentaBeneficiario,
CuentaBeneficiario=model.cuentaBeneficiario,
RFCCurpBeneficiario=getattr(model, 'rfcCurpBeneficiario', 'NA'),
ConceptoPago=model.conceptoPago,
ReferenciaNumerica=model.referenciaNumerica,
Empresa=model.empresa,
)
99 changes: 64 additions & 35 deletions speid/models/transaction.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import datetime as dt
import os
from datetime import datetime
from enum import Enum
from typing import Optional

Expand All @@ -16,12 +16,12 @@
)
from sentry_sdk import capture_exception
from stpmex.business_days import get_next_business_day
from stpmex.exc import NoEntityFound, StpmexException
from stpmex.exc import EmptyResultsError, StpmexException
from stpmex.resources import Orden
from stpmex.types import Estado as STPEstado

from speid import STP_EMPRESA
from speid.exc import MalformedOrderException
from speid.exc import MalformedOrderException, TransactionNeedManualReviewError
from speid.helpers import callback_helper
from speid.processors import stpmex_client
from speid.types import Estado, EventType, TipoTransaccion
Expand All @@ -42,17 +42,32 @@
os.getenv('SKIP_VALIDATION_PRIOR_SEND_ORDER', 'false').lower() == 'true'
)

STP_FAILED_STATUSES = [
STP_FAILED_TRANSFERS_STATUSES = {
STPEstado.traspaso_cancelado,
STPEstado.cancelada,
STPEstado.cancelada_adapter,
STPEstado.cancelada_rechazada,
]
STPEstado.devuelta,
}

STP_SUCCEDED_TRANSFERS_STATUSES = {
STPEstado.liquidada,
STPEstado.traspaso_liquidado,
}


STP_VALID_DEPOSITS_STATUSES = {
STPEstado.confirmada,
STPEstado.liquidada,
STPEstado.traspaso_liquidado,
}

REFUNDS_PAYMENTS_TYPES = {0, 16, 17, 18, 23, 24}


@handler(signals.pre_save)
def pre_save_transaction(sender, document):
date = document.fecha_operacion or datetime.today()
date = document.fecha_operacion or dt.datetime.today()
document.compound_key = (
f'{document.clave_rastreo}:{date.strftime("%Y%m%d")}'
)
Expand Down Expand Up @@ -130,6 +145,18 @@ class Transaction(Document, BaseModel):
]
}

@property
def created_at_cdmx(self) -> dt.datetime:
utc_created_at = self.created_at.replace(tzinfo=pytz.utc)
return utc_created_at.astimezone(pytz.timezone('America/Mexico_City'))

@property
def created_at_fecha_operacion(self) -> dt.date:
# STP doesn't return `fecha_operacion` on withdrawal creation, but we
# can calculate it.
assert self.tipo is TipoTransaccion.retiro
return get_next_business_day(self.created_at_cdmx)

def set_state(self, state: Estado):
from ..tasks.transactions import send_transaction_status

Expand Down Expand Up @@ -162,39 +189,41 @@ def is_valid_account(self) -> bool:
pass
return is_valid

def fetch_stp_status(self) -> Optional[STPEstado]:
# checa status en stp
estado = None
try:
stp_order = stpmex_client.ordenes.consulta_clave_rastreo(
claveRastreo=self.clave_rastreo,
institucionOperante=self.institucion_ordenante,
fechaOperacion=get_next_business_day(self.created_at),
)
estado = stp_order.estado
except NoEntityFound:
...
return estado

def is_current_working_day(self) -> bool:
# checks if transaction was made in the current working day
local = self.created_at.replace(tzinfo=pytz.utc)
local = local.astimezone(pytz.timezone('America/Mexico_City'))
return get_next_business_day(local) == datetime.utcnow().date()

def fail_if_not_found_stp(self) -> None:
# if transaction is not found in stp, or has a failed status,
# return to origin. Only checking for curent working day
if not self.is_current_working_day():
return
def fetch_stp_status(self) -> STPEstado:
stp_order = stpmex_client.ordenes_v2.consulta_clave_rastreo_enviada(
clave_rastreo=self.clave_rastreo,
fecha_operacion=self.created_at_fecha_operacion,
)
return stp_order.estado

def update_stp_status(self) -> None:
try:
estado = self.fetch_stp_status()
status: Optional[STPEstado] = self.fetch_stp_status()
except EmptyResultsError:
status = None
except StpmexException as ex:
capture_exception(ex)
return

if not status:
raise TransactionNeedManualReviewError(
self.speid_id,
f'Can not retrieve transaction stp_id: {self.stp_id}',
)
elif status in STP_FAILED_TRANSFERS_STATUSES:
self.set_state(Estado.failed)
self.save()
elif status in STP_SUCCEDED_TRANSFERS_STATUSES:
self.set_state(Estado.succeeded)
self.save()
elif status is STPEstado.autorizada:
return
else:
if not estado or estado in STP_FAILED_STATUSES:
self.set_state(Estado.failed)
self.save()
# Cualquier otro caso se debe revisar manualmente y aplicar
# el fix correspondiente
raise TransactionNeedManualReviewError(
self.speid_id, f'Unhandled stp status: {status}'
)

def create_order(self) -> Orden:
# Validate account has already been created
Expand Down
58 changes: 38 additions & 20 deletions speid/tasks/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
MalformedOrderException,
ResendSuccessOrderException,
ScheduleError,
TransactionNeedManualReviewError,
)
from speid.helpers.task_helpers import time_in_range
from speid.models import Event, Transaction
Expand Down Expand Up @@ -52,7 +53,11 @@ def retry_timeout(attempts: int) -> int:
def send_order(self, order_val: dict):
try:
execute(order_val)
except (MalformedOrderException, ResendSuccessOrderException) as exc:
except (
MalformedOrderException,
ResendSuccessOrderException,
TransactionNeedManualReviewError,
) as exc:
capture_exception(exc)
except ScheduleError:
self.retry(countdown=STP_COUNTDOWN)
Expand Down Expand Up @@ -93,30 +98,43 @@ def execute(order_val: dict):
transaction.save()
pass
except AssertionError:
# Se hace un reenvío del estado de la transferencia
# transaction.set_state(Estado.succeeded)
# Para evitar que se vuelva a mandar o regresar se manda la excepción
raise ResendSuccessOrderException()

# Estas validaciones aplican para transferencias existentes que
# pudieron haber fallado o han sido enviadas a STP
if transaction.estado in [Estado.failed, Estado.error]:
transaction.set_state(Estado.failed)
return

# Revisa el estado de una transferencia si ya tiene asignado stp_id o ha
# pasado más de 2 hrs.
now = datetime.utcnow()
if transaction.stp_id or (now - transaction.created_at) > timedelta(
hours=2
):
transaction.update_stp_status()
return

# A partir de aquí son validaciones para transferencias nuevas
if transaction.monto > MAX_AMOUNT:
transaction.events.append(Event(type=EventType.error))
transaction.save()
raise MalformedOrderException()

now = datetime.utcnow()
# Return transaction after 2 hours of creation
if (now - transaction.created_at) > timedelta(hours=2):
transaction.fail_if_not_found_stp()
else:
try:
transaction.create_order()
except (
AccountDoesNotExist,
BankCodeClabeMismatch,
InvalidAccountType,
InvalidAmount,
InvalidInstitution,
InvalidTrackingKey,
PldRejected,
ValidationError,
):
transaction.set_state(Estado.failed)
transaction.save()
try:
transaction.create_order()
except (
AccountDoesNotExist,
BankCodeClabeMismatch,
InvalidAccountType,
InvalidAmount,
InvalidInstitution,
InvalidTrackingKey,
PldRejected,
ValidationError,
):
transaction.set_state(Estado.failed)
transaction.save()
Loading

0 comments on commit 3c8c204

Please sign in to comment.