feat(pos): add gunicorn, marketplace B2B, and subscription billing (#7, #8, #12)

- Gunicorn production server with auto-scaled workers, run.sh, updated systemd service
- Marketplace B2B: cross-tenant inventory search, ordering, seller management with full UI
- Subscription billing: plan limits enforced on products/employees/branches, billing API + upgrade flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:17:33 +00:00
parent ecdc3526a6
commit e00dce7d5a
12 changed files with 1132 additions and 2 deletions

View File

@@ -29,6 +29,22 @@ def create_branch():
if not data.get('name'):
return jsonify({'error': 'name required'}), 400
# Plan limit check
from services.billing import check_limit, next_plan, PLANS, get_plan
conn_chk = get_tenant_conn(g.tenant_id)
cur_chk = conn_chk.cursor()
cur_chk.execute("SELECT count(*) FROM branches WHERE is_active = true")
current_branches = cur_chk.fetchone()[0]
cur_chk.close()
conn_chk.close()
allowed, limit, current = check_limit(g.tenant_id, 'max_branches', current_branches)
if not allowed:
plan_key = get_plan(g.tenant_id)
nxt = next_plan(plan_key)
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} branches). Upgrade to {nxt_name}.'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
@@ -76,6 +92,22 @@ def create_employee():
if not data.get(f):
return jsonify({'error': f'{f} required'}), 400
# Plan limit check
from services.billing import check_limit, next_plan, PLANS, get_plan
conn_chk = get_tenant_conn(g.tenant_id)
cur_chk = conn_chk.cursor()
cur_chk.execute("SELECT count(*) FROM employees WHERE is_active = true")
current_employees = cur_chk.fetchone()[0]
cur_chk.close()
conn_chk.close()
allowed, limit, current = check_limit(g.tenant_id, 'max_employees', current_employees)
if not allowed:
plan_key = get_plan(g.tenant_id)
nxt = next_plan(plan_key)
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -126,6 +158,69 @@ def create_employee():
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
@config_bp.route('/currency', methods=['GET'])
@require_auth()
def get_currency():
"""Get currency config for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('currency', 'exchange_rate_usd_mxn')")
cfg = {}
for row in cur.fetchall():
cfg[row[0]] = row[1]
cur.close()
conn.close()
from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN
return jsonify({
'currency': cfg.get('currency', DEFAULT_CURRENCY),
'exchange_rate': float(cfg.get('exchange_rate_usd_mxn', str(EXCHANGE_RATE_USD_MXN))),
'currencies': {
'MXN': {'symbol': '$', 'name': 'Peso Mexicano'},
'USD': {'symbol': 'US$', 'name': 'US Dollar'},
}
})
@config_bp.route('/currency', methods=['PUT'])
@require_auth('config.edit')
def update_currency():
"""Update currency config for this tenant."""
data = request.get_json() or {}
currency = data.get('currency', 'MXN')
rate = data.get('exchange_rate')
if currency not in ('MXN', 'USD'):
return jsonify({'error': 'currency must be MXN or USD'}), 400
if rate is not None:
try:
rate = float(rate)
if rate <= 0:
raise ValueError
except (TypeError, ValueError):
return jsonify({'error': 'exchange_rate must be a positive number'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('currency', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (currency,))
if rate is not None:
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('exchange_rate_usd_mxn', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (str(rate),))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Currency config updated', 'currency': currency})
@config_bp.route('/business', methods=['GET'])
@require_auth()
def get_business():
@@ -170,3 +265,52 @@ def get_theme():
'--radius': '8px',
}
})
# ─── Billing / Subscription ──────────────────────────
@config_bp.route('/billing', methods=['GET'])
@require_auth()
def get_billing():
"""Get current plan, usage stats, and available plans."""
from services.billing import get_plan_details, PLANS, PLAN_ORDER
plan = get_plan_details(g.tenant_id)
# Get current usage counts
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT count(*) FROM inventory WHERE is_active = true")
products = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM employees WHERE is_active = true")
employees = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM branches WHERE is_active = true")
branches = cur.fetchone()[0]
cur.close()
conn.close()
return jsonify({
'current_plan': plan,
'usage': {
'products': products,
'employees': employees,
'branches': branches,
},
'plans': {k: {**v, 'key': k} for k, v in PLANS.items()},
'plan_order': PLAN_ORDER,
})
@config_bp.route('/billing/upgrade', methods=['POST'])
@require_auth('config.edit')
def upgrade_billing():
"""Upgrade tenant plan."""
from services.billing import upgrade_plan
data = request.get_json() or {}
new_plan = data.get('plan')
if not new_plan:
return jsonify({'error': 'plan required'}), 400
result = upgrade_plan(g.tenant_id, new_plan)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)

View File

@@ -204,6 +204,23 @@ def create_item():
if not branch_id:
return jsonify({'error': 'branch_id required'}), 400
# Plan limit check
from services.billing import check_limit, next_plan, PLANS, get_plan
conn = get_tenant_conn(g.tenant_id)
cur_count = conn.cursor()
cur_count.execute("SELECT count(*) FROM inventory WHERE is_active = true")
current_products = cur_count.fetchone()[0]
cur_count.close()
allowed, limit, current = check_limit(g.tenant_id, 'max_products', current_products)
if not allowed:
conn.close()
plan_key = get_plan(g.tenant_id)
nxt = next_plan(plan_key)
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} products). Upgrade to {nxt_name}.'}), 403
conn.close()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()

View File

@@ -0,0 +1,360 @@
# /home/Autopartes/pos/blueprints/marketplace_bp.py
"""Marketplace B2B: bodegas publish inventory, talleres/refaccionarias browse and order."""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_master_conn, get_tenant_conn
marketplace_bp = Blueprint('marketplace', __name__, url_prefix='/pos/api/marketplace')
@marketplace_bp.route('/sellers', methods=['GET'])
@require_auth()
def list_sellers():
"""List active sellers/bodegas."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT id, name, subdomain, rfc
FROM tenants
WHERE is_active = true AND is_seller = true
ORDER BY name
""")
sellers = []
for r in cur.fetchall():
sellers.append({'id': r[0], 'name': r[1], 'subdomain': r[2], 'rfc': r[3]})
cur.close()
conn.close()
return jsonify({'data': sellers})
@marketplace_bp.route('/search', methods=['GET'])
@require_auth()
def search_inventory():
"""Search across ALL seller tenant inventories.
Query params:
q: search term (required, min 2 chars)
seller_id: optional filter by specific seller
page: page number (default 1)
per_page: results per page (default 50, max 200)
"""
q = request.args.get('q', '').strip()
if len(q) < 2:
return jsonify({'error': 'Search query must be at least 2 characters'}), 400
seller_id = request.args.get('seller_id')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
offset = (page - 1) * per_page
# Get all seller tenants
master = get_master_conn()
mcur = master.cursor()
if seller_id:
mcur.execute("""
SELECT id, name, db_name FROM tenants
WHERE is_active = true AND is_seller = true AND id = %s
""", (seller_id,))
else:
mcur.execute("""
SELECT id, name, db_name FROM tenants
WHERE is_active = true AND is_seller = true
ORDER BY name
""")
sellers = mcur.fetchall()
mcur.close()
master.close()
results = []
search_pattern = f'%{q}%'
for s_id, s_name, db_name in sellers:
try:
conn = get_tenant_conn(s_id)
cur = conn.cursor()
cur.execute("""
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate, i.unit,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN (
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = true
AND COALESCE(s.stock, 0) > 0
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)
ORDER BY i.name
LIMIT %s
""", (search_pattern, search_pattern, search_pattern, per_page))
for r in cur.fetchall():
results.append({
'seller_id': s_id,
'seller_name': s_name,
'part_number': r[0],
'name': r[1],
'brand': r[2],
'price': float(r[3]) if r[3] else 0,
'tax_rate': float(r[4]) if r[4] else 0.16,
'unit': r[5] or 'PZA',
'stock': r[6],
})
cur.close()
conn.close()
except Exception:
# Skip tenants with connection issues
continue
# Sort all results by name, then paginate
results.sort(key=lambda x: x['name'])
total = len(results)
paged = results[offset:offset + per_page]
return jsonify({
'data': paged,
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page if per_page else 1,
}
})
@marketplace_bp.route('/order', methods=['POST'])
@require_auth()
def create_order():
"""Create a marketplace order from buyer to seller.
Body:
seller_id: int (required)
items: [{ part_number, part_name, quantity, unit_price }] (required)
notes: str (optional)
"""
data = request.get_json() or {}
seller_id = data.get('seller_id')
items = data.get('items', [])
if not seller_id:
return jsonify({'error': 'seller_id required'}), 400
if not items:
return jsonify({'error': 'items required (non-empty array)'}), 400
buyer_id = g.tenant_id
# Get buyer and seller names
master = get_master_conn()
mcur = master.cursor()
mcur.execute("SELECT name FROM tenants WHERE id = %s", (buyer_id,))
buyer_row = mcur.fetchone()
mcur.execute("SELECT name FROM tenants WHERE id = %s AND is_seller = true AND is_active = true", (seller_id,))
seller_row = mcur.fetchone()
mcur.close()
if not buyer_row:
master.close()
return jsonify({'error': 'Buyer tenant not found'}), 404
if not seller_row:
master.close()
return jsonify({'error': 'Seller not found or not active'}), 404
buyer_name = buyer_row[0]
seller_name = seller_row[0]
# Calculate total
total = 0
for item in items:
qty = item.get('quantity', 0)
price = item.get('unit_price', 0)
item['subtotal'] = round(qty * price, 2)
total += item['subtotal']
mcur2 = master.cursor()
mcur2.execute("""
INSERT INTO marketplace_orders (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, notes)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id
""", (buyer_id, seller_id, buyer_name, seller_name, round(total, 2), data.get('notes')))
order_id = mcur2.fetchone()[0]
for item in items:
mcur2.execute("""
INSERT INTO marketplace_order_items (order_id, part_number, part_name, quantity, unit_price, subtotal)
VALUES (%s, %s, %s, %s, %s, %s)
""", (order_id, item.get('part_number'), item.get('part_name'),
item.get('quantity', 0), item.get('unit_price', 0), item.get('subtotal', 0)))
master.commit()
mcur2.close()
master.close()
return jsonify({'id': order_id, 'total': round(total, 2), 'message': 'Order created'}), 201
@marketplace_bp.route('/orders', methods=['GET'])
@require_auth()
def list_orders():
"""List marketplace orders (as buyer or seller).
Query params:
role: 'buyer' or 'seller' (default: both)
status: filter by status
page: page number
per_page: results per page
"""
tenant_id = g.tenant_id
role = request.args.get('role', '')
status = request.args.get('status', '')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
offset = (page - 1) * per_page
master = get_master_conn()
mcur = master.cursor()
where_clauses = []
params = []
if role == 'buyer':
where_clauses.append("buyer_tenant_id = %s")
params.append(tenant_id)
elif role == 'seller':
where_clauses.append("seller_tenant_id = %s")
params.append(tenant_id)
else:
where_clauses.append("(buyer_tenant_id = %s OR seller_tenant_id = %s)")
params.extend([tenant_id, tenant_id])
if status:
where_clauses.append("status = %s")
params.append(status)
where = " AND ".join(where_clauses)
mcur.execute(f"SELECT count(*) FROM marketplace_orders WHERE {where}", params)
total = mcur.fetchone()[0]
mcur.execute(f"""
SELECT id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, status, notes, created_at, updated_at
FROM marketplace_orders
WHERE {where}
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, offset])
orders = []
for r in mcur.fetchall():
orders.append({
'id': r[0], 'buyer_tenant_id': r[1], 'seller_tenant_id': r[2],
'buyer_name': r[3], 'seller_name': r[4],
'total': float(r[5]) if r[5] else 0,
'status': r[6], 'notes': r[7],
'created_at': str(r[8]), 'updated_at': str(r[9]),
})
mcur.close()
master.close()
return jsonify({
'data': orders,
'pagination': {
'page': page, 'per_page': per_page,
'total': total, 'pages': (total + per_page - 1) // per_page if per_page else 1,
}
})
@marketplace_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
@require_auth()
def update_order_status(order_id):
"""Update order status. Seller can confirm/ship/deliver/cancel. Buyer can cancel if pending.
Body:
status: 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
"""
data = request.get_json() or {}
new_status = data.get('status')
valid_statuses = ['confirmed', 'shipped', 'delivered', 'cancelled']
if new_status not in valid_statuses:
return jsonify({'error': f'status must be one of: {", ".join(valid_statuses)}'}), 400
tenant_id = g.tenant_id
master = get_master_conn()
mcur = master.cursor()
mcur.execute("""
SELECT buyer_tenant_id, seller_tenant_id, status
FROM marketplace_orders WHERE id = %s
""", (order_id,))
row = mcur.fetchone()
if not row:
mcur.close()
master.close()
return jsonify({'error': 'Order not found'}), 404
buyer_id, seller_id, current_status = row
# Permission check
if tenant_id == buyer_id:
# Buyer can only cancel pending orders
if new_status != 'cancelled' or current_status != 'pending':
mcur.close()
master.close()
return jsonify({'error': 'Buyer can only cancel pending orders'}), 403
elif tenant_id == seller_id:
# Seller can do any transition
pass
else:
mcur.close()
master.close()
return jsonify({'error': 'Not authorized for this order'}), 403
mcur.execute("""
UPDATE marketplace_orders SET status = %s, updated_at = NOW()
WHERE id = %s
""", (new_status, order_id))
master.commit()
mcur.close()
master.close()
return jsonify({'id': order_id, 'status': new_status, 'message': 'Order updated'})
@marketplace_bp.route('/orders/<int:order_id>/items', methods=['GET'])
@require_auth()
def get_order_items(order_id):
"""Get items for a specific order."""
tenant_id = g.tenant_id
master = get_master_conn()
mcur = master.cursor()
# Verify tenant is buyer or seller
mcur.execute("""
SELECT buyer_tenant_id, seller_tenant_id FROM marketplace_orders WHERE id = %s
""", (order_id,))
row = mcur.fetchone()
if not row or (row[0] != tenant_id and row[1] != tenant_id):
mcur.close()
master.close()
return jsonify({'error': 'Not authorized'}), 403
mcur.execute("""
SELECT id, part_number, part_name, quantity, unit_price, subtotal
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
""", (order_id,))
items = []
for r in mcur.fetchall():
items.append({
'id': r[0], 'part_number': r[1], 'part_name': r[2],
'quantity': r[3], 'unit_price': float(r[4]) if r[4] else 0,
'subtotal': float(r[5]) if r[5] else 0,
})
mcur.close()
master.close()
return jsonify({'data': items})