Cuando construyes una plataforma de pagos en el Africa francofona, heredas un marco regulatorio que la mayoria de las startups de Silicon Valley nunca han escuchado: OHADA. La Organisation pour l'Harmonisation en Afrique du Droit des Affaires (Organizacion para la Armonizacion del Derecho Empresarial en Africa) es un sistema basado en tratados de leyes comerciales uniformes adoptado por 17 paises africanos, incluyendo Costa de Marfil, donde construimos 0fee.dev.
El Acta Uniforme de Contabilidad de OHADA exige que las empresas conserven los documentos financieros durante un minimo de 10 anos. Este unico requisito moldeo fundamentalmente como 0fee.dev maneja la eliminacion de datos.
La regla que lo cambia todo
El articulo 24 del Acta Uniforme de OHADA sobre Derecho Contable establece que los libros contables y documentos de soporte deben preservarse durante al menos diez anos a partir de la fecha de la ultima entrada. Para una plataforma de pagos, los "documentos de soporte" incluyen registros de transacciones, facturas, recibos y las configuraciones de aplicaciones que generaron esas transacciones.
Esto significa:
- Los registros de transacciones nunca pueden eliminarse. Una transaccion de 2026 debe ser recuperable en 2036.
- Las aplicaciones con transacciones en vivo nunca pueden eliminarse. Si eliminas la app, pierdes el contexto necesario para entender sus transacciones.
- Las cuentas de usuario con historial financiero nunca pueden purgarse completamente. Las transacciones que generaron deben permanecer.
- Las facturas, recibos y registros de facturacion son permanentes. Son los "documentos de soporte" a los que la ley hace referencia.
En la sesion 054, implementamos estas restricciones en todo el backend de 0fee.dev.
Las apps con transacciones en vivo nunca pueden eliminarse
Esta es la regla de mayor impacto. Un desarrollador crea una app, configura proveedores, procesa pagos. Luego decide eliminar la app. En una plataforma SaaS tipica, harias una eliminacion en cascada de la app y todos sus datos asociados. Bajo las reglas de OHADA, eso es ilegal si existen transacciones en vivo.
python# services/app.py
async def can_delete_app(app_id: str) -> dict:
"""Verificar si una aplicacion puede ser eliminada permanentemente."""
# Contar transacciones en vivo (no de prueba)
live_tx_count = await db.scalar(
select(func.count(Transaction.id)).where(
Transaction.app_id == app_id,
Transaction.mode == "live",
)
)
# Contar facturas completadas
invoice_count = await db.scalar(
select(func.count(Invoice.id)).where(
Invoice.app_id == app_id,
Invoice.status.in_(["paid", "issued"]),
)
)
can_delete = live_tx_count == 0 and invoice_count == 0
reasons = []
if live_tx_count > 0:
reasons.append(
f"App has {live_tx_count} live transaction(s). "
f"OHADA requires 10-year retention of financial records."
)
if invoice_count > 0:
reasons.append(
f"App has {invoice_count} invoice(s). "
f"Financial documents must be preserved per OHADA Article 24."
)
return {
"can_delete": can_delete,
"can_archive": True, # Archivar siempre esta disponible
"live_transactions": live_tx_count,
"invoices": invoice_count,
"reasons": reasons,
}La funcion can_delete_app se llama antes de cualquier intento de eliminacion. La API devuelve razones claras de por que la eliminacion esta bloqueada, referenciando OHADA especificamente. Esto no es un vago "no se puede eliminar" -- es una explicacion fundamentada en el cumplimiento normativo.
El endpoint de validacion can-delete
Expusimos un endpoint dedicado que el frontend llama antes de mostrar las opciones de eliminar o archivar:
python@router.get("/apps/{app_id}/can-delete")
async def check_app_deletability(
app_id: str,
current_user: User = Depends(get_current_user),
):
app = await get_app_or_404(app_id, current_user.id)
result = await can_delete_app(app_id)
return resultEl frontend usa esta respuesta para determinar que opciones mostrar:
typescript// Frontend: pagina de configuracion de la app
const deletability = await api.get(`/apps/${appId}/can-delete`);
if (deletability.can_delete) {
// Mostrar ambos botones: archivar y eliminar
showDeleteButton = true;
showArchiveButton = true;
} else {
// Solo mostrar boton de archivar, con explicacion de cumplimiento
showDeleteButton = false;
showArchiveButton = true;
complianceWarning = deletability.reasons.join(' ');
}Eliminacion logica con marca de tiempo archived_at
Dado que la mayoria de las apps no pueden eliminarse, el archivado se convierte en el mecanismo principal de "eliminacion". Las apps archivadas son invisibles en el panel de control pero permanecen en la base de datos con todos sus datos intactos:
python# models/app.py
class App(Base):
__tablename__ = "apps"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
name = Column(String, nullable=False)
mode = Column(String, default="test")
is_active = Column(Boolean, default=True)
archived_at = Column(DateTime, nullable=True) # Marca de eliminacion logica
archived_by = Column(String, nullable=True) # Quien la archivo
archive_reason = Column(String, nullable=True) # Por que se archivo
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())El campo archived_at es el marcador de eliminacion logica. Cuando no es nulo, la app esta archivada. Tambien almacenamos quien la archivo y por que, porque el cumplimiento no se trata solo de retener datos -- se trata de retener contexto.
python# services/app.py
async def archive_app(
app_id: str,
user_id: str,
reason: str = None,
) -> App:
"""Archivar una aplicacion (eliminacion logica)."""
app = await get_app_or_404(app_id, user_id)
if app.archived_at:
raise HTTPException(400, "App is already archived")
app.archived_at = datetime.utcnow()
app.archived_by = user_id
app.archive_reason = reason
app.is_active = False # Prevenir nuevas transacciones
# Desactivar todos los webhooks de esta app
await db.execute(
update(Webhook).where(Webhook.app_id == app_id).values(is_active=False)
)
await db.commit()
# Registrar la accion de archivado para auditoria
await create_audit_log(
user_id=user_id,
action="app_archived",
resource_type="app",
resource_id=app_id,
details={"reason": reason},
)
return appArchivar una app tambien desactiva sus webhooks y establece is_active en False. Esto previene que se creen nuevas transacciones a traves de la app archivada, mientras se preservan todos los datos historicos.
El flujo de archivado/restauracion
El archivado es reversible. Un desarrollador que archiva una app puede restaurarla:
python@router.post("/apps/{app_id}/archive")
async def archive_app_endpoint(
app_id: str,
data: ArchiveRequest = None,
current_user: User = Depends(get_current_user),
):
reason = data.reason if data else None
app = await archive_app(app_id, current_user.id, reason)
return {"message": "App archived successfully", "app": app}
@router.post("/apps/{app_id}/restore") async def restore_app_endpoint( app_id: str, current_user: User = Depends(get_current_user), ): app = await get_app_or_404(app_id, current_user.id) BLANK if not app.archived_at: raise HTTPException(400, "App is not archived") BLANK app.archived_at = None app.archived_by = None app.archive_reason = None app.is_active = True BLANK await db.commit() BLANK await create_audit_log( user_id=current_user.id, action="app_restored", resource_type="app", resource_id=app_id, ) BLANK return {"message": "App restored successfully", "app": app} ```
Ten en cuenta que restaurar una app no reactiva automaticamente los webhooks. El desarrollador debe reconfigurar manualmente las URLs de webhook despues de la restauracion, porque los endpoints receptores pueden haber cambiado durante el periodo de archivado.
Archivado vs. eliminacion de apps: el arbol de decision
El frontend presenta este flujo al desarrollador:
El desarrollador hace clic en "Eliminar app"
|
v
[Verificar endpoint can-delete]
|
+----+----+
| |
Puede No puede
eliminar eliminar
| |
v v
Mostrar Mostrar solo archivar
ambas + advertencia de cumplimiento
opciones
| |
v v
"Eliminar "Archivar app"
para (siempre disponible)
siempre"
o
"Archivar"Cuando la eliminacion es posible (apps solo de prueba sin transacciones en vivo), se muestran ambas opciones. Cuando no lo es, solo el archivado esta disponible, con una explicacion clara:
typescript// Frontend: dialogo de confirmacion de eliminacion
{#if !deletability.can_delete}
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200
dark:border-amber-800 rounded-lg p-4 mb-4">
<h4 class="font-semibold text-amber-800 dark:text-amber-200">
Permanent deletion is not available
</h4>
<p class="text-amber-700 dark:text-amber-300 text-sm mt-1">
{deletability.reasons[0]}
</p>
<p class="text-amber-600 dark:text-amber-400 text-xs mt-2">
You can archive this app instead. Archived apps are hidden from
your dashboard but all data is preserved as required by law.
</p>
</div>
{/if}Filtrado de apps archivadas
Las apps archivadas se excluyen de todas las consultas estandar:
python# La consulta predeterminada excluye apps archivadas
async def get_user_apps(user_id: str, include_archived: bool = False):
query = select(App).where(App.user_id == user_id)
if not include_archived:
query = query.where(App.archived_at.is_(None))
query = query.order_by(App.created_at.desc())
result = await db.scalars(query)
return result.all()El panel de control muestra las apps activas por defecto. Una pestana o interruptor "Archivadas" permite al desarrollador ver y potencialmente restaurar apps archivadas.
Inmutabilidad de transacciones
Los registros de transacciones son inmutables por diseno, aplicado a multiples niveles:
python# Vista de administrador: las transacciones son solo lectura
class TransactionAdmin(ModelView, model=Transaction):
can_create = False
can_edit = False
can_delete = False
# API: no hay endpoints de actualizacion o eliminacion para transacciones
# El enrutador simplemente no define rutas PATCH o DELETE para /transactions/{id}
# Base de datos: no hay eliminacion en cascada desde apps
class Transaction(Base):
__tablename__ = "transactions"
app_id = Column(String, ForeignKey("apps.id", ondelete="RESTRICT"), nullable=False)
# RESTRICT previene eliminar una app que tiene transaccionesLa restriccion de clave foranea ondelete="RESTRICT" es la aplicacion a nivel de base de datos. Incluso si un error en el codigo de la aplicacion intenta eliminar una app con transacciones, PostgreSQL lo rechazara.
Advertencias de cumplimiento en la confirmacion de eliminacion
Cuando un desarrollador intenta eliminar una app que tiene transacciones en vivo, la interfaz no solo bloquea la accion. Explica la base legal:
typescriptconst complianceText = `This application has processed ${deletability.live_transactions} ` +
`live transaction(s). Under OHADA Uniform Act on Accounting (Article 24), ` +
`financial records and supporting documents must be retained for a minimum ` +
`of 10 years from the date of the last entry. ` +
`This application and its transaction history cannot be permanently deleted.`;Este nivel de transparencia importa. Los desarrolladores necesitan entender que la restriccion no es arbitraria -- es un requisito legal en los 17 estados miembros de OHADA donde muchos de los comerciantes de 0fee.dev operan.
El panorama mas amplio del cumplimiento
Las reglas de retencion de OHADA no son la unica regulacion financiera que afecta a una plataforma de pagos. Pero son las de mayor impacto directo para la gestion del ciclo de vida de los datos. Asi es como encajan en el panorama general:
| Regulacion | Requisito | Implementacion en 0fee.dev |
|---|---|---|
| OHADA Articulo 24 | Retencion de documentos por 10 anos | Eliminacion logica, sin eliminacion permanente para apps con transacciones |
| OHADA Articulo 19 | Registro cronologico | Las transacciones son inmutables, solo adicion |
| Regulaciones BCEAO | KYC para servicios de pago | Funcionalidad planificada para la Fase 13 de expansion del administrador |
| RGPD (para usuarios de la UE) | Derecho al olvido | Los datos personales pueden anonimizarse; los registros de transacciones se preservan con referencias anonimizadas |
La interseccion con el RGPD es particularmente interesante. Un usuario europeo puede solicitar la eliminacion de sus datos personales, pero los registros de transacciones deben permanecer por cumplimiento con OHADA. La solucion es la anonimizacion: reemplazar el nombre y correo del usuario con identificadores anonimizados mientras se mantienen los datos financieros intactos.
Lo que aprendimos
El cumplimiento debe moldear el modelo de datos desde el primer dia. Tuvimos la suerte de implementar el cumplimiento con OHADA relativamente temprano (sesion 054). Incorporar la prevencion de eliminacion a un sistema que ya soporta eliminacion permanente es mucho mas dificil que construirlo desde el inicio.
Los desarrolladores aceptan las restricciones cuando explicas la base legal. A nadie le gusta que le digan que no puede eliminar sus propios datos. Pero cuando la interfaz explica que es un requisito legal en 17 paises, los desarrolladores entienden y aceptan el archivado como alternativa.
La eliminacion logica no es solo un buen patron -- es un requisito legal. En muchas jurisdicciones, la capacidad de destruir permanentemente registros financieros no es una funcionalidad. Es una responsabilidad legal. Construir la eliminacion logica como predeterminada, con la eliminacion permanente como excepcion, se alinea con las expectativas regulatorias.
La marca de tiempo archived_at es mas util que un booleano is_archived. Saber cuando se archivo una app ayuda con la auditoria. Combinado con archived_by y archive_reason, crea un rastro de auditoria completo para la accion de archivado en si misma.
El cumplimiento financiero no es un trabajo glamoroso. Pero para una plataforma de pagos que opera en el Africa francofona, es innegociable. Las reglas de retencion de 10 anos de OHADA son claras, ejecutables y afectan cada decision del ciclo de vida de los datos. Construir el cumplimiento en el ADN de la plataforma -- no como algo posterior -- es lo que separa una fintech de grado de produccion de un prototipo.
Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.