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)