642 lines
23 KiB
Python
642 lines
23 KiB
Python
# /home/Autopartes/pos/blueprints/customers_bp.py
|
|
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
|
|
|
|
import json
|
|
from flask import Blueprint, request, jsonify, g
|
|
from middleware import require_auth, has_permission
|
|
from tenant_db import get_tenant_conn
|
|
from services.audit import log_action
|
|
|
|
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
|
|
|
|
|
def _can_view_customers():
|
|
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
|
|
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
|
|
|
|
|
|
def _can_create_customer():
|
|
"""Cashiers, counter and workshop employees can create customers on the fly."""
|
|
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
|
|
|
|
|
|
# ─── Customer CRUD ─────────────────────────────
|
|
|
|
@customers_bp.route('', methods=['GET'])
|
|
@require_auth()
|
|
def list_customers():
|
|
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
|
|
|
Query params:
|
|
q: search string (matches name, RFC, phone via ILIKE)
|
|
page: page number (default 1)
|
|
per_page: items per page (default 50, max 200)
|
|
branch_id: filter by branch (default: current user's branch)
|
|
"""
|
|
if not _can_view_customers():
|
|
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
page = int(request.args.get('page', 1))
|
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
|
search = request.args.get('q', '').strip()
|
|
branch_id = request.args.get('branch_id')
|
|
price_tier = request.args.get('price_tier', '').strip()
|
|
status = request.args.get('status', '').strip().lower()
|
|
|
|
where_clauses = []
|
|
params = []
|
|
|
|
if status == 'inactive':
|
|
where_clauses.append("c.is_active = false")
|
|
elif status == 'overdue':
|
|
where_clauses.append(
|
|
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
|
|
)
|
|
elif status == 'all':
|
|
pass # no is_active filter
|
|
else:
|
|
# Default to active customers for backwards compatibility
|
|
where_clauses.append("c.is_active = true")
|
|
|
|
if branch_id:
|
|
where_clauses.append("c.branch_id = %s")
|
|
params.append(int(branch_id))
|
|
if search:
|
|
where_clauses.append(
|
|
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
|
)
|
|
params.extend([f'%{search}%'] * 4)
|
|
if price_tier:
|
|
# Support numeric tier or Spanish labels
|
|
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
|
|
tier_val = tier_map.get(price_tier.lower())
|
|
if tier_val is None:
|
|
try:
|
|
tier_val = int(price_tier)
|
|
except ValueError:
|
|
tier_val = None
|
|
if tier_val in (1, 2, 3):
|
|
where_clauses.append("c.price_tier = %s")
|
|
params.append(tier_val)
|
|
|
|
where = " AND ".join(where_clauses) if where_clauses else "true"
|
|
|
|
# Count
|
|
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
|
total = cur.fetchone()[0]
|
|
|
|
# Fetch
|
|
cur.execute(f"""
|
|
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
|
c.address, c.cp,
|
|
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
|
c.branch_id, c.is_active, c.created_at,
|
|
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
|
|
FROM customers c
|
|
WHERE {where}
|
|
ORDER BY c.name
|
|
LIMIT %s OFFSET %s
|
|
""", params + [per_page, (page - 1) * per_page])
|
|
|
|
customers = []
|
|
for r in cur.fetchall():
|
|
customers.append({
|
|
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
|
|
'phone': r[4], 'email': r[5], 'address': r[6], 'cp': r[7],
|
|
'price_tier': r[8],
|
|
'credit_limit': float(r[9]) if r[9] else 0,
|
|
'credit_balance': float(r[10]) if r[10] else 0,
|
|
'vehicle_info': r[11],
|
|
'branch_id': r[12],
|
|
'is_active': r[13],
|
|
'created_at': str(r[14]) if r[14] else None,
|
|
'last_purchase': str(r[15]) if r[15] else None,
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
total_pages = (total + per_page - 1) // per_page
|
|
return jsonify({
|
|
'data': customers,
|
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
|
})
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
|
@require_auth()
|
|
def get_customer(customer_id):
|
|
if not _can_view_customers():
|
|
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
"""Get customer details with credit info, vehicle history, and recent purchases."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("""
|
|
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
|
cp, email, phone, address, price_tier, credit_limit, credit_balance,
|
|
is_active, vehicle_info, created_at, max_discount_pct
|
|
FROM customers WHERE id = %s
|
|
""", (customer_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Customer not found'}), 404
|
|
|
|
cols = [desc[0] for desc in cur.description]
|
|
customer = dict(zip(cols, row))
|
|
|
|
# Convert Decimal to float
|
|
for k in ('credit_limit', 'credit_balance', 'max_discount_pct'):
|
|
if customer.get(k) is not None:
|
|
customer[k] = float(customer[k])
|
|
|
|
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
|
|
|
|
# Recent purchases (last 20)
|
|
cur.execute("""
|
|
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
|
e.name as employee_name
|
|
FROM sales s
|
|
LEFT JOIN employees e ON s.employee_id = e.id
|
|
WHERE s.customer_id = %s AND s.status != 'cancelled'
|
|
ORDER BY s.created_at DESC
|
|
LIMIT 20
|
|
""", (customer_id,))
|
|
customer['recent_purchases'] = []
|
|
for r in cur.fetchall():
|
|
customer['recent_purchases'].append({
|
|
'id': r[0], 'total': float(r[1]) if r[1] else 0,
|
|
'payment_method': r[2], 'sale_type': r[3],
|
|
'status': r[4], 'created_at': str(r[5]),
|
|
'employee_name': r[6],
|
|
})
|
|
|
|
# Credit summary
|
|
customer['credit_available'] = round(
|
|
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
|
)
|
|
|
|
# Fleet vehicles assigned to this customer
|
|
cur.execute("""
|
|
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
|
|
FROM fleet_vehicles
|
|
WHERE customer_id = %s
|
|
ORDER BY is_active DESC, created_at DESC
|
|
""", (customer_id,))
|
|
customer['fleet_vehicles'] = []
|
|
for r in cur.fetchall():
|
|
customer['fleet_vehicles'].append({
|
|
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
|
|
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
|
|
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify(customer)
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
|
|
@require_auth()
|
|
def get_customer_purchases(customer_id):
|
|
if not _can_view_customers():
|
|
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
"""Return full purchase history for a customer."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("""
|
|
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
|
e.name as employee_name
|
|
FROM sales s
|
|
LEFT JOIN employees e ON s.employee_id = e.id
|
|
WHERE s.customer_id = %s
|
|
ORDER BY s.created_at DESC
|
|
""", (customer_id,))
|
|
|
|
purchases = []
|
|
for r in cur.fetchall():
|
|
purchases.append({
|
|
'id': r[0],
|
|
'total': float(r[1]) if r[1] else 0,
|
|
'payment_method': r[2],
|
|
'sale_type': r[3],
|
|
'status': r[4],
|
|
'created_at': str(r[5]),
|
|
'employee_name': r[6],
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'data': purchases})
|
|
|
|
|
|
@customers_bp.route('', methods=['POST'])
|
|
@require_auth()
|
|
def create_customer():
|
|
"""Create a new customer.
|
|
|
|
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
|
phone, address, price_tier, credit_limit, vehicle_info}
|
|
"""
|
|
if not _can_create_customer():
|
|
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
|
data = request.get_json() or {}
|
|
if not data.get('name'):
|
|
return jsonify({'error': 'name is required'}), 400
|
|
|
|
branch_id = data.get('branch_id', g.branch_id)
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
try:
|
|
cur.execute("""
|
|
INSERT INTO customers
|
|
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
|
cp, email, phone, address, price_tier, credit_limit,
|
|
max_discount_pct, vehicle_info)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
RETURNING id
|
|
""", (
|
|
branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
|
|
data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
|
|
data.get('cp'), data.get('email'), data.get('phone'),
|
|
data.get('address'), data.get('price_tier', 1),
|
|
data.get('credit_limit', 0),
|
|
data.get('max_discount_pct', 0),
|
|
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
|
|
))
|
|
customer_id = cur.fetchone()[0]
|
|
|
|
log_action(conn, 'CUSTOMER_CREATE', 'customer', customer_id,
|
|
new_value={'name': data['name'], 'rfc': data.get('rfc')})
|
|
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return jsonify({'id': customer_id, 'message': 'Customer created'}), 201
|
|
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>', methods=['PUT'])
|
|
@require_auth('customers.edit')
|
|
def update_customer(customer_id):
|
|
"""Update customer fields. Credit limit changes require customers.edit_credit permission."""
|
|
data = request.get_json() or {}
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
# Verify customer exists
|
|
cur.execute("SELECT id, credit_limit FROM customers WHERE id = %s", (customer_id,))
|
|
existing = cur.fetchone()
|
|
if not existing:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Customer not found'}), 404
|
|
|
|
# Credit limit change requires special permission
|
|
if 'credit_limit' in data and float(data['credit_limit']) != float(existing[1] or 0):
|
|
if not has_permission('customers.edit_credit'):
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Permission customers.edit_credit required to change credit limit'}), 403
|
|
|
|
log_action(conn, 'CREDIT_CHANGE', 'customer', customer_id,
|
|
old_value={'credit_limit': float(existing[1] or 0)},
|
|
new_value={'credit_limit': float(data['credit_limit'])})
|
|
|
|
# Build dynamic update
|
|
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
|
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
|
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
|
|
sets = []
|
|
vals = []
|
|
for field in allowed:
|
|
if field in data:
|
|
val = data[field]
|
|
if field == 'vehicle_info' and isinstance(val, (dict, list)):
|
|
val = json.dumps(val)
|
|
sets.append(f"{field} = %s")
|
|
vals.append(val)
|
|
|
|
if not sets:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'No fields to update'}), 400
|
|
|
|
vals.append(customer_id)
|
|
cur.execute(f"UPDATE customers SET {', '.join(sets)} WHERE id = %s", vals)
|
|
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return jsonify({'message': 'Customer updated'})
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
|
|
@require_auth('customers.delete')
|
|
def delete_customer(customer_id):
|
|
"""Hard-delete a customer.
|
|
|
|
Related records (sales, service orders, vehicles, etc.) keep their data but
|
|
lose the customer reference. Layaways for this customer are removed because
|
|
they require a customer_id.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
|
if not cur.fetchone():
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Customer not found'}), 404
|
|
|
|
# Remove layaway dependencies first, then the layaways themselves
|
|
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
|
|
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
|
|
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
|
|
|
|
# Nullify every other FK reference back to customers.
|
|
cur.execute("""
|
|
SELECT c.relname::text AS tbl, a.attname::text AS col
|
|
FROM pg_constraint con
|
|
JOIN pg_class c ON c.oid = con.conrelid
|
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
|
WHERE con.confrelid = 'public.customers'::regclass
|
|
AND con.contype = 'f'
|
|
""")
|
|
refs = cur.fetchall()
|
|
for tbl, col in refs:
|
|
# Cascade-delete tables and already-handled layaways are skipped.
|
|
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
|
|
continue
|
|
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
|
|
|
|
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
|
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'message': 'Cliente eliminado'})
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
|
@require_auth()
|
|
def customer_statement(customer_id):
|
|
if not _can_view_customers():
|
|
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
"""Account statement: sales (invoices), payments, running balance.
|
|
|
|
Query params:
|
|
from_date: start date (YYYY-MM-DD), default 30 days ago
|
|
to_date: end date (YYYY-MM-DD), default today
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
from_date = request.args.get('from_date')
|
|
to_date = request.args.get('to_date')
|
|
|
|
# Verify customer exists
|
|
cur.execute("SELECT name, credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
|
cust = cur.fetchone()
|
|
if not cust:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Customer not found'}), 404
|
|
|
|
where_date = ""
|
|
params = [customer_id]
|
|
if from_date:
|
|
where_date += " AND s.created_at >= %s"
|
|
params.append(from_date)
|
|
if to_date:
|
|
where_date += " AND s.created_at < %s::date + interval '1 day'"
|
|
params.append(to_date)
|
|
|
|
# Get credit sales (charges / cargos)
|
|
cur.execute(f"""
|
|
SELECT s.id, 'charge' as type, s.total as amount, s.created_at,
|
|
'Venta #' || s.id as description, s.status
|
|
FROM sales s
|
|
WHERE s.customer_id = %s AND s.sale_type = 'credit' {where_date}
|
|
ORDER BY s.created_at
|
|
""", params)
|
|
|
|
entries = []
|
|
for r in cur.fetchall():
|
|
entries.append({
|
|
'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
|
|
'date': str(r[3]), 'description': r[4], 'status': r[5]
|
|
})
|
|
|
|
# Get customer payments (abonos) from the customer_payments table
|
|
pay_params = [customer_id]
|
|
pay_where = ""
|
|
if from_date:
|
|
pay_where += " AND cp.created_at >= %s"
|
|
pay_params.append(from_date)
|
|
if to_date:
|
|
pay_where += " AND cp.created_at < %s::date + interval '1 day'"
|
|
pay_params.append(to_date)
|
|
|
|
cur.execute(f"""
|
|
SELECT cp.id, 'payment' as type, cp.amount, cp.created_at,
|
|
'Abono - ' || cp.payment_method as description, 'completed' as status
|
|
FROM customer_payments cp
|
|
WHERE cp.customer_id = %s {pay_where}
|
|
ORDER BY cp.created_at
|
|
""", pay_params)
|
|
|
|
for r in cur.fetchall():
|
|
entries.append({
|
|
'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
|
|
'date': str(r[3]), 'description': r[4], 'status': r[5]
|
|
})
|
|
|
|
# Sort all entries by date for correct running balance
|
|
entries.sort(key=lambda e: e['date'])
|
|
|
|
# Compute running balance
|
|
balance = 0.0
|
|
for entry in entries:
|
|
if entry['status'] == 'cancelled':
|
|
continue
|
|
if entry['type'] == 'charge':
|
|
balance += entry['amount']
|
|
elif entry['type'] == 'payment':
|
|
balance -= entry['amount']
|
|
entry['running_balance'] = round(balance, 2)
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
return jsonify({
|
|
'customer': {
|
|
'id': customer_id,
|
|
'name': cust[0],
|
|
'credit_limit': float(cust[1]) if cust[1] else 0,
|
|
'credit_balance': float(cust[2]) if cust[2] else 0,
|
|
},
|
|
'entries': entries,
|
|
'balance': round(balance, 2),
|
|
})
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
|
@require_auth()
|
|
def customer_vehicles(customer_id):
|
|
if not _can_view_customers():
|
|
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
"""Get customer's vehicle list with last purchases per vehicle.
|
|
|
|
Vehicle info is stored as JSONB in customers.vehicle_info:
|
|
[{make, model, year, vin, plates}, ...]
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("SELECT vehicle_info FROM customers WHERE id = %s", (customer_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Customer not found'}), 404
|
|
|
|
vehicles = row[0] or []
|
|
|
|
# Get recent purchases for this customer to match with vehicles
|
|
cur.execute("""
|
|
SELECT s.id, s.total, s.created_at, s.notes,
|
|
array_agg(si.name ORDER BY si.id) as items
|
|
FROM sales s
|
|
JOIN sale_items si ON si.sale_id = s.id
|
|
WHERE s.customer_id = %s AND s.status = 'completed'
|
|
GROUP BY s.id, s.total, s.created_at, s.notes
|
|
ORDER BY s.created_at DESC
|
|
LIMIT 50
|
|
""", (customer_id,))
|
|
|
|
recent_sales = []
|
|
for r in cur.fetchall():
|
|
recent_sales.append({
|
|
'sale_id': r[0], 'total': float(r[1]) if r[1] else 0,
|
|
'date': str(r[2]), 'notes': r[3], 'items': r[4]
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
return jsonify({
|
|
'vehicles': vehicles,
|
|
'recent_sales': recent_sales,
|
|
})
|
|
|
|
|
|
@customers_bp.route('/<int:customer_id>/payment', methods=['POST'])
|
|
@require_auth('customers.edit')
|
|
def record_customer_payment(customer_id):
|
|
"""Record a payment against a customer's credit balance (abono).
|
|
|
|
Body: {amount: float, payment_method: str, reference: str, register_id: int}
|
|
"""
|
|
data = request.get_json() or {}
|
|
amount = float(data.get('amount', 0))
|
|
payment_method = data.get('payment_method', 'efectivo')
|
|
reference = data.get('reference', '')
|
|
register_id = data.get('register_id')
|
|
|
|
if amount <= 0:
|
|
return jsonify({'error': 'Amount must be greater than 0'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
# Verify customer exists and has a balance
|
|
cur.execute("SELECT name, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
|
cust = cur.fetchone()
|
|
if not cust:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Customer not found'}), 404
|
|
|
|
credit_balance = float(cust[1] or 0)
|
|
if amount > credit_balance:
|
|
cur.close(); conn.close()
|
|
return jsonify({
|
|
'error': f'Payment ${amount:.2f} exceeds current balance ${credit_balance:.2f}'
|
|
}), 400
|
|
|
|
try:
|
|
# Record the payment
|
|
cur.execute("""
|
|
INSERT INTO customer_payments
|
|
(customer_id, amount, payment_method, reference, employee_id, register_id)
|
|
VALUES (%s,%s,%s,%s,%s,%s)
|
|
RETURNING id, created_at
|
|
""", (
|
|
customer_id, amount, payment_method, reference,
|
|
getattr(g, 'employee_id', None), register_id
|
|
))
|
|
payment_id, created_at = cur.fetchone()
|
|
|
|
# Reduce customer credit balance
|
|
new_balance = round(credit_balance - amount, 2)
|
|
cur.execute("""
|
|
UPDATE customers SET credit_balance = %s WHERE id = %s
|
|
""", (new_balance, customer_id))
|
|
|
|
# Allocate the customer payment to oldest unpaid credit sales so the
|
|
# aging report and per-sale balances reflect the remaining debt.
|
|
remaining = amount
|
|
cur.execute("""
|
|
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
|
|
FROM sales s
|
|
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
|
WHERE s.customer_id = %s
|
|
AND s.sale_type = 'credit'
|
|
AND s.status = 'completed'
|
|
GROUP BY s.id, s.total, s.created_at
|
|
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
|
ORDER BY s.created_at
|
|
""", (customer_id,))
|
|
for sale_id, balance in cur.fetchall():
|
|
if remaining <= 0:
|
|
break
|
|
pay = round(min(remaining, float(balance)), 2)
|
|
cur.execute("""
|
|
INSERT INTO sale_payments
|
|
(sale_id, register_id, method, amount, reference)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""", (sale_id, register_id, payment_method, pay,
|
|
f'Abono cliente #{customer_id}'))
|
|
remaining = round(remaining - pay, 2)
|
|
|
|
# Record cash movement on register if cash payment
|
|
if register_id and payment_method == 'efectivo':
|
|
cur.execute("""
|
|
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
|
VALUES (%s, 'in', %s, %s, %s)
|
|
""", (register_id, amount, f'Abono cliente #{customer_id} - {cust[0]}',
|
|
getattr(g, 'employee_id', None)))
|
|
|
|
log_action(conn, 'CUSTOMER_PAYMENT', 'customer', customer_id,
|
|
old_value={'credit_balance': credit_balance},
|
|
new_value={'credit_balance': new_balance, 'payment': amount})
|
|
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
|
|
return jsonify({
|
|
'payment_id': payment_id,
|
|
'amount': amount,
|
|
'previous_balance': credit_balance,
|
|
'new_balance': new_balance,
|
|
'created_at': str(created_at),
|
|
'message': 'Payment recorded'
|
|
}), 201
|
|
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 500
|