From e00dce7d5a85ff560b73267e1c0df11d157c0b3e Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sat, 4 Apr 2026 08:17:33 +0000 Subject: [PATCH] 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) --- docs/INSTALACION.md | 14 +- pos/app.py | 7 + pos/blueprints/config_bp.py | 144 +++++++++ pos/blueprints/inventory_bp.py | 17 ++ pos/blueprints/marketplace_bp.py | 360 ++++++++++++++++++++++ pos/gunicorn.conf.py | 10 + pos/migrations/v1.6_marketplace.sql | 32 ++ pos/requirements.txt | 1 + pos/run.sh | 3 + pos/services/billing.py | 74 +++++ pos/templates/dashboard.html | 13 + pos/templates/marketplace.html | 459 ++++++++++++++++++++++++++++ 12 files changed, 1132 insertions(+), 2 deletions(-) create mode 100644 pos/blueprints/marketplace_bp.py create mode 100644 pos/gunicorn.conf.py create mode 100644 pos/migrations/v1.6_marketplace.sql create mode 100755 pos/run.sh create mode 100644 pos/services/billing.py create mode 100644 pos/templates/marketplace.html diff --git a/docs/INSTALACION.md b/docs/INSTALACION.md index ccc684d..d261477 100644 --- a/docs/INSTALACION.md +++ b/docs/INSTALACION.md @@ -105,11 +105,20 @@ print('Migraciones aplicadas correctamente') ### 8. Iniciar el POS +**Desarrollo:** ```bash cd /home/Autopartes/pos python3 app.py ``` +**Produccion (Gunicorn):** +```bash +pip install gunicorn --break-system-packages +sudo mkdir -p /var/log/nexus-pos +cd /home/Autopartes/pos +./run.sh +``` + Acceder desde el navegador: ``` @@ -162,9 +171,10 @@ After=postgresql.service Wants=postgresql.service [Service] -Type=simple +Type=notify WorkingDirectory=/home/Autopartes/pos -ExecStart=/usr/bin/python3 app.py +ExecStartPre=/bin/mkdir -p /var/log/nexus-pos +ExecStart=/usr/local/bin/gunicorn -c gunicorn.conf.py "app:create_app()" Restart=always RestartSec=5 User=root diff --git a/pos/app.py b/pos/app.py index 66bb16c..7fb6ef3 100644 --- a/pos/app.py +++ b/pos/app.py @@ -51,6 +51,9 @@ def create_app(): from blueprints.whatsapp_bp import whatsapp_bp app.register_blueprint(whatsapp_bp) + from blueprints.marketplace_bp import marketplace_bp + app.register_blueprint(marketplace_bp) + # Health check @app.route('/pos/health') def health(): @@ -113,6 +116,10 @@ def create_app(): def pos_whatsapp(): return render_template('whatsapp.html') + @app.route('/pos/marketplace') + def pos_marketplace(): + return render_template('marketplace.html') + @app.route('/pos/static/') def pos_static(filename): return send_from_directory('static', filename) diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 922149e..158c076 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -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) diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index b9416af..d5768b0 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -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() diff --git a/pos/blueprints/marketplace_bp.py b/pos/blueprints/marketplace_bp.py new file mode 100644 index 0000000..4db5a4a --- /dev/null +++ b/pos/blueprints/marketplace_bp.py @@ -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//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//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}) diff --git a/pos/gunicorn.conf.py b/pos/gunicorn.conf.py new file mode 100644 index 0000000..92906dc --- /dev/null +++ b/pos/gunicorn.conf.py @@ -0,0 +1,10 @@ +import multiprocessing + +bind = "0.0.0.0:5001" +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +timeout = 120 +keepalive = 5 +accesslog = "/var/log/nexus-pos/access.log" +errorlog = "/var/log/nexus-pos/error.log" +loglevel = "info" diff --git a/pos/migrations/v1.6_marketplace.sql b/pos/migrations/v1.6_marketplace.sql new file mode 100644 index 0000000..b8e6f26 --- /dev/null +++ b/pos/migrations/v1.6_marketplace.sql @@ -0,0 +1,32 @@ +-- Marketplace orders (cross-tenant) +-- These tables go in nexus_autoparts (master DB), not tenant DBs + +CREATE TABLE IF NOT EXISTS marketplace_orders ( + id SERIAL PRIMARY KEY, + buyer_tenant_id INTEGER NOT NULL, + seller_tenant_id INTEGER NOT NULL, + buyer_name VARCHAR(200), + seller_name VARCHAR(200), + total NUMERIC(12,2), + status VARCHAR(20) DEFAULT 'pending', -- pending, confirmed, shipped, delivered, cancelled + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS marketplace_order_items ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES marketplace_orders(id), + part_number VARCHAR(100), + part_name VARCHAR(300), + quantity INTEGER, + unit_price NUMERIC(12,2), + subtotal NUMERIC(12,2) +); + +CREATE INDEX IF NOT EXISTS idx_mp_orders_buyer ON marketplace_orders(buyer_tenant_id); +CREATE INDEX IF NOT EXISTS idx_mp_orders_seller ON marketplace_orders(seller_tenant_id); +CREATE INDEX IF NOT EXISTS idx_mp_orders_status ON marketplace_orders(status); + +-- Add is_seller flag to tenants table +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS is_seller BOOLEAN DEFAULT FALSE; diff --git a/pos/requirements.txt b/pos/requirements.txt index b820df8..d590bf1 100644 --- a/pos/requirements.txt +++ b/pos/requirements.txt @@ -3,3 +3,4 @@ psycopg2-binary>=2.9 PyJWT>=2.8 bcrypt>=4.0 lxml>=4.9 +gunicorn>=22.0 diff --git a/pos/run.sh b/pos/run.sh new file mode 100755 index 0000000..f8a23e9 --- /dev/null +++ b/pos/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/Autopartes/pos +gunicorn -c gunicorn.conf.py "app:create_app()" diff --git a/pos/services/billing.py b/pos/services/billing.py new file mode 100644 index 0000000..3814344 --- /dev/null +++ b/pos/services/billing.py @@ -0,0 +1,74 @@ +"""Subscription billing for Nexus POS SaaS.""" + +from tenant_db import get_master_conn + +PLANS = { + 'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1}, + 'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2}, + 'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5}, + 'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None}, +} + +PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise'] + + +def get_plan(tenant_id): + """Get current plan for a tenant.""" + conn = get_master_conn() + cur = conn.cursor() + cur.execute("SELECT plan FROM tenants WHERE id = %s", (tenant_id,)) + row = cur.fetchone() + cur.close() + conn.close() + if not row: + return 'free' + plan_key = row[0] or 'free' + if plan_key not in PLANS: + plan_key = 'free' + return plan_key + + +def get_plan_details(tenant_id): + """Get plan key + full details for a tenant.""" + plan_key = get_plan(tenant_id) + return {**PLANS[plan_key], 'key': plan_key} + + +def next_plan(current_plan): + """Return the next upgrade plan key, or None if already enterprise.""" + idx = PLAN_ORDER.index(current_plan) if current_plan in PLAN_ORDER else 0 + if idx < len(PLAN_ORDER) - 1: + return PLAN_ORDER[idx + 1] + return None + + +def check_limit(tenant_id, resource, current_count): + """Check if tenant is within plan limits. + + Args: + tenant_id: Tenant ID + resource: One of 'max_products', 'max_employees', 'max_branches' + current_count: Current count of that resource + + Returns: + (allowed: bool, limit: int|None, current: int) + """ + plan_key = get_plan(tenant_id) + plan = PLANS[plan_key] + limit = plan.get(resource) + if limit is None: + return (True, None, current_count) + return (current_count < limit, limit, current_count) + + +def upgrade_plan(tenant_id, new_plan): + """Change tenant's plan.""" + if new_plan not in PLANS: + return {'error': f'Invalid plan: {new_plan}'} + conn = get_master_conn() + cur = conn.cursor() + cur.execute("UPDATE tenants SET plan = %s WHERE id = %s", (new_plan, tenant_id)) + conn.commit() + cur.close() + conn.close() + return {'success': True, 'plan': new_plan, 'details': PLANS[new_plan]} diff --git a/pos/templates/dashboard.html b/pos/templates/dashboard.html index 299bfd9..7435227 100644 --- a/pos/templates/dashboard.html +++ b/pos/templates/dashboard.html @@ -1378,6 +1378,18 @@ Reportes + + + + + + + + + + Marketplace B2B + + @@ -1673,6 +1685,7 @@ + diff --git a/pos/templates/marketplace.html b/pos/templates/marketplace.html new file mode 100644 index 0000000..2e46917 --- /dev/null +++ b/pos/templates/marketplace.html @@ -0,0 +1,459 @@ + + + + + + + Marketplace B2B — Nexus Autoparts POS + + + + + + + + +
+ + +
+ + + +
+ + + + + + + + + +
+ + +
+
+

Carrito de Pedido

+ +
+
+
Total: $0.00
+
+ + +
+
+ + + + +