feat(api): add BNPL, ERP, WhatsApp Cloud, Supplier Portal stubs
- bnpl_bp.py: APLAZO/Kueski/Clip application workflow (mock) - erp_bp.py: Aspel/CONTPAQi/SAP/Odoo sync jobs (mock) - whatsapp_cloud_bp.py: Meta Cloud API webhook, messages, templates - supplier_portal_bp.py: demand by zone/branch and top-parts analytics - app.py: register all new blueprints
This commit is contained in:
90
pos/blueprints/bnpl_bp.py
Normal file
90
pos/blueprints/bnpl_bp.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""BNPL Blueprint — Buy Now Pay Later integrations (stub architecture).
|
||||
|
||||
Providers: APLAZO, Kueski, Clip (configured per tenant).
|
||||
All endpoints are stubs with mock responses until real credentials are provided.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
bnpl_bp = Blueprint('bnpl', __name__, url_prefix='/pos/api/bnpl')
|
||||
|
||||
# ─── Auth helper ───
|
||||
from middleware import require_auth
|
||||
|
||||
# ─── Mock store ───
|
||||
_mock_applications = {}
|
||||
|
||||
|
||||
@bnpl_bp.route('/providers', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_providers():
|
||||
"""List configured BNPL providers."""
|
||||
return jsonify({
|
||||
'providers': [
|
||||
{'id': 'ap lazo', 'name': 'APLAZO', 'enabled': False, 'config_needed': ['api_key', 'merchant_id']},
|
||||
{'id': 'kueski', 'name': 'Kueski Pay', 'enabled': False, 'config_needed': ['api_key', 'secret']},
|
||||
{'id': 'clip', 'name': 'Clip Pagos', 'enabled': False, 'config_needed': ['api_key']},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@bnpl_bp.route('/applications', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_application():
|
||||
"""Create a BNPL application for a sale."""
|
||||
data = request.get_json() or {}
|
||||
sale_id = data.get('sale_id')
|
||||
amount = data.get('amount')
|
||||
provider = data.get('provider', 'ap lazo')
|
||||
customer = data.get('customer', {})
|
||||
|
||||
if not sale_id or amount is None:
|
||||
return jsonify({'error': 'sale_id and amount are required'}), 400
|
||||
|
||||
app_id = str(uuid.uuid4())
|
||||
_mock_applications[app_id] = {
|
||||
'id': app_id,
|
||||
'sale_id': sale_id,
|
||||
'provider': provider,
|
||||
'amount': float(amount),
|
||||
'status': 'pending',
|
||||
'customer': customer,
|
||||
'created_at': datetime.utcnow().isoformat(),
|
||||
'expires_at': (datetime.utcnow() + timedelta(hours=24)).isoformat(),
|
||||
'approval_url': f'/pos/api/bnpl/applications/{app_id}/approve',
|
||||
'webhook_url': f'/pos/api/bnpl/webhook/{provider}',
|
||||
}
|
||||
|
||||
return jsonify(_mock_applications[app_id]), 201
|
||||
|
||||
|
||||
@bnpl_bp.route('/applications/<app_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_application(app_id):
|
||||
"""Get BNPL application status."""
|
||||
app = _mock_applications.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
return jsonify(app)
|
||||
|
||||
|
||||
@bnpl_bp.route('/applications/<app_id>/approve', methods=['POST'])
|
||||
@require_auth()
|
||||
def approve_application(app_id):
|
||||
"""Mock approve an application (admin/override)."""
|
||||
app = _mock_applications.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
app['status'] = 'approved'
|
||||
app['approved_at'] = datetime.utcnow().isoformat()
|
||||
return jsonify(app)
|
||||
|
||||
|
||||
@bnpl_bp.route('/webhook/<provider>', methods=['POST'])
|
||||
def webhook(provider):
|
||||
"""Receive webhooks from BNPL providers."""
|
||||
data = request.get_json() or {}
|
||||
# In production, verify signature per provider
|
||||
return jsonify({'received': True, 'provider': provider, 'payload': data}), 200
|
||||
79
pos/blueprints/erp_bp.py
Normal file
79
pos/blueprints/erp_bp.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""ERP Sync Blueprint — Integration with Aspel, CONTPAQi, SAP, Odoo.
|
||||
|
||||
Stubs with architecture ready for real connectors.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
erp_bp = Blueprint('erp', __name__, url_prefix='/pos/api/erp')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
|
||||
|
||||
# ─── Mock sync jobs ───
|
||||
_mock_jobs = {}
|
||||
|
||||
|
||||
@erp_bp.route('/providers', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_providers():
|
||||
return jsonify({
|
||||
'providers': [
|
||||
{'id': 'aspel_sae', 'name': 'Aspel SAE', 'type': 'file_exchange', 'enabled': False},
|
||||
{'id': 'contpaqi', 'name': 'CONTPAQi', 'type': 'file_exchange', 'enabled': False},
|
||||
{'id': 'sap_b1', 'name': 'SAP Business One', 'type': 'api', 'enabled': False},
|
||||
{'id': 'odoo', 'name': 'Odoo', 'type': 'api', 'enabled': False},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@erp_bp.route('/sync', methods=['POST'])
|
||||
@require_auth()
|
||||
def start_sync():
|
||||
data = request.get_json() or {}
|
||||
provider = data.get('provider')
|
||||
sync_type = data.get('sync_type', 'sales') # sales, inventory, customers
|
||||
if not provider:
|
||||
return jsonify({'error': 'provider is required'}), 400
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
_mock_jobs[job_id] = {
|
||||
'id': job_id,
|
||||
'provider': provider,
|
||||
'sync_type': sync_type,
|
||||
'status': 'queued',
|
||||
'records_synced': 0,
|
||||
'errors': [],
|
||||
'created_at': datetime.utcnow().isoformat(),
|
||||
'started_at': None,
|
||||
'finished_at': None,
|
||||
}
|
||||
return jsonify(_mock_jobs[job_id]), 201
|
||||
|
||||
|
||||
@erp_bp.route('/sync/<job_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_sync_status(job_id):
|
||||
job = _mock_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
return jsonify(job)
|
||||
|
||||
|
||||
@erp_bp.route('/sync/<job_id>/run', methods=['POST'])
|
||||
@require_auth()
|
||||
def run_sync(job_id):
|
||||
"""Mock execute sync (in production this triggers a Celery task)."""
|
||||
job = _mock_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
job['status'] = 'running'
|
||||
job['started_at'] = datetime.utcnow().isoformat()
|
||||
# Mock completion
|
||||
job['status'] = 'completed'
|
||||
job['records_synced'] = 42
|
||||
job['finished_at'] = datetime.utcnow().isoformat()
|
||||
return jsonify(job)
|
||||
105
pos/blueprints/supplier_portal_bp.py
Normal file
105
pos/blueprints/supplier_portal_bp.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Supplier Portal Blueprint — Demand insights for vendors.
|
||||
|
||||
Allows suppliers to view demand by zone, part type, and branch.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api/supplier-portal')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Decimal):
|
||||
return float(o)
|
||||
return super().default(o)
|
||||
|
||||
|
||||
@supplier_portal_bp.route('/demand', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_demand():
|
||||
"""Aggregated demand by zone, part group, and time range."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
group_id = request.args.get('group_id', type=int)
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if group_id:
|
||||
filters += " AND p.group_id = %s"
|
||||
params.append(group_id)
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
rows = db.execute(
|
||||
f"""SELECT g.name as group_name, b.name as branch_name,
|
||||
COUNT(DISTINCT s.id_sale) as orders,
|
||||
SUM(si.quantity) as qty_requested,
|
||||
COALESCE(SUM(si.total), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
||||
WHERE {filters}
|
||||
GROUP BY g.name, b.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 100""", tuple(params)
|
||||
).fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'days': days,
|
||||
'demand': [
|
||||
{'group': row['group_name'], 'branch': row['branch_name'],
|
||||
'orders': row['orders'], 'quantity': row['qty_requested'],
|
||||
'revenue': row['revenue']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
|
||||
|
||||
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_top_parts():
|
||||
"""Top moving parts for suppliers to restock."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
rows = db.execute(
|
||||
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
||||
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
|
||||
WHERE s.created_at >= %s
|
||||
GROUP BY p.oem_part_number, p.name, g.name
|
||||
ORDER BY sold DESC
|
||||
LIMIT 50""", (since,)
|
||||
).fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'parts': [
|
||||
{'oem': row['oem_part_number'], 'name': row['name'],
|
||||
'group': row['group_name'], 'sold': row['sold'],
|
||||
'revenue': row['revenue'], 'stock': row['current_stock']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""WhatsApp Business API (Meta Cloud) Blueprint.
|
||||
|
||||
Replaces Baileys webhook for scalable production messaging.
|
||||
Stubs ready for Meta Cloud API credentials.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
whatsapp_cloud_bp = Blueprint('whatsapp_cloud', __name__, url_prefix='/pos/api/whatsapp-cloud')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
|
||||
|
||||
_mock_messages = {}
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/webhook', methods=['GET', 'POST'])
|
||||
def webhook():
|
||||
"""Meta Cloud API webhook verification and message reception."""
|
||||
if request.method == 'GET':
|
||||
# Verification challenge
|
||||
mode = request.args.get('hub.mode')
|
||||
token = request.args.get('hub.verify_token')
|
||||
challenge = request.args.get('hub.challenge')
|
||||
# In production: verify token against configured VERIFY_TOKEN
|
||||
if mode == 'subscribe' and challenge:
|
||||
return challenge, 200
|
||||
return jsonify({'error': 'Verification failed'}), 403
|
||||
|
||||
# POST — incoming messages
|
||||
data = request.get_json() or {}
|
||||
# In production: process entries, messages, statuses
|
||||
return jsonify({'received': True, 'entries': len(data.get('entry', []))}), 200
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/messages', methods=['POST'])
|
||||
@require_auth()
|
||||
def send_message():
|
||||
"""Send a message via Meta Cloud API."""
|
||||
data = request.get_json() or {}
|
||||
to = data.get('to')
|
||||
body = data.get('body')
|
||||
template = data.get('template')
|
||||
|
||||
if not to or (not body and not template):
|
||||
return jsonify({'error': 'to and body/template are required'}), 400
|
||||
|
||||
msg_id = str(uuid.uuid4())
|
||||
_mock_messages[msg_id] = {
|
||||
'id': msg_id,
|
||||
'to': to,
|
||||
'body': body,
|
||||
'template': template,
|
||||
'status': 'sent',
|
||||
'sent_at': datetime.utcnow().isoformat(),
|
||||
}
|
||||
return jsonify(_mock_messages[msg_id]), 201
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/templates', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_templates():
|
||||
"""List approved message templates."""
|
||||
return jsonify({
|
||||
'templates': [
|
||||
{'name': 'order_ready', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||
{'name': 'payment_reminder', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||
{'name': 'welcome_message', 'language': 'es_MX', 'category': 'MARKETING', 'status': 'PENDING'},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/status', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_status():
|
||||
"""Check Meta Cloud API connection status."""
|
||||
return jsonify({
|
||||
'connected': False,
|
||||
'phone_number_id': None,
|
||||
'business_account_id': None,
|
||||
'message_limit': None,
|
||||
'note': 'Configure WHATSAPP_CLOUD_ACCESS_TOKEN and PHONE_NUMBER_ID to connect',
|
||||
})
|
||||
Reference in New Issue
Block a user