from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename from datetime import datetime import os, json, smtplib, re, base64 from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email.mime.text import MIMEText from email import encoders app = Flask(__name__) app.config['SECRET_KEY'] = 'marineinvoice-secret-key-2024' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///marineinvoice.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['UPLOAD_FOLDER'] = 'static/logos' app.config['PDF_FOLDER'] = 'static/pdfs' os.makedirs('static/logos', exist_ok=True) os.makedirs('static/pdfs', exist_ok=True) db = SQLAlchemy(app) login_manager = LoginManager(app) login_manager.login_view = 'login' login_manager.login_message = 'Por favor inicia sesión para continuar.' # ============================================================ # MODELS # ============================================================ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(256), nullable=False) role = db.Column(db.String(20), default='user') # superadmin, admin, user company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True) full_name = db.Column(db.String(120)) signature = db.Column(db.Text) # base64 PNG firma del usuario smtp_user = db.Column(db.String(120)) # email corporativo smtp_password = db.Column(db.String(200)) # contraseña del email corporativo email_title = db.Column(db.String(200)) # Título/cargo que aparece en el From del email active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) def set_password(self, pw): self.password_hash = generate_password_hash(pw) def check_password(self, pw): return check_password_hash(self.password_hash, pw) def is_superadmin(self): return self.role == 'superadmin' def can_access_company(self, company_id): if self.role == 'superadmin': return True return self.company_id == int(company_id) class Company(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False) ein = db.Column(db.String(20)) license_num = db.Column(db.String(50)) phone = db.Column(db.String(30)) address = db.Column(db.String(200)) city = db.Column(db.String(80)) state = db.Column(db.String(30)) email = db.Column(db.String(120)) website = db.Column(db.String(120)) manager = db.Column(db.String(120)) authorized = db.Column(db.String(120)) tax_rate = db.Column(db.Float, default=7.0) notes = db.Column(db.Text) # quote notes (backward compat) invoice_notes = db.Column(db.Text) # notes specific to invoices quote_notes = db.Column(db.Text) # notes specific to quotations logo_path = db.Column(db.String(200)) signature_path = db.Column(db.String(200)) # firma guardada de la compañía # Numbering format: prefix letters only, e.g. "IPY" for invoices, "QPY" for quotes invoice_prefix = db.Column(db.String(10), default='INV') quote_prefix = db.Column(db.String(10), default='QUO') # Internal counters per month — stored as JSON: {"2024-03": 5, "2024-04": 2} invoice_counters = db.Column(db.Text, default='{}') quote_counters = db.Column(db.Text, default='{}') # Email config for sending PDFs smtp_host = db.Column(db.String(120)) smtp_port = db.Column(db.Integer, default=587) smtp_user = db.Column(db.String(120)) smtp_password = db.Column(db.String(200)) smtp_from_name = db.Column(db.String(120)) active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) clients = db.relationship('Client', backref='company', lazy=True) products = db.relationship('Product', backref='company', lazy=True) documents = db.relationship('Document', backref='company', lazy=True) users = db.relationship('User', backref='company', lazy=True) def get_next_number(self, doc_type): """Get next auto number for invoice (I) or quote (Q). Does NOT increment — call increment_counter after saving.""" now = datetime.utcnow() month_key = now.strftime('%Y-%m') month_str = now.strftime('%m%Y') if doc_type == 'invoice': counters = json.loads(self.invoice_counters or '{}') prefix = self.invoice_prefix or 'INV' else: counters = json.loads(self.quote_counters or '{}') prefix = self.quote_prefix or 'QUO' current_count = counters.get(month_key, 0) + 1 return f"{prefix}-{str(current_count).zfill(3)}-{month_str}" def increment_counter(self, doc_type): """Increment the internal counter for this month.""" now = datetime.utcnow() month_key = now.strftime('%Y-%m') if doc_type == 'invoice': counters = json.loads(self.invoice_counters or '{}') counters[month_key] = counters.get(month_key, 0) + 1 self.invoice_counters = json.dumps(counters) else: counters = json.loads(self.quote_counters or '{}') counters[month_key] = counters.get(month_key, 0) + 1 self.quote_counters = json.dumps(counters) class Client(db.Model): id = db.Column(db.Integer, primary_key=True) company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) name = db.Column(db.String(120), nullable=False) contact = db.Column(db.String(120)) email = db.Column(db.String(120)) phone = db.Column(db.String(30)) address = db.Column(db.String(200)) city = db.Column(db.String(80)) state = db.Column(db.String(30)) yacht_name = db.Column(db.String(80)) yacht_info = db.Column(db.String(120)) notes = db.Column(db.Text) active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) class Product(db.Model): id = db.Column(db.Integer, primary_key=True) company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) name = db.Column(db.String(120), nullable=False) description = db.Column(db.Text) price = db.Column(db.Float, nullable=False) unit = db.Column(db.String(20), default='hr') item_type = db.Column(db.String(20), default='service') active = db.Column(db.Boolean, default=True) class Document(db.Model): """Unified model for both Invoices and Quotes""" id = db.Column(db.Integer, primary_key=True) doc_type = db.Column(db.String(10), nullable=False) # 'invoice' or 'quote' company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) client_id = db.Column(db.Integer, db.ForeignKey('client.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('user.id')) number = db.Column(db.String(40), nullable=False) # display number (user can adjust) internal_seq = db.Column(db.Integer, default=0) # internal counter, never changes date = db.Column(db.Date, nullable=False) due_date = db.Column(db.Date) status = db.Column(db.String(20), default='draft') # invoice statuses: draft, sent, paid, cancelled # quote statuses: draft, sent, accepted, rejected language = db.Column(db.String(5), default='en') description = db.Column(db.Text) line_items = db.Column(db.Text) # JSON subtotal = db.Column(db.Float, default=0) tax_rate = db.Column(db.Float, default=7) tax_amount = db.Column(db.Float, default=0) total = db.Column(db.Float, default=0) notes = db.Column(db.Text) pdf_path = db.Column(db.String(300)) # stored PDF path on server prepared_by = db.Column(db.String(120)) signed_by = db.Column(db.String(120)) signature = db.Column(db.Text) # base64 PNG o URL de firma created_at = db.Column(db.DateTime, default=datetime.utcnow) sent_at = db.Column(db.DateTime, nullable=True) # ✅ FIX: última vez enviado por email sent_to = db.Column(db.String(200), nullable=True) # ✅ FIX: email(s) destinatario client = db.relationship('Client', backref='documents', lazy=True) creator = db.relationship('User', backref='documents', lazy=True, foreign_keys=[created_by]) @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) # ============================================================ # AUTH # ============================================================ @app.route('/login', methods=['GET','POST']) def login(): if current_user.is_authenticated: return redirect(url_for('dashboard')) if request.method == 'POST': u = request.form.get('username','').strip() p = request.form.get('password','') user = User.query.filter_by(username=u, active=True).first() if user and user.check_password(p): login_user(user, remember=True) return redirect(url_for('dashboard')) flash('Usuario o contraseña incorrectos', 'error') return render_template('login.html') @app.route('/logout') @login_required def logout(): logout_user() return redirect(url_for('login')) # ============================================================ # DASHBOARD # ============================================================ @app.route('/') @login_required def dashboard(): if current_user.is_superadmin(): companies = Company.query.filter_by(active=True).all() total_invoices = Document.query.filter_by(doc_type='invoice').count() total_quotes = Document.query.filter_by(doc_type='quote').count() total_clients = Client.query.count() total_billed = db.session.query(db.func.sum(Document.total)).filter_by(doc_type='invoice').scalar() or 0 recent_invoices = Document.query.filter_by(doc_type='invoice').order_by(Document.created_at.desc()).limit(6).all() recent_quotes = Document.query.filter_by(doc_type='quote').order_by(Document.created_at.desc()).limit(6).all() else: companies = [current_user.company] if current_user.company else [] cid = current_user.company_id total_invoices = Document.query.filter_by(company_id=cid, doc_type='invoice').count() total_quotes = Document.query.filter_by(company_id=cid, doc_type='quote').count() total_clients = Client.query.filter_by(company_id=cid).count() total_billed = db.session.query(db.func.sum(Document.total)).filter_by(company_id=cid, doc_type='invoice').scalar() or 0 recent_invoices = Document.query.filter_by(company_id=cid, doc_type='invoice').order_by(Document.created_at.desc()).limit(6).all() recent_quotes = Document.query.filter_by(company_id=cid, doc_type='quote').order_by(Document.created_at.desc()).limit(6).all() return render_template('dashboard.html', companies=companies, total_invoices=total_invoices, total_quotes=total_quotes, total_clients=total_clients, total_billed=total_billed, recent_invoices=recent_invoices, recent_quotes=recent_quotes) # ============================================================ # COMPANIES # ============================================================ @app.route('/companies') @login_required def companies(): if not current_user.is_superadmin(): return redirect(url_for('dashboard')) return render_template('companies.html', companies=Company.query.filter_by(active=True).all()) @app.route('/companies/new', methods=['GET','POST']) @login_required def new_company(): if not current_user.is_superadmin(): return redirect(url_for('dashboard')) if request.method == 'POST': logo_path = None if 'logo' in request.files: f = request.files['logo'] if f and f.filename: fn = secure_filename(f.filename) f.save(os.path.join(app.config['UPLOAD_FOLDER'], fn)) logo_path = f'logos/{fn}' c = Company( name=request.form['name'], ein=request.form.get('ein',''), license_num=request.form.get('license',''), phone=request.form.get('phone',''), address=request.form.get('address',''), city=request.form.get('city',''), state=request.form.get('state',''), email=request.form.get('email',''), website=request.form.get('website',''), manager=request.form.get('manager',''), authorized=request.form.get('authorized',''), tax_rate=float(request.form.get('tax_rate',7)), invoice_prefix=request.form.get('invoice_prefix','INV').upper().strip(), quote_prefix=request.form.get('quote_prefix','QUO').upper().strip(), smtp_host=request.form.get('smtp_host',''), smtp_port=int(request.form.get('smtp_port',587) or 587), smtp_user=request.form.get('smtp_user',''), smtp_password=request.form.get('smtp_password',''), smtp_from_name=request.form.get('smtp_from_name',''), notes=request.form.get('notes',''), invoice_notes=request.form.get('invoice_notes',''), quote_notes=request.form.get('quote_notes',''), logo_path=logo_path ) db.session.add(c) db.session.commit() flash('Compañía creada exitosamente', 'success') return redirect(url_for('companies')) return render_template('company_form.html', company=None) @app.route('/companies//edit', methods=['GET','POST']) @login_required def edit_company(id): if not current_user.is_superadmin(): return redirect(url_for('dashboard')) c = Company.query.get_or_404(id) if request.method == 'POST': if 'logo' in request.files: f = request.files['logo'] if f and f.filename: fn = secure_filename(f.filename) f.save(os.path.join(app.config['UPLOAD_FOLDER'], fn)) c.logo_path = f'logos/{fn}' if 'signature' in request.files: f = request.files['signature'] if f and f.filename: fn = 'sig_' + secure_filename(f.filename) f.save(os.path.join(app.config['UPLOAD_FOLDER'], fn)) c.signature_path = f'logos/{fn}' c.name=request.form['name']; c.ein=request.form.get('ein','') c.license_num=request.form.get('license',''); c.phone=request.form.get('phone','') c.address=request.form.get('address',''); c.city=request.form.get('city','') c.state=request.form.get('state',''); c.email=request.form.get('email','') c.website=request.form.get('website',''); c.manager=request.form.get('manager','') c.authorized=request.form.get('authorized','') c.tax_rate=float(request.form.get('tax_rate',7)) c.invoice_prefix=request.form.get('invoice_prefix','INV').upper().strip() c.quote_prefix=request.form.get('quote_prefix','QUO').upper().strip() c.smtp_host=request.form.get('smtp_host','') c.smtp_port=int(request.form.get('smtp_port',587) or 587) c.smtp_user=request.form.get('smtp_user','') if request.form.get('smtp_password'): c.smtp_password=request.form.get('smtp_password') c.smtp_from_name=request.form.get('smtp_from_name','') c.notes=request.form.get('notes','') c.invoice_notes=request.form.get('invoice_notes','') c.quote_notes=request.form.get('quote_notes','') db.session.commit() flash('Compañía actualizada', 'success') return redirect(url_for('companies')) return render_template('company_form.html', company=c) @app.route('/companies//delete', methods=['POST']) @login_required def delete_company(id): if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403 c = Company.query.get_or_404(id) c.active = False; db.session.commit() return jsonify({'success':True}) # ============================================================ # USERS # ============================================================ @app.route('/users') @login_required def users(): if not current_user.is_superadmin(): return redirect(url_for('dashboard')) return render_template('users.html', users=User.query.filter_by(active=True).all(), companies=Company.query.filter_by(active=True).all()) @app.route('/users/new', methods=['POST']) @login_required def new_user(): if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403 d = request.get_json() if User.query.filter_by(username=d['username']).first(): return jsonify({'error':'Usuario ya existe'}),400 u = User(username=d['username'], email=d.get('email',''), full_name=d.get('full_name',''), role=d.get('role','user'), company_id=d.get('company_id') or None, smtp_user=d.get('smtp_user',''), smtp_password=d.get('smtp_password',''), email_title=d.get('email_title','')) u.set_password(d['password']) db.session.add(u); db.session.commit() return jsonify({'success':True,'id':u.id}) @app.route('/profile') @login_required def profile(): return render_template('profile.html') @app.route('/profile/save', methods=['POST']) @login_required def save_profile(): d = request.get_json() current_user.full_name = d.get('full_name', current_user.full_name) current_user.smtp_user = d.get('smtp_user', current_user.smtp_user or '') current_user.email_title = d.get('email_title', current_user.email_title or '') if d.get('smtp_password'): current_user.smtp_password = d.get('smtp_password') if d.get('password'): current_user.set_password(d['password']) db.session.commit() return jsonify({'success': True}) @app.route('/users/', methods=['PUT']) @login_required def edit_user(id): # Superadmin puede editar cualquiera; cualquier usuario puede editarse a sí mismo if not current_user.is_superadmin() and current_user.id != id: return jsonify({'error':'No autorizado'}),403 u = User.query.get_or_404(id) d = request.get_json() u.full_name = d.get('full_name', u.full_name) u.email = d.get('email', u.email) u.smtp_user = d.get('smtp_user', u.smtp_user or '') u.email_title = d.get('email_title', u.email_title or '') if d.get('smtp_password'): u.smtp_password = d.get('smtp_password') # Only superadmin can change role and company if current_user.is_superadmin(): u.role = d.get('role', u.role) u.company_id = d.get('company_id') or None if d.get('password'): u.set_password(d['password']) db.session.commit() return jsonify({'success': True}) @app.route('/users//delete', methods=['POST']) @login_required def delete_user(id): if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403 u = User.query.get_or_404(id); u.active=False; db.session.commit() return jsonify({'success':True}) @app.route('/users//reset-password', methods=['POST']) @login_required def reset_password(id): if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403 d = request.get_json() u = User.query.get_or_404(id); u.set_password(d['password']); db.session.commit() return jsonify({'success':True}) # ============================================================ # CLIENTS # ============================================================ @app.route('/clients') @login_required def clients(): if current_user.is_superadmin(): all_clients = Client.query.filter_by(active=True).all() else: all_clients = Client.query.filter_by(company_id=current_user.company_id, active=True).all() companies = Company.query.filter_by(active=True).all() return render_template('clients.html', clients=all_clients, companies=companies) @app.route('/clients/new', methods=['POST']) @login_required def new_client(): d = request.get_json() cid = d.get('company_id') if not current_user.can_access_company(cid): return jsonify({'error':'No autorizado'}),403 c = Client(company_id=cid, name=d['name'], contact=d.get('contact',''), email=d.get('email',''), phone=d.get('phone',''), address=d.get('address',''), city=d.get('city',''), state=d.get('state',''), yacht_name=d.get('yacht_name',''), yacht_info=d.get('yacht_info',''), notes=d.get('notes','')) db.session.add(c); db.session.commit() return jsonify({'success':True,'id':c.id}) @app.route('/clients/', methods=['PUT']) @login_required def update_client(id): c = Client.query.get_or_404(id) if not current_user.can_access_company(c.company_id): return jsonify({'error':'No autorizado'}),403 d = request.get_json() for f in ['name','contact','email','phone','address','city','state','yacht_name','yacht_info','notes']: if f in d: setattr(c, f, d[f]) db.session.commit(); return jsonify({'success':True}) @app.route('/clients/', methods=['DELETE']) @login_required def delete_client(id): c = Client.query.get_or_404(id) if not current_user.can_access_company(c.company_id): return jsonify({'error':'No autorizado'}),403 c.active=False; db.session.commit(); return jsonify({'success':True}) # ============================================================ # PRODUCTS # ============================================================ @app.route('/products') @login_required def products(): if current_user.is_superadmin(): all_products = Product.query.filter_by(active=True).all() else: all_products = Product.query.filter_by(company_id=current_user.company_id, active=True).all() companies = Company.query.filter_by(active=True).all() return render_template('products.html', products=all_products, companies=companies) @app.route('/products/new', methods=['POST']) @login_required def new_product(): d = request.get_json() cid = d.get('company_id') if not current_user.can_access_company(cid): return jsonify({'error':'No autorizado'}),403 p = Product(company_id=cid, name=d['name'], description=d.get('description',''), price=float(d['price']), unit=d.get('unit','hr'), item_type=d.get('item_type','service')) db.session.add(p); db.session.commit(); return jsonify({'success':True,'id':p.id}) @app.route('/products/', methods=['PUT']) @login_required def update_product(id): p = Product.query.get_or_404(id) if not current_user.can_access_company(p.company_id): return jsonify({'error':'No autorizado'}),403 d = request.get_json() for f in ['name','description','unit','item_type']: if f in d: setattr(p, f, d[f]) if 'price' in d: p.price=float(d['price']) db.session.commit(); return jsonify({'success':True}) @app.route('/products/', methods=['DELETE']) @login_required def delete_product(id): p = Product.query.get_or_404(id) if not current_user.can_access_company(p.company_id): return jsonify({'error':'No autorizado'}),403 p.active=False; db.session.commit(); return jsonify({'success':True}) # ============================================================ # DOCUMENTS (INVOICES + QUOTES) # ============================================================ @app.route('/invoices') @login_required def invoices(): return _doc_list_page('invoice') @app.route('/quotes') @login_required def quotes(): return _doc_list_page('quote') def _doc_list_page(doc_type): if current_user.is_superadmin(): docs = Document.query.filter_by(doc_type=doc_type).order_by(Document.created_at.desc()).all() else: docs = Document.query.filter_by(doc_type=doc_type, company_id=current_user.company_id).order_by(Document.created_at.desc()).all() companies = Company.query.filter_by(active=True).all() clients = Client.query.filter_by(active=True).all() products = Product.query.filter_by(active=True).all() return render_template('documents.html', docs=docs, doc_type=doc_type, companies=companies, clients=clients, products=products) @app.route('/documents/new', methods=['POST']) @login_required def new_document(): d = request.get_json() cid = int(d['company_id']) if not current_user.can_access_company(cid): return jsonify({'error':'No autorizado'}),403 company = Company.query.get(cid) doc_type = d.get('doc_type','invoice') line_items = d.get('line_items',[]) subtotal = sum(i['qty']*i['price'] for i in line_items) tax_rate = company.tax_rate if company else 7 # Tax only on items marked taxable (products/materials). Default: taxable if item_type is product or material def is_taxable(item): if 'taxable' in item: return item['taxable'] return item.get('item_type','service') in ('product','material') taxable_amt = sum(i['qty']*i['price'] for i in line_items if is_taxable(i)) tax_amount = taxable_amt*(tax_rate/100) # Auto number — ignore any user-provided number for the internal counter auto_number = company.get_next_number(doc_type) # User can override display number but internal counter is untouched display_number = d.get('number','').strip() or auto_number doc = Document( doc_type=doc_type, company_id=cid, client_id=int(d['client_id']), created_by=current_user.id, number=display_number, date=datetime.strptime(d['date'],'%Y-%m-%d').date(), due_date=datetime.strptime(d['due_date'],'%Y-%m-%d').date() if d.get('due_date') else None, status=d.get('status','draft'), language=d.get('language','en'), description=d.get('description',''), line_items=json.dumps(line_items), subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, total=subtotal+tax_amount, notes=d.get('notes',''), prepared_by=d.get('prepared_by',''), signed_by=d.get('signed_by',''), signature=d.get('signature','') ) db.session.add(doc) # Increment internal counter regardless of display number company.increment_counter(doc_type) db.session.commit() return jsonify({'success':True,'id':doc.id,'auto_number':auto_number,'display_number':display_number}) @app.route('/documents/', methods=['PUT']) @login_required def update_document(id): doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403 d = request.get_json() line_items = d.get('line_items', json.loads(doc.line_items or '[]')) subtotal = sum(i['qty']*i['price'] for i in line_items) company = Company.query.get(doc.company_id) tax_rate = company.tax_rate if company else 7 def is_taxable(item): if 'taxable' in item: return item['taxable'] return item.get('item_type','service') in ('product','material') taxable_amt = sum(i['qty']*i['price'] for i in line_items if is_taxable(i)) tax_amount = taxable_amt*(tax_rate/100) doc.client_id = int(d.get('client_id', doc.client_id)) doc.number = d.get('number', doc.number) doc.date = datetime.strptime(d['date'],'%Y-%m-%d').date() if d.get('date') else doc.date doc.due_date = datetime.strptime(d['due_date'],'%Y-%m-%d').date() if d.get('due_date') else doc.due_date doc.status = d.get('status', doc.status) doc.language = d.get('language', doc.language) doc.description = d.get('description', doc.description) doc.line_items = json.dumps(line_items) doc.subtotal=subtotal; doc.tax_rate=tax_rate; doc.tax_amount=tax_amount; doc.total=subtotal+tax_amount doc.notes = d.get('notes', doc.notes) doc.prepared_by = d.get('prepared_by', doc.prepared_by or '') doc.signed_by = d.get('signed_by', doc.signed_by or '') doc.signature = d.get('signature', doc.signature or '') db.session.commit() return jsonify({'success':True}) @app.route('/documents//status', methods=['POST']) @login_required def update_doc_status(id): doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403 d = request.get_json() doc.status = d['status']; db.session.commit() return jsonify({'success':True}) @app.route('/documents/', methods=['DELETE']) @login_required def delete_document(id): doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403 # Delete stored PDF if exists if doc.pdf_path and os.path.exists(doc.pdf_path): os.remove(doc.pdf_path) db.session.delete(doc); db.session.commit() return jsonify({'success':True}) @app.route('/documents//data') @login_required def get_doc_data(id): doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403 return jsonify({ 'id':doc.id, 'doc_type':doc.doc_type, 'number':doc.number, 'company_id':doc.company_id, 'client_id':doc.client_id, 'date':doc.date.strftime('%Y-%m-%d'), 'due_date':doc.due_date.strftime('%Y-%m-%d') if doc.due_date else '', 'status':doc.status, 'language':doc.language, 'description':doc.description or '', 'line_items':json.loads(doc.line_items or '[]'), 'subtotal':doc.subtotal, 'tax_rate':doc.tax_rate, 'tax_amount':doc.tax_amount, 'total':doc.total, 'notes':doc.notes or '', 'prepared_by':doc.prepared_by or '', 'signed_by':doc.signed_by or '', 'signature':doc.signature or '', 'has_pdf': bool(doc.pdf_path and os.path.exists(doc.pdf_path)) }) # ============================================================ # PDF - SAVE ON SERVER + DOWNLOAD # ============================================================ @app.route('/documents//save-pdf', methods=['POST']) @login_required def save_pdf(id): """Receive PDF as raw binary (multipart) or base64 JSON, store on server.""" doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403 company_pdf_dir = os.path.join(app.config['PDF_FOLDER'], str(doc.company_id)) os.makedirs(company_pdf_dir, exist_ok=True) safe_number = re.sub(r'[^\w\-]','_', doc.number) filename = f"{doc.doc_type}_{safe_number}_{doc.id}.pdf" filepath = os.path.join(company_pdf_dir, filename) if request.files.get('pdf'): f = request.files['pdf'] f.save(filepath) # Verify file was written correctly size = os.path.getsize(filepath) if size < 100: return jsonify({'error': f'PDF muy pequeño ({size} bytes), posiblemente corrupto'}), 400 else: d = request.get_json() if not d: return jsonify({'error':'No data received'}),400 pdf_b64 = d.get('pdf_b64','').strip() if not pdf_b64: return jsonify({'error':'No PDF data'}),400 if ',' in pdf_b64: pdf_b64 = pdf_b64.split(',',1)[1] pdf_b64 += '=' * (-len(pdf_b64) % 4) try: pdf_bytes = base64.b64decode(pdf_b64) with open(filepath, 'wb') as wf: wf.write(pdf_bytes) except Exception as e: return jsonify({'error': f'Error decodificando PDF: {str(e)}'}), 400 doc.pdf_path = filepath db.session.commit() return jsonify({'success':True, 'filename': filename}) @app.route('/documents//preview-pdf') @login_required def preview_pdf(id): """Serve PDF inline for browser preview.""" doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): abort(403) if not doc.pdf_path or not os.path.exists(doc.pdf_path): return "

