- 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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
360
pos/blueprints/marketplace_bp.py
Normal file
360
pos/blueprints/marketplace_bp.py
Normal 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})
|
||||
Reference in New Issue
Block a user