⚠️ PDF no encontrado

Genera el PDF primero usando el botón 📄 Generar PDF

", 404 response = send_file(doc.pdf_path, as_attachment=False, download_name=f"{doc.number}.pdf", mimetype='application/pdf') response.headers['Content-Disposition'] = f'inline; filename="{doc.number}.pdf"' return response @app.route('/documents//download-pdf') @login_required def download_pdf(id): doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): abort(403) if not doc.pdf_path or not os.path.exists(doc.pdf_path): flash('PDF no encontrado. Genera el PDF primero.', 'error') return redirect(url_for('invoices') if doc.doc_type=='invoice' else url_for('quotes')) return send_file(doc.pdf_path, as_attachment=True, download_name=f"{doc.number}.pdf", mimetype='application/pdf') # ============================================================ # EMAIL PDF ✅ CORREGIDO # ============================================================ @app.route('/documents//send-email', methods=['POST']) @login_required def send_email_pdf(id): doc = Document.query.get_or_404(id) if not current_user.can_access_company(doc.company_id): return jsonify({'error': 'No autorizado'}), 403 if not doc.pdf_path or not os.path.exists(doc.pdf_path): return jsonify({'error': 'PDF no encontrado. Genera el PDF primero.'}), 400 company = Company.query.get(doc.company_id) # SMTP: servidor de la compañía, credenciales del usuario (fallback a compañía) smtp_host = (company.smtp_host or '').strip() smtp_port = company.smtp_port or 587 smtp_user = (current_user.smtp_user or company.smtp_user or '').strip() smtp_pass = (current_user.smtp_password or company.smtp_password or '').strip() from_name = current_user.email_title or current_user.full_name or company.smtp_from_name or company.name if not smtp_host or not smtp_user: return jsonify({'error': 'Configura tu email en tu perfil o el SMTP en la compañía primero.'}), 400 if not smtp_pass: return jsonify({'error': 'Falta la contraseña del email. Configúrala en tu perfil.'}), 400 d = request.get_json() to_email = d.get('to_email', '').strip() if not to_email: return jsonify({'error': 'Email del destinatario requerido.'}), 400 subject = d.get('subject', f"{doc.number} - {company.name}") body = d.get('body', f"Please find attached {doc.doc_type} {doc.number}.\n\nThank you for your business.\n\n{company.name}") try: # --- Construir mensaje principal (solo al cliente) --- msg = MIMEMultipart() msg['From'] = f"{from_name} <{smtp_user}>" msg['To'] = to_email msg['Subject'] = subject msg.attach(MIMEText(body, 'plain')) with open(doc.pdf_path, 'rb') as f: part = MIMEBase('application', 'octet-stream') part.set_payload(f.read()) encoders.encode_base64(part) part.add_header('Content-Disposition', f'attachment; filename="{doc.number}.pdf"') msg.attach(part) # --- Enviar SOLO al cliente --- with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server: server.ehlo() server.starttls() server.ehlo() server.login(smtp_user, smtp_pass) server.sendmail(smtp_user, [to_email], msg.as_string()) # --- Enviar copia separada al remitente (no bloquea si falla) --- try: msg_copy = MIMEMultipart() msg_copy['From'] = f"{from_name} <{smtp_user}>" msg_copy['To'] = smtp_user msg_copy['Subject'] = f"[COPIA] {subject}" msg_copy.attach(MIMEText(f"Copia del envío a: {to_email}\n\n{body}", 'plain')) with open(doc.pdf_path, 'rb') as f: part2 = MIMEBase('application', 'octet-stream') part2.set_payload(f.read()) encoders.encode_base64(part2) part2.add_header('Content-Disposition', f'attachment; filename="{doc.number}.pdf"') msg_copy.attach(part2) with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server2: server2.ehlo() server2.starttls() server2.ehlo() server2.login(smtp_user, smtp_pass) server2.sendmail(smtp_user, [smtp_user], msg_copy.as_string()) except Exception: pass # La copia al remitente falla silenciosamente — no afecta el envío principal # --- Registrar en DB siempre --- doc.status = 'sent' doc.sent_at = datetime.utcnow() doc.sent_to = to_email db.session.commit() return jsonify({'success': True}) except smtplib.SMTPAuthenticationError: return jsonify({'error': 'Error de autenticación SMTP. Verifica tu email y contraseña en Mi Perfil.'}), 500 except smtplib.SMTPConnectError: return jsonify({'error': f'No se pudo conectar al servidor {smtp_host}:{smtp_port}. Verifica el SMTP en la compañía.'}), 500 except Exception as e: return jsonify({'error': f'Error enviando email: {str(e)}'}), 500 # ============================================================ # API # ============================================================ @app.route('/api/clients/') @login_required def api_clients(company_id): clients = Client.query.filter_by(company_id=company_id, active=True).all() return jsonify([{'id':c.id,'name':c.name,'yacht':c.yacht_name,'email':c.email, 'phone':c.phone,'address':c.address,'city':c.city,'state':c.state, 'contact':c.contact,'yacht_info':c.yacht_info} for c in clients]) @app.route('/api/products/') @login_required def api_products(company_id): prods = Product.query.filter_by(company_id=company_id, active=True).all() return jsonify([{'id':p.id,'name':p.name,'price':p.price,'unit':p.unit,'desc':p.description} for p in prods]) @app.route('/api/next-number//') @login_required def api_next_number(company_id, doc_type): company = Company.query.get_or_404(company_id) return jsonify({'number': company.get_next_number(doc_type)}) @app.route('/api/company/') @login_required def api_company(company_id): c = Company.query.get_or_404(company_id) return jsonify({ 'id':c.id,'name':c.name,'ein':c.ein,'license_num':c.license_num, 'phone':c.phone,'address':c.address,'city':c.city,'state':c.state, 'email':c.email,'website':c.website,'manager':c.manager,'authorized':c.authorized, 'tax_rate':c.tax_rate, 'notes':c.notes, 'invoice_notes': c.invoice_notes or c.notes or '', 'quote_notes': c.quote_notes or c.notes or '', 'logo_path':c.logo_path or '', 'signature_path': c.signature_path or '', 'invoice_prefix':c.invoice_prefix,'quote_prefix':c.quote_prefix }) @app.route('/api/me') @login_required def api_me(): """Retorna info del usuario loggeado incluyendo su firma""" return jsonify({ 'id': current_user.id, 'full_name': current_user.full_name or current_user.username, 'username': current_user.username, 'smtp_user': current_user.smtp_user or '', 'signature': current_user.signature or '' }) @app.route('/api/me/signature', methods=['POST']) @login_required def save_my_signature(): """Guarda la firma del usuario loggeado""" d = request.get_json() sig = d.get('signature','') if not sig: return jsonify({'error':'No signature data'}), 400 current_user.signature = sig db.session.commit() return jsonify({'success': True}) @app.route('/api/users/') @login_required def api_company_users(company_id): """Lista usuarios activos de una compañía para el dropdown de 'Autorizado por'""" if not current_user.can_access_company(company_id): return jsonify({'error':'No autorizado'}), 403 users = User.query.filter_by(company_id=company_id, active=True).all() # Superadmin también aparece admins = User.query.filter_by(role='superadmin', active=True).all() all_users = {u.id: u for u in users + admins} return jsonify([{ 'id': u.id, 'full_name': u.full_name or u.username, 'username': u.username } for u in all_users.values()]) # ============================================================ # INIT # ============================================================ def init_db(): with app.app_context(): db.create_all() # Migrate: add new columns if they don't exist (safe for existing DBs) with db.engine.connect() as conn: try: conn.execute(db.text('ALTER TABLE document ADD COLUMN sent_at DATETIME')) except Exception: pass try: conn.execute(db.text('ALTER TABLE document ADD COLUMN sent_to VARCHAR(200)')) except Exception: pass if not User.query.filter_by(username='admin').first(): admin = User(username='admin', email='admin@marineinvoice.com', full_name='Super Admin', role='superadmin') admin.set_password('admin123') db.session.add(admin); db.session.commit() print('✅ Admin creado: usuario=admin, contraseña=admin123') print('⚠️ Cambia la contraseña después del primer login!') if __name__ == '__main__': init_db() print('🚀 MarineInvoice Pro corriendo en http://localhost:5000') print('📱 Desde Tailscale: http://100.96.43.86:5000') app.run(host='0.0.0.0', port=5000, debug=False)