Compare commits
75 Commits
911b25d96b
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
| 229e658831 | |||
| 5a7ba66c27 | |||
| b9f9df74c0 | |||
| d38195eae5 | |||
| e9d88b1400 | |||
| e333628f1c | |||
| 27ecafea5f | |||
| 15d33a116c | |||
| 0fbb298e89 | |||
| 6d0f4c49be | |||
| 7226883a2e | |||
| 198126c13e | |||
| c17cc362e4 | |||
| a9c190bbf6 | |||
| 39de7f7340 | |||
| bdfd096e99 | |||
| cc19cfcfad | |||
| 66736ef09d | |||
| 10380f26c4 | |||
| 5c86bd56aa | |||
| 8ce739d713 | |||
| b90a2a6340 | |||
| 667a4c2a48 | |||
| d47d52b3d1 | |||
| 8f68ed02c5 | |||
| 54c43fd052 | |||
| 503adf5bbc | |||
| 55b4cba35a | |||
| 053f01c5ec | |||
| cc41ed6ec9 | |||
| 326c45cca0 | |||
| 6b05917352 | |||
| ab2f064de9 | |||
| b5ac7f0807 | |||
| a8b82783b1 | |||
| 0750fd86b4 | |||
| 13f931978a | |||
| 731ac9f5d9 | |||
| b7db400e53 | |||
| 851ca7a037 | |||
| 503dd4051f | |||
| 73b869e2d9 | |||
| ec3cd2b659 | |||
| 29a37ad98a | |||
| be834fa9a0 | |||
| a0555eddd4 | |||
| 9a9cabdec6 | |||
| 9ebfd9b8c3 | |||
| 5e63adfee8 | |||
| c44fc7f63e | |||
| 4e6429e9ac | |||
| cbfbcf9b06 | |||
| 8643aebcfc | |||
| 7ba4eef5db | |||
| 444e8555f3 | |||
| 451f6f66c1 | |||
| 410faee6d1 | |||
| 49aeff8cb6 | |||
| 171450400a | |||
| a9465aa812 | |||
| b24a360fba | |||
| 48fa86916b | |||
| acb588425a | |||
| 2bb304babe | |||
| a0c62d639e | |||
| 84b0836234 | |||
| 2d5be21bf2 | |||
| 31f03b0d7c | |||
| 03e0516c08 | |||
| 52dcdce8bb | |||
| 272f325d98 | |||
| c34f0391b3 | |||
| eb19b2785e | |||
| df1be3b823 | |||
| 25da1a46a8 |
11
.env.example
11
.env.example
@@ -8,3 +8,14 @@ DB_NAME=quotes_db
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# QBO API Credentials
|
||||
QBO_CLIENT_ID=client_id
|
||||
QBO_CLIENT_SECRET=client_secret
|
||||
QBO_ENVIRONMENT=production
|
||||
QBO_REDIRECT_URI=https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl
|
||||
|
||||
# QBO Tokens (aus dem Playground)
|
||||
QBO_ACCESS_TOKEN=access_token
|
||||
QBO_REFRESH_TOKEN=refresh_token
|
||||
QBO_REALM_ID=realm_id
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
*.png
|
||||
public/uploads/*.png
|
||||
node_modules
|
||||
qbo_token.json
|
||||
212
CHANGELOG.md
212
CHANGELOG.md
@@ -1,212 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## Version 2.0.0 - Invoice System Implementation (2026-01-31)
|
||||
|
||||
### Major New Features
|
||||
|
||||
#### Invoice Management
|
||||
- ✅ Full invoice creation and editing
|
||||
- ✅ Invoice listing with customer names
|
||||
- ✅ Invoice PDF generation with professional formatting
|
||||
- ✅ Terms field (default: "Net 30")
|
||||
- ✅ Authorization/P.O. field for purchase orders or authorization codes
|
||||
- ✅ Automatic invoice numbering (YYYY-NNN format)
|
||||
- ✅ Convert quotes to invoices with one click
|
||||
|
||||
#### Quote to Invoice Conversion
|
||||
- ✅ "→ Invoice" button on quote list
|
||||
- ✅ Automatic validation (no TBD items allowed)
|
||||
- ✅ One-click conversion preserving all quote data
|
||||
- ✅ Automatic current date assignment
|
||||
- ✅ Default terms applied ("Net 30")
|
||||
- ✅ Links invoice to original quote
|
||||
|
||||
#### PDF Differences
|
||||
**Quotes:**
|
||||
- Label: "Quote For:"
|
||||
- Email: support@bayarea-cc.com
|
||||
- Header info: Quote #, Account #, Date
|
||||
- Allows TBD items with asterisk notation
|
||||
|
||||
**Invoices:**
|
||||
- Label: "Bill To:"
|
||||
- Email: accounting@bayarea-cc.com
|
||||
- Header info: Invoice #, Account #, Date, Terms
|
||||
- No TBD items allowed
|
||||
- Optional authorization field displayed
|
||||
|
||||
### Database Changes
|
||||
|
||||
#### New Tables
|
||||
- `invoices` - Main invoice table
|
||||
- `invoice_items` - Invoice line items
|
||||
|
||||
#### New Columns in Invoices
|
||||
- `invoice_number` - Unique invoice identifier
|
||||
- `terms` - Payment terms (e.g., "Net 30")
|
||||
- `authorization` - P.O. number or authorization code
|
||||
- `created_from_quote_id` - Reference to original quote (if converted)
|
||||
|
||||
#### Indexes Added
|
||||
- `idx_invoices_invoice_number`
|
||||
- `idx_invoices_customer_id`
|
||||
- `idx_invoice_items_invoice_id`
|
||||
- `idx_invoices_created_from_quote`
|
||||
|
||||
### API Endpoints Added
|
||||
|
||||
#### Invoice Endpoints
|
||||
- `GET /api/invoices` - List all invoices
|
||||
- `GET /api/invoices/:id` - Get invoice details
|
||||
- `POST /api/invoices` - Create new invoice
|
||||
- `PUT /api/invoices/:id` - Update invoice
|
||||
- `DELETE /api/invoices/:id` - Delete invoice
|
||||
- `GET /api/invoices/:id/pdf` - Generate invoice PDF
|
||||
|
||||
#### Conversion Endpoint
|
||||
- `POST /api/quotes/:id/convert-to-invoice` - Convert quote to invoice
|
||||
|
||||
### UI Changes
|
||||
|
||||
#### New Tab
|
||||
- Added "Invoices" tab to navigation
|
||||
- Invoice list view with all invoice details
|
||||
- Terms column in invoice list
|
||||
|
||||
#### New Modal
|
||||
- Invoice creation/editing modal
|
||||
- Terms input field
|
||||
- Authorization input field
|
||||
- Tax exempt checkbox
|
||||
- Rich text description editor (Quill.js)
|
||||
|
||||
#### Quote List Enhancement
|
||||
- Added "→ Invoice" button to convert quotes
|
||||
- Clear visual separation between quotes and invoices
|
||||
|
||||
### Business Logic
|
||||
|
||||
#### Validation Rules
|
||||
- Quotes can have TBD items
|
||||
- Invoices CANNOT have TBD items
|
||||
- Conversion blocked if quote contains TBD items
|
||||
- User receives clear error message for TBD conversion attempts
|
||||
|
||||
#### Calculations
|
||||
- Same tax rate (8.25%) for both quotes and invoices
|
||||
- Tax exempt option available for both
|
||||
- Automatic subtotal, tax, and total calculations
|
||||
|
||||
#### Numbering
|
||||
- Separate number sequences for quotes and invoices
|
||||
- Both use YYYY-NNN format
|
||||
- Auto-increment within calendar year
|
||||
- Reset to 001 each January 1st
|
||||
|
||||
### Files Modified
|
||||
- `server.js` - Added invoice routes and PDF generation
|
||||
- `public/app.js` - Added invoice management functions
|
||||
- `public/index.html` - Added invoice tab and modal
|
||||
|
||||
### Files Added
|
||||
- `add_invoices.sql` - Database migration for invoices
|
||||
- `INSTALLATION.md` - Detailed installation guide
|
||||
- `CHANGELOG.md` - This file
|
||||
- `docker-compose.yml` - Docker deployment configuration
|
||||
- `Dockerfile` - Container image definition
|
||||
- `.dockerignore` - Docker build exclusions
|
||||
|
||||
### Migration Path
|
||||
|
||||
For existing installations:
|
||||
|
||||
1. Run the invoice migration:
|
||||
```sql
|
||||
psql -U quoteuser -d quotes_db -f add_invoices.sql
|
||||
```
|
||||
|
||||
2. No changes to existing quotes data
|
||||
3. Invoice numbering starts fresh (2026-001)
|
||||
4. All existing features remain unchanged
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### Invoice Number Generation
|
||||
```javascript
|
||||
async function getNextInvoiceNumber() {
|
||||
const year = new Date().getFullYear();
|
||||
const result = await pool.query(
|
||||
'SELECT invoice_number FROM invoices WHERE invoice_number LIKE $1 ORDER BY invoice_number DESC LIMIT 1',
|
||||
[`${year}-%`]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return `${year}-001`;
|
||||
}
|
||||
|
||||
const lastNumber = parseInt(result.rows[0].invoice_number.split('-')[1]);
|
||||
const nextNumber = String(lastNumber + 1).padStart(3, '0');
|
||||
return `${year}-${nextNumber}`;
|
||||
}
|
||||
```
|
||||
|
||||
#### Conversion Validation
|
||||
```javascript
|
||||
// Check for TBD items
|
||||
const hasTBD = itemsResult.rows.some(item =>
|
||||
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
|
||||
);
|
||||
|
||||
if (hasTBD) {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
- ✅ Fully backward compatible with existing quote system
|
||||
- ✅ No breaking changes to quote functionality
|
||||
- ✅ Existing PDFs continue to work
|
||||
- ✅ Customer data unchanged
|
||||
|
||||
### Testing Checklist
|
||||
- [x] Create new invoice manually
|
||||
- [x] Edit existing invoice
|
||||
- [x] Delete invoice
|
||||
- [x] Generate invoice PDF
|
||||
- [x] Convert quote without TBD to invoice
|
||||
- [x] Block conversion of quote with TBD items
|
||||
- [x] Verify "Bill To:" label on invoice PDF
|
||||
- [x] Verify accounting@bayarea-cc.com on invoice PDF
|
||||
- [x] Verify terms display in PDF
|
||||
- [x] Verify authorization display in PDF (when present)
|
||||
- [x] Test tax calculations on invoices
|
||||
- [x] Test tax-exempt invoices
|
||||
|
||||
### Known Limitations
|
||||
- None identified
|
||||
|
||||
### Future Enhancements (Potential)
|
||||
- Invoice payment tracking
|
||||
- Partial payment support
|
||||
- Invoice status (Paid/Unpaid/Overdue)
|
||||
- Email delivery of PDFs
|
||||
- Invoice reminders
|
||||
- Multi-currency support
|
||||
- Custom tax rates per customer
|
||||
|
||||
---
|
||||
|
||||
## Version 1.0.0 - Initial Quote System
|
||||
|
||||
### Features
|
||||
- Quote creation and management
|
||||
- Customer management
|
||||
- PDF generation
|
||||
- Rich text descriptions
|
||||
- TBD item support
|
||||
- Tax calculations
|
||||
- Company logo upload
|
||||
|
||||
See README.md for full documentation.
|
||||
@@ -23,8 +23,10 @@ COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY server.js ./
|
||||
COPY qbo_helper.js ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY templates ./templates
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p public/uploads && \
|
||||
@@ -37,5 +39,5 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/customers', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
|
||||
|
||||
# Start server
|
||||
CMD ["node", "server.js"]
|
||||
# Start server (using modular entry point)
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
# Invoice System Implementation Summary
|
||||
|
||||
## Übersicht / Overview
|
||||
|
||||
Dieses Dokument fasst die komplette Invoice-System-Implementierung für Bay Area Affiliates zusammen.
|
||||
|
||||
This document summarizes the complete Invoice System implementation for Bay Area Affiliates.
|
||||
|
||||
---
|
||||
|
||||
## Was wurde implementiert / What Was Implemented
|
||||
|
||||
### 1. Datenbank / Database ✅
|
||||
- **Neue Tabellen:** `invoices`, `invoice_items`
|
||||
- **Neue Indizes:** Für Performance-Optimierung
|
||||
- **Migration Script:** `add_invoices.sql`
|
||||
- **Rückwärtskompatibel:** Keine Änderungen an bestehenden Quotes
|
||||
|
||||
### 2. Backend (server.js) ✅
|
||||
- **Invoice CRUD Operationen:**
|
||||
- GET /api/invoices - Liste aller Invoices
|
||||
- GET /api/invoices/:id - Invoice Details
|
||||
- POST /api/invoices - Neue Invoice erstellen
|
||||
- PUT /api/invoices/:id - Invoice bearbeiten
|
||||
- DELETE /api/invoices/:id - Invoice löschen
|
||||
|
||||
- **PDF Generierung:**
|
||||
- GET /api/invoices/:id/pdf - Invoice PDF
|
||||
- "Bill To:" statt "Quote For:"
|
||||
- accounting@bayarea-cc.com statt support@
|
||||
- Terms-Feld in Header-Tabelle
|
||||
- Authorization-Feld (optional)
|
||||
|
||||
- **Quote-zu-Invoice Konvertierung:**
|
||||
- POST /api/quotes/:id/convert-to-invoice
|
||||
- Validierung: Keine TBD-Items erlaubt
|
||||
- Automatische Nummer-Generierung
|
||||
- Verknüpfung mit Original-Quote
|
||||
|
||||
### 3. Frontend (app.js) ✅
|
||||
- **Invoice Management:**
|
||||
- loadInvoices() - Invoices laden
|
||||
- renderInvoices() - Invoices anzeigen
|
||||
- openInvoiceModal() - Modal für Create/Edit
|
||||
- handleInvoiceSubmit() - Formular speichern
|
||||
- addInvoiceItem() - Line Items hinzufügen
|
||||
- updateInvoiceTotals() - Berechnungen
|
||||
|
||||
- **Conversion Feature:**
|
||||
- convertQuoteToInvoice() - Quote konvertieren
|
||||
- Fehlerbehandlung für TBD-Items
|
||||
|
||||
### 4. UI (index.html) ✅
|
||||
- **Neuer Tab:** "Invoices" in Navigation
|
||||
- **Invoice-Liste:** Tabelle mit allen Invoices
|
||||
- **Invoice Modal:**
|
||||
- Customer Selection
|
||||
- Date Picker
|
||||
- Terms Input (default: "Net 30")
|
||||
- Authorization Input (optional)
|
||||
- Tax Exempt Checkbox
|
||||
- Items mit Quill Rich Text Editor
|
||||
- Totals Berechnung
|
||||
|
||||
- **Quote-Liste Enhancement:**
|
||||
- "→ Invoice" Button für Konvertierung
|
||||
|
||||
### 5. Dokumentation ✅
|
||||
- **README.md:** Komplette Dokumentation
|
||||
- **INSTALLATION.md:** Installations-Anleitung (DE/EN)
|
||||
- **CHANGELOG.md:** Änderungsprotokoll
|
||||
- **setup.sh:** Automatisches Setup-Script
|
||||
|
||||
### 6. Deployment ✅
|
||||
- **Docker Support:**
|
||||
- Dockerfile
|
||||
- docker-compose.yml
|
||||
- .dockerignore
|
||||
|
||||
- **Environment:**
|
||||
- .env.example
|
||||
- Konfigurierbare Settings
|
||||
|
||||
---
|
||||
|
||||
## Key Unterschiede: Quotes vs Invoices
|
||||
|
||||
| Feature | Quotes | Invoices |
|
||||
|---------|--------|----------|
|
||||
| **TBD Items** | ✅ Erlaubt | ❌ Nicht erlaubt |
|
||||
| **Email** | support@bayarea-cc.com | accounting@bayarea-cc.com |
|
||||
| **Label** | "Quote For:" | "Bill To:" |
|
||||
| **Terms** | Nein | Ja (z.B. "Net 30") |
|
||||
| **Authorization** | Nein | Ja (optional, P.O. etc.) |
|
||||
| **Header Info** | Quote #, Account #, Date | Invoice #, Account #, Date, Terms |
|
||||
| **Konvertierung** | → zu Invoice | - |
|
||||
|
||||
---
|
||||
|
||||
## Dateistruktur / File Structure
|
||||
|
||||
```
|
||||
invoice-system/
|
||||
├── server.js # Express Backend mit allen Routes
|
||||
├── public/
|
||||
│ ├── index.html # UI mit Tabs (Quotes/Invoices/Customers/Settings)
|
||||
│ ├── app.js # Frontend JavaScript
|
||||
│ └── uploads/ # Logo-Speicher
|
||||
├── package.json # Dependencies
|
||||
├── init.sql # Initial DB Schema (Customers, Quotes)
|
||||
├── add_invoices.sql # Invoice Tables Migration
|
||||
├── setup.sh # Auto-Installations-Script
|
||||
├── .env.example # Environment Template
|
||||
├── docker-compose.yml # Docker Deployment
|
||||
├── Dockerfile # Container Image
|
||||
├── README.md # Haupt-Dokumentation
|
||||
├── INSTALLATION.md # Setup-Anleitung (DE/EN)
|
||||
└── CHANGELOG.md # Versions-Historie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation / Setup
|
||||
|
||||
### Schnellstart / Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Dateien entpacken
|
||||
cd /installation/directory
|
||||
|
||||
# 2. Setup ausführen
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
|
||||
# 3. Server starten
|
||||
npm start
|
||||
|
||||
# 4. Browser öffnen
|
||||
# http://localhost:3000
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build und Start
|
||||
docker-compose up -d
|
||||
|
||||
# Logs ansehen
|
||||
docker-compose logs -f
|
||||
|
||||
# Stoppen
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validierungs-Regeln / Validation Rules
|
||||
|
||||
### Quote zu Invoice Konvertierung
|
||||
|
||||
**ERLAUBT / ALLOWED:**
|
||||
```javascript
|
||||
Quote Item: { qty: "2", rate: "125.00/hr", amount: "250.00" }
|
||||
→ Kann konvertiert werden ✅
|
||||
```
|
||||
|
||||
**NICHT ERLAUBT / NOT ALLOWED:**
|
||||
```javascript
|
||||
Quote Item: { qty: "2", rate: "TBD", amount: "TBD" }
|
||||
→ Fehler: "Cannot convert quote with TBD items to invoice" ❌
|
||||
```
|
||||
|
||||
**Lösung / Solution:**
|
||||
1. Quote bearbeiten
|
||||
2. TBD durch tatsächliche Werte ersetzen
|
||||
3. Quote speichern
|
||||
4. Dann konvertieren
|
||||
|
||||
---
|
||||
|
||||
## API Beispiele / API Examples
|
||||
|
||||
### Invoice erstellen / Create Invoice
|
||||
|
||||
```javascript
|
||||
POST /api/invoices
|
||||
{
|
||||
"customer_id": 1,
|
||||
"invoice_date": "2026-01-31",
|
||||
"terms": "Net 30",
|
||||
"authorization": "P.O. #12345",
|
||||
"tax_exempt": false,
|
||||
"items": [
|
||||
{
|
||||
"quantity": "2",
|
||||
"description": "<p>Email Hosting - Monthly</p>",
|
||||
"rate": "25.00",
|
||||
"amount": "50.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Quote zu Invoice / Quote to Invoice
|
||||
|
||||
```javascript
|
||||
POST /api/quotes/5/convert-to-invoice
|
||||
// Response bei Erfolg:
|
||||
{
|
||||
"id": 1,
|
||||
"invoice_number": "2026-001",
|
||||
"customer_id": 1,
|
||||
"total": 54.13,
|
||||
...
|
||||
}
|
||||
|
||||
// Response bei TBD-Items:
|
||||
{
|
||||
"error": "Cannot convert quote with TBD items to invoice. Please update all TBD items first."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist ✅
|
||||
|
||||
- [x] Invoice erstellen
|
||||
- [x] Invoice bearbeiten
|
||||
- [x] Invoice löschen
|
||||
- [x] Invoice PDF generieren
|
||||
- [x] Quote ohne TBD zu Invoice konvertieren
|
||||
- [x] Quote mit TBD Konvertierung blockieren
|
||||
- [x] "Bill To:" Label im PDF
|
||||
- [x] accounting@bayarea-cc.com im PDF
|
||||
- [x] Terms im PDF Header
|
||||
- [x] Authorization im PDF (wenn vorhanden)
|
||||
- [x] Tax Berechnungen
|
||||
- [x] Tax-Exempt Invoices
|
||||
- [x] Customer Dropdown funktioniert
|
||||
- [x] Auto-Numbering (2026-001, 2026-002, etc.)
|
||||
- [x] Rich Text Editor in Items
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte / Next Steps
|
||||
|
||||
### Deployment auf deinem Server
|
||||
1. Dateien hochladen
|
||||
2. `setup.sh` ausführen
|
||||
3. Logo hochladen (Settings Tab)
|
||||
4. Ersten Customer erstellen
|
||||
5. Test-Quote erstellen
|
||||
6. Quote zu Invoice konvertieren
|
||||
7. PDFs testen
|
||||
|
||||
### Optional: Docker
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Backup einrichten
|
||||
```bash
|
||||
# Cronjob für tägliches Backup
|
||||
0 2 * * * pg_dump -U quoteuser quotes_db > /backups/quotes_$(date +\%Y\%m\%d).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support & Hilfe
|
||||
|
||||
- **Dokumentation:** README.md
|
||||
- **Installation:** INSTALLATION.md
|
||||
- **Änderungen:** CHANGELOG.md
|
||||
- **Logs:** `journalctl -u quote-system -f` (systemd)
|
||||
- **Docker Logs:** `docker-compose logs -f`
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung / Summary
|
||||
|
||||
**Vollständiges Invoice-System implementiert mit:**
|
||||
- ✅ Separate Invoice-Verwaltung
|
||||
- ✅ Quote-zu-Invoice Konvertierung
|
||||
- ✅ TBD-Validierung
|
||||
- ✅ Professionelle PDFs
|
||||
- ✅ Unterschiedliche Email-Adressen
|
||||
- ✅ Terms & Authorization Felder
|
||||
- ✅ Automatische Nummerierung
|
||||
- ✅ Vollständige Dokumentation
|
||||
- ✅ Docker Support
|
||||
- ✅ Auto-Setup Script
|
||||
- ✅ Rückwärtskompatibel
|
||||
|
||||
**Bereit für Produktion!** 🚀
|
||||
264
INSTALLATION.md
264
INSTALLATION.md
@@ -1,264 +0,0 @@
|
||||
# Installation Guide / Installationsanleitung
|
||||
|
||||
## English Version
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18 or higher
|
||||
- PostgreSQL 12 or higher
|
||||
- npm (comes with Node.js)
|
||||
|
||||
### Quick Installation
|
||||
|
||||
1. **Extract files to your server**
|
||||
```bash
|
||||
cd /your/installation/directory
|
||||
```
|
||||
|
||||
2. **Run the setup script**
|
||||
```bash
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
- Create the PostgreSQL database and user
|
||||
- Set up environment variables
|
||||
- Run database migrations
|
||||
- Install Node.js dependencies
|
||||
- Create necessary directories
|
||||
|
||||
3. **Start the server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **Access the application**
|
||||
Open your browser to: http://localhost:3000
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer to install manually:
|
||||
|
||||
1. **Create PostgreSQL database**
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
CREATE DATABASE quotes_db;
|
||||
CREATE USER quoteuser WITH PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE quotes_db TO quoteuser;
|
||||
\q
|
||||
```
|
||||
|
||||
2. **Run database migrations**
|
||||
```bash
|
||||
psql -U quoteuser -d quotes_db -f init.sql
|
||||
psql -U quoteuser -d quotes_db -f add_invoices.sql
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. **Configure environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
5. **Create directories**
|
||||
```bash
|
||||
mkdir -p public/uploads
|
||||
```
|
||||
|
||||
6. **Start the server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deutsche Version
|
||||
|
||||
### Voraussetzungen
|
||||
- Node.js 18 oder höher
|
||||
- PostgreSQL 12 oder höher
|
||||
- npm (kommt mit Node.js)
|
||||
|
||||
### Schnell-Installation
|
||||
|
||||
1. **Dateien auf deinen Server entpacken**
|
||||
```bash
|
||||
cd /dein/installations/verzeichnis
|
||||
```
|
||||
|
||||
2. **Setup-Script ausführen**
|
||||
```bash
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
Das Script wird:
|
||||
- PostgreSQL-Datenbank und Benutzer erstellen
|
||||
- Umgebungsvariablen einrichten
|
||||
- Datenbank-Migrationen ausführen
|
||||
- Node.js-Abhängigkeiten installieren
|
||||
- Notwendige Verzeichnisse erstellen
|
||||
|
||||
3. **Server starten**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **Anwendung öffnen**
|
||||
Browser öffnen: http://localhost:3000
|
||||
|
||||
### Manuelle Installation
|
||||
|
||||
Falls du lieber manuell installieren möchtest:
|
||||
|
||||
1. **PostgreSQL-Datenbank erstellen**
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
CREATE DATABASE quotes_db;
|
||||
CREATE USER quoteuser WITH PASSWORD 'dein_passwort';
|
||||
GRANT ALL PRIVILEGES ON DATABASE quotes_db TO quoteuser;
|
||||
\q
|
||||
```
|
||||
|
||||
2. **Datenbank-Migrationen ausführen**
|
||||
```bash
|
||||
psql -U quoteuser -d quotes_db -f init.sql
|
||||
psql -U quoteuser -d quotes_db -f add_invoices.sql
|
||||
```
|
||||
|
||||
3. **Abhängigkeiten installieren**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. **Umgebung konfigurieren**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env mit deinen Einstellungen bearbeiten
|
||||
```
|
||||
|
||||
5. **Verzeichnisse erstellen**
|
||||
```bash
|
||||
mkdir -p public/uploads
|
||||
```
|
||||
|
||||
6. **Server starten**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure / Dateistruktur
|
||||
|
||||
```
|
||||
quote-invoice-system/
|
||||
├── server.js # Express server / Backend-Server
|
||||
├── public/
|
||||
│ ├── index.html # Main UI / Hauptoberfläche
|
||||
│ ├── app.js # Frontend JavaScript
|
||||
│ └── uploads/ # Logo storage / Logo-Speicher
|
||||
├── package.json # Dependencies / Abhängigkeiten
|
||||
├── init.sql # Initial DB schema / Initiales DB-Schema
|
||||
├── add_invoices.sql # Invoice tables / Rechnungs-Tabellen
|
||||
├── setup.sh # Auto-installation / Auto-Installation
|
||||
├── .env.example # Environment template / Umgebungs-Vorlage
|
||||
└── README.md # Documentation / Dokumentation
|
||||
```
|
||||
|
||||
## Troubleshooting / Fehlerbehebung
|
||||
|
||||
### Database connection fails / Datenbankverbindung fehlgeschlagen
|
||||
- Check PostgreSQL is running: `sudo systemctl status postgresql`
|
||||
- Verify credentials in `.env` file
|
||||
- Ensure user has permissions: `GRANT ALL ON SCHEMA public TO quoteuser;`
|
||||
|
||||
### Port 3000 already in use / Port 3000 bereits belegt
|
||||
- Change `PORT` in `.env` file
|
||||
- Or stop the service using port 3000
|
||||
|
||||
### PDF generation fails / PDF-Generierung fehlgeschlagen
|
||||
- Puppeteer requires Chromium
|
||||
- On Ubuntu/Debian: `sudo apt-get install chromium-browser`
|
||||
- On Alpine/Docker: Already configured in Dockerfile
|
||||
|
||||
### Permission errors / Berechtigungsfehler
|
||||
- Ensure `public/uploads` directory exists and is writable
|
||||
- Run: `chmod 755 public/uploads`
|
||||
|
||||
## Production Deployment / Produktions-Deployment
|
||||
|
||||
### Using PM2 (Recommended / Empfohlen)
|
||||
|
||||
```bash
|
||||
# Install PM2
|
||||
npm install -g pm2
|
||||
|
||||
# Start application
|
||||
pm2 start server.js --name quote-system
|
||||
|
||||
# Save PM2 configuration
|
||||
pm2 save
|
||||
|
||||
# Auto-start on boot
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
### Using systemd
|
||||
|
||||
Create `/etc/systemd/system/quote-system.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Quote & Invoice System
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your_user
|
||||
WorkingDirectory=/path/to/quote-invoice-system
|
||||
Environment="NODE_ENV=production"
|
||||
ExecStart=/usr/bin/node server.js
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
sudo systemctl enable quote-system
|
||||
sudo systemctl start quote-system
|
||||
```
|
||||
|
||||
## Security Recommendations / Sicherheitsempfehlungen
|
||||
|
||||
1. **Use strong database passwords / Starke Datenbank-Passwörter verwenden**
|
||||
2. **Run behind reverse proxy (nginx) / Hinter Reverse-Proxy betreiben**
|
||||
3. **Enable HTTPS / HTTPS aktivieren**
|
||||
4. **Regular backups / Regelmäßige Backups**
|
||||
5. **Keep dependencies updated / Abhängigkeiten aktuell halten**
|
||||
|
||||
## Backup / Sicherung
|
||||
|
||||
### Database Backup / Datenbank-Backup
|
||||
```bash
|
||||
pg_dump -U quoteuser quotes_db > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Restore / Wiederherstellen
|
||||
```bash
|
||||
psql -U quoteuser quotes_db < backup_20260131.sql
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For technical support / Für technischen Support:
|
||||
- Check README.md for usage instructions
|
||||
- Review error logs: `journalctl -u quote-system -f`
|
||||
- Contact Bay Area Affiliates, Inc.
|
||||
@@ -1,38 +0,0 @@
|
||||
-- Migration to add Invoice functionality
|
||||
-- Run this on your existing database
|
||||
|
||||
-- Create invoices table
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
invoice_date DATE NOT NULL,
|
||||
terms VARCHAR(100) DEFAULT 'Net 30',
|
||||
auth_code VARCHAR(255),
|
||||
tax_exempt BOOLEAN DEFAULT FALSE,
|
||||
tax_rate DECIMAL(5,2) DEFAULT 8.25,
|
||||
subtotal DECIMAL(10,2) DEFAULT 0,
|
||||
tax_amount DECIMAL(10,2) DEFAULT 0,
|
||||
total DECIMAL(10,2) DEFAULT 0,
|
||||
created_from_quote_id INTEGER REFERENCES quotes(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create invoice_items table
|
||||
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
quantity VARCHAR(20) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
rate VARCHAR(50) NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
item_order INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_invoice_number ON invoices(invoice_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_customer_id ON invoices(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice_id ON invoice_items(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_created_from_quote ON invoices(created_from_quote_id);
|
||||
67
auth.js
Normal file
67
auth.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const OAuthClient = require('intuit-oauth');
|
||||
const express = require('express');
|
||||
|
||||
const app = express();
|
||||
|
||||
// 1. Konfiguration (Füge hier deine Development Keys ein)
|
||||
|
||||
const oauthClient = new OAuthClient({
|
||||
clientId: process.env.QBO_CLIENT_ID,
|
||||
clientSecret: process.env.QBO_CLIENT_SECRET,
|
||||
environment: process.env.QBO_ENVIRONMENT, // Wichtig: 'sandbox' für Development Keys
|
||||
redirectUri: process.env.QBO_REDIRECT_URI,
|
||||
});
|
||||
// 2. Start-Route: Generiert die Login-URL und leitet dich weiter
|
||||
app.get('/', (req, res) => {
|
||||
const authUri = oauthClient.authorizeUri({
|
||||
scope: [OAuthClient.scopes.Accounting, OAuthClient.scopes.Payment],
|
||||
state: 'testState',
|
||||
});
|
||||
|
||||
console.log('Öffne Browser für Login...');
|
||||
res.redirect(authUri);
|
||||
});
|
||||
|
||||
// 3. Callback-Route: Hierhin kommt QBO zurück mit dem Code
|
||||
app.get('/callback', async (req, res) => {
|
||||
try {
|
||||
// 1. Tokens holen
|
||||
const authResponse = await oauthClient.createToken(req.url);
|
||||
const tokens = authResponse.getJson();
|
||||
const realmId = authResponse.token.realmId;
|
||||
|
||||
// 2. Test-Abruf (Kunden)
|
||||
const url = oauthClient.environment == 'sandbox'
|
||||
? OAuthClient.environment.sandbox
|
||||
: OAuthClient.environment.production;
|
||||
|
||||
const apiResponse = await oauthClient.makeApiCall({
|
||||
url: `${url}v3/company/${realmId}/query?query=select * from Customer MAXRESULTS 5`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// 3. Ausgabe in der Konsole
|
||||
console.log('\n--- DEINE TOKENS (BITTE SICHERN) ---');
|
||||
console.log('Realm ID:', realmId);
|
||||
console.log('Access Token:', tokens.access_token);
|
||||
console.log('Refresh Token:', tokens.refresh_token);
|
||||
console.log('------------------------------------\n');
|
||||
|
||||
console.log("Test-Abruf Ergebnis:");
|
||||
// KORREKTUR: .getJson() statt .text()
|
||||
console.log(JSON.stringify(apiResponse.getJson(), null, 2));
|
||||
|
||||
// 4. Antwort an Browser (Erst ganz am Ende senden!)
|
||||
res.send(`<h1>Erfolg!</h1><p>Tokens sind in der Konsole.</p>`);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Ein Fehler ist aufgetreten:", e);
|
||||
// Nur senden, wenn noch nichts gesendet wurde
|
||||
if (!res.headersSent) res.send('Fehler: Siehe Konsole');
|
||||
}
|
||||
});
|
||||
|
||||
// Server starten
|
||||
app.listen(3000, async () => {
|
||||
console.log('Server läuft auf http://localhost:3000');
|
||||
});
|
||||
@@ -31,9 +31,22 @@ services:
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
# --- NEU: QBO Variablen durchreichen ---
|
||||
QBO_CLIENT_ID: ${QBO_CLIENT_ID}
|
||||
QBO_CLIENT_SECRET: ${QBO_CLIENT_SECRET}
|
||||
QBO_ENVIRONMENT: ${QBO_ENVIRONMENT}
|
||||
QBO_REDIRECT_URI: ${QBO_REDIRECT_URI}
|
||||
QBO_REALM_ID: ${QBO_REALM_ID}
|
||||
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
||||
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
||||
volumes:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
- ./templates:/app/templates # NEU!
|
||||
- ./qbo_token.json:/app/qbo_token.json
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
157
import_customers_qbo.js
Normal file
157
import_customers_qbo.js
Normal file
@@ -0,0 +1,157 @@
|
||||
require('dotenv').config();
|
||||
const OAuthClient = require('intuit-oauth');
|
||||
const { Client } = require('pg');
|
||||
|
||||
// --- KONFIGURATION ---
|
||||
const totalLimit = null;
|
||||
// ---------------------
|
||||
|
||||
const config = {
|
||||
clientId: process.env.QBO_CLIENT_ID,
|
||||
clientSecret: process.env.QBO_CLIENT_SECRET,
|
||||
environment: process.env.QBO_ENVIRONMENT || 'sandbox',
|
||||
redirectUri: process.env.QBO_REDIRECT_URI,
|
||||
token: {
|
||||
// Wir brauchen initial nur den Refresh Token, Access holen wir uns neu
|
||||
access_token: process.env.QBO_ACCESS_TOKEN,
|
||||
refresh_token: process.env.QBO_REFRESH_TOKEN,
|
||||
realmId: process.env.QBO_REALM_ID
|
||||
}
|
||||
};
|
||||
|
||||
// SPEZIAL-CONFIG FÜR LOKALEN ZUGRIFF AUF DOCKER DB
|
||||
const dbConfig = {
|
||||
user: process.env.DB_USER,
|
||||
// WICHTIG: Lokal ist es immer localhost
|
||||
host: 'localhost',
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
// WICHTIG: Laut deinem docker-compose mapst du 5433 auf 5432!
|
||||
port: 5433,
|
||||
};
|
||||
|
||||
async function importCustomers() {
|
||||
const oauthClient = new OAuthClient(config);
|
||||
const pgClient = new Client(dbConfig);
|
||||
|
||||
try {
|
||||
// console.log("🔄 1. Versuche Token zu erneuern...");
|
||||
// try {
|
||||
// // Token Refresh erzwingen bevor wir starten
|
||||
// const authResponse = await oauthClient.refresh();
|
||||
// console.log("✅ Token erfolgreich erneuert!");
|
||||
// // Optional: Das neue Token in der Session speichern, falls nötig
|
||||
// } catch (tokenErr) {
|
||||
// console.error("❌ Token Refresh fehlgeschlagen. Prüfe QBO_REFRESH_TOKEN in .env");
|
||||
// console.error(tokenErr.originalMessage || tokenErr);
|
||||
// return; // Abbruch
|
||||
// }
|
||||
|
||||
console.log(`🔌 2. Verbinde zur DB (Port ${dbConfig.port})...`);
|
||||
await pgClient.connect();
|
||||
console.log(`✅ DB Verbunden.`);
|
||||
|
||||
// --- AB HIER DER NORMALE IMPORT ---
|
||||
let startPosition = 1;
|
||||
let totalProcessed = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
let limitForThisBatch = 100;
|
||||
if (totalLimit) {
|
||||
const remaining = totalLimit - totalProcessed;
|
||||
if (remaining <= 0) break;
|
||||
limitForThisBatch = Math.min(100, remaining);
|
||||
}
|
||||
|
||||
const query = `SELECT * FROM Customer STARTPOSITION ${startPosition} MAXRESULTS ${limitForThisBatch}`;
|
||||
console.log(`📡 QBO Request: Hole ${limitForThisBatch} Kunden ab Pos ${startPosition}...`);
|
||||
|
||||
const baseUrl = config.environment === 'production'
|
||||
? 'https://quickbooks.api.intuit.com/'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com/';
|
||||
|
||||
const response = await oauthClient.makeApiCall({
|
||||
url: `${baseUrl}v3/company/${config.token.realmId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const customers = data.QueryResponse?.Customer || [];
|
||||
|
||||
console.log(`📥 QBO Response: ${customers.length} Kunden erhalten.`);
|
||||
|
||||
if (customers.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const c of customers) {
|
||||
try {
|
||||
const rawPhone = c.PrimaryPhone?.FreeFormNumber || "";
|
||||
const formattedAccountNumber = rawPhone.replace(/\D/g, "");
|
||||
|
||||
const sql = `
|
||||
INSERT INTO customers (
|
||||
name, line1, line2, line3, line4, city, state, zip_code,
|
||||
account_number, email, phone, phone2, taxable, qbo_id, qbo_sync_token, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW())
|
||||
ON CONFLICT (qbo_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
line1 = EXCLUDED.line1,
|
||||
line2 = EXCLUDED.line2,
|
||||
line3 = EXCLUDED.line3,
|
||||
line4 = EXCLUDED.line4,
|
||||
city = EXCLUDED.city,
|
||||
state = EXCLUDED.state,
|
||||
zip_code = EXCLUDED.zip_code,
|
||||
email = EXCLUDED.email,
|
||||
phone = EXCLUDED.phone,
|
||||
phone2 = EXCLUDED.phone2,
|
||||
qbo_sync_token = EXCLUDED.qbo_sync_token,
|
||||
taxable = EXCLUDED.taxable,
|
||||
updated_at = NOW();
|
||||
`;
|
||||
|
||||
const values = [
|
||||
c.CompanyName || c.DisplayName,
|
||||
c.BillAddr?.Line1 || null,
|
||||
c.BillAddr?.Line2 || null,
|
||||
c.BillAddr?.Line3 || null,
|
||||
c.BillAddr?.Line4 || null,
|
||||
c.BillAddr?.City || null,
|
||||
c.BillAddr?.CountrySubDivisionCode || null,
|
||||
c.BillAddr?.PostalCode || null,
|
||||
formattedAccountNumber || null,
|
||||
c.PrimaryEmailAddr?.Address || null,
|
||||
c.PrimaryPhone?.FreeFormNumber || null,
|
||||
c.AlternatePhone?.FreeFormNumber || null,
|
||||
c.Taxable || false,
|
||||
c.Id,
|
||||
c.SyncToken
|
||||
];
|
||||
|
||||
await pgClient.query(sql, values);
|
||||
totalProcessed++;
|
||||
process.stdout.write(".");
|
||||
} catch (rowError) {
|
||||
console.error(`\n❌ DB Fehler bei Kunde ID ${c.Id}:`, rowError.message);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
|
||||
if (customers.length < limitForThisBatch) hasMore = false;
|
||||
startPosition += customers.length;
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Fertig! ${totalProcessed} Kunden verarbeitet.`);
|
||||
|
||||
} catch (e) {
|
||||
console.error("\n💀 FATAL ERROR:", e.message);
|
||||
if(e.authResponse) console.log(JSON.stringify(e.authResponse, null, 2));
|
||||
} finally {
|
||||
await pgClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
importCustomers();
|
||||
250
import_qbo_payment.js
Normal file
250
import_qbo_payment.js
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env node
|
||||
// import_qbo_payment.js — Importiert ein QBO Payment in die lokale DB
|
||||
//
|
||||
// Suche nach Payment über:
|
||||
// --invoice <QBO_INVOICE_ID_OR_DOCNUMBER> Findet Payment über die Invoice
|
||||
// --ref <CHECK_NUMBER> Findet Payment über Referenznummer
|
||||
// --payment <QBO_PAYMENT_ID> Direkt über interne QBO Payment ID
|
||||
//
|
||||
// Beispiele:
|
||||
// node import_qbo_payment.js --invoice 110483
|
||||
// node import_qbo_payment.js --ref 20616
|
||||
// node import_qbo_payment.js --payment 456
|
||||
|
||||
require('dotenv').config();
|
||||
const { Pool } = require('pg');
|
||||
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
database: process.env.DB_NAME || 'quotes_db',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
});
|
||||
|
||||
async function getBaseUrl() {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const base = process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
return { base, companyId };
|
||||
}
|
||||
|
||||
// --- Suche: Payment über Invoice finden ---
|
||||
async function findPaymentByInvoice(invoiceRef) {
|
||||
const { base, companyId } = await getBaseUrl();
|
||||
console.log(`\n🔍 Suche Invoice "${invoiceRef}" in QBO...`);
|
||||
|
||||
// Zuerst als DocNumber suchen (das ist was du siehst)
|
||||
let invoice = null;
|
||||
const query = `SELECT * FROM Invoice WHERE DocNumber = '${invoiceRef}'`;
|
||||
const qRes = await makeQboApiCall({
|
||||
url: `${base}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const qData = qRes.getJson ? qRes.getJson() : qRes.json;
|
||||
|
||||
if (qData.QueryResponse?.Invoice?.length > 0) {
|
||||
invoice = qData.QueryResponse.Invoice[0];
|
||||
} else {
|
||||
// Fallback: direkt als ID versuchen
|
||||
try {
|
||||
const invRes = await makeQboApiCall({
|
||||
url: `${base}/v3/company/${companyId}/invoice/${invoiceRef}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const invData = invRes.getJson ? invRes.getJson() : invRes.json;
|
||||
invoice = invData.Invoice;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
console.error(`❌ Invoice "${invoiceRef}" nicht in QBO gefunden.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(` ✅ Invoice: ID ${invoice.Id}, DocNumber ${invoice.DocNumber}, Balance: $${invoice.Balance}, Kunde: ${invoice.CustomerRef?.name}`);
|
||||
|
||||
// Verknüpfte Payments aus LinkedTxn
|
||||
if (invoice.LinkedTxn && invoice.LinkedTxn.length > 0) {
|
||||
const paymentLinks = invoice.LinkedTxn.filter(lt => lt.TxnType === 'Payment');
|
||||
if (paymentLinks.length > 0) {
|
||||
console.log(` 📎 ${paymentLinks.length} verknüpfte Payment(s):`);
|
||||
for (const pl of paymentLinks) {
|
||||
console.log(` QBO Payment ID: ${pl.TxnId}`);
|
||||
}
|
||||
return paymentLinks[0].TxnId;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ⚠️ Keine Payments verknüpft (Balance: $${invoice.Balance}).`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Suche: Payment über Reference Number ---
|
||||
async function findPaymentByRef(refNumber) {
|
||||
const { base, companyId } = await getBaseUrl();
|
||||
console.log(`\n🔍 Suche Payment mit Reference Number "${refNumber}"...`);
|
||||
|
||||
const query = `SELECT * FROM Payment WHERE PaymentRefNum = '${refNumber}'`;
|
||||
const response = await makeQboApiCall({
|
||||
url: `${base}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const payments = data.QueryResponse?.Payment || [];
|
||||
|
||||
if (payments.length === 0) {
|
||||
console.log(` ❌ Kein Payment mit Ref "${refNumber}" gefunden.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(` ✅ ${payments.length} Payment(s) gefunden:`);
|
||||
for (const p of payments) {
|
||||
console.log(` ID: ${p.Id}, Datum: ${p.TxnDate}, Betrag: $${p.TotalAmt}, Ref: ${p.PaymentRefNum}`);
|
||||
}
|
||||
return payments[0].Id;
|
||||
}
|
||||
|
||||
// --- Payment laden und lokal importieren ---
|
||||
async function importPayment(qboPaymentId) {
|
||||
const { base, companyId } = await getBaseUrl();
|
||||
console.log(`\n📥 Lade QBO Payment ID ${qboPaymentId}...`);
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${base}/v3/company/${companyId}/payment/${qboPaymentId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const payment = data.Payment;
|
||||
|
||||
if (!payment) { console.error('❌ Payment nicht gefunden.'); return; }
|
||||
|
||||
console.log(`\n✅ Payment geladen:`);
|
||||
console.log(` QBO ID: ${payment.Id}`);
|
||||
console.log(` Datum: ${payment.TxnDate}`);
|
||||
console.log(` Betrag: $${payment.TotalAmt}`);
|
||||
console.log(` Referenz: ${payment.PaymentRefNum || '(keine)'}`);
|
||||
console.log(` Kunde: ${payment.CustomerRef?.name || payment.CustomerRef?.value}`);
|
||||
|
||||
// Verknüpfte Invoices
|
||||
const linkedInvoices = [];
|
||||
if (payment.Line) {
|
||||
for (const line of payment.Line) {
|
||||
if (line.LinkedTxn) {
|
||||
for (const txn of line.LinkedTxn) {
|
||||
if (txn.TxnType === 'Invoice') {
|
||||
linkedInvoices.push({ qbo_invoice_id: txn.TxnId, amount: line.Amount });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` Invoices: ${linkedInvoices.length}`);
|
||||
linkedInvoices.forEach(li => console.log(` - QBO Invoice ${li.qbo_invoice_id}: $${li.amount}`));
|
||||
|
||||
// Namen auflösen
|
||||
let paymentMethodName = 'Unknown';
|
||||
if (payment.PaymentMethodRef?.value) {
|
||||
try {
|
||||
const pmRes = await makeQboApiCall({
|
||||
url: `${base}/v3/company/${companyId}/paymentmethod/${payment.PaymentMethodRef.value}`,
|
||||
method: 'GET'
|
||||
});
|
||||
paymentMethodName = (pmRes.getJson ? pmRes.getJson() : pmRes.json).PaymentMethod?.Name || 'Unknown';
|
||||
} catch (e) { /* ok */ }
|
||||
}
|
||||
|
||||
let depositToName = '';
|
||||
if (payment.DepositToAccountRef?.value) {
|
||||
try {
|
||||
const accRes = await makeQboApiCall({
|
||||
url: `${base}/v3/company/${companyId}/account/${payment.DepositToAccountRef.value}`,
|
||||
method: 'GET'
|
||||
});
|
||||
depositToName = (accRes.getJson ? accRes.getJson() : accRes.json).Account?.Name || '';
|
||||
} catch (e) { /* ok */ }
|
||||
}
|
||||
|
||||
console.log(` Methode: ${paymentMethodName}`);
|
||||
console.log(` Konto: ${depositToName}`);
|
||||
|
||||
// --- DB ---
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const existing = await dbClient.query('SELECT id FROM payments WHERE qbo_payment_id = $1', [String(payment.Id)]);
|
||||
if (existing.rows.length > 0) {
|
||||
console.log(`\n⚠️ Bereits importiert (lokale ID: ${existing.rows[0].id}). Übersprungen.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
const custResult = await dbClient.query('SELECT id FROM customers WHERE qbo_id = $1', [payment.CustomerRef?.value]);
|
||||
const customerId = custResult.rows[0]?.id || null;
|
||||
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[payment.TxnDate, payment.PaymentRefNum || null, paymentMethodName, depositToName, payment.TotalAmt, customerId, String(payment.Id)]
|
||||
);
|
||||
const localId = payResult.rows[0].id;
|
||||
console.log(`\n💾 Payment lokal gespeichert: ID ${localId}`);
|
||||
|
||||
let matched = 0;
|
||||
for (const li of linkedInvoices) {
|
||||
const invResult = await dbClient.query('SELECT id, invoice_number FROM invoices WHERE qbo_id = $1', [li.qbo_invoice_id]);
|
||||
if (invResult.rows.length > 0) {
|
||||
const inv = invResult.rows[0];
|
||||
await dbClient.query('INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [localId, inv.id, li.amount]);
|
||||
await dbClient.query('UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND paid_date IS NULL', [payment.TxnDate, inv.id]);
|
||||
console.log(` ✅ Invoice #${inv.invoice_number || inv.id} → bezahlt`);
|
||||
matched++;
|
||||
} else {
|
||||
console.log(` ⚠️ QBO Invoice ${li.qbo_invoice_id} nicht in lokaler DB`);
|
||||
}
|
||||
}
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
console.log(`\n✅ Fertig: ${matched}/${linkedInvoices.length} Invoices verknüpft.`);
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Fehler:', error);
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.log(`
|
||||
Verwendung:
|
||||
node import_qbo_payment.js --invoice <DOCNUMBER_OR_QBO_ID>
|
||||
node import_qbo_payment.js --ref <CHECK_NUMBER>
|
||||
node import_qbo_payment.js --payment <QBO_PAYMENT_ID>
|
||||
|
||||
Beispiele:
|
||||
node import_qbo_payment.js --invoice 110483
|
||||
node import_qbo_payment.js --ref 20616
|
||||
node import_qbo_payment.js --payment 456
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let qboPaymentId = null;
|
||||
if (args[0] === '--payment') qboPaymentId = args[1];
|
||||
else if (args[0] === '--invoice') qboPaymentId = await findPaymentByInvoice(args[1]);
|
||||
else if (args[0] === '--ref') qboPaymentId = await findPaymentByRef(args[1]);
|
||||
else { console.error(`Unbekannt: ${args[0]}`); process.exit(1); }
|
||||
|
||||
if (!qboPaymentId) { console.error('\n❌ Payment nicht gefunden.'); process.exit(1); }
|
||||
|
||||
await importPayment(qboPaymentId);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
53
init.sql
53
init.sql
@@ -1,53 +0,0 @@
|
||||
-- Initial Database Setup for Quote & Invoice System
|
||||
-- Run this first to create the basic tables
|
||||
|
||||
-- Create customers table
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
street VARCHAR(255) NOT NULL,
|
||||
city VARCHAR(100) NOT NULL,
|
||||
state VARCHAR(2) NOT NULL,
|
||||
zip_code VARCHAR(10) NOT NULL,
|
||||
account_number VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create quotes table
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
quote_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
quote_date DATE NOT NULL,
|
||||
tax_exempt BOOLEAN DEFAULT FALSE,
|
||||
tax_rate DECIMAL(5,2) DEFAULT 8.25,
|
||||
subtotal DECIMAL(10,2) DEFAULT 0,
|
||||
tax_amount DECIMAL(10,2) DEFAULT 0,
|
||||
total DECIMAL(10,2) DEFAULT 0,
|
||||
has_tbd BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create quote_items table
|
||||
CREATE TABLE IF NOT EXISTS quote_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
quote_id INTEGER REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
quantity VARCHAR(20) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
rate VARCHAR(50) NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
item_order INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_quotes_quote_number ON quotes(quote_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotes_customer_id ON quotes(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_quote_items_quote_id ON quote_items(quote_id);
|
||||
|
||||
-- Insert sample customer
|
||||
INSERT INTO customers (name, street, city, state, zip_code, account_number)
|
||||
VALUES ('Braselton Development', '5337 Yorktown Blvd. Suite 10-D', 'Corpus Christi', 'TX', '78414', '3617790060')
|
||||
ON CONFLICT DO NOTHING;
|
||||
6338
package-lock.json
generated
Normal file
6338
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -2,16 +2,23 @@
|
||||
"name": "quote-invoice-system",
|
||||
"version": "2.0.0",
|
||||
"description": "Quote & Invoice Management System for Bay Area Affiliates",
|
||||
"main": "server.js",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.1009.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.13.1",
|
||||
"intuit-oauth": "^4.2.2",
|
||||
"mjml": "^4.18.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"puppeteer": "^23.11.1"
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.13.1",
|
||||
"puppeteer": "^23.11.1",
|
||||
"stripe": "^20.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
321
prod_backup.sql
321
prod_backup.sql
@@ -1,321 +0,0 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict bxGU7dQ4DrNrHU2OuyEH16NHE6ZA8yFm2MADa6p2XI8qbowdWdtlaDeKSSp2NYx
|
||||
|
||||
-- Dumped from database version 15.15
|
||||
-- Dumped by pg_dump version 15.15
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: customers; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.customers (
|
||||
id integer NOT NULL,
|
||||
name character varying(255) NOT NULL,
|
||||
street character varying(255) NOT NULL,
|
||||
city character varying(100) NOT NULL,
|
||||
state character varying(2) NOT NULL,
|
||||
zip_code character varying(10) NOT NULL,
|
||||
account_number character varying(50),
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.customers OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: customers_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.customers_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.customers_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: customers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.customers_id_seq OWNED BY public.customers.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.quote_items (
|
||||
id integer NOT NULL,
|
||||
quote_id integer,
|
||||
quantity character varying(20) NOT NULL,
|
||||
description text NOT NULL,
|
||||
rate character varying(50) NOT NULL,
|
||||
amount character varying(50) NOT NULL,
|
||||
is_tbd boolean DEFAULT false,
|
||||
item_order integer NOT NULL,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.quote_items OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quote_items_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.quote_items_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.quote_items_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quote_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.quote_items_id_seq OWNED BY public.quote_items.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.quotes (
|
||||
id integer NOT NULL,
|
||||
quote_number character varying(50) NOT NULL,
|
||||
customer_id integer,
|
||||
quote_date date NOT NULL,
|
||||
tax_exempt boolean DEFAULT false,
|
||||
tax_rate numeric(5,2) DEFAULT 8.25,
|
||||
subtotal numeric(10,2) DEFAULT 0,
|
||||
tax_amount numeric(10,2) DEFAULT 0,
|
||||
total numeric(10,2) DEFAULT 0,
|
||||
has_tbd boolean DEFAULT false,
|
||||
tbd_note text,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.quotes OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quotes_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.quotes_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.quotes_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quotes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.quotes_id_seq OWNED BY public.quotes.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.customers ALTER COLUMN id SET DEFAULT nextval('public.customers_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quote_items ALTER COLUMN id SET DEFAULT nextval('public.quote_items_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes ALTER COLUMN id SET DEFAULT nextval('public.quotes_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: customers; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
COPY public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) FROM stdin;
|
||||
1 Braselton Development 5337 Yorktown Blvd. Suite 10-D Corpus Christi TX 78414 3617790060 2026-01-22 01:09:30.914655 2026-01-22 01:09:30.914655
|
||||
2 Karen Menn 5134 Graford Place Corpus Christi TX 78413 3619933550 2026-01-22 01:19:49.357044 2026-01-22 01:49:16.051712
|
||||
3 Hearing Aid Company of Texas 6468 Holly Road Corpus Christi TX 78412 3618143487 2026-01-22 03:33:56.090479 2026-01-22 03:33:56.090479
|
||||
4 South Shore Christian Church 4710 S. Alameda Corpus Christi TX 78412 3619926391 2026-01-22 03:40:33.012646 2026-01-22 03:40:33.012646
|
||||
5 JE Construction Services, LLC 7505 Up River Road Corpus Christi TX 78409 3612892901 2026-01-22 03:41:08.716604 2026-01-22 03:41:08.716604
|
||||
6 John T. Thompson, DDS 4101 US-77 Corpus Christi TX 78410 3612423151 2026-01-30 20:50:22.987565 2026-01-30 21:06:23.354743
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: quote_items; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
COPY public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) FROM stdin;
|
||||
26 5 1 <p>HPE ProLiant MicroServer Gen11 Ultra Micro Tower Server - 1 x Intel Xeon E-2414, 32 GB DDR5 RAM</p> 1900 1900.00 f 0 2026-01-22 18:02:30.878526
|
||||
27 5 2 <p>Western Digital 24TB WD Red Pro NAS Internal Hard Drive HDD</p> 850 1700.00 f 1 2026-01-22 18:02:30.878526
|
||||
28 5 1 <ul><li>Off-site installation and base configuration of the TrueNAS system</li><li>Creation of storage pools, datasets, and network shares as specified\n</li><li>Configuration of users, user groups, and access permissions\nSetup of automated snapshots with defined retention and rollback capability\n</li><li>Configuration of cloud backups to iDrive360\nSetup of system monitoring and email notifications for proactive issue detection\n</li><li>Installation and configuration of AOMEI Backup on selected desktops and laptops, storing backups on designated TrueNAS shares</li></ul> 2250 2250.00 f 2 2026-01-22 18:02:30.878526
|
||||
44 1 1 <ul><li>Dell OptiPlex 7010 SFF Desktop Intel Core i5-13600,14 Cores</li><li>16GB</li><li>Windows 11 Pro</li><li>Crucial - P310 2TB Internal SSD PCIe Gen 4 x4 NVMe M.2</li></ul> 1079 1079.00 f 0 2026-01-22 18:51:00.998206
|
||||
45 1 1 <p>DisplayPort to HDMI cable 10ft</p> 20 20.00 f 1 2026-01-22 18:51:00.998206
|
||||
46 1 3 <p>Setup and configure Dell OptiPlex 7010 off-site\nInstall all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network\nTransferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. \nSetup printing and scanning as required. Install customer requested software.Test all hardware for proper Operation</p> 125 375.00 f 2 2026-01-22 18:51:00.998206
|
||||
47 2 1 <p>Lenovo Yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD</p> 500 500.00 f 0 2026-01-22 18:54:57.288474
|
||||
48 2 2 <p>Setup and configure Lenovo yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD off-site. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p> 125 250.00 f 1 2026-01-22 18:54:57.288474
|
||||
49 4 1 <ul><li>Dell OptiPlex 7020 Plus Tower Desktop PC – Core i7-14700 </li><li>32GB DDR5 RAM, </li><li>2 TB SSD M.2 PCIe Gen4 TLC, </li><li>NVIDIA® GeForce RTX™ 5050</li></ul> 2080 2080.00 f 0 2026-01-22 20:00:28.631846
|
||||
50 4 2 <p>Setup and configure Lenovo Yoga as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transfer data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p> 125 250.00 f 1 2026-01-22 20:00:28.631846
|
||||
59 3 3 <ul><li><strong>Lenovo </strong>ThinkPad P16s Mobile Workstation</li><li><strong>Processor:</strong> Intel® Core™ Ultra 7 155H</li><li><strong>Graphics Card:</strong> NVIDIA RTX™ 500 Ada Generation Laptop GPU, <strong>4 GB GDDR6</strong></li><li><strong>Memory: 64 GB DDR5-5600 MT/s</strong></li><li><strong>Storage: 1 TB SSD</strong> M.2 2280 PCIe Gen4 </li><li><strong>Display: </strong>16" WQUXGA (3840 × 2400) OLED</li><li><strong>Operating System:</strong> Windows 11 Pro 64-bit</li><li><strong>1 Year Warranty</strong></li><li><strong>(This device is new, not refurbished)</strong></li></ul> 1949 5847.00 f 0 2026-01-26 18:41:05.501558
|
||||
60 3 <p>Setup and configure Lenovo Laptops as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p> 125 TBD t 1 2026-01-26 18:41:05.501558
|
||||
74 7 1 <p>Dell Optiplex 7010 (or similar) Tower configured with Intel Core i5-13500 processor, 16GB RAM, 512GB solid state drive and Windows 11 Professional. Refurbished with a one year warranty.\t</p> 725.00 725.00 f 0 2026-01-30 21:04:38.661279
|
||||
75 7 3 <p>Delivery and installation of Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p> 125 375.00 f 1 2026-01-30 21:04:38.661279
|
||||
76 6 1 <ul><li>Dell Tower Computer configured with Intel Core Ultra 5 235 processor, 16GB RAM, 512GB SSD and Windows 11 Professional. New with One Year Warranty.</li></ul> 1325.00 1325.00 f 0 2026-01-30 21:07:26.820637
|
||||
77 6 3 <p>Delivery and installation of new Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p> 125.00 375.00 f 1 2026-01-30 21:07:26.820637
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: quotes; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
COPY public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) FROM stdin;
|
||||
5 2026-01-0005 5 2026-01-22 f 8.25 5850.00 482.63 6332.63 f 2026-01-22 16:28:42.374654 2026-01-22 18:02:30.878526
|
||||
1 2026-01-0001 2 2026-01-22 f 8.25 1474.00 121.61 1595.61 f 2026-01-22 01:34:06.558046 2026-01-22 18:51:00.998206
|
||||
2 2026-01-0002 3 2026-01-22 f 8.25 750.00 61.88 811.88 f 2026-01-22 03:35:15.021729 2026-01-22 18:54:57.288474
|
||||
4 2026-01-0004 4 2026-01-22 f 8.25 2330.00 192.23 2522.23 f 2026-01-22 03:45:56.686598 2026-01-22 20:00:28.631846
|
||||
3 2026-01-0003 1 2026-01-26 f 8.25 5847.00 482.38 6329.38 t Total excludes labor charges which will be determined based on actual time required. 2026-01-22 03:36:47.795674 2026-01-26 18:41:05.501558
|
||||
7 2026-01-0007 6 2026-01-30 f 8.25 1100.00 90.75 1190.75 f 2026-01-30 21:01:43.538202 2026-01-30 21:04:38.661279
|
||||
6 2026-01-0006 6 2026-01-30 f 8.25 1700.00 140.25 1840.25 f 2026-01-30 20:58:23.014874 2026-01-30 21:07:26.820637
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.customers_id_seq', 6, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.quote_items_id_seq', 77, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.quotes_id_seq', 7, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.customers
|
||||
ADD CONSTRAINT customers_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items quote_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quote_items
|
||||
ADD CONSTRAINT quote_items_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes quotes_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes
|
||||
ADD CONSTRAINT quotes_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes quotes_quote_number_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes
|
||||
ADD CONSTRAINT quotes_quote_number_key UNIQUE (quote_number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_quote_items_quote_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_quote_items_quote_id ON public.quote_items USING btree (quote_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_quotes_customer_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_quotes_customer_id ON public.quotes USING btree (customer_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_quotes_quote_number; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_quotes_quote_number ON public.quotes USING btree (quote_number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items quote_items_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quote_items
|
||||
ADD CONSTRAINT quote_items_quote_id_fkey FOREIGN KEY (quote_id) REFERENCES public.quotes(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes quotes_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes
|
||||
ADD CONSTRAINT quotes_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict bxGU7dQ4DrNrHU2OuyEH16NHE6ZA8yFm2MADa6p2XI8qbowdWdtlaDeKSSp2NYx
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict KCbrUeHdJ7srnFlBFWbQWdZ6A6bdMlTKPXbmEoc5qE3gaNBouFxTyfvdD9oETV4
|
||||
|
||||
-- Dumped from database version 15.15
|
||||
-- Dumped by pg_dump version 15.15
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Data for Name: customers; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (1, 'Braselton Development', '5337 Yorktown Blvd. Suite 10-D', 'Corpus Christi', 'TX', '78414', '3617790060', '2026-01-22 01:09:30.914655', '2026-01-22 01:09:30.914655');
|
||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (2, 'Karen Menn', '5134 Graford Place', 'Corpus Christi', 'TX', '78413', '3619933550', '2026-01-22 01:19:49.357044', '2026-01-22 01:49:16.051712');
|
||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (3, 'Hearing Aid Company of Texas', '6468 Holly Road', 'Corpus Christi', 'TX', '78412', '3618143487', '2026-01-22 03:33:56.090479', '2026-01-22 03:33:56.090479');
|
||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (4, 'South Shore Christian Church', '4710 S. Alameda', 'Corpus Christi', 'TX', '78412', '3619926391', '2026-01-22 03:40:33.012646', '2026-01-22 03:40:33.012646');
|
||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (5, 'JE Construction Services, LLC', '7505 Up River Road', 'Corpus Christi', 'TX', '78409', '3612892901', '2026-01-22 03:41:08.716604', '2026-01-22 03:41:08.716604');
|
||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (6, 'John T. Thompson, DDS', '4101 US-77', 'Corpus Christi', 'TX', '78410', '3612423151', '2026-01-30 20:50:22.987565', '2026-01-30 21:06:23.354743');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: quotes; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (5, '2026-01-0005', 5, '2026-01-22', false, 8.25, 5850.00, 482.63, 6332.63, false, '', '2026-01-22 16:28:42.374654', '2026-01-22 18:02:30.878526');
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (1, '2026-01-0001', 2, '2026-01-22', false, 8.25, 1474.00, 121.61, 1595.61, false, '', '2026-01-22 01:34:06.558046', '2026-01-22 18:51:00.998206');
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (2, '2026-01-0002', 3, '2026-01-22', false, 8.25, 750.00, 61.88, 811.88, false, '', '2026-01-22 03:35:15.021729', '2026-01-22 18:54:57.288474');
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (4, '2026-01-0004', 4, '2026-01-22', false, 8.25, 2330.00, 192.23, 2522.23, false, '', '2026-01-22 03:45:56.686598', '2026-01-22 20:00:28.631846');
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (3, '2026-01-0003', 1, '2026-01-26', false, 8.25, 5847.00, 482.38, 6329.38, true, 'Total excludes labor charges which will be determined based on actual time required.', '2026-01-22 03:36:47.795674', '2026-01-26 18:41:05.501558');
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (7, '2026-01-0007', 6, '2026-01-30', false, 8.25, 1100.00, 90.75, 1190.75, false, '', '2026-01-30 21:01:43.538202', '2026-01-30 21:04:38.661279');
|
||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (6, '2026-01-0006', 6, '2026-01-30', false, 8.25, 1700.00, 140.25, 1840.25, false, '', '2026-01-30 20:58:23.014874', '2026-01-30 21:07:26.820637');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: quote_items; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (26, 5, '1', '<p>HPE ProLiant MicroServer Gen11 Ultra Micro Tower Server - 1 x Intel Xeon E-2414, 32 GB DDR5 RAM</p>', '1900', '1900.00', false, 0, '2026-01-22 18:02:30.878526');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (27, 5, '2', '<p>Western Digital 24TB WD Red Pro NAS Internal Hard Drive HDD</p>', '850', '1700.00', false, 1, '2026-01-22 18:02:30.878526');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (28, 5, '1', '<ul><li>Off-site installation and base configuration of the TrueNAS system</li><li>Creation of storage pools, datasets, and network shares as specified
|
||||
</li><li>Configuration of users, user groups, and access permissions
|
||||
Setup of automated snapshots with defined retention and rollback capability
|
||||
</li><li>Configuration of cloud backups to iDrive360
|
||||
Setup of system monitoring and email notifications for proactive issue detection
|
||||
</li><li>Installation and configuration of AOMEI Backup on selected desktops and laptops, storing backups on designated TrueNAS shares</li></ul>', '2250', '2250.00', false, 2, '2026-01-22 18:02:30.878526');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (44, 1, '1', '<ul><li>Dell OptiPlex 7010 SFF Desktop Intel Core i5-13600,14 Cores</li><li>16GB</li><li>Windows 11 Pro</li><li>Crucial - P310 2TB Internal SSD PCIe Gen 4 x4 NVMe M.2</li></ul>', '1079', '1079.00', false, 0, '2026-01-22 18:51:00.998206');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (45, 1, '1', '<p>DisplayPort to HDMI cable 10ft</p>', '20', '20.00', false, 1, '2026-01-22 18:51:00.998206');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (46, 1, '3', '<p>Setup and configure Dell OptiPlex 7010 off-site
|
||||
Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network
|
||||
Transferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites.
|
||||
Setup printing and scanning as required. Install customer requested software.Test all hardware for proper Operation</p>', '125', '375.00', false, 2, '2026-01-22 18:51:00.998206');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (47, 2, '1', '<p>Lenovo Yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD</p>', '500', '500.00', false, 0, '2026-01-22 18:54:57.288474');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (48, 2, '2', '<p>Setup and configure Lenovo yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD off-site. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p>', '125', '250.00', false, 1, '2026-01-22 18:54:57.288474');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (49, 4, '1', '<ul><li>Dell OptiPlex 7020 Plus Tower Desktop PC – Core i7-14700 </li><li>32GB DDR5 RAM, </li><li>2 TB SSD M.2 PCIe Gen4 TLC, </li><li>NVIDIA® GeForce RTX™ 5050</li></ul>', '2080', '2080.00', false, 0, '2026-01-22 20:00:28.631846');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (50, 4, '2', '<p>Setup and configure Lenovo Yoga as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transfer data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p>', '125', '250.00', false, 1, '2026-01-22 20:00:28.631846');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (59, 3, '3', '<ul><li><strong>Lenovo </strong>ThinkPad P16s Mobile Workstation</li><li><strong>Processor:</strong> Intel® Core™ Ultra 7 155H</li><li><strong>Graphics Card:</strong> NVIDIA RTX™ 500 Ada Generation Laptop GPU, <strong>4 GB GDDR6</strong></li><li><strong>Memory: 64 GB DDR5-5600 MT/s</strong></li><li><strong>Storage: 1 TB SSD</strong> M.2 2280 PCIe Gen4 </li><li><strong>Display: </strong>16" WQUXGA (3840 × 2400) OLED</li><li><strong>Operating System:</strong> Windows 11 Pro 64-bit</li><li><strong>1 Year Warranty</strong></li><li><strong>(This device is new, not refurbished)</strong></li></ul>', '1949', '5847.00', false, 0, '2026-01-26 18:41:05.501558');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (60, 3, '', '<p>Setup and configure Lenovo Laptops as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p>', '125', 'TBD', true, 1, '2026-01-26 18:41:05.501558');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (74, 7, '1', '<p>Dell Optiplex 7010 (or similar) Tower configured with Intel Core i5-13500 processor, 16GB RAM, 512GB solid state drive and Windows 11 Professional. Refurbished with a one year warranty. </p>', '725.00', '725.00', false, 0, '2026-01-30 21:04:38.661279');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (75, 7, '3', '<p>Delivery and installation of Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p>', '125', '375.00', false, 1, '2026-01-30 21:04:38.661279');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (76, 6, '1', '<ul><li>Dell Tower Computer configured with Intel Core Ultra 5 235 processor, 16GB RAM, 512GB SSD and Windows 11 Professional. New with One Year Warranty.</li></ul>', '1325.00', '1325.00', false, 0, '2026-01-30 21:07:26.820637');
|
||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (77, 6, '3', '<p>Delivery and installation of new Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p>', '125.00', '375.00', false, 1, '2026-01-30 21:07:26.820637');
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.customers_id_seq', 6, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.quote_items_id_seq', 77, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.quotes_id_seq', 7, true);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict KCbrUeHdJ7srnFlBFWbQWdZ6A6bdMlTKPXbmEoc5qE3gaNBouFxTyfvdD9oETV4
|
||||
|
||||
1116
public/app.js
1116
public/app.js
File diff suppressed because it is too large
Load Diff
54
public/css/styles.css
Normal file
54
public/css/styles.css
Normal file
@@ -0,0 +1,54 @@
|
||||
/* styles.css — Application styles extracted from index.html */
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Invoice/Quote Modal — visible field borders */
|
||||
#invoice-modal input,
|
||||
#invoice-modal select,
|
||||
#invoice-modal textarea,
|
||||
#quote-modal input,
|
||||
#quote-modal select,
|
||||
#quote-modal textarea {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
#invoice-modal input:focus,
|
||||
#invoice-modal select:focus,
|
||||
#invoice-modal textarea:focus,
|
||||
#quote-modal input:focus,
|
||||
#quote-modal select:focus,
|
||||
#quote-modal textarea:focus {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Rich Text Editor borders */
|
||||
#invoice-modal .ql-container,
|
||||
#invoice-modal .ql-toolbar,
|
||||
#quote-modal .ql-container,
|
||||
#quote-modal .ql-toolbar {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
.item-row input,
|
||||
.item-row select,
|
||||
.invoice-item input,
|
||||
.invoice-item select,
|
||||
#invoice-items input,
|
||||
#invoice-items select,
|
||||
#quote-items input,
|
||||
#quote-items select {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
#invoice-items > div,
|
||||
#quote-items > div,
|
||||
#invoice-items .border,
|
||||
#quote-items .border {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
BIN
public/favicon-192x192.png
Normal file
BIN
public/favicon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 933 B |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -7,20 +7,17 @@
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||
<script src="js/components/customer-search.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
.modal {
|
||||
display: none;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="min-h-screen">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-blue-900 text-white shadow-lg">
|
||||
<nav class="bg-blue-900 text-white shadow-lg sticky top-0 z-40">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -67,23 +64,26 @@
|
||||
|
||||
<!-- Invoices Tab -->
|
||||
<div id="invoices-tab" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
||||
<button onclick="openInvoiceModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
|
||||
+ New Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="invoice-toolbar"></div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Send Date</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
|
||||
@@ -94,21 +94,16 @@
|
||||
|
||||
<!-- Customers Tab -->
|
||||
<div id="customers-tab" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Customers</h2>
|
||||
<button onclick="openCustomerModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
|
||||
+ New Customer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="customer-toolbar"></div>
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customers-list" class="bg-white divide-y divide-gray-200">
|
||||
@@ -146,75 +141,85 @@
|
||||
</button>
|
||||
|
||||
<div id="upload-status" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Modal -->
|
||||
<div id="customer-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center">
|
||||
<div class="relative mx-auto p-8 border w-full max-w-2xl shadow-lg rounded-lg bg-white">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900" id="customer-modal-title">New Customer</h3>
|
||||
<button onclick="closeCustomerModal()" class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<hr class="my-8 border-gray-200">
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QBO Rechnungs-Import</h3>
|
||||
<p class="text-gray-600 mb-2">
|
||||
Importiert alle <strong>unbezahlten</strong> Rechnungen aus QuickBooks Online in dein lokales System.
|
||||
</p>
|
||||
<ul class="text-sm text-gray-500 mb-4 list-disc list-inside">
|
||||
<li>Bereits importierte Rechnungen werden übersprungen</li>
|
||||
<li>Nur Kunden die lokal mit QBO verknüpft sind</li>
|
||||
<li>Line Items (Labor/Parts) werden mit importiert</li>
|
||||
</ul>
|
||||
|
||||
<button onclick="importFromQBO()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md flex items-center">
|
||||
<span class="mr-2">📥</span> Unbezahlte Rechnungen importieren
|
||||
</button>
|
||||
|
||||
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
||||
|
||||
<hr class="my-8 border-gray-200">
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||
hier neu autorisieren. Du wirst zu Intuit weitergeleitet.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<a href="/auth/qbo"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md inline-flex items-center">
|
||||
🔑 Authorize QBO
|
||||
</a>
|
||||
<span id="qbo-status" class="text-sm text-gray-500">Checking...</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('/api/qbo/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const el = document.getElementById('qbo-status');
|
||||
if (data.connected) {
|
||||
el.innerHTML = '<span class="text-green-600">✅ Connected (Realm: ' + data.realmId + ')</span>';
|
||||
} else {
|
||||
el.innerHTML = '<span class="text-red-600">❌ Not connected — please authorize</span>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('qbo-status').innerHTML = '<span class="text-gray-400">Status unknown</span>';
|
||||
});
|
||||
</script>
|
||||
|
||||
<hr class="my-8 border-gray-200">
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
||||
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
||||
|
||||
<button onclick="checkQboOverdue()" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md flex items-center">
|
||||
<span id="qbo-btn-icon" class="mr-2">📡</span> Test Connection & Get Overdue Report
|
||||
</button>
|
||||
|
||||
<div id="qbo-result" class="mt-6 hidden">
|
||||
<h4 class="font-bold text-gray-700 mb-2">Results from QBO:</h4>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Inv #</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Customer</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Due Date</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="qbo-result-list" class="divide-y divide-gray-200 text-sm">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="customer-form" class="space-y-4">
|
||||
<input type="hidden" id="customer-id">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
|
||||
<input type="text" id="customer-name" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address</label>
|
||||
<input type="text" id="customer-street" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
|
||||
<input type="text" id="customer-city" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
|
||||
<input type="text" id="customer-state" required maxlength="2" placeholder="TX"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
|
||||
<input type="text" id="customer-zip" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Account Number</label>
|
||||
<input type="text" id="customer-account"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeCustomerModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Customer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -310,13 +315,9 @@
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeQuoteModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Quote
|
||||
</button>
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -335,12 +336,13 @@
|
||||
</div>
|
||||
|
||||
<form id="invoice-form" class="space-y-6">
|
||||
<div class="grid grid-cols-5 gap-4">
|
||||
<div class="grid grid-cols-6 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
|
||||
<input type="text" id="invoice-number" required pattern="[0-9]+"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
title="Must be a numeric value">
|
||||
<input type="text" id="invoice-number" pattern="[0-9]*"
|
||||
placeholder="Auto (QBO)"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
|
||||
title="Optional — wird beim QBO Export automatisch vergeben">
|
||||
</div>
|
||||
<div x-data="customerSearch('invoice')" class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||||
@@ -378,20 +380,45 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Bill To Name (optional)</label>
|
||||
<input type="text" id="invoice-bill-to-name" placeholder="Default: Company name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
||||
<input type="date" id="invoice-date" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Send Date</label>
|
||||
<input type="date" id="invoice-send-date"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
title="Wann soll die Rechnung versendet werden?">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
|
||||
<input type="text" id="invoice-terms" value="Net 30" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<input type="checkbox" id="invoice-tax-exempt"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
||||
<div class="flex items-center gap-6 pt-6 w-max">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="invoice-tax-exempt"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="invoice-recurring"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-recurring" class="text-sm text-gray-900">Recurring</label>
|
||||
<div id="invoice-recurring-group" style="display: none;">
|
||||
<select id="invoice-recurring-interval"
|
||||
class="px-2 py-1 border border-gray-300 rounded-md text-sm bg-white">
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -432,18 +459,15 @@
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeInvoiceModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Invoice
|
||||
</button>
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<!-- Single module entry point — all JS loaded from here -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
86
public/js/app.js
Normal file
86
public/js/app.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* app.js — Application Bootstrap
|
||||
*
|
||||
* This is the main entry point. All business logic has been moved to modules:
|
||||
* - js/views/quote-view.js → Quote list
|
||||
* - js/views/invoice-view.js → Invoice list (existing)
|
||||
* - js/views/settings-view.js → Logo, QBO import/test
|
||||
* - js/modals/quote-modal.js → Quote create/edit
|
||||
* - js/modals/invoice-modal.js → Invoice create/edit
|
||||
* - js/modals/payment-modal.js → Payment recording (existing)
|
||||
* - js/components/customer-search.js → Alpine dropdown
|
||||
* - js/utils/item-editor.js → Shared accordion item editor
|
||||
* - js/utils/helpers.js → formatDate, spinner
|
||||
* - js/utils/api.js → API wrapper (existing)
|
||||
*/
|
||||
|
||||
// --- Imports ---
|
||||
import { loadQuotes } from './views/quote-view.js';
|
||||
import { loadInvoices, injectToolbar as injectInvoiceToolbar, renderInvoiceView } from './views/invoice-view.js';
|
||||
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './views/customer-view.js';
|
||||
import { checkCurrentLogo, initSettingsView } from './views/settings-view.js';
|
||||
import { initQuoteModal } from './modals/quote-modal.js';
|
||||
import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js';
|
||||
import './modals/payment-modal.js';
|
||||
import './modals/email-modal.js';
|
||||
import { setDefaultDate } from './utils/helpers.js';
|
||||
|
||||
// ============================================================
|
||||
// Tab Management
|
||||
// ============================================================
|
||||
|
||||
function showTab(tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800'));
|
||||
|
||||
document.getElementById(`${tabName}-tab`).classList.remove('hidden');
|
||||
document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
|
||||
|
||||
localStorage.setItem('activeTab', tabName);
|
||||
|
||||
if (tabName === 'quotes') {
|
||||
loadQuotes();
|
||||
} else if (tabName === 'invoices') {
|
||||
injectInvoiceToolbar();
|
||||
loadInvoices();
|
||||
} else if (tabName === 'customers') {
|
||||
injectCustomerToolbar();
|
||||
renderCustomerView();
|
||||
} else if (tabName === 'settings') {
|
||||
checkCurrentLogo();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load shared data
|
||||
loadCustomers();
|
||||
loadLaborRate();
|
||||
setDefaultDate();
|
||||
|
||||
// Init modals (wire up form handlers)
|
||||
initQuoteModal();
|
||||
initInvoiceModal();
|
||||
initSettingsView();
|
||||
|
||||
// Restore saved tab (or default to quotes)
|
||||
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||
showTab(savedTab);
|
||||
|
||||
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
||||
if (window.location.hash) {
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
|
||||
showTab(hashTab);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Expose to HTML onclick handlers
|
||||
// ============================================================
|
||||
|
||||
window.showTab = showTab;
|
||||
67
public/js/components/customer-search.js
Normal file
67
public/js/components/customer-search.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* customer-search.js — Alpine.js Customer Search Component
|
||||
* Used in Quote and Invoice modals for customer dropdown
|
||||
*/
|
||||
|
||||
function customerSearch(type) {
|
||||
return {
|
||||
search: '',
|
||||
selectedId: '',
|
||||
selectedName: '',
|
||||
open: false,
|
||||
highlighted: 0,
|
||||
|
||||
get filteredCustomers() {
|
||||
const allCustomers = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
|
||||
if (!this.search) {
|
||||
return allCustomers;
|
||||
}
|
||||
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return allCustomers.filter(c =>
|
||||
(c.name || '').toLowerCase().includes(searchLower) ||
|
||||
(c.line1 || '').toLowerCase().includes(searchLower) ||
|
||||
(c.city || '').toLowerCase().includes(searchLower) ||
|
||||
(c.account_number && c.account_number.includes(searchLower))
|
||||
);
|
||||
},
|
||||
|
||||
selectCustomer(customer) {
|
||||
this.selectedId = customer.id;
|
||||
this.selectedName = customer.name;
|
||||
this.search = customer.name;
|
||||
this.open = false;
|
||||
this.highlighted = 0;
|
||||
},
|
||||
|
||||
highlightNext() {
|
||||
if (this.highlighted < this.filteredCustomers.length - 1) {
|
||||
this.highlighted++;
|
||||
}
|
||||
},
|
||||
|
||||
highlightPrev() {
|
||||
if (this.highlighted > 0) {
|
||||
this.highlighted--;
|
||||
}
|
||||
},
|
||||
|
||||
selectHighlighted() {
|
||||
if (this.filteredCustomers[this.highlighted]) {
|
||||
this.selectCustomer(this.filteredCustomers[this.highlighted]);
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.search = '';
|
||||
this.selectedId = '';
|
||||
this.selectedName = '';
|
||||
this.open = false;
|
||||
this.highlighted = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Make globally available for Alpine x-data
|
||||
window.customerSearch = customerSearch;
|
||||
296
public/js/modals/email-modal.js
Normal file
296
public/js/modals/email-modal.js
Normal file
@@ -0,0 +1,296 @@
|
||||
// email-modal.js — ES Module
|
||||
// Modal to review and send invoice emails via AWS SES
|
||||
// With Stripe Payment Link integration
|
||||
|
||||
import { showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentInvoice = null;
|
||||
let quillInstance = null;
|
||||
|
||||
// ============================================================
|
||||
// DOM & Render
|
||||
// ============================================================
|
||||
|
||||
function ensureModalElement() {
|
||||
let modal = document.getElementById('email-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'email-modal';
|
||||
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto hidden';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalContent() {
|
||||
const modal = document.getElementById('email-modal');
|
||||
if (!modal) return;
|
||||
|
||||
const defaultEmail = currentInvoice.email || '';
|
||||
const existingStripeUrl = currentInvoice.stripe_payment_link_url || '';
|
||||
const stripeStatus = currentInvoice.stripe_payment_status || '';
|
||||
|
||||
// Status indicator for existing link
|
||||
let stripeBadgeHtml = '';
|
||||
if (existingStripeUrl && stripeStatus === 'paid') {
|
||||
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">Paid</span>';
|
||||
} else if (existingStripeUrl && stripeStatus === 'processing') {
|
||||
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Processing</span>';
|
||||
} else if (existingStripeUrl) {
|
||||
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">Active</span>';
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-3xl mx-auto p-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">📤 Send Invoice #${currentInvoice.invoice_number || currentInvoice.id}</h2>
|
||||
<button onclick="window.emailModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="email-send-form" class="space-y-5">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Recipient Email *</label>
|
||||
<input type="email" id="email-recipient" value="${defaultEmail}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stripe Payment Link${stripeBadgeHtml}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="url" id="email-stripe-link" value="${existingStripeUrl}" readonly
|
||||
placeholder="Click Generate to create link..."
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-600 focus:ring-purple-500 focus:border-purple-500">
|
||||
<button type="button" id="stripe-generate-btn" onclick="window.emailModal.generateStripeLink()"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm font-semibold whitespace-nowrap">
|
||||
${existingStripeUrl ? '♻️ Regenerate' : '💳 Generate'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1" id="stripe-link-info">
|
||||
${existingStripeUrl
|
||||
? 'Link exists. Regenerate will create a new link for the current balance.'
|
||||
: 'Generates a Stripe Payment Link for Card and ACH payments.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Message Body</label>
|
||||
<div id="email-message-editor" class="border border-gray-300 rounded-md bg-white h-48 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 p-4 rounded-md flex items-center gap-3">
|
||||
<span class="text-2xl">📎</span>
|
||||
<div class="text-sm text-blue-800">
|
||||
<strong>Invoice_${currentInvoice.invoice_number || currentInvoice.id}.pdf</strong> will be generated and attached automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="window.emailModal.close()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit" id="email-submit-btn"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-semibold">
|
||||
Send via AWS SES
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
// Initialize Quill
|
||||
const editorDiv = document.getElementById('email-message-editor');
|
||||
quillInstance = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Variablen für den Text aufbereiten
|
||||
const invoiceNum = currentInvoice.invoice_number || currentInvoice.id;
|
||||
const totalDue = parseFloat(currentInvoice.balance ?? currentInvoice.total).toFixed(2);
|
||||
|
||||
// Datum formatieren
|
||||
let dueDateStr = 'Upon Receipt';
|
||||
if (currentInvoice.due_date) {
|
||||
const d = new Date(currentInvoice.due_date);
|
||||
dueDateStr = d.toLocaleDateString('en-US', { timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
// Dynamischer Text für die Fälligkeit
|
||||
let paymentText = '';
|
||||
if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) {
|
||||
paymentText = 'Our terms are Net 30.';
|
||||
} else if (dueDateStr !== 'Upon Receipt') {
|
||||
paymentText = `payable by <strong>${dueDateStr}</strong>.`;
|
||||
} else {
|
||||
paymentText = 'Our terms are Net 30.';
|
||||
}
|
||||
|
||||
const defaultHtml = `
|
||||
<p>Good afternoon,</p>
|
||||
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>. ${paymentText}</p>
|
||||
<p>Please pay at your earliest convenience. We appreciate your continued business.</p>
|
||||
<p>If you have any questions about the invoice, feel free to reply to this email.</p>
|
||||
<p>Best regards,</p>
|
||||
<p><strong>Claudia Knuth</strong></p>
|
||||
<p>Bay Area Affiliates, Inc.</p>
|
||||
<p>accounting@bayarea-cc.com</p>
|
||||
`;
|
||||
quillInstance.root.innerHTML = defaultHtml;
|
||||
|
||||
// Bind Submit Handler
|
||||
document.getElementById('email-send-form').addEventListener('submit', submitEmail);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stripe Payment Link Generation
|
||||
// ============================================================
|
||||
|
||||
async function generateStripeLink() {
|
||||
const btn = document.getElementById('stripe-generate-btn');
|
||||
const input = document.getElementById('email-stripe-link');
|
||||
const info = document.getElementById('stripe-link-info');
|
||||
|
||||
const originalBtnText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳ Creating...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${currentInvoice.id}/create-payment-link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
input.value = result.paymentLinkUrl;
|
||||
currentInvoice.stripe_payment_link_url = result.paymentLinkUrl;
|
||||
currentInvoice.stripe_payment_link_id = result.paymentLinkId;
|
||||
currentInvoice.stripe_payment_status = 'pending';
|
||||
|
||||
btn.innerHTML = '♻️ Regenerate';
|
||||
info.innerHTML = `✅ Payment link created for <strong>$${result.amount.toFixed(2)}</strong>. Will be included in the email.`;
|
||||
info.classList.remove('text-gray-400');
|
||||
info.classList.add('text-green-600');
|
||||
} else {
|
||||
info.textContent = `❌ ${result.error}`;
|
||||
info.classList.remove('text-gray-400');
|
||||
info.classList.add('text-red-500');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Stripe link generation error:', e);
|
||||
info.textContent = '❌ Network error creating payment link.';
|
||||
info.classList.remove('text-gray-400');
|
||||
info.classList.add('text-red-500');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (btn.innerHTML === '⏳ Creating...') {
|
||||
btn.innerHTML = originalBtnText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Logic & API
|
||||
// ============================================================
|
||||
|
||||
export async function openEmailModal(invoiceId) {
|
||||
ensureModalElement();
|
||||
|
||||
if (typeof showSpinner === 'function') showSpinner('Loading invoice data...');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.invoice) throw new Error('Invoice not found');
|
||||
currentInvoice = data.invoice;
|
||||
|
||||
renderModalContent();
|
||||
|
||||
document.getElementById('email-modal').classList.remove('hidden');
|
||||
document.getElementById('email-modal').classList.add('flex');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice for email:', e);
|
||||
alert('Could not load invoice details.');
|
||||
} finally {
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeEmailModal() {
|
||||
const modal = document.getElementById('email-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
currentInvoice = null;
|
||||
quillInstance = null;
|
||||
}
|
||||
|
||||
async function submitEmail(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const recipientEmail = document.getElementById('email-recipient').value.trim();
|
||||
const customText = quillInstance.root.innerHTML;
|
||||
|
||||
if (!recipientEmail) {
|
||||
alert('Please enter a recipient email.');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('email-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Sending...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
if (typeof showSpinner === 'function') showSpinner('Generating PDF and sending email...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${currentInvoice.id}/send-email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipientEmail,
|
||||
customText
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Invoice sent successfully!');
|
||||
closeEmailModal();
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Send email error:', e);
|
||||
alert('Network error while sending email.');
|
||||
} finally {
|
||||
submitBtn.innerHTML = 'Send via AWS SES';
|
||||
submitBtn.disabled = false;
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
window.emailModal = {
|
||||
open: openEmailModal,
|
||||
close: closeEmailModal,
|
||||
generateStripeLink
|
||||
};
|
||||
233
public/js/modals/invoice-modal.js
Normal file
233
public/js/modals/invoice-modal.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* invoice-modal.js — Invoice create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*
|
||||
* Features:
|
||||
* - Auto-sets tax-exempt based on customer's taxable flag
|
||||
* - Recurring invoice support (monthly/yearly)
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentInvoiceId = null;
|
||||
let qboLaborRate = null;
|
||||
|
||||
export async function loadLaborRate() {
|
||||
try {
|
||||
const response = await fetch('/api/qbo/labor-rate');
|
||||
const data = await response.json();
|
||||
if (data.rate) {
|
||||
qboLaborRate = data.rate;
|
||||
console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Labor Rate konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getLaborRate() { return qboLaborRate; }
|
||||
|
||||
/**
|
||||
* Auto-set tax exempt based on customer's taxable flag
|
||||
*/
|
||||
function applyCustomerTaxStatus(customerId) {
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||||
if (customer) {
|
||||
const cb = document.getElementById('invoice-tax-exempt');
|
||||
if (cb) {
|
||||
cb.checked = (customer.taxable === false);
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function openInvoiceModal(invoiceId = null) {
|
||||
currentInvoiceId = invoiceId;
|
||||
if (invoiceId) {
|
||||
await loadInvoiceForEdit(invoiceId);
|
||||
} else {
|
||||
prepareNewInvoice();
|
||||
}
|
||||
document.getElementById('invoice-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeInvoiceModal() {
|
||||
document.getElementById('invoice-modal').classList.remove('active');
|
||||
currentInvoiceId = null;
|
||||
}
|
||||
|
||||
async function loadInvoiceForEdit(invoiceId) {
|
||||
document.getElementById('invoice-modal-title').textContent = 'Edit Invoice';
|
||||
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === data.invoice.customer_id);
|
||||
if (customer) {
|
||||
const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]');
|
||||
if (customerInput) {
|
||||
customerInput.value = customer.name;
|
||||
customerInput.dispatchEvent(new Event('input'));
|
||||
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||
if (alpineData) {
|
||||
alpineData.search = customer.name;
|
||||
alpineData.selectedId = customer.id;
|
||||
alpineData.selectedName = customer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
|
||||
document.getElementById('invoice-customer').value = data.invoice.customer_id;
|
||||
document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0];
|
||||
document.getElementById('invoice-terms').value = data.invoice.terms;
|
||||
document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
|
||||
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
|
||||
document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || '';
|
||||
|
||||
const sendDateEl = document.getElementById('invoice-send-date');
|
||||
if (sendDateEl) {
|
||||
sendDateEl.value = data.invoice.scheduled_send_date
|
||||
? data.invoice.scheduled_send_date.split('T')[0] : '';
|
||||
}
|
||||
|
||||
// Recurring fields
|
||||
const recurringCb = document.getElementById('invoice-recurring');
|
||||
const recurringInterval = document.getElementById('invoice-recurring-interval');
|
||||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||
if (recurringCb) {
|
||||
recurringCb.checked = data.invoice.is_recurring || false;
|
||||
if (recurringInterval) recurringInterval.value = data.invoice.recurring_interval || 'monthly';
|
||||
if (recurringGroup) recurringGroup.style.display = data.invoice.is_recurring ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Load items
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||
});
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
|
||||
function prepareNewInvoice() {
|
||||
document.getElementById('invoice-modal-title').textContent = 'New Invoice';
|
||||
document.getElementById('invoice-form').reset();
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
document.getElementById('invoice-terms').value = 'Net 30';
|
||||
document.getElementById('invoice-number').value = '';
|
||||
document.getElementById('invoice-send-date').value = '';
|
||||
|
||||
// Reset recurring
|
||||
const recurringCb = document.getElementById('invoice-recurring');
|
||||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||
if (recurringCb) recurringCb.checked = false;
|
||||
if (recurringGroup) recurringGroup.style.display = 'none';
|
||||
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
addItem('invoice-items', { type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||
}
|
||||
|
||||
export function addInvoiceItem(item = null) {
|
||||
addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||
}
|
||||
|
||||
export function updateInvoiceTotals() {
|
||||
const items = getItems('invoice-items');
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt').checked;
|
||||
let subtotal = 0;
|
||||
items.forEach(item => {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
|
||||
subtotal += amount;
|
||||
});
|
||||
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`;
|
||||
document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
|
||||
document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`;
|
||||
document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
}
|
||||
|
||||
export async function handleInvoiceSubmit(e) {
|
||||
e.preventDefault();
|
||||
const isRecurring = document.getElementById('invoice-recurring')?.checked || false;
|
||||
const recurringInterval = isRecurring
|
||||
? (document.getElementById('invoice-recurring-interval')?.value || 'monthly') : null;
|
||||
|
||||
const data = {
|
||||
invoice_number: document.getElementById('invoice-number').value || null,
|
||||
customer_id: document.getElementById('invoice-customer').value,
|
||||
invoice_date: document.getElementById('invoice-date').value,
|
||||
terms: document.getElementById('invoice-terms').value,
|
||||
auth_code: document.getElementById('invoice-authorization').value,
|
||||
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
|
||||
scheduled_send_date: document.getElementById('invoice-send-date')?.value || null,
|
||||
bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null,
|
||||
is_recurring: isRecurring,
|
||||
recurring_interval: recurringInterval,
|
||||
items: getItems('invoice-items')
|
||||
};
|
||||
|
||||
if (!data.customer_id) { alert('Please select a customer.'); return; }
|
||||
if (!data.items || data.items.length === 0) { alert('Please add at least one item.'); return; }
|
||||
|
||||
const invoiceId = currentInvoiceId;
|
||||
const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
|
||||
const method = invoiceId ? 'PUT' : 'POST';
|
||||
showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
closeInvoiceModal();
|
||||
if (result.qbo_doc_number) console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
|
||||
else if (result.qbo_synced) console.log('✅ Invoice saved & synced to QBO');
|
||||
else console.log('✅ Invoice saved locally (QBO sync pending)');
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving invoice');
|
||||
} finally {
|
||||
hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
export function initInvoiceModal() {
|
||||
const form = document.getElementById('invoice-form');
|
||||
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||||
|
||||
// Recurring toggle
|
||||
const recurringCb = document.getElementById('invoice-recurring');
|
||||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||
if (recurringCb && recurringGroup) {
|
||||
recurringCb.addEventListener('change', () => {
|
||||
recurringGroup.style.display = recurringCb.checked ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Watch for customer selection → auto-set tax exempt (only for new invoices)
|
||||
const customerHidden = document.getElementById('invoice-customer');
|
||||
if (customerHidden) {
|
||||
const observer = new MutationObserver(() => {
|
||||
// Only auto-apply when creating new (not editing existing)
|
||||
if (!currentInvoiceId && customerHidden.value) {
|
||||
applyCustomerTaxStatus(customerHidden.value);
|
||||
}
|
||||
});
|
||||
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||||
}
|
||||
}
|
||||
|
||||
window.openInvoiceModal = openInvoiceModal;
|
||||
window.closeInvoiceModal = closeInvoiceModal;
|
||||
window.addInvoiceItem = addInvoiceItem;
|
||||
364
public/js/modals/payment-modal.js
Normal file
364
public/js/modals/payment-modal.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// payment-modal.js — ES Module v3 (clean)
|
||||
// Invoice payments: multi-invoice, partial, overpay
|
||||
// No downpayment functionality
|
||||
|
||||
let bankAccounts = [];
|
||||
let paymentMethods = [];
|
||||
let selectedInvoices = []; // { invoice, payAmount }
|
||||
let dataLoaded = false;
|
||||
|
||||
// ============================================================
|
||||
// Load QBO Data
|
||||
// ============================================================
|
||||
|
||||
async function loadQboData() {
|
||||
if (dataLoaded) return;
|
||||
try {
|
||||
const [accRes, pmRes] = await Promise.all([
|
||||
fetch('/api/qbo/accounts'),
|
||||
fetch('/api/qbo/payment-methods')
|
||||
]);
|
||||
if (accRes.ok) bankAccounts = await accRes.json();
|
||||
if (pmRes.ok) paymentMethods = await pmRes.json();
|
||||
dataLoaded = true;
|
||||
} catch (e) { console.error('Error loading QBO data:', e); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Open / Close
|
||||
// ============================================================
|
||||
|
||||
export async function openPaymentModal(invoiceIds = []) {
|
||||
await loadQboData();
|
||||
selectedInvoices = [];
|
||||
|
||||
for (const id of invoiceIds) {
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${id}`);
|
||||
const data = await res.json();
|
||||
if (data.invoice) {
|
||||
const total = parseFloat(data.invoice.total);
|
||||
const amountPaid = parseFloat(data.invoice.amount_paid) || 0;
|
||||
const balance = total - amountPaid;
|
||||
selectedInvoices.push({
|
||||
invoice: data.invoice,
|
||||
payAmount: balance > 0 ? balance : total
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||||
}
|
||||
|
||||
ensureModalElement();
|
||||
renderModalContent();
|
||||
document.getElementById('payment-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closePaymentModal() {
|
||||
const modal = document.getElementById('payment-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
selectedInvoices = [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Add / Remove Invoices
|
||||
// ============================================================
|
||||
|
||||
async function addInvoiceById() {
|
||||
const input = document.getElementById('payment-add-invoice-id');
|
||||
const searchVal = input.value.trim();
|
||||
if (!searchVal) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/invoices');
|
||||
const allInvoices = await res.json();
|
||||
const match = allInvoices.find(inv =>
|
||||
String(inv.id) === searchVal || String(inv.invoice_number) === searchVal
|
||||
);
|
||||
|
||||
if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; }
|
||||
if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; }
|
||||
if (match.paid_date) { alert('This invoice is already paid.'); return; }
|
||||
if (selectedInvoices.find(si => si.invoice.id === match.id)) { alert('Invoice already in list.'); return; }
|
||||
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||||
alert('All invoices must belong to the same customer.'); return;
|
||||
}
|
||||
|
||||
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
||||
const detailData = await detailRes.json();
|
||||
const detailInv = detailData.invoice;
|
||||
const detailTotal = parseFloat(detailInv.total);
|
||||
const detailPaid = parseFloat(detailInv.amount_paid) || 0;
|
||||
const detailBalance = detailTotal - detailPaid;
|
||||
selectedInvoices.push({
|
||||
invoice: detailInv,
|
||||
payAmount: detailBalance > 0 ? detailBalance : detailTotal
|
||||
});
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
input.value = '';
|
||||
} catch (e) {
|
||||
console.error('Error adding invoice:', e);
|
||||
alert('Error searching for invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
function removeInvoice(invoiceId) {
|
||||
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function updatePayAmount(invoiceId, newAmount) {
|
||||
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
||||
if (si) {
|
||||
si.payAmount = Math.max(0, parseFloat(newAmount) || 0);
|
||||
}
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM
|
||||
// ============================================================
|
||||
|
||||
function ensureModalElement() {
|
||||
let modal = document.getElementById('payment-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'payment-modal';
|
||||
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalContent() {
|
||||
const modal = document.getElementById('payment-modal');
|
||||
if (!modal) return;
|
||||
|
||||
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
||||
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
|
||||
const methods = filtered.length > 0 ? filtered : paymentMethods;
|
||||
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
||||
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Invoice List -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
||||
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input type="text" id="payment-add-invoice-id" placeholder="Add by Invoice # or ID..."
|
||||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
|
||||
<button onclick="window.paymentModal.addById()"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
|
||||
<input type="date" id="payment-date" value="${today}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Reference # (Check / ACH)</label>
|
||||
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
|
||||
<select id="payment-method"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
${methodOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
|
||||
<select id="payment-deposit-to"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
${accountOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
|
||||
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||
</div>
|
||||
<div id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="window.paymentModal.close()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
|
||||
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
|
||||
💰 Record Payment in QBO
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function renderInvoiceList() {
|
||||
const container = document.getElementById('payment-invoice-list');
|
||||
if (!container) return;
|
||||
|
||||
if (selectedInvoices.length === 0) {
|
||||
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">No invoices selected — add below</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = selectedInvoices.map(si => {
|
||||
const inv = si.invoice;
|
||||
const total = parseFloat(inv.total);
|
||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||
const balance = total - amountPaid;
|
||||
const isPartial = si.payAmount < balance;
|
||||
const isOver = si.payAmount > balance;
|
||||
|
||||
const paidInfo = amountPaid > 0
|
||||
? `<span class="text-green-600 text-xs ml-1">Paid: $${amountPaid.toFixed(2)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
|
||||
<span class="text-gray-500 text-sm ml-2 truncate">${inv.customer_name || ''}</span>
|
||||
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
|
||||
${paidInfo}
|
||||
${isPartial ? '<span class="text-xs text-yellow-600 ml-1 font-semibold">Partial</span>' : ''}
|
||||
${isOver ? '<span class="text-xs text-blue-600 ml-1 font-semibold">Overpay</span>' : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="text-gray-500 text-sm">$</span>
|
||||
<input type="number" step="0.01" min="0.01"
|
||||
value="${si.payAmount.toFixed(2)}"
|
||||
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
|
||||
class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold
|
||||
${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
|
||||
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
const totalEl = document.getElementById('payment-total');
|
||||
const noteEl = document.getElementById('payment-overpay-note');
|
||||
if (!totalEl) return;
|
||||
|
||||
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||
totalEl.textContent = `$${payTotal.toFixed(2)}`;
|
||||
|
||||
if (noteEl) {
|
||||
if (payTotal > invTotal && invTotal > 0) {
|
||||
noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
|
||||
noteEl.classList.remove('hidden');
|
||||
} else {
|
||||
noteEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Submit
|
||||
// ============================================================
|
||||
|
||||
async function submitPayment() {
|
||||
if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; }
|
||||
|
||||
const paymentDate = document.getElementById('payment-date').value;
|
||||
const reference = document.getElementById('payment-reference').value;
|
||||
const methodSelect = document.getElementById('payment-method');
|
||||
const depositSelect = document.getElementById('payment-deposit-to');
|
||||
|
||||
if (!paymentDate || !methodSelect.value || !depositSelect.value) {
|
||||
alert('Please fill in all fields.'); return;
|
||||
}
|
||||
|
||||
const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||
const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
|
||||
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
||||
const hasOverpay = total > invTotal;
|
||||
|
||||
let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`;
|
||||
if (hasPartial) msg += '\n⚠️ Contains partial payment(s).';
|
||||
if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
const submitBtn = document.getElementById('payment-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Processing...';
|
||||
submitBtn.disabled = true;
|
||||
if (typeof showSpinner === 'function') showSpinner('Recording payment in QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/record-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
invoice_payments: selectedInvoices.map(si => ({
|
||||
invoice_id: si.invoice.id,
|
||||
amount: si.payAmount
|
||||
})),
|
||||
payment_date: paymentDate,
|
||||
reference_number: reference,
|
||||
payment_method_id: methodSelect.value,
|
||||
payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '',
|
||||
deposit_to_account_id: depositSelect.value,
|
||||
deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || ''
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
alert(`✅ ${result.message}`);
|
||||
closePaymentModal();
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Payment error:', e);
|
||||
alert('Network error.');
|
||||
} finally {
|
||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||
submitBtn.disabled = false;
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
|
||||
window.paymentModal = {
|
||||
open: openPaymentModal,
|
||||
close: closePaymentModal,
|
||||
submit: submitPayment,
|
||||
addById: addInvoiceById,
|
||||
removeInvoice: removeInvoice,
|
||||
updateAmount: updatePayAmount,
|
||||
updateTotal: updateTotal
|
||||
};
|
||||
180
public/js/modals/quote-modal.js
Normal file
180
public/js/modals/quote-modal.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* quote-modal.js — Quote create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentQuoteId = null;
|
||||
|
||||
/**
|
||||
* Auto-set tax exempt based on customer's taxable flag
|
||||
*/
|
||||
function applyCustomerTaxStatus(customerId) {
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||||
if (customer) {
|
||||
const cb = document.getElementById('quote-tax-exempt');
|
||||
if (cb) {
|
||||
cb.checked = (customer.taxable === false);
|
||||
updateQuoteTotals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openQuoteModal(quoteId = null) {
|
||||
currentQuoteId = quoteId;
|
||||
|
||||
if (quoteId) {
|
||||
loadQuoteForEdit(quoteId);
|
||||
} else {
|
||||
prepareNewQuote();
|
||||
}
|
||||
|
||||
document.getElementById('quote-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeQuoteModal() {
|
||||
document.getElementById('quote-modal').classList.remove('active');
|
||||
currentQuoteId = null;
|
||||
}
|
||||
|
||||
async function loadQuoteForEdit(quoteId) {
|
||||
document.getElementById('quote-modal-title').textContent = 'Edit Quote';
|
||||
|
||||
const response = await fetch(`/api/quotes/${quoteId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === data.quote.customer_id);
|
||||
if (customer) {
|
||||
const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]');
|
||||
if (customerInput) {
|
||||
customerInput.value = customer.name;
|
||||
customerInput.dispatchEvent(new Event('input'));
|
||||
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||
if (alpineData) {
|
||||
alpineData.search = customer.name;
|
||||
alpineData.selectedId = customer.id;
|
||||
alpineData.selectedName = customer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('quote-customer').value = data.quote.customer_id;
|
||||
document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0];
|
||||
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||
|
||||
// Load items using shared editor
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
});
|
||||
|
||||
updateQuoteTotals();
|
||||
}
|
||||
|
||||
function prepareNewQuote() {
|
||||
document.getElementById('quote-modal-title').textContent = 'New Quote';
|
||||
document.getElementById('quote-form').reset();
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function addQuoteItem(item = null) {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function updateQuoteTotals() {
|
||||
const items = getItems('quote-items');
|
||||
const taxExempt = document.getElementById('quote-tax-exempt').checked;
|
||||
|
||||
let subtotal = 0;
|
||||
let hasTbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
hasTbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`;
|
||||
document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
|
||||
document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`;
|
||||
document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
}
|
||||
|
||||
export async function handleQuoteSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const items = getItems('quote-items');
|
||||
if (items.length === 0) {
|
||||
alert('Please add at least one item');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
customer_id: parseInt(document.getElementById('quote-customer').value),
|
||||
quote_date: document.getElementById('quote-date').value,
|
||||
tax_exempt: document.getElementById('quote-tax-exempt').checked,
|
||||
items: items
|
||||
};
|
||||
|
||||
try {
|
||||
const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes';
|
||||
const method = currentQuoteId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeQuoteModal();
|
||||
if (window.quoteView) window.quoteView.loadQuotes();
|
||||
} else {
|
||||
alert('Error saving quote');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving quote');
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up form submit and tax-exempt checkbox
|
||||
export function initQuoteModal() {
|
||||
const form = document.getElementById('quote-form');
|
||||
if (form) form.addEventListener('submit', handleQuoteSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('quote-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals);
|
||||
|
||||
// Watch for customer selection → auto-set tax exempt (only for new quotes)
|
||||
const customerHidden = document.getElementById('quote-customer');
|
||||
if (customerHidden) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!currentQuoteId && customerHidden.value) {
|
||||
applyCustomerTaxStatus(customerHidden.value);
|
||||
}
|
||||
});
|
||||
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.openQuoteModal = openQuoteModal;
|
||||
window.closeQuoteModal = closeQuoteModal;
|
||||
window.addQuoteItem = addQuoteItem;
|
||||
129
public/js/utils/api.js
Normal file
129
public/js/utils/api.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* API Utility
|
||||
* Centralized API calls for the frontend
|
||||
*/
|
||||
|
||||
const API = {
|
||||
// Customer API
|
||||
customers: {
|
||||
getAll: () => fetch('/api/customers').then(r => r.json()),
|
||||
get: (id) => fetch(`/api/customers/${id}`).then(r => r.json()),
|
||||
create: (data) => fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
update: (id, data) => fetch(`/api/customers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
delete: (id) => fetch(`/api/customers/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||
exportToQbo: (id) => fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' }).then(r => r.json())
|
||||
},
|
||||
|
||||
// Quote API
|
||||
quotes: {
|
||||
getAll: () => fetch('/api/quotes').then(r => r.json()),
|
||||
get: (id) => fetch(`/api/quotes/${id}`).then(r => r.json()),
|
||||
create: (data) => fetch('/api/quotes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
update: (id, data) => fetch(`/api/quotes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
delete: (id) => fetch(`/api/quotes/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||
convertToInvoice: (id) => fetch(`/api/quotes/${id}/convert-to-invoice`, { method: 'POST' }).then(r => r.json()),
|
||||
getPdf: (id) => window.open(`/api/quotes/${id}/pdf`, '_blank'),
|
||||
getHtml: (id) => window.open(`/api/quotes/${id}/html`, '_blank')
|
||||
},
|
||||
|
||||
// Invoice API
|
||||
invoices: {
|
||||
getAll: () => fetch('/api/invoices').then(r => r.json()),
|
||||
get: (id) => fetch(`/api/invoices/${id}`).then(r => r.json()),
|
||||
getNextNumber: () => fetch('/api/invoices/next-number').then(r => r.json()),
|
||||
create: (data) => fetch('/api/invoices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
update: (id, data) => fetch(`/api/invoices/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
delete: (id) => fetch(`/api/invoices/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||
exportToQbo: (id) => fetch(`/api/invoices/${id}/export`, { method: 'POST' }).then(r => r.json()),
|
||||
updateQbo: (id) => fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' }).then(r => r.json()),
|
||||
markPaid: (id, paidDate) => fetch(`/api/invoices/${id}/mark-paid`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paid_date: paidDate })
|
||||
}).then(r => r.json()),
|
||||
markUnpaid: (id) => fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }).then(r => r.json()),
|
||||
resetQbo: (id) => fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }).then(r => r.json()),
|
||||
setEmailStatus: (id, status) => fetch(`/api/invoices/${id}/email-status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
}).then(r => r.json()),
|
||||
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
|
||||
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank')
|
||||
},
|
||||
|
||||
// Payment API
|
||||
payments: {
|
||||
getAll: () => fetch('/api/payments').then(r => r.json()),
|
||||
record: (data) => fetch('/api/qbo/record-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json())
|
||||
},
|
||||
|
||||
// NEU: Stripe API
|
||||
stripe: {
|
||||
createPaymentLink: (invoiceId) => fetch(`/api/invoices/${invoiceId}/create-payment-link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(r => r.json()),
|
||||
checkPayment: (invoiceId) => fetch(`/api/invoices/${invoiceId}/check-payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(r => r.json())
|
||||
},
|
||||
|
||||
// QBO API
|
||||
qbo: {
|
||||
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
||||
getAccounts: () => fetch('/api/qbo/accounts').then(r => r.json()),
|
||||
getPaymentMethods: () => fetch('/api/qbo/payment-methods').then(r => r.json()),
|
||||
getLaborRate: () => fetch('/api/qbo/labor-rate').then(r => r.json()),
|
||||
getLastSync: () => fetch('/api/qbo/last-sync').then(r => r.json()),
|
||||
getOverdue: () => fetch('/api/qbo/overdue').then(r => r.json()),
|
||||
importUnpaid: () => fetch('/api/qbo/import-unpaid', { method: 'POST' }).then(r => r.json()),
|
||||
syncPayments: () => fetch('/api/qbo/sync-payments', { method: 'POST' }).then(r => r.json()),
|
||||
auth: () => window.location.href = '/auth/qbo'
|
||||
},
|
||||
|
||||
// Settings API
|
||||
settings: {
|
||||
getLogo: () => fetch('/api/logo-info').then(r => r.json()),
|
||||
uploadLogo: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
return fetch('/api/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(r => r.json());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Make globally available
|
||||
window.API = API;
|
||||
48
public/js/utils/helpers.js
Normal file
48
public/js/utils/helpers.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* helpers.js — Shared UI utility functions
|
||||
* Extracted from app.js
|
||||
*/
|
||||
|
||||
export function formatDate(date) {
|
||||
const d = new Date(date);
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
export function setDefaultDate() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const quoteDateEl = document.getElementById('quote-date');
|
||||
const invoiceDateEl = document.getElementById('invoice-date');
|
||||
if (quoteDateEl) quoteDateEl.value = today;
|
||||
if (invoiceDateEl) invoiceDateEl.value = today;
|
||||
}
|
||||
|
||||
export function showSpinner(message = 'Bitte warten...') {
|
||||
let overlay = document.getElementById('qbo-spinner');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'qbo-spinner';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-2xl px-8 py-6 flex items-center gap-4">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-lg font-medium text-gray-700" id="qbo-spinner-text">${message}</span>
|
||||
</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
export function hideSpinner() {
|
||||
const overlay = document.getElementById('qbo-spinner');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// Keep backward compat for onclick handlers and modules using typeof check
|
||||
window.showSpinner = showSpinner;
|
||||
window.hideSpinner = hideSpinner;
|
||||
293
public/js/utils/item-editor.js
Normal file
293
public/js/utils/item-editor.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* item-editor.js — Shared accordion item editor for Quotes and Invoices
|
||||
*
|
||||
* Replaces the duplicated addQuoteItem/addInvoiceItem logic (~300 lines → 1 function).
|
||||
*
|
||||
* Usage:
|
||||
* import { addItem, getItems, removeItem, moveItemUp, moveItemDown, updateTotals } from './item-editor.js';
|
||||
* addItem('quote-items', { item: existingItem, type: 'quote', laborRate: 125 });
|
||||
*/
|
||||
|
||||
let itemCounter = 0;
|
||||
|
||||
export function resetItemCounter() {
|
||||
itemCounter = 0;
|
||||
}
|
||||
|
||||
export function getItemCounter() {
|
||||
return itemCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item row to the specified container.
|
||||
*
|
||||
* @param {string} containerId - DOM id of the items container ('quote-items' or 'invoice-items')
|
||||
* @param {object} options
|
||||
* @param {object|null} options.item - Existing item data (null for new empty item)
|
||||
* @param {string} options.type - 'quote' or 'invoice'
|
||||
* @param {number|null} options.laborRate - QBO labor rate for auto-fill (invoice only)
|
||||
* @param {function} options.onUpdate - Callback after any change (for recalculating totals)
|
||||
*/
|
||||
export function addItem(containerId, { item = null, type = 'invoice', laborRate = null, onUpdate = () => {} } = {}) {
|
||||
const itemId = itemCounter++;
|
||||
const itemsDiv = document.getElementById(containerId);
|
||||
if (!itemsDiv) return;
|
||||
|
||||
const prefix = type; // 'quote' or 'invoice'
|
||||
const cssClass = `${prefix}-item-input`;
|
||||
const editorClass = `${prefix}-item-description-editor`;
|
||||
const amountClass = `${prefix}-item-amount`;
|
||||
|
||||
// Preview defaults
|
||||
const previewQty = item ? item.quantity : '';
|
||||
const previewAmount = item ? item.amount : '$0.00';
|
||||
let previewDesc = 'New item';
|
||||
if (item && item.description) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = item.description;
|
||||
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||
}
|
||||
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||
itemDiv.id = `${prefix}-item-${itemId}`;
|
||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||
|
||||
itemDiv.innerHTML = `
|
||||
<div class="flex items-center p-4">
|
||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||
<button type="button" onclick="window.itemEditor.moveUp('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||
<button type="button" onclick="window.itemEditor.moveDown('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||
</div>
|
||||
|
||||
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||
|
||||
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||
|
||||
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="window.itemEditor.remove('${prefix}', ${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||
<div class="grid grid-cols-12 gap-3 items-start">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="${cssClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="window.itemEditor.handleTypeChange(this, '${prefix}', ${itemId})">
|
||||
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||
<div data-item="${itemId}" data-field="description" class="${editorClass} border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||||
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="${cssClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||||
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="${amountClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemsDiv.appendChild(itemDiv);
|
||||
|
||||
// --- Quill Rich Text Editor ---
|
||||
const editorDiv = itemDiv.querySelector(`.${editorClass}`);
|
||||
const quill = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered' }, { 'list': 'bullet' }], ['clean']]
|
||||
}
|
||||
});
|
||||
if (item && item.description) quill.root.innerHTML = item.description;
|
||||
|
||||
quill.on('text-change', () => {
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
});
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// --- Auto-calculate Amount ---
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
|
||||
const calculateAmount = () => {
|
||||
if (qtyInput.value && rateInput.value) {
|
||||
// Quote supports TBD
|
||||
if (type === 'quote' && rateInput.value.toUpperCase() === 'TBD') {
|
||||
// Don't auto-calculate for TBD
|
||||
} else {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||
amountInput.value = (qty * rateValue).toFixed(2);
|
||||
}
|
||||
}
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', () => {
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
});
|
||||
|
||||
// Store metadata on the div for later retrieval
|
||||
itemDiv._itemEditor = { type, laborRate, onUpdate };
|
||||
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the collapsed preview bar of an item
|
||||
*/
|
||||
function updateItemPreview(itemDiv) {
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]');
|
||||
const editorDivs = itemDiv.querySelectorAll('[data-field="description"]');
|
||||
const editorDiv = editorDivs.length > 0 ? editorDivs[0] : null;
|
||||
|
||||
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||
const typePreview = itemDiv.querySelector('.item-type-preview');
|
||||
|
||||
if (qtyPreview && qtyInput) qtyPreview.textContent = qtyInput.value || '0';
|
||||
if (amountPreview && amountInput) amountPreview.textContent = amountInput.value || '$0.00';
|
||||
|
||||
if (typePreview && typeInput) {
|
||||
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||
}
|
||||
|
||||
if (descPreview && editorDiv && editorDiv.quillInstance) {
|
||||
const plainText = editorDiv.quillInstance.getText().trim();
|
||||
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||
descPreview.textContent = preview || 'New item';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle type change (Labor/Parts).
|
||||
* When Labor is selected and rate is empty, auto-fill with labor rate.
|
||||
*/
|
||||
export function handleTypeChange(selectEl, prefix, itemId) {
|
||||
const itemDiv = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!itemDiv) return;
|
||||
|
||||
const meta = itemDiv._itemEditor || {};
|
||||
const laborRate = meta.laborRate;
|
||||
const onUpdate = meta.onUpdate || (() => {});
|
||||
|
||||
// Auto-fill labor rate when switching to Labor and rate is empty
|
||||
if (selectEl.value === '5' && laborRate) {
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
|
||||
rateInput.value = laborRate;
|
||||
// Recalculate amount
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
if (qtyInput.value) {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
amountInput.value = (qty * laborRate).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items from a container as an array of objects.
|
||||
*/
|
||||
export function getItems(containerId) {
|
||||
const items = [];
|
||||
const itemDivs = document.querySelectorAll(`#${containerId} > div`);
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const descEditor = div.querySelector('[data-field="description"]');
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||
? descEditor.quillInstance.root.innerHTML
|
||||
: '';
|
||||
|
||||
items.push({
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||
description: descriptionHTML,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item by prefix and itemId
|
||||
*/
|
||||
export function removeItem(prefix, itemId) {
|
||||
const el = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!el) return;
|
||||
const meta = el._itemEditor || {};
|
||||
el.remove();
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item up
|
||||
*/
|
||||
export function moveItemUp(prefix, itemId) {
|
||||
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!item) return;
|
||||
const prevItem = item.previousElementSibling;
|
||||
if (prevItem) {
|
||||
item.parentNode.insertBefore(item, prevItem);
|
||||
const meta = item._itemEditor || {};
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item down
|
||||
*/
|
||||
export function moveItemDown(prefix, itemId) {
|
||||
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!item) return;
|
||||
const nextItem = item.nextElementSibling;
|
||||
if (nextItem) {
|
||||
item.parentNode.insertBefore(nextItem, item);
|
||||
const meta = item._itemEditor || {};
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose to window for onclick handlers in HTML
|
||||
// ============================================================
|
||||
|
||||
window.itemEditor = {
|
||||
moveUp: moveItemUp,
|
||||
moveDown: moveItemDown,
|
||||
remove: removeItem,
|
||||
handleTypeChange: handleTypeChange
|
||||
};
|
||||
405
public/js/views/customer-view.js
Normal file
405
public/js/views/customer-view.js
Normal file
@@ -0,0 +1,405 @@
|
||||
// customer-view.js — ES Module
|
||||
// Customer list with filtering, QBO status, email, modal with contact/remarks
|
||||
|
||||
let customers = [];
|
||||
let filterName = localStorage.getItem('cust_filterName') || '';
|
||||
let filterQbo = localStorage.getItem('cust_filterQbo') || 'all'; // all | qbo | local
|
||||
|
||||
// ============================================================
|
||||
// Data
|
||||
// ============================================================
|
||||
|
||||
export async function loadCustomers() {
|
||||
try {
|
||||
const response = await fetch('/api/customers');
|
||||
customers = await response.json();
|
||||
// Backward compat: quote/invoice modals use global 'customers' variable
|
||||
window.customers = customers;
|
||||
renderCustomerView();
|
||||
} catch (error) {
|
||||
console.error('Error loading customers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomers() { return customers; }
|
||||
|
||||
// ============================================================
|
||||
// Filter
|
||||
// ============================================================
|
||||
|
||||
function getFilteredCustomers() {
|
||||
let f = [...customers];
|
||||
if (filterName.trim()) {
|
||||
const s = filterName.toLowerCase();
|
||||
f = f.filter(c => (c.name || '').toLowerCase().includes(s) ||
|
||||
(c.contact || '').toLowerCase().includes(s) ||
|
||||
(c.email || '').toLowerCase().includes(s));
|
||||
}
|
||||
if (filterQbo === 'qbo') f = f.filter(c => c.qbo_id);
|
||||
else if (filterQbo === 'local') f = f.filter(c => !c.qbo_id);
|
||||
|
||||
f.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return f;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
localStorage.setItem('cust_filterName', filterName);
|
||||
localStorage.setItem('cust_filterQbo', filterQbo);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render
|
||||
// ============================================================
|
||||
|
||||
export function renderCustomerView() {
|
||||
const tbody = document.getElementById('customers-list');
|
||||
if (!tbody) return;
|
||||
|
||||
const filtered = getFilteredCustomers();
|
||||
|
||||
tbody.innerHTML = filtered.map(customer => {
|
||||
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
|
||||
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
||||
let fullAddress = lines.join(', ');
|
||||
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||
|
||||
// QBO Status
|
||||
const qboStatus = customer.qbo_id
|
||||
? `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`
|
||||
: `<button onclick="window.customerView.exportToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer" title="Export customer to QBO">QBO Export</button>`;
|
||||
|
||||
// Contact
|
||||
const contactDisplay = customer.contact
|
||||
? `<span class="text-xs text-gray-400 ml-1">(${customer.contact})</span>`
|
||||
: '';
|
||||
|
||||
// Email
|
||||
const emailDisplay = customer.email
|
||||
? `<a href="mailto:${customer.email}" class="text-blue-600 hover:text-blue-800 text-sm">${customer.email}</a>`
|
||||
: '<span class="text-gray-300 text-sm">—</span>';
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
${customer.name} ${qboStatus} ${contactDisplay}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">${fullAddress || '—'}</td>
|
||||
<td class="px-4 py-3 text-sm">${emailDisplay}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '—'}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onclick="window.customerView.edit(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||
<button onclick="window.customerView.remove(${customer.id})" class="text-red-600 hover:text-red-900">Del</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No customers found.</td></tr>`;
|
||||
}
|
||||
|
||||
const countEl = document.getElementById('customer-count');
|
||||
if (countEl) countEl.textContent = filtered.length;
|
||||
|
||||
updateFilterButtons();
|
||||
}
|
||||
|
||||
function updateFilterButtons() {
|
||||
document.querySelectorAll('[data-qbo-filter]').forEach(btn => {
|
||||
const s = btn.getAttribute('data-qbo-filter');
|
||||
btn.classList.toggle('bg-blue-600', s === filterQbo);
|
||||
btn.classList.toggle('text-white', s === filterQbo);
|
||||
btn.classList.toggle('bg-white', s !== filterQbo);
|
||||
btn.classList.toggle('text-gray-600', s !== filterQbo);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbar
|
||||
// ============================================================
|
||||
|
||||
export function injectToolbar() {
|
||||
const c = document.getElementById('customer-toolbar');
|
||||
if (!c) return;
|
||||
c.innerHTML = `
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Search:</label>
|
||||
<input type="text" id="customer-filter-name" placeholder="Name, contact, email..."
|
||||
value="${filterName}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-56 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="w-px h-8 bg-gray-300"></div>
|
||||
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||||
<button data-qbo-filter="all" onclick="window.customerView.setQboFilter('all')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">All</button>
|
||||
<button data-qbo-filter="qbo" onclick="window.customerView.setQboFilter('qbo')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">In QBO</button>
|
||||
<button data-qbo-filter="local" onclick="window.customerView.setQboFilter('local')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">Local Only</button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500">
|
||||
<span id="customer-count" class="font-semibold text-gray-700">0</span> customers
|
||||
</span>
|
||||
<button onclick="window.customerView.openModal()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">+ New Customer</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
updateFilterButtons();
|
||||
document.getElementById('customer-filter-name').addEventListener('input', (e) => {
|
||||
filterName = e.target.value; saveSettings(); renderCustomerView();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Modal
|
||||
// ============================================================
|
||||
|
||||
function ensureModalElement() {
|
||||
let modal = document.getElementById('customer-modal-v2');
|
||||
if (modal) return;
|
||||
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'customer-modal-v2';
|
||||
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
export function openModal(customerId = null) {
|
||||
ensureModalElement();
|
||||
const modal = document.getElementById('customer-modal-v2');
|
||||
const isEdit = !!customerId;
|
||||
const customer = isEdit ? customers.find(c => c.id === customerId) : null;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900">${isEdit ? 'Edit Customer' : 'New Customer'}</h3>
|
||||
<button onclick="window.customerView.closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="customer-form-v2" class="space-y-4">
|
||||
<input type="hidden" id="cf-id" value="${customer?.id || ''}">
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name *</label>
|
||||
<input type="text" id="cf-name" required value="${customer?.name || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Person</label>
|
||||
<input type="text" id="cf-contact" value="${customer?.contact || ''}" placeholder="First Last"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Billing Address</label>
|
||||
<input type="text" id="cf-line1" placeholder="Line 1 (Street / PO Box)" value="${customer?.line1 || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="text" id="cf-line2" placeholder="Line 2" value="${customer?.line2 || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<input type="text" id="cf-line3" placeholder="Line 3" value="${customer?.line3 || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="text" id="cf-line4" placeholder="Line 4" value="${customer?.line4 || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
|
||||
<input type="text" id="cf-city" value="${customer?.city || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
|
||||
<input type="text" id="cf-state" maxlength="2" placeholder="TX" value="${customer?.state || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
|
||||
<input type="text" id="cf-zip" value="${customer?.zip_code || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Account #</label>
|
||||
<input type="text" id="cf-account" value="${customer?.account_number || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-end pb-2">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="cf-taxable" ${customer?.taxable !== false ? 'checked' : ''}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="cf-taxable" class="ml-2 text-sm text-gray-700">Taxable</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" id="cf-email" value="${customer?.email || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||
<input type="tel" id="cf-phone" value="${customer?.phone || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
|
||||
<textarea id="cf-remarks" rows="3" placeholder="Internal notes about this customer..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">${customer?.remarks || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="window.customerView.closeModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-semibold">Save Customer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
modal.classList.add('active');
|
||||
|
||||
document.getElementById('customer-form-v2').addEventListener('submit', handleSubmit);
|
||||
}
|
||||
|
||||
export function closeModal() {
|
||||
const modal = document.getElementById('customer-modal-v2');
|
||||
if (modal) modal.classList.remove('active');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Submit
|
||||
// ============================================================
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('cf-name').value,
|
||||
contact: document.getElementById('cf-contact').value || null,
|
||||
line1: document.getElementById('cf-line1').value || null,
|
||||
line2: document.getElementById('cf-line2').value || null,
|
||||
line3: document.getElementById('cf-line3').value || null,
|
||||
line4: document.getElementById('cf-line4').value || null,
|
||||
city: document.getElementById('cf-city').value || null,
|
||||
state: (document.getElementById('cf-state').value || '').toUpperCase() || null,
|
||||
zip_code: document.getElementById('cf-zip').value || null,
|
||||
account_number: document.getElementById('cf-account').value || null,
|
||||
email: document.getElementById('cf-email').value || null,
|
||||
phone: document.getElementById('cf-phone').value || null,
|
||||
phone2: null,
|
||||
taxable: document.getElementById('cf-taxable').checked,
|
||||
remarks: document.getElementById('cf-remarks').value || null
|
||||
};
|
||||
|
||||
const customerId = document.getElementById('cf-id').value;
|
||||
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
|
||||
const method = customerId ? 'PUT' : 'POST';
|
||||
|
||||
if (typeof showSpinner === 'function') showSpinner(customerId ? 'Saving customer & syncing QBO...' : 'Creating customer...');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
await loadCustomers();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
alert(`Error: ${err.error || 'Failed to save customer'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving customer:', error);
|
||||
alert('Network error saving customer.');
|
||||
} finally {
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Actions
|
||||
// ============================================================
|
||||
|
||||
export function edit(id) { openModal(id); }
|
||||
|
||||
export async function remove(id) {
|
||||
const customer = customers.find(c => c.id === id);
|
||||
if (!customer) return;
|
||||
|
||||
let msg = `Delete customer "${customer.name}"?`;
|
||||
if (customer.qbo_id) msg += '\nThis will also deactivate the customer in QBO.';
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) await loadCustomers();
|
||||
else {
|
||||
const err = await response.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Network error.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportToQbo(id) {
|
||||
const customer = customers.find(c => c.id === id);
|
||||
if (!customer) return;
|
||||
if (!confirm(`Export "${customer.name}" to QuickBooks Online?`)) return;
|
||||
|
||||
if (typeof showSpinner === 'function') showSpinner('Exporting customer to QBO...');
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
alert(`✅ "${result.name}" exported to QBO (ID: ${result.qbo_id}).`);
|
||||
await loadCustomers();
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error.');
|
||||
} finally {
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
export function setQboFilter(val) {
|
||||
filterQbo = val;
|
||||
saveSettings();
|
||||
renderCustomerView();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
|
||||
window.customerView = {
|
||||
loadCustomers, renderCustomerView, getCustomers,
|
||||
openModal, closeModal, edit, remove, exportToQbo, setQboFilter
|
||||
};
|
||||
|
||||
// Make customers available globally for other modules (quote/invoice dropdowns)
|
||||
window.getCustomers = () => customers;
|
||||
589
public/js/views/invoice-view.js
Normal file
589
public/js/views/invoice-view.js
Normal file
@@ -0,0 +1,589 @@
|
||||
// invoice-view.js — ES Module v5
|
||||
// Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button
|
||||
|
||||
let invoices = [];
|
||||
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
|
||||
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
|
||||
let groupBy = localStorage.getItem('inv_groupBy') || 'none';
|
||||
|
||||
const OVERDUE_DAYS = 30;
|
||||
|
||||
// ============================================================
|
||||
// Date Helpers
|
||||
// ============================================================
|
||||
|
||||
function parseLocalDate(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const str = String(dateStr).split('T')[0];
|
||||
const parts = str.split('-');
|
||||
if (parts.length !== 3) return null;
|
||||
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '—';
|
||||
const d = parseLocalDate(date);
|
||||
if (!d) return '—';
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function formatDateTime(isoStr) {
|
||||
if (!isoStr) return 'Never';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) +
|
||||
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
function daysSince(date) {
|
||||
const d = parseLocalDate(date);
|
||||
if (!d) return 0;
|
||||
const now = new Date(); now.setHours(0, 0, 0, 0);
|
||||
return Math.floor((now - d) / 86400000);
|
||||
}
|
||||
|
||||
function getWeekNumber(date) {
|
||||
const d = parseLocalDate(date);
|
||||
if (!d) return { year: 0, week: 0 };
|
||||
const copy = new Date(d.getTime());
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
copy.setDate(copy.getDate() + 3 - ((copy.getDay() + 6) % 7));
|
||||
const week1 = new Date(copy.getFullYear(), 0, 4);
|
||||
return {
|
||||
year: copy.getFullYear(),
|
||||
week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
|
||||
};
|
||||
}
|
||||
|
||||
function getWeekRange(year, weekNum) {
|
||||
const jan4 = new Date(year, 0, 4);
|
||||
const dayOfWeek = jan4.getDay() || 7;
|
||||
const monday = new Date(jan4);
|
||||
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
return { start: fmt(monday), end: fmt(sunday) };
|
||||
}
|
||||
|
||||
function getMonthName(i) {
|
||||
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
|
||||
}
|
||||
|
||||
function isPaid(inv) { return !!inv.paid_date; }
|
||||
function isDraft(inv) { return !inv.qbo_id; }
|
||||
function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
|
||||
function isPartiallyPaid(inv) {
|
||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
|
||||
return !inv.paid_date && amountPaid > 0 && balance > 0;
|
||||
}
|
||||
function isSent(inv) {
|
||||
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status === 'sent';
|
||||
}
|
||||
function isOpen(inv) {
|
||||
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status !== 'sent';
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
localStorage.setItem('inv_filterStatus', filterStatus);
|
||||
localStorage.setItem('inv_groupBy', groupBy);
|
||||
localStorage.setItem('inv_filterCustomer', filterCustomer);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Data
|
||||
// ============================================================
|
||||
|
||||
export async function loadInvoices() {
|
||||
try {
|
||||
const response = await fetch('/api/invoices');
|
||||
invoices = await response.json();
|
||||
renderInvoiceView();
|
||||
loadLastSync();
|
||||
} catch (error) { console.error('Error loading invoices:', error); }
|
||||
}
|
||||
|
||||
async function loadLastSync() {
|
||||
try {
|
||||
const res = await fetch('/api/qbo/last-sync');
|
||||
const data = await res.json();
|
||||
const el = document.getElementById('last-sync-time');
|
||||
if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced';
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getInvoicesData() { return invoices; }
|
||||
|
||||
// ============================================================
|
||||
// Filter / Sort / Group
|
||||
// ============================================================
|
||||
|
||||
function getFilteredInvoices() {
|
||||
let f = [...invoices];
|
||||
if (filterStatus === 'unpaid') f = f.filter(i => !isPaid(i));
|
||||
else if (filterStatus === 'paid') f = f.filter(i => isPaid(i));
|
||||
else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i));
|
||||
else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(i));
|
||||
else if (filterStatus === 'sent') f = f.filter(i => isSent(i));
|
||||
else if (filterStatus === 'open') f = f.filter(i => isOpen(i));
|
||||
|
||||
if (filterCustomer.trim()) {
|
||||
const s = filterCustomer.toLowerCase();
|
||||
f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s));
|
||||
}
|
||||
f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
|
||||
return f;
|
||||
}
|
||||
|
||||
// Effective amount: for unpaid/partial show balance, for paid show total
|
||||
function effectiveAmount(inv) {
|
||||
const total = parseFloat(inv.total) || 0;
|
||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||
if (inv.paid_date) return total; // Paid → show full total
|
||||
if (amountPaid > 0) return total - amountPaid; // Partial → show balance
|
||||
return total; // Unpaid → show total
|
||||
}
|
||||
|
||||
function groupInvoices(filtered) {
|
||||
if (groupBy === 'none') return null;
|
||||
const groups = new Map();
|
||||
filtered.forEach(inv => {
|
||||
const d = parseLocalDate(inv.invoice_date);
|
||||
if (!d) return;
|
||||
let key, label;
|
||||
if (groupBy === 'week') {
|
||||
const wk = getWeekNumber(inv.invoice_date);
|
||||
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
|
||||
const range = getWeekRange(wk.year, wk.week);
|
||||
label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`;
|
||||
} else {
|
||||
key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
|
||||
label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`;
|
||||
}
|
||||
if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 });
|
||||
const g = groups.get(key);
|
||||
g.invoices.push(inv);
|
||||
g.total += effectiveAmount(inv);
|
||||
});
|
||||
for (const g of groups.values()) {
|
||||
g.invoices.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
|
||||
}
|
||||
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render
|
||||
// ============================================================
|
||||
|
||||
function renderInvoiceRow(invoice) {
|
||||
const hasQbo = !!invoice.qbo_id;
|
||||
const paid = isPaid(invoice);
|
||||
const overdue = isOverdue(invoice);
|
||||
const draft = isDraft(invoice);
|
||||
const amountPaid = parseFloat(invoice.amount_paid) || 0;
|
||||
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
||||
const partial = isPartiallyPaid(invoice);
|
||||
|
||||
const stripeIndicator = invoice.stripe_payment_link_id
|
||||
? (invoice.stripe_payment_status === 'paid'
|
||||
? ' <span title="Stripe payment received" class="text-purple-500 text-xs">💳✓</span>'
|
||||
: ' <span title="Stripe payment link active" class="text-purple-400 text-xs">💳</span>')
|
||||
: '';
|
||||
|
||||
const invNumDisplay = invoice.invoice_number
|
||||
? invoice.invoice_number + stripeIndicator
|
||||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||||
|
||||
// Status Badge (left side, next to invoice number)
|
||||
let statusBadge = '';
|
||||
if (paid && invoice.payment_status === 'Deposited') {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
||||
} else if (paid && invoice.payment_status === 'Stripe') {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800" title="Stripe payment ${formatDate(invoice.paid_date)}">Stripe</span>`;
|
||||
} else if (paid) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||||
} else if (partial) {
|
||||
// Partial: show delivery status badge + Partial badge
|
||||
if (hasQbo && invoice.email_status === 'sent') {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span> `;
|
||||
} else if (hasQbo) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span> `;
|
||||
}
|
||||
statusBadge += `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial</span>`;
|
||||
} else if (overdue) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
|
||||
} else if (hasQbo && invoice.email_status === 'sent') {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
|
||||
} else if (hasQbo) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
|
||||
}
|
||||
|
||||
// Send Date
|
||||
let sendDateDisplay = '—';
|
||||
if (invoice.scheduled_send_date) {
|
||||
const sendDate = parseLocalDate(invoice.scheduled_send_date);
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const daysUntil = Math.floor((sendDate - today) / 86400000);
|
||||
sendDateDisplay = formatDate(invoice.scheduled_send_date);
|
||||
if (!paid && invoice.email_status !== 'sent') {
|
||||
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
||||
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
|
||||
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Amount column — show balance when partially paid
|
||||
let amountDisplay;
|
||||
if (partial) {
|
||||
amountDisplay = `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`;
|
||||
} else {
|
||||
amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del ---
|
||||
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||||
|
||||
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||
let qboBtn;
|
||||
if (hasQbo) {
|
||||
qboBtn = `<span class="text-green-600 text-xs" title="QBO ID: ${invoice.qbo_id}">✓ QBO</span>`;
|
||||
} else if (!customerHasQbo) {
|
||||
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
||||
} else {
|
||||
qboBtn = `<span class="text-gray-400 text-xs" title="Will be exported to QBO on save">QBO pending</span>`;
|
||||
}
|
||||
|
||||
const pdfBtn = draft
|
||||
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF available after QBO Export">PDF</span>`
|
||||
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
|
||||
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
|
||||
|
||||
// Payment button — only for QBO invoices that are not fully paid
|
||||
let paidBtn = '';
|
||||
if (!paid && hasQbo) {
|
||||
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
|
||||
}
|
||||
|
||||
// Mark Sent button (right side) — only when open, not paid/partial
|
||||
let sendBtn = '';
|
||||
if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
|
||||
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
||||
}
|
||||
// if (hasQbo && !paid && !overdue) {
|
||||
// sendBtn = `
|
||||
// <button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-gray-600 hover:text-gray-900 text-xs font-medium mr-4" title="Nur Status ändern">
|
||||
// ✔️ Mark Sent
|
||||
// </button>
|
||||
// <button onclick="window.emailModal.open(${invoice.id})" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="E-Mail via SES versenden">
|
||||
// 📧 Send Email
|
||||
// </button>
|
||||
// `; }
|
||||
|
||||
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||
|
||||
const stripeEmailBtn = (!paid && hasQbo)
|
||||
? `<button onclick="window.emailModal.open(${invoice.id})" title="Email with Stripe Payment Link" class="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 text-xs font-semibold">💳 Pay Link</button>`
|
||||
: '';
|
||||
|
||||
const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid)
|
||||
? `<button onclick="window.invoiceView.checkStripePayment(${invoice.id})" title="Check Stripe Payment Status" class="px-2 py-1 bg-purple-50 text-purple-600 rounded hover:bg-purple-100 text-xs font-semibold">🔍 Check</button>`
|
||||
: '';
|
||||
|
||||
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||||
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderGroupHeader(label) {
|
||||
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
|
||||
}
|
||||
function renderGroupFooter(total, count) {
|
||||
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
|
||||
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
|
||||
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
|
||||
}
|
||||
|
||||
export function renderInvoiceView() {
|
||||
const tbody = document.getElementById('invoices-list');
|
||||
if (!tbody) return;
|
||||
const filtered = getFilteredInvoices();
|
||||
const groups = groupInvoices(filtered);
|
||||
let html = '', grandTotal = 0;
|
||||
|
||||
if (groups) {
|
||||
for (const [, group] of groups) {
|
||||
html += renderGroupHeader(group.label);
|
||||
group.invoices.forEach(inv => { html += renderInvoiceRow(inv); });
|
||||
html += renderGroupFooter(group.total, group.invoices.length);
|
||||
grandTotal += group.total;
|
||||
}
|
||||
if (groups.size > 1) {
|
||||
html += `<tr class="bg-blue-100 border-t-4 border-blue-400">
|
||||
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
|
||||
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||||
}
|
||||
} else {
|
||||
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += effectiveAmount(inv); });
|
||||
if (filtered.length > 0) {
|
||||
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
|
||||
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
|
||||
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
||||
tbody.innerHTML = html;
|
||||
|
||||
const countEl = document.getElementById('invoice-count');
|
||||
if (countEl) countEl.textContent = filtered.length;
|
||||
updateStatusButtons();
|
||||
}
|
||||
|
||||
function updateStatusButtons() {
|
||||
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
||||
const s = btn.getAttribute('data-status-filter');
|
||||
btn.classList.toggle('bg-blue-600', s === filterStatus);
|
||||
btn.classList.toggle('text-white', s === filterStatus);
|
||||
btn.classList.toggle('bg-white', s !== filterStatus);
|
||||
btn.classList.toggle('text-gray-600', s !== filterStatus);
|
||||
});
|
||||
|
||||
const counts = {
|
||||
unpaid: invoices.filter(i => !isPaid(i)).length,
|
||||
open: invoices.filter(i => isOpen(i)).length,
|
||||
sent: invoices.filter(i => isSent(i)).length,
|
||||
partial: invoices.filter(i => isPartiallyPaid(i)).length,
|
||||
paid: invoices.filter(i => isPaid(i)).length,
|
||||
overdue: invoices.filter(i => isOverdue(i)).length
|
||||
};
|
||||
|
||||
['unpaid', 'open', 'sent', 'partial', 'paid', 'overdue'].forEach(key => {
|
||||
const el = document.getElementById(`${key}-badge`);
|
||||
if (el) {
|
||||
el.textContent = counts[key];
|
||||
el.classList.toggle('hidden', counts[key] === 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbar
|
||||
// ============================================================
|
||||
|
||||
export function injectToolbar() {
|
||||
const c = document.getElementById('invoice-toolbar');
|
||||
if (!c) return;
|
||||
c.innerHTML = `
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||||
<button data-status-filter="all" onclick="window.invoiceView.setStatus('all')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">All</button>
|
||||
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Unpaid
|
||||
<span id="unpaid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
<button data-status-filter="open" onclick="window.invoiceView.setStatus('open')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Open
|
||||
<span id="open-badge" class="hidden absolute -top-1.5 -right-1.5 bg-orange-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
<button data-status-filter="sent" onclick="window.invoiceView.setStatus('sent')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Sent
|
||||
<span id="sent-badge" class="hidden absolute -top-1.5 -right-1.5 bg-cyan-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
<button data-status-filter="partial" onclick="window.invoiceView.setStatus('partial')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Partial
|
||||
<span id="partial-badge" class="hidden absolute -top-1.5 -right-1.5 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid
|
||||
<span id="paid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
|
||||
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-gray-300"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
||||
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="w-px h-8 bg-gray-300"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Group:</label>
|
||||
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
|
||||
<option value="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
|
||||
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
|
||||
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-gray-300"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="window.invoiceView.syncFromQBO()" class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-xs font-medium hover:bg-indigo-700">
|
||||
⟳ Sync from QBO
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<span id="last-sync-time" class="text-xs text-gray-400">...</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
updateStatusButtons();
|
||||
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
||||
filterCustomer = e.target.value; saveSettings(); renderInvoiceView();
|
||||
});
|
||||
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
|
||||
groupBy = e.target.value; saveSettings(); renderInvoiceView();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Actions
|
||||
// ============================================================
|
||||
|
||||
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
|
||||
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
|
||||
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
|
||||
|
||||
export async function exportToQBO(id) {
|
||||
if (!confirm('Export invoice to QuickBooks Online?')) return;
|
||||
if (typeof showSpinner === 'function') showSpinner('Exporting invoice to QBO...');
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
||||
else alert(`❌ ${d.error}`);
|
||||
} catch (e) { alert('Network error.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function syncToQBO(id) {
|
||||
if (!confirm('Sync changes to QuickBooks Online?')) return;
|
||||
if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...');
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) { alert(`✅ ${d.message}`); loadInvoices(); }
|
||||
else alert(`❌ ${d.error}`);
|
||||
} catch (e) { alert('Network error.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function syncFromQBO() {
|
||||
if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...');
|
||||
try {
|
||||
const r = await fetch('/api/qbo/sync-payments', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
alert(`✅ ${d.message}`);
|
||||
loadInvoices();
|
||||
} else {
|
||||
alert(`❌ ${d.error}`);
|
||||
}
|
||||
} catch (e) { alert('Network error.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function setEmailStatus(id, status) {
|
||||
const label = status === 'sent' ? 'Mark as sent' : 'Mark as not sent';
|
||||
if (!confirm(`${label}?`)) return;
|
||||
if (typeof showSpinner === 'function') showSpinner(`Updating status in QBO...`);
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/email-status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) loadInvoices();
|
||||
else alert(`❌ ${d.error}`);
|
||||
} catch (e) { alert('Network error.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function resetQbo(id) {
|
||||
if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
|
||||
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
export async function markPaid(id) {
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/mark-paid`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
|
||||
});
|
||||
if (r.ok) loadInvoices();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
|
||||
|
||||
export async function remove(id) {
|
||||
if (!confirm('Delete this invoice?')) return;
|
||||
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
||||
catch (e) { console.error(e); }
|
||||
}
|
||||
async function checkStripePayment(invoiceId) {
|
||||
if (typeof showSpinner === 'function') showSpinner('Checking Stripe payment status...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoiceId}/check-payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (result.paid) {
|
||||
let msg = `✅ ${result.message}`;
|
||||
if (result.qbo) {
|
||||
if (result.qbo.error) {
|
||||
msg += `\n\n⚠️ QBO booking failed: ${result.qbo.error}`;
|
||||
} else {
|
||||
msg += `\n\n📗 QBO Payment recorded (ID: ${result.qbo.paymentId})`;
|
||||
if (result.qbo.feeBooked) msg += '\n📗 Processing fee booked';
|
||||
}
|
||||
}
|
||||
if (!result.fullyPaid) {
|
||||
msg += '\n\n⚠️ Partial payment — invoice is not fully paid yet.';
|
||||
}
|
||||
alert(msg);
|
||||
loadInvoices(); // Refresh the list
|
||||
} else if (result.alreadyProcessed) {
|
||||
alert('ℹ️ Stripe payment was already recorded for this invoice.');
|
||||
} else if (result.status === 'processing') {
|
||||
alert('⏳ ACH payment is processing (typically 3-5 business days).\n\nCheck again later.');
|
||||
} else {
|
||||
alert('ℹ️ No payment received yet.\n\nThe customer may not have clicked the payment link, or the payment is still being processed.');
|
||||
}
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Check Stripe payment error:', e);
|
||||
alert('Network error checking payment status.');
|
||||
} finally {
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
|
||||
window.invoiceView = {
|
||||
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
|
||||
loadInvoices, renderInvoiceView, setStatus, checkStripePayment
|
||||
};
|
||||
101
public/js/views/quote-view.js
Normal file
101
public/js/views/quote-view.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* quote-view.js — Quote list rendering and actions
|
||||
* Analog to invoice-view.js
|
||||
*/
|
||||
import { formatDate } from '../utils/helpers.js';
|
||||
|
||||
let quotes = [];
|
||||
|
||||
export async function loadQuotes() {
|
||||
try {
|
||||
const response = await fetch('/api/quotes');
|
||||
quotes = await response.json();
|
||||
renderQuotes();
|
||||
} catch (error) {
|
||||
console.error('Error loading quotes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuotesData() {
|
||||
return quotes;
|
||||
}
|
||||
|
||||
export function renderQuotes() {
|
||||
const tbody = document.getElementById('quotes-list');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = quotes.map(quote => {
|
||||
const total = quote.has_tbd
|
||||
? `$${parseFloat(quote.total).toFixed(2)}*`
|
||||
: `$${parseFloat(quote.total).toFixed(2)}`;
|
||||
return `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onclick="window.quoteView.viewPDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||
<button onclick="window.quoteView.convertToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
|
||||
<button onclick="window.quoteView.edit(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||
<button onclick="window.quoteView.remove(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (quotes.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No quotes found.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function viewPDF(id) {
|
||||
window.open(`/api/quotes/${id}/pdf`, '_blank');
|
||||
}
|
||||
|
||||
export async function edit(id) {
|
||||
if (typeof window.openQuoteModal === 'function') {
|
||||
await window.openQuoteModal(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
if (!confirm('Are you sure you want to delete this quote?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) loadQuotes();
|
||||
else alert('Error deleting quote');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting quote');
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertToInvoice(quoteId) {
|
||||
if (!confirm('Convert this quote to an invoice?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
const invoice = await response.json();
|
||||
alert(`Invoice ${invoice.invoice_number || '(Draft)'} created successfully!`);
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
if (typeof window.showTab === 'function') window.showTab('invoices');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Error converting quote to invoice');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error converting quote to invoice');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.quoteView = {
|
||||
loadQuotes,
|
||||
renderQuotes,
|
||||
viewPDF,
|
||||
edit,
|
||||
remove,
|
||||
convertToInvoice
|
||||
};
|
||||
182
public/js/views/settings-view.js
Normal file
182
public/js/views/settings-view.js
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* settings-view.js — Logo upload, QBO import, QBO connection test
|
||||
* Extracted from app.js
|
||||
*/
|
||||
|
||||
let currentLogoFile = null;
|
||||
|
||||
export async function checkCurrentLogo() {
|
||||
try {
|
||||
const response = await fetch('/api/logo-info');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.hasLogo) {
|
||||
document.getElementById('logo-preview').classList.remove('hidden');
|
||||
document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking logo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadLogo() {
|
||||
if (!currentLogoFile) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('logo', currentLogoFile);
|
||||
|
||||
const statusDiv = document.getElementById('upload-status');
|
||||
statusDiv.innerHTML = '<p class="text-blue-600">Uploading...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
|
||||
document.getElementById('logo-preview').classList.remove('hidden');
|
||||
document.getElementById('logo-image').src = data.path + '?t=' + Date.now();
|
||||
document.getElementById('upload-btn').disabled = true;
|
||||
currentLogoFile = null;
|
||||
document.getElementById('logo-filename').textContent = '';
|
||||
document.getElementById('logo-upload').value = '';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
statusDiv.innerHTML = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
|
||||
}
|
||||
}
|
||||
|
||||
export function initSettingsView() {
|
||||
const logoUpload = document.getElementById('logo-upload');
|
||||
if (logoUpload) {
|
||||
logoUpload.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
currentLogoFile = file;
|
||||
document.getElementById('logo-filename').textContent = file.name;
|
||||
document.getElementById('upload-btn').disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkQboOverdue() {
|
||||
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
|
||||
const resultDiv = document.getElementById('qbo-result');
|
||||
const tbody = document.getElementById('qbo-result-list');
|
||||
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳ Connecting to QBO...';
|
||||
btn.disabled = true;
|
||||
resultDiv.classList.add('hidden');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/overdue');
|
||||
const invoices = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (invoices.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = invoices.map(inv => `
|
||||
<tr>
|
||||
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
|
||||
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
|
||||
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
|
||||
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
|
||||
} else {
|
||||
throw new Error(invoices.error || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('QBO Test Error:', error);
|
||||
alert('❌ Connection Test Failed: ' + error.message);
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importFromQBO() {
|
||||
if (!confirm(
|
||||
'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
|
||||
'• Bereits importierte werden übersprungen\n' +
|
||||
'• Nur Kunden die lokal verknüpft sind\n\n' +
|
||||
'Fortfahren?'
|
||||
)) return;
|
||||
|
||||
const btn = document.querySelector('button[onclick="importFromQBO()"]');
|
||||
const resultDiv = document.getElementById('qbo-import-result');
|
||||
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳ Importiere aus QBO...';
|
||||
btn.disabled = true;
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (response.ok) {
|
||||
let html = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
|
||||
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
|
||||
html += `<ul class="text-sm text-gray-700 space-y-1">`;
|
||||
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
|
||||
|
||||
if (result.skipped > 0) {
|
||||
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
|
||||
}
|
||||
if (result.skippedNoCustomer > 0) {
|
||||
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
|
||||
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
|
||||
}
|
||||
|
||||
html += `</ul></div>`;
|
||||
resultDiv.innerHTML = html;
|
||||
|
||||
if (result.imported > 0 && window.invoiceView) {
|
||||
window.invoiceView.loadInvoices();
|
||||
}
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
|
||||
<p class="text-sm text-red-600 mt-1">${result.error}</p>
|
||||
</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import Error:', error);
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-red-600">Netzwerkfehler beim Import.</p>
|
||||
</div>`;
|
||||
} finally {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.uploadLogo = uploadLogo;
|
||||
window.checkQboOverdue = checkQboOverdue;
|
||||
window.importFromQBO = importFromQBO;
|
||||
41
public/logo.svg
Normal file
41
public/logo.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 10.583333 10.583333"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1">
|
||||
<g
|
||||
id="g2"
|
||||
transform="translate(-59.849599,-62.962344)">
|
||||
<g
|
||||
id="g3"
|
||||
transform="matrix(2.4622373,0,0,2.4622373,-103.30064,-98.368198)">
|
||||
<path
|
||||
id="rect4"
|
||||
style="opacity:0.92;fill:#000080;fill-opacity:0;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||
d="m 69.557843,68.36669 v -1.921262 h -1.960031 v 1.445588 c 0,0.26351 0.212068,0.475674 0.475578,0.475674 z" />
|
||||
<path
|
||||
id="path1"
|
||||
style="opacity:0.92;fill:#000080;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||
d="m 68.07339,65.851028 c -0.26351,0 -0.475578,0.212067 -0.475578,0.475579 v 0.118821 h 1.960031 v 1.921262 h 0.10113 c 0.26351,0 0.475578,-0.212164 0.475578,-0.475674 v -1.564409 c 0,-0.263512 -0.212068,-0.475579 -0.475578,-0.475579 z" />
|
||||
<path
|
||||
id="rect14"
|
||||
style="opacity:0.92;fill:#0000ff;fill-opacity:0;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||
d="m 68.587351,69.424584 v -1.960128 h -1.99822 v 1.484453 c 0,0.26351 0.212067,0.475675 0.475577,0.475675 z" />
|
||||
<path
|
||||
id="path2"
|
||||
style="opacity:0.92;fill:#0000ff;stroke-width:0.0110529;-inkscape-stroke:none;paint-order:markers fill stroke"
|
||||
d="m 67.064708,66.908921 c -0.26351,0 -0.475577,0.212068 -0.475577,0.475578 v 0.07996 h 1.99822 v 1.960128 h 0.06294 c 0.263512,0 0.475579,-0.212165 0.475579,-0.475675 v -1.56441 c 0,-0.26351 -0.212067,-0.475578 -0.475579,-0.475578 z" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
181
qbo_helper.js
Normal file
181
qbo_helper.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// qbo_helper.js - DEFINITIVER FIX
|
||||
//
|
||||
// Kernproblem: client.refresh() ruft intern validateToken() auf,
|
||||
// das das Token-Objekt prüft und "invalid" wirft wenn das Format
|
||||
// nicht stimmt. Das passiert LOKAL, nicht bei Intuit.
|
||||
//
|
||||
// Lösung: refreshUsingToken(refreshTokenString) verwenden.
|
||||
// Diese Methode akzeptiert den RT direkt als String und umgeht
|
||||
// die validateToken()-Prüfung komplett.
|
||||
|
||||
require('dotenv').config();
|
||||
const OAuthClient = require('intuit-oauth');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let oauthClient = null;
|
||||
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
||||
|
||||
const getOAuthClient = () => {
|
||||
if (!oauthClient) {
|
||||
oauthClient = new OAuthClient({
|
||||
clientId: process.env.QBO_CLIENT_ID,
|
||||
clientSecret: process.env.QBO_CLIENT_SECRET,
|
||||
environment: process.env.QBO_ENVIRONMENT || 'sandbox',
|
||||
redirectUri: process.env.QBO_REDIRECT_URI
|
||||
});
|
||||
|
||||
let savedToken = null;
|
||||
try {
|
||||
if (fs.existsSync(tokenFile)) {
|
||||
const stat = fs.statSync(tokenFile);
|
||||
if (stat.isFile()) {
|
||||
const content = fs.readFileSync(tokenFile, 'utf8');
|
||||
if (content.trim() !== "{}") {
|
||||
savedToken = JSON.parse(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ Fehler beim Laden des gespeicherten Tokens:", e.message);
|
||||
}
|
||||
|
||||
if (savedToken && savedToken.refresh_token) {
|
||||
oauthClient.setToken(savedToken);
|
||||
console.log("✅ Gespeicherter Token aus qbo_token.json geladen.");
|
||||
} else {
|
||||
const envToken = {
|
||||
token_type: 'bearer',
|
||||
access_token: process.env.QBO_ACCESS_TOKEN || '',
|
||||
refresh_token: process.env.QBO_REFRESH_TOKEN || '',
|
||||
expires_in: 3600,
|
||||
x_refresh_token_expires_in: 8726400,
|
||||
realmId: process.env.QBO_REALM_ID,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
if (envToken.refresh_token) {
|
||||
oauthClient.setToken(envToken);
|
||||
console.log("ℹ️ Token aus .env geladen (Fallback).");
|
||||
} else {
|
||||
console.warn("⚠️ Kein gültiger Token vorhanden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return oauthClient;
|
||||
};
|
||||
|
||||
function resetOAuthClient() {
|
||||
oauthClient = null;
|
||||
}
|
||||
|
||||
function saveTokens() {
|
||||
try {
|
||||
const client = getOAuthClient();
|
||||
const token = client.getToken();
|
||||
|
||||
// Debug: Was genau bekommen wir vom Client?
|
||||
console.log("💾 Speichere Token... refresh_token vorhanden:", !!token.refresh_token,
|
||||
"| access_token Länge:", (token.access_token || '').length,
|
||||
"| realmId:", token.realmId || 'FEHLT');
|
||||
|
||||
// Sicherstellen dass alle Pflichtfelder vorhanden sind
|
||||
const tokenToSave = {
|
||||
token_type: token.token_type || 'bearer',
|
||||
access_token: token.access_token,
|
||||
refresh_token: token.refresh_token,
|
||||
expires_in: token.expires_in || 3600,
|
||||
x_refresh_token_expires_in: token.x_refresh_token_expires_in || 8726400,
|
||||
realmId: token.realmId || process.env.QBO_REALM_ID,
|
||||
createdAt: token.createdAt || new Date().toISOString()
|
||||
};
|
||||
|
||||
fs.writeFileSync(tokenFile, JSON.stringify(tokenToSave, null, 2));
|
||||
console.log("💾 Tokens erfolgreich in qbo_token.json gespeichert.");
|
||||
} catch (e) {
|
||||
console.error("❌ Fehler beim Speichern der Tokens:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function makeQboApiCall(requestOptions) {
|
||||
const client = getOAuthClient();
|
||||
|
||||
const currentToken = client.getToken();
|
||||
if (!currentToken || !currentToken.refresh_token) {
|
||||
throw new Error("Kein gültiger QBO Token vorhanden. Bitte Token erneuern.");
|
||||
}
|
||||
|
||||
const doRefresh = async () => {
|
||||
console.log("🔄 QBO Token Refresh wird ausgeführt...");
|
||||
|
||||
// Den Refresh Token als String extrahieren
|
||||
const refreshTokenStr = currentToken.refresh_token;
|
||||
console.log("🔑 Refresh Token (erste 15 Zeichen):", refreshTokenStr.substring(0, 15) + "...");
|
||||
|
||||
try {
|
||||
// KRITISCHER FIX: refreshUsingToken() statt refresh() verwenden!
|
||||
//
|
||||
// refresh() ruft intern validateToken() auf, das bei unvollständigem
|
||||
// Token-Objekt "The Refresh token is invalid" wirft — OHNE jemals
|
||||
// Intuit zu kontaktieren.
|
||||
//
|
||||
// refreshUsingToken() akzeptiert den RT als String und umgeht das.
|
||||
const authResponse = await client.refreshUsingToken(refreshTokenStr);
|
||||
console.log("✅ Token erfolgreich erneuert via refreshUsingToken().");
|
||||
saveTokens();
|
||||
return authResponse;
|
||||
} catch (e) {
|
||||
const errMsg = e.originalMessage || e.message || String(e);
|
||||
console.error("❌ Refresh fehlgeschlagen:", errMsg);
|
||||
if (e.intuit_tid) console.error(" intuit_tid:", e.intuit_tid);
|
||||
|
||||
if (errMsg.includes('invalid_grant')) {
|
||||
throw new Error(
|
||||
"Der Refresh Token ist bei Intuit ungültig (invalid_grant). " +
|
||||
"Bitte im Playground einen neuen Token holen und set_qbo_token.js ausführen."
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await client.makeApiCall(requestOptions);
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
|
||||
if (data.fault && data.fault.error) {
|
||||
const errorCode = data.fault.error[0].code;
|
||||
|
||||
if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') {
|
||||
console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`);
|
||||
await doRefresh();
|
||||
return await client.makeApiCall(requestOptions);
|
||||
}
|
||||
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
|
||||
}
|
||||
|
||||
saveTokens();
|
||||
return response;
|
||||
|
||||
} catch (e) {
|
||||
const isAuthError =
|
||||
e.response?.status === 401 ||
|
||||
(e.authResponse && e.authResponse.response && e.authResponse.response.status === 401) ||
|
||||
e.message?.includes('AuthenticationFailed');
|
||||
|
||||
if (isAuthError) {
|
||||
console.log("⚠️ 401 Unauthorized / AuthFailed erhalten. Versuche Refresh und Retry...");
|
||||
await doRefresh();
|
||||
return await client.makeApiCall(requestOptions);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOAuthClient,
|
||||
makeQboApiCall,
|
||||
saveTokens,
|
||||
resetOAuthClient
|
||||
};
|
||||
49
qbo_query.js
Normal file
49
qbo_query.js
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// qbo_query.js — Quick QBO Query Tool
|
||||
//
|
||||
// Usage:
|
||||
// node qbo_query.js invoice 110444 # By DocNumber
|
||||
// node qbo_query.js invoice-id 37973 # By QBO ID
|
||||
// node qbo_query.js payment 38733 # By Payment ID
|
||||
// node qbo_query.js query "SELECT * FROM Invoice WHERE Balance = '0' MAXRESULTS 3"
|
||||
|
||||
require('dotenv').config();
|
||||
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
|
||||
|
||||
async function main() {
|
||||
const [type, value] = process.argv.slice(2);
|
||||
if (!type || !value) {
|
||||
console.log('Usage:\n node qbo_query.js invoice <DocNumber>\n node qbo_query.js invoice-id <QBO_ID>\n node qbo_query.js payment <Payment_ID>\n node qbo_query.js query "<QUERY>"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const base = process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
|
||||
let url;
|
||||
if (type === 'invoice') {
|
||||
url = `${base}/v3/company/${companyId}/query?query=${encodeURI(`SELECT * FROM Invoice WHERE DocNumber = '${value}'`)}`;
|
||||
} else if (type === 'invoice-id') {
|
||||
url = `${base}/v3/company/${companyId}/invoice/${value}`;
|
||||
} else if (type === 'payment') {
|
||||
url = `${base}/v3/company/${companyId}/payment/${value}`;
|
||||
} else if (type === 'query') {
|
||||
url = `${base}/v3/company/${companyId}/query?query=${encodeURI(value)}`;
|
||||
} else {
|
||||
console.error('Unknown type:', type);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await makeQboApiCall({ url, method: 'GET' });
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
453
schema.sql
Normal file
453
schema.sql
Normal file
@@ -0,0 +1,453 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict XHJaQEVNwjEtL1FZTBb0Sf7ooBX1Ld95BOqQlHUgJxKe87sxBoQbgpWG7aympDU
|
||||
|
||||
-- Dumped from database version 17.6
|
||||
-- Dumped by pg_dump version 17.6
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: customers; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.customers (
|
||||
id integer NOT NULL,
|
||||
name character varying(255) NOT NULL,
|
||||
city character varying(100),
|
||||
state character varying(2),
|
||||
zip_code character varying(10),
|
||||
account_number character varying(50),
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
email character varying(255),
|
||||
phone character varying(50),
|
||||
phone2 character varying(50),
|
||||
taxable boolean DEFAULT true,
|
||||
line1 character varying(255),
|
||||
line2 character varying(255),
|
||||
line3 character varying(255),
|
||||
line4 character varying(255),
|
||||
qbo_id character varying(50),
|
||||
qbo_sync_token character varying(50)
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.customers OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: customers_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.customers_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.customers_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: customers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.customers_id_seq OWNED BY public.customers.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoice_items; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.invoice_items (
|
||||
id integer NOT NULL,
|
||||
invoice_id integer,
|
||||
quantity character varying(20) NOT NULL,
|
||||
description text NOT NULL,
|
||||
rate character varying(50) NOT NULL,
|
||||
amount character varying(50) NOT NULL,
|
||||
item_order integer NOT NULL,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
qbo_item_id character varying(10) DEFAULT '9'::character varying
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.invoice_items OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: invoice_items_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.invoice_items_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.invoice_items_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: invoice_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.invoices (
|
||||
id integer NOT NULL,
|
||||
invoice_number character varying(50) NOT NULL,
|
||||
customer_id integer,
|
||||
invoice_date date NOT NULL,
|
||||
terms character varying(100) DEFAULT 'Net 30'::character varying,
|
||||
auth_code character varying(255),
|
||||
tax_exempt boolean DEFAULT false,
|
||||
tax_rate numeric(5,2) DEFAULT 8.25,
|
||||
subtotal numeric(10,2) DEFAULT 0,
|
||||
tax_amount numeric(10,2) DEFAULT 0,
|
||||
total numeric(10,2) DEFAULT 0,
|
||||
created_from_quote_id integer,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
qbo_id character varying(50),
|
||||
qbo_sync_token character varying(50),
|
||||
qbo_doc_number character varying(50)
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.invoices OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: invoices_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.invoices_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.invoices_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: invoices_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.invoices_id_seq OWNED BY public.invoices.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.quote_items (
|
||||
id integer NOT NULL,
|
||||
quote_id integer,
|
||||
quantity character varying(20) NOT NULL,
|
||||
description text NOT NULL,
|
||||
rate character varying(50) NOT NULL,
|
||||
amount character varying(50) NOT NULL,
|
||||
item_order integer NOT NULL,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
is_tbd boolean DEFAULT false,
|
||||
qbo_item_id character varying(10) DEFAULT '9'::character varying
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.quote_items OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quote_items_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.quote_items_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.quote_items_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quote_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.quote_items_id_seq OWNED BY public.quote_items.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes; Type: TABLE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE TABLE public.quotes (
|
||||
id integer NOT NULL,
|
||||
quote_number character varying(50) NOT NULL,
|
||||
customer_id integer,
|
||||
quote_date date NOT NULL,
|
||||
tax_exempt boolean DEFAULT false,
|
||||
tax_rate numeric(5,2) DEFAULT 8.25,
|
||||
subtotal numeric(10,2) DEFAULT 0,
|
||||
tax_amount numeric(10,2) DEFAULT 0,
|
||||
total numeric(10,2) DEFAULT 0,
|
||||
has_tbd boolean DEFAULT false,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
tbd_note text
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.quotes OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quotes_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.quotes_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.quotes_id_seq OWNER TO quoteuser;
|
||||
|
||||
--
|
||||
-- Name: quotes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.quotes_id_seq OWNED BY public.quotes.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.customers ALTER COLUMN id SET DEFAULT nextval('public.customers_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoice_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoice_items ALTER COLUMN id SET DEFAULT nextval('public.invoice_items_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoices ALTER COLUMN id SET DEFAULT nextval('public.invoices_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quote_items ALTER COLUMN id SET DEFAULT nextval('public.quote_items_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes ALTER COLUMN id SET DEFAULT nextval('public.quotes_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.customers
|
||||
ADD CONSTRAINT customers_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: customers customers_qbo_id_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.customers
|
||||
ADD CONSTRAINT customers_qbo_id_key UNIQUE (qbo_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoice_items invoice_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoice_items
|
||||
ADD CONSTRAINT invoice_items_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices invoices_invoice_number_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoices
|
||||
ADD CONSTRAINT invoices_invoice_number_key UNIQUE (invoice_number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoices
|
||||
ADD CONSTRAINT invoices_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items quote_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quote_items
|
||||
ADD CONSTRAINT quote_items_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes quotes_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes
|
||||
ADD CONSTRAINT quotes_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes quotes_quote_number_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes
|
||||
ADD CONSTRAINT quotes_quote_number_key UNIQUE (quote_number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_customers_qbo_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX idx_customers_qbo_id ON public.customers USING btree (qbo_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_invoice_items_invoice_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_invoice_items_invoice_id ON public.invoice_items USING btree (invoice_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_invoices_created_from_quote; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_invoices_created_from_quote ON public.invoices USING btree (created_from_quote_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_invoices_customer_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_invoices_customer_id ON public.invoices USING btree (customer_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_invoices_invoice_number; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_invoices_invoice_number ON public.invoices USING btree (invoice_number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_quote_items_quote_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_quote_items_quote_id ON public.quote_items USING btree (quote_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_quotes_customer_id; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_quotes_customer_id ON public.quotes USING btree (customer_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_quotes_quote_number; Type: INDEX; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
CREATE INDEX idx_quotes_quote_number ON public.quotes USING btree (quote_number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoice_items invoice_items_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoice_items
|
||||
ADD CONSTRAINT invoice_items_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices invoices_created_from_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoices
|
||||
ADD CONSTRAINT invoices_created_from_quote_id_fkey FOREIGN KEY (created_from_quote_id) REFERENCES public.quotes(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices invoices_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.invoices
|
||||
ADD CONSTRAINT invoices_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: quote_items quote_items_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quote_items
|
||||
ADD CONSTRAINT quote_items_quote_id_fkey FOREIGN KEY (quote_id) REFERENCES public.quotes(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: quotes quotes_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.quotes
|
||||
ADD CONSTRAINT quotes_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict XHJaQEVNwjEtL1FZTBb0Sf7ooBX1Ld95BOqQlHUgJxKe87sxBoQbgpWG7aympDU
|
||||
|
||||
59
set_qbo_token.js
Normal file
59
set_qbo_token.js
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// =====================================================
|
||||
// set_qbo_token.js
|
||||
//
|
||||
// Einmalig ausführen um qbo_token.json korrekt zu setzen.
|
||||
// Die intuit-oauth Library braucht ein vollständiges Token-Objekt,
|
||||
// nicht nur access_token + refresh_token.
|
||||
//
|
||||
// Verwendung:
|
||||
// node set_qbo_token.js <ACCESS_TOKEN> <REFRESH_TOKEN> <REALM_ID>
|
||||
//
|
||||
// Beispiel:
|
||||
// node set_qbo_token.js "eyJlbmMi..." "AB11..." "9341..."
|
||||
// =====================================================
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const accessToken = process.argv[2];
|
||||
const refreshToken = process.argv[3];
|
||||
const realmId = process.argv[4];
|
||||
|
||||
if (!accessToken || !refreshToken || !realmId) {
|
||||
console.log('');
|
||||
console.log('Verwendung:');
|
||||
console.log(' node set_qbo_token.js <ACCESS_TOKEN> <REFRESH_TOKEN> <REALM_ID>');
|
||||
console.log('');
|
||||
console.log('Die Werte bekommst du aus dem Intuit OAuth Playground:');
|
||||
console.log(' https://developer.intuit.com/app/developer/playground');
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Das ist das Format, das die intuit-oauth Library erwartet
|
||||
const tokenObject = {
|
||||
token_type: "bearer",
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: 3600,
|
||||
x_refresh_token_expires_in: 8726400,
|
||||
realmId: realmId,
|
||||
// createdAt wird von der Library geprüft um zu sehen ob der Token abgelaufen ist
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
||||
fs.writeFileSync(tokenFile, JSON.stringify(tokenObject, null, 2));
|
||||
|
||||
console.log('');
|
||||
console.log('✅ qbo_token.json erfolgreich erstellt!');
|
||||
console.log(` 📁 ${tokenFile}`);
|
||||
console.log(` 🔑 Access Token: ${accessToken.substring(0, 20)}...`);
|
||||
console.log(` 🔄 Refresh Token: ${refreshToken.substring(0, 15)}...`);
|
||||
console.log(` 🏢 Realm ID: ${realmId}`);
|
||||
console.log('');
|
||||
console.log('Nächste Schritte:');
|
||||
console.log(' 1. Docker Container neu starten: docker compose restart quote_app');
|
||||
console.log(' 2. In Settings → "Test Connection" klicken');
|
||||
console.log('');
|
||||
11
src/config/database.js
Normal file
11
src/config/database.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
database: process.env.DB_NAME || 'quotes_db',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
});
|
||||
|
||||
module.exports = { pool };
|
||||
27
src/config/qbo.js
Normal file
27
src/config/qbo.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// src/config/qbo.js
|
||||
const OAuthClient = require('intuit-oauth');
|
||||
const {
|
||||
getOAuthClient: getClient,
|
||||
saveTokens,
|
||||
resetOAuthClient,
|
||||
makeQboApiCall // <-- NEU: Direkt hier mit importieren
|
||||
} = require('../../qbo_helper');
|
||||
|
||||
function getOAuthClient() {
|
||||
return getClient();
|
||||
}
|
||||
|
||||
function getQboBaseUrl() {
|
||||
return process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OAuthClient,
|
||||
getOAuthClient,
|
||||
getQboBaseUrl,
|
||||
saveTokens,
|
||||
resetOAuthClient,
|
||||
makeQboApiCall // <-- NEU: Und sauber weiterreichen
|
||||
};
|
||||
135
src/index.js
Normal file
135
src/index.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Quote & Invoice System - Main Entry Point
|
||||
* Modularized Backend
|
||||
*/
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
// Import config
|
||||
const { pool } = require('./config/database');
|
||||
const { OAuthClient, getOAuthClient, saveTokens } = require('./config/qbo');
|
||||
|
||||
// Import routes
|
||||
const customerRoutes = require('./routes/customers');
|
||||
const quoteRoutes = require('./routes/quotes');
|
||||
const invoiceRoutes = require('./routes/invoices');
|
||||
const paymentRoutes = require('./routes/payments');
|
||||
const qboRoutes = require('./routes/qbo');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
|
||||
// Import PDF service for browser initialization
|
||||
const { setBrowser } = require('./services/pdf-service');
|
||||
|
||||
// Import recurring invoice scheduler
|
||||
const { startRecurringScheduler } = require('./services/recurring-service');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Global browser instance
|
||||
let browser = null;
|
||||
|
||||
// Initialize browser on startup
|
||||
async function initBrowser() {
|
||||
if (!browser) {
|
||||
console.log('[BROWSER] Launching persistent browser...');
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--disable-software-rasterizer',
|
||||
'--no-zygote',
|
||||
'--single-process'
|
||||
],
|
||||
protocolTimeout: 180000,
|
||||
timeout: 180000
|
||||
});
|
||||
console.log('[BROWSER] Browser launched and ready');
|
||||
|
||||
// Pass browser to PDF service
|
||||
setBrowser(browser);
|
||||
|
||||
// Restart browser if it crashes
|
||||
browser.on('disconnected', () => {
|
||||
console.log('[BROWSER] Browser disconnected, restarting...');
|
||||
browser = null;
|
||||
setBrowser(null);
|
||||
initBrowser();
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// =====================================================
|
||||
// QBO OAuth Routes — mounted at root level (not under /api/qbo)
|
||||
// These must match the Intuit callback URL configuration
|
||||
// =====================================================
|
||||
app.get('/auth/qbo', (req, res) => {
|
||||
const client = getOAuthClient();
|
||||
const authUri = client.authorizeUri({
|
||||
scope: [OAuthClient.scopes.Accounting],
|
||||
state: 'intuit-qbo-auth'
|
||||
});
|
||||
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
||||
res.redirect(authUri);
|
||||
});
|
||||
|
||||
app.get('/auth/qbo/callback', async (req, res) => {
|
||||
const client = getOAuthClient();
|
||||
try {
|
||||
const authResponse = await client.createToken(req.url);
|
||||
console.log('✅ QBO Authorization erfolgreich!');
|
||||
saveTokens();
|
||||
res.redirect('/#settings');
|
||||
} catch (e) {
|
||||
console.error('❌ QBO Authorization fehlgeschlagen:', e);
|
||||
res.status(500).send(`
|
||||
<h2>QBO Authorization Failed</h2>
|
||||
<p>${e.message || e}</p>
|
||||
<a href="/">Zurück zur App</a>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// API Routes
|
||||
// =====================================================
|
||||
app.use('/api/customers', customerRoutes);
|
||||
app.use('/api/quotes', quoteRoutes);
|
||||
app.use('/api/invoices', invoiceRoutes);
|
||||
app.use('/api/payments', paymentRoutes);
|
||||
app.use('/api/qbo', qboRoutes);
|
||||
app.use('/api', settingsRoutes);
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
await initBrowser();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Quote System running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Start recurring invoice scheduler (checks every 24h)
|
||||
startRecurringScheduler();
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = app;
|
||||
270
src/routes/customers.js
Normal file
270
src/routes/customers.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Customer Routes
|
||||
* Handles customer CRUD operations and QBO sync
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||
|
||||
// GET all customers
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM customers ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching customers:', error);
|
||||
res.status(500).json({ error: 'Error fetching customers' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create customer
|
||||
router.post('/', async (req, res) => {
|
||||
const {
|
||||
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
||||
account_number, email, phone, phone2, taxable, remarks
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`,
|
||||
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||
city || null, state || null, zip_code || null, account_number || null,
|
||||
email || null, phone || null, phone2 || null,
|
||||
taxable !== undefined ? taxable : true, remarks || null]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
res.status(500).json({ error: 'Error creating customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT update customer
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
||||
account_number, email, phone, phone2, taxable, remarks
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE customers
|
||||
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
|
||||
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
|
||||
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $16
|
||||
RETURNING *`,
|
||||
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||
city || null, state || null, zip_code || null, account_number || null,
|
||||
email || null, phone || null, phone2 || null,
|
||||
taxable !== undefined ? taxable : true, remarks || null, id]
|
||||
);
|
||||
|
||||
const customer = result.rows[0];
|
||||
|
||||
// QBO Update
|
||||
if (customer.qbo_id) {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
// Get SyncToken
|
||||
const qboRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||
const syncToken = qboData.Customer?.SyncToken;
|
||||
|
||||
if (syncToken !== undefined) {
|
||||
const updatePayload = {
|
||||
Id: customer.qbo_id,
|
||||
SyncToken: syncToken,
|
||||
sparse: true,
|
||||
DisplayName: name,
|
||||
CompanyName: name,
|
||||
PrimaryEmailAddr: email ? { Address: email } : undefined,
|
||||
PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined,
|
||||
Taxable: taxable !== false,
|
||||
Notes: remarks || undefined
|
||||
};
|
||||
|
||||
// Contact → GivenName / FamilyName
|
||||
if (contact) {
|
||||
const parts = contact.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
updatePayload.GivenName = parts[0];
|
||||
updatePayload.FamilyName = parts.slice(1).join(' ');
|
||||
} else {
|
||||
updatePayload.GivenName = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
const addr = {};
|
||||
if (line1) addr.Line1 = line1;
|
||||
if (line2) addr.Line2 = line2;
|
||||
if (line3) addr.Line3 = line3;
|
||||
if (line4) addr.Line4 = line4;
|
||||
if (city) addr.City = city;
|
||||
if (state) addr.CountrySubDivisionCode = state;
|
||||
if (zip_code) addr.PostalCode = zip_code;
|
||||
if (Object.keys(addr).length > 0) updatePayload.BillAddr = addr;
|
||||
|
||||
console.log(`📤 Updating QBO Customer ${customer.qbo_id} (${name})...`);
|
||||
|
||||
await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatePayload)
|
||||
});
|
||||
|
||||
console.log(`✅ QBO Customer ${customer.qbo_id} updated.`);
|
||||
}
|
||||
} catch (qboError) {
|
||||
console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(customer);
|
||||
} catch (error) {
|
||||
console.error('Error updating customer:', error);
|
||||
res.status(500).json({ error: 'Error updating customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE customer
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
// Load customer
|
||||
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||
if (custResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
|
||||
const customer = custResult.rows[0];
|
||||
|
||||
// Deactivate in QBO if present
|
||||
if (customer.qbo_id) {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
// Get SyncToken
|
||||
const qboRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||
const syncToken = qboData.Customer?.SyncToken;
|
||||
|
||||
if (syncToken !== undefined) {
|
||||
console.log(`🗑️ Deactivating QBO Customer ${customer.qbo_id} (${customer.name})...`);
|
||||
|
||||
await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Id: customer.qbo_id,
|
||||
SyncToken: syncToken,
|
||||
sparse: true,
|
||||
Active: false
|
||||
})
|
||||
});
|
||||
|
||||
console.log(`✅ QBO Customer ${customer.qbo_id} deactivated.`);
|
||||
}
|
||||
} catch (qboError) {
|
||||
console.error(`⚠️ QBO deactivate failed for Customer ${customer.qbo_id}:`, qboError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete locally
|
||||
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
|
||||
res.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting customer:', error);
|
||||
res.status(500).json({ error: 'Error deleting customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST export customer to QBO
|
||||
router.post('/:id/export-qbo', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||
const customer = custResult.rows[0];
|
||||
|
||||
if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' });
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const qboCustomer = {
|
||||
DisplayName: customer.name,
|
||||
CompanyName: customer.name,
|
||||
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
|
||||
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
|
||||
Taxable: customer.taxable !== false,
|
||||
Notes: customer.remarks || undefined
|
||||
};
|
||||
|
||||
// Contact
|
||||
if (customer.contact) {
|
||||
const parts = customer.contact.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
qboCustomer.GivenName = parts[0];
|
||||
qboCustomer.FamilyName = parts.slice(1).join(' ');
|
||||
} else {
|
||||
qboCustomer.GivenName = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
const addr = {};
|
||||
if (customer.line1) addr.Line1 = customer.line1;
|
||||
if (customer.line2) addr.Line2 = customer.line2;
|
||||
if (customer.line3) addr.Line3 = customer.line3;
|
||||
if (customer.line4) addr.Line4 = customer.line4;
|
||||
if (customer.city) addr.City = customer.city;
|
||||
if (customer.state) addr.CountrySubDivisionCode = customer.state;
|
||||
if (customer.zip_code) addr.PostalCode = customer.zip_code;
|
||||
if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr;
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(qboCustomer)
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const qboId = data.Customer?.Id;
|
||||
|
||||
if (!qboId) throw new Error('QBO returned no ID');
|
||||
|
||||
await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]);
|
||||
|
||||
console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`);
|
||||
res.json({ success: true, qbo_id: qboId, name: customer.name });
|
||||
|
||||
} catch (error) {
|
||||
console.error('QBO Customer Export Error:', error);
|
||||
res.status(500).json({ error: 'Export failed: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
1163
src/routes/invoices.js
Normal file
1163
src/routes/invoices.js
Normal file
File diff suppressed because it is too large
Load Diff
29
src/routes/payments.js
Normal file
29
src/routes/payments.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Payment Routes
|
||||
* Handles payment recording and listing
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
// GET all payments
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT p.*, c.name as customer_name,
|
||||
COALESCE(json_agg(json_build_object(
|
||||
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
|
||||
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
||||
FROM payments p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
||||
LEFT JOIN invoices i ON i.id = pi.invoice_id
|
||||
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Error fetching payments' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
572
src/routes/qbo.js
Normal file
572
src/routes/qbo.js
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* QBO Routes
|
||||
* Handles QBO sync and data operations
|
||||
* NOTE: OAuth auth/callback routes are in index.js (root-level paths)
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||
|
||||
// GET QBO status
|
||||
router.get('/status', (req, res) => {
|
||||
try {
|
||||
const client = getOAuthClient();
|
||||
const token = client.getToken();
|
||||
const hasToken = !!(token && token.refresh_token);
|
||||
res.json({
|
||||
connected: hasToken,
|
||||
realmId: token?.realmId || null
|
||||
});
|
||||
} catch (e) {
|
||||
res.json({ connected: false });
|
||||
}
|
||||
});
|
||||
|
||||
// GET bank accounts from QBO
|
||||
router.get('/accounts', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET payment methods from QBO
|
||||
router.get('/payment-methods', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET labor rate from QBO
|
||||
router.get('/labor-rate', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/item/5`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const rate = data.Item?.UnitPrice || null;
|
||||
|
||||
console.log(`💰 QBO Labor Rate: $${rate}`);
|
||||
res.json({ rate });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching labor rate:', error);
|
||||
res.json({ rate: null });
|
||||
}
|
||||
});
|
||||
|
||||
// GET last sync timestamp
|
||||
router.get('/last-sync', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
|
||||
res.json({ last_sync: result.rows[0]?.value || null });
|
||||
} catch (error) {
|
||||
res.json({ last_sync: null });
|
||||
}
|
||||
});
|
||||
|
||||
// GET overdue invoices from QBO
|
||||
router.get('/overdue', async (req, res) => {
|
||||
try {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 30);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
|
||||
|
||||
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const invoices = data.QueryResponse?.Invoice || [];
|
||||
|
||||
console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`);
|
||||
res.json(invoices);
|
||||
|
||||
} catch (error) {
|
||||
console.error("QBO Report Error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST import unpaid invoices from QBO
|
||||
router.post('/import-unpaid', async (req, res) => {
|
||||
const dbClient = await pool.connect();
|
||||
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
console.log('📥 QBO Import: Lade unbezahlte Rechnungen...');
|
||||
|
||||
const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const qboInvoices = data.QueryResponse?.Invoice || [];
|
||||
|
||||
console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`);
|
||||
|
||||
if (qboInvoices.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
skippedNoCustomer: 0,
|
||||
message: 'Keine unbezahlten Rechnungen in QBO gefunden.'
|
||||
});
|
||||
}
|
||||
|
||||
// Load local customers
|
||||
const customersResult = await dbClient.query(
|
||||
'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL'
|
||||
);
|
||||
const customerMap = new Map();
|
||||
customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c));
|
||||
|
||||
// Get already imported QBO invoices
|
||||
const existingResult = await dbClient.query(
|
||||
'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL'
|
||||
);
|
||||
const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id));
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let skippedNoCustomer = 0;
|
||||
const skippedCustomerNames = [];
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
for (const qboInv of qboInvoices) {
|
||||
const qboId = String(qboInv.Id);
|
||||
|
||||
if (existingQboIds.has(qboId)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const customerQboId = String(qboInv.CustomerRef?.value || '');
|
||||
const localCustomer = customerMap.get(customerQboId);
|
||||
|
||||
if (!localCustomer) {
|
||||
skippedNoCustomer++;
|
||||
const custName = qboInv.CustomerRef?.name || 'Unbekannt';
|
||||
if (!skippedCustomerNames.includes(custName)) {
|
||||
skippedCustomerNames.push(custName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const docNumber = qboInv.DocNumber || '';
|
||||
const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0];
|
||||
const syncToken = qboInv.SyncToken || '';
|
||||
|
||||
let terms = 'Net 30';
|
||||
if (qboInv.SalesTermRef?.name) {
|
||||
terms = qboInv.SalesTermRef.name;
|
||||
}
|
||||
|
||||
const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0;
|
||||
const taxExempt = taxAmount === 0;
|
||||
|
||||
const total = parseFloat(qboInv.TotalAmt) || 0;
|
||||
const subtotal = total - taxAmount;
|
||||
const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25;
|
||||
|
||||
const authCode = qboInv.CustomerMemo?.value || '';
|
||||
|
||||
const invoiceResult = await dbClient.query(
|
||||
`INSERT INTO invoices
|
||||
(invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||
qbo_id, qbo_sync_token, qbo_doc_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id`,
|
||||
[docNumber, localCustomer.id, txnDate, terms, authCode,
|
||||
taxExempt, taxRate, subtotal, taxAmount, total,
|
||||
qboId, syncToken, docNumber]
|
||||
);
|
||||
|
||||
const localInvoiceId = invoiceResult.rows[0].id;
|
||||
|
||||
const lines = qboInv.Line || [];
|
||||
let itemOrder = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.DetailType !== 'SalesItemLineDetail') continue;
|
||||
|
||||
const detail = line.SalesItemLineDetail || {};
|
||||
const qty = String(detail.Qty || 1);
|
||||
const rate = String(detail.UnitPrice || 0);
|
||||
const amount = String(line.Amount || 0);
|
||||
const description = line.Description || '';
|
||||
|
||||
const itemRefValue = detail.ItemRef?.value || '9';
|
||||
const itemRefName = (detail.ItemRef?.name || '').toLowerCase();
|
||||
let qboItemId = '9';
|
||||
if (itemRefValue === '5' || itemRefName.includes('labor')) {
|
||||
qboItemId = '5';
|
||||
}
|
||||
|
||||
await dbClient.query(
|
||||
`INSERT INTO invoice_items
|
||||
(invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId]
|
||||
);
|
||||
itemOrder++;
|
||||
}
|
||||
|
||||
imported++;
|
||||
console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`);
|
||||
}
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
const message = [
|
||||
`${imported} Rechnungen importiert.`,
|
||||
skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '',
|
||||
skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
console.log(`📥 QBO Import abgeschlossen: ${message}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imported,
|
||||
skipped,
|
||||
skippedNoCustomer,
|
||||
skippedCustomerNames,
|
||||
message
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK');
|
||||
console.error('❌ QBO Import Error:', error);
|
||||
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST record payment in QBO
|
||||
router.post('/record-payment', async (req, res) => {
|
||||
const {
|
||||
invoice_payments,
|
||||
payment_date,
|
||||
reference_number,
|
||||
payment_method_id,
|
||||
payment_method_name,
|
||||
deposit_to_account_id,
|
||||
deposit_to_account_name
|
||||
} = req.body;
|
||||
|
||||
if (!invoice_payments || invoice_payments.length === 0) {
|
||||
return res.status(400).json({ error: 'No invoices selected.' });
|
||||
}
|
||||
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const ids = invoice_payments.map(ip => ip.invoice_id);
|
||||
const result = await dbClient.query(
|
||||
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = ANY($1)`, [ids]
|
||||
);
|
||||
const invoicesData = result.rows;
|
||||
|
||||
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||
if (notInQbo.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
||||
});
|
||||
}
|
||||
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||
if (custIds.length > 1) {
|
||||
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
|
||||
}
|
||||
|
||||
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
|
||||
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
|
||||
|
||||
const qboPayment = {
|
||||
CustomerRef: { value: custIds[0] },
|
||||
TotalAmt: totalAmt,
|
||||
TxnDate: payment_date,
|
||||
PaymentRefNum: reference_number || '',
|
||||
PaymentMethodRef: { value: payment_method_id },
|
||||
DepositToAccountRef: { value: deposit_to_account_id },
|
||||
Line: invoicesData.map(inv => ({
|
||||
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
|
||||
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
|
||||
}))
|
||||
};
|
||||
|
||||
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(qboPayment)
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
|
||||
if (!data.Payment) {
|
||||
return res.status(500).json({
|
||||
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
||||
});
|
||||
}
|
||||
|
||||
const qboPaymentId = data.Payment.Id;
|
||||
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[payment_date, reference_number || null, payment_method_name || 'Check',
|
||||
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
|
||||
);
|
||||
const localPaymentId = payResult.rows[0].id;
|
||||
|
||||
for (const ip of invoice_payments) {
|
||||
const payAmt = parseFloat(ip.amount);
|
||||
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
||||
const invTotal = inv ? parseFloat(inv.total) : 0;
|
||||
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[localPaymentId, ip.invoice_id, payAmt]
|
||||
);
|
||||
if (payAmt >= invTotal) {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[payment_date, ip.invoice_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment_id: localPaymentId,
|
||||
qbo_payment_id: qboPaymentId,
|
||||
total: totalAmt,
|
||||
invoices_paid: invoice_payments.length,
|
||||
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
||||
});
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Payment Error:', error);
|
||||
res.status(500).json({ error: 'Payment failed: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST sync payments from QBO
|
||||
router.post('/sync-payments', async (req, res) => {
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const openResult = await dbClient.query(`
|
||||
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
|
||||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
|
||||
FROM invoices i
|
||||
WHERE i.qbo_id IS NOT NULL
|
||||
`);
|
||||
|
||||
const openInvoices = openResult.rows;
|
||||
if (openInvoices.length === 0) {
|
||||
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
|
||||
return res.json({ synced: 0, message: 'All invoices up to date.' });
|
||||
}
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const batchSize = 50;
|
||||
const qboInvoices = new Map();
|
||||
|
||||
for (let i = 0; i < openInvoices.length; i += batchSize) {
|
||||
const batch = openInvoices.slice(i, i + batchSize);
|
||||
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
||||
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const invoices = data.QueryResponse?.Invoice || [];
|
||||
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||
}
|
||||
|
||||
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
||||
|
||||
let updated = 0;
|
||||
let newPayments = 0;
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
for (const localInv of openInvoices) {
|
||||
const qboInv = qboInvoices.get(localInv.qbo_id);
|
||||
if (!qboInv) continue;
|
||||
|
||||
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
||||
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
||||
const localPaid = parseFloat(localInv.local_paid) || 0;
|
||||
|
||||
if (qboBalance === 0 && qboTotal > 0) {
|
||||
const UNDEPOSITED_FUNDS_ID = '221';
|
||||
let status = 'Paid';
|
||||
|
||||
if (qboInv.LinkedTxn) {
|
||||
for (const txn of qboInv.LinkedTxn) {
|
||||
if (txn.TxnType === 'Payment') {
|
||||
try {
|
||||
const pmRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||
const payment = pmData.Payment;
|
||||
if (payment && payment.DepositToAccountRef &&
|
||||
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
|
||||
status = 'Deposited';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
|
||||
if (needsUpdate) {
|
||||
await dbClient.query(
|
||||
`UPDATE invoices SET
|
||||
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
||||
payment_status = $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`,
|
||||
[status, localInv.id]
|
||||
);
|
||||
updated++;
|
||||
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
|
||||
}
|
||||
|
||||
const diff = qboTotal - localPaid;
|
||||
if (diff > 0.01) {
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
|
||||
}
|
||||
|
||||
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
||||
const qboPaid = qboTotal - qboBalance;
|
||||
const diff = qboPaid - localPaid;
|
||||
|
||||
const needsUpdate = localInv.payment_status !== 'Partial';
|
||||
if (needsUpdate) {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['Partial', localInv.id]
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (diff > 0.01) {
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbClient.query(`
|
||||
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1
|
||||
`, [new Date().toISOString()]);
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
||||
res.json({
|
||||
synced: updated,
|
||||
new_payments: newPayments,
|
||||
total_checked: openInvoices.length,
|
||||
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Sync Error:', error);
|
||||
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
370
src/routes/quotes.js
Normal file
370
src/routes/quotes.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Quote Routes
|
||||
* Handles quote CRUD operations and PDF generation
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { pool } = require('../config/database');
|
||||
const { getNextQuoteNumber } = require('../utils/numberGenerators');
|
||||
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderQuoteItems, formatAddressLines } = require('../services/pdf-service');
|
||||
|
||||
// GET all quotes
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
ORDER BY q.created_at DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching quotes:', error);
|
||||
res.status(500).json({ error: 'Error fetching quotes' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET single quote
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
quote: quoteResult.rows[0],
|
||||
items: itemsResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching quote:', error);
|
||||
res.status(500).json({ error: 'Error fetching quote' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create quote
|
||||
router.post('/', async (req, res) => {
|
||||
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const quote_number = await getNextQuoteNumber();
|
||||
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||
if (!isNaN(amount)) {
|
||||
subtotal += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tax_rate = 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
const quoteResult = await client.query(
|
||||
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd]
|
||||
);
|
||||
|
||||
const quoteId = quoteResult.rows[0].id;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await client.query(
|
||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error creating quote:', error);
|
||||
res.status(500).json({ error: 'Error creating quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT update quote
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||
if (!isNaN(amount)) {
|
||||
subtotal += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tax_rate = 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
|
||||
await client.query(
|
||||
`UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4,
|
||||
subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9`,
|
||||
[customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id]
|
||||
);
|
||||
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await client.query(
|
||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error updating quote:', error);
|
||||
res.status(500).json({ error: 'Error updating quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE quote
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||
await client.query('DELETE FROM quotes WHERE id = $1', [id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error deleting quote:', error);
|
||||
res.status(500).json({ error: 'Error deleting quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// GET quote PDF
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
||||
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||
|
||||
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||
|
||||
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||
.replace('{{ITEMS}}', itemsHTML)
|
||||
.replace('{{TBD_NOTE}}', tbdNote);
|
||||
|
||||
const pdf = await generatePdfFromHtml(html);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': pdf.length,
|
||||
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
|
||||
});
|
||||
res.end(pdf, 'binary');
|
||||
console.log('[PDF] Quote PDF sent successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PDF] ERROR:', error);
|
||||
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET quote HTML (debug)
|
||||
router.get('/:id/html', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||
|
||||
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||
|
||||
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||
.replace('{{ITEMS}}', itemsHTML)
|
||||
.replace('{{TBD_NOTE}}', tbdNote);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[HTML] ERROR:', error);
|
||||
res.status(500).json({ error: 'Error generating HTML' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST convert quote to invoice
|
||||
router.post('/:id/convert-to-invoice', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const hasTBD = itemsResult.rows.some(item =>
|
||||
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
|
||||
);
|
||||
|
||||
if (hasTBD) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
|
||||
}
|
||||
|
||||
const invoice_number = null;
|
||||
const invoiceDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
const invoiceResult = await client.query(
|
||||
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id]
|
||||
);
|
||||
|
||||
const invoiceId = invoiceResult.rows[0].id;
|
||||
|
||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||
const item = itemsResult.rows[i];
|
||||
await client.query(
|
||||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(invoiceResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error converting quote to invoice:', error);
|
||||
res.status(500).json({ error: 'Error converting quote to invoice' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
71
src/routes/settings.js
Normal file
71
src/routes/settings.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Settings Routes
|
||||
* Handles logo upload and settings
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Configure multer for logo upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads');
|
||||
try {
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error('Error creating upload directory:', err);
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, 'company-logo.png');
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET logo info
|
||||
router.get('/logo-info', async (req, res) => {
|
||||
try {
|
||||
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
|
||||
try {
|
||||
await fs.access(logoPath);
|
||||
res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' });
|
||||
} catch {
|
||||
res.json({ hasLogo: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking logo:', error);
|
||||
res.status(500).json({ error: 'Error checking logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST upload logo
|
||||
router.post('/upload-logo', upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
res.json({
|
||||
message: 'Logo uploaded successfully',
|
||||
path: '/uploads/company-logo.png'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({ error: 'Error uploading logo' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
134
src/services/email-service.js
Normal file
134
src/services/email-service.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// src/services/email-service.js
|
||||
const { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
|
||||
const nodemailer = require('nodemailer');
|
||||
const mjml2html = require('mjml');
|
||||
|
||||
const sesClient = new SESv2Client({
|
||||
region: process.env.AWS_REGION || 'us-east-2'
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
SES: {
|
||||
sesClient,
|
||||
SendEmailCommand
|
||||
}
|
||||
});
|
||||
|
||||
function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) {
|
||||
const formattedText = customText || '';
|
||||
|
||||
// Stripe Pay Button — only if payment link exists
|
||||
let paymentButtonMjml = '';
|
||||
if (stripePaymentUrl) {
|
||||
paymentButtonMjml = `
|
||||
<mj-section background-color="#ffffff" padding="0 30px">
|
||||
<mj-column>
|
||||
<mj-button
|
||||
background-color="#635bff"
|
||||
color="white"
|
||||
border-radius="6px"
|
||||
href="${stripePaymentUrl}"
|
||||
font-weight="600"
|
||||
font-size="16px"
|
||||
padding="25px 0 10px 0"
|
||||
inner-padding="14px 30px"
|
||||
width="100%">
|
||||
Pay Online — Credit Card or ACH
|
||||
</mj-button>
|
||||
<mj-text font-size="12px" color="#94a3b8" align="center" padding="0 0 20px 0">
|
||||
ACH payments incur lower processing fees. Secure payment powered by Stripe.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>`;
|
||||
}
|
||||
|
||||
const template = `
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" />
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
.email-body p {
|
||||
margin: 0 0 14px 0 !important;
|
||||
}
|
||||
.email-body p:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#f4f4f5">
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-spacer height="20px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#ffffff" padding="30px" border-radius="8px 8px 0 0">
|
||||
<mj-column>
|
||||
<mj-text font-size="22px" font-weight="700" color="#1e3a8a" padding="0">
|
||||
Bay Area Affiliates, Inc.
|
||||
</mj-text>
|
||||
<mj-text font-size="15px" color="#64748b" padding="5px 0 0 0">
|
||||
Invoice #${invoice.invoice_number || invoice.id}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#ffffff" padding="0 30px 30px 30px">
|
||||
<mj-column>
|
||||
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
|
||||
${formattedText}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
${paymentButtonMjml}
|
||||
|
||||
<mj-section background-color="#ffffff" padding="0 30px 30px 30px" border-radius="0 0 8px 8px">
|
||||
<mj-column>
|
||||
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="10px" padding-bottom="20px" />
|
||||
|
||||
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
|
||||
<strong>Prefer to pay by check?</strong><br/>
|
||||
Please make checks payable to Bay Area Affiliates, Inc. and mail to:<br/>
|
||||
1001 Blucher Street<br/>
|
||||
Corpus Christi, Texas 78401
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
`;
|
||||
|
||||
const result = mjml2html(template, { validationLevel: 'strict' });
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.error('MJML Parse Errors:', result.errors);
|
||||
}
|
||||
|
||||
return result.html;
|
||||
}
|
||||
|
||||
async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePaymentUrl, pdfBuffer) {
|
||||
const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl);
|
||||
|
||||
const mailOptions = {
|
||||
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||
to: recipientEmail,
|
||||
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Invoice_${invoice.invoice_number || invoice.id}_BayAreaAffiliates.pdf`,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
module.exports = { sendInvoiceEmail };
|
||||
206
src/services/pdf-service.js
Normal file
206
src/services/pdf-service.js
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* PDF Generation Service
|
||||
* Handles HTML to PDF conversion using Puppeteer
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { formatMoney, formatDate } = require('../utils/helpers');
|
||||
|
||||
// Initialize browser - will be set from main app
|
||||
let browserInstance = null;
|
||||
|
||||
function setBrowser(browser) {
|
||||
browserInstance = browser;
|
||||
}
|
||||
|
||||
async function getBrowser() {
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF from HTML template
|
||||
*/
|
||||
async function generatePdfFromHtml(html, options = {}) {
|
||||
const {
|
||||
format = 'Letter',
|
||||
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
||||
printBackground = true
|
||||
} = options;
|
||||
|
||||
const browser = await getBrowser();
|
||||
if (!browser) {
|
||||
throw new Error('Browser not initialized');
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
//await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.setContent(html, { waitUntil: 'load', timeout: 5000 });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format,
|
||||
printBackground,
|
||||
margin
|
||||
});
|
||||
|
||||
await page.close();
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company logo as base64 HTML
|
||||
*/
|
||||
async function getLogoHtml() {
|
||||
let logoHTML = '';
|
||||
try {
|
||||
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
|
||||
const logoData = await fs.readFile(logoPath);
|
||||
const logoBase64 = logoData.toString('base64');
|
||||
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||
} catch (err) {
|
||||
// No logo found
|
||||
}
|
||||
return logoHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render invoice items to HTML table rows
|
||||
*/
|
||||
function renderInvoiceItems(items, invoice = null) {
|
||||
let itemsHTML = items.map(item => {
|
||||
let rateFormatted = item.rate;
|
||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="description">${item.description}</td>
|
||||
<td class="rate">${rateFormatted}</td>
|
||||
<td class="amount">${item.amount}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Add subtotal
|
||||
const subtotal = invoice ? invoice.subtotal : 0;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Subtotal:</td>
|
||||
<td class="total-amount">$${formatMoney(subtotal)}</td>
|
||||
</tr>`;
|
||||
|
||||
// Add tax if not exempt
|
||||
if (invoice && !invoice.tax_exempt) {
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
|
||||
<td class="total-amount">$${formatMoney(invoice.tax_amount)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Add total
|
||||
const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0;
|
||||
const total = invoice ? parseFloat(invoice.total) : 0;
|
||||
const balanceDue = total - amountPaid;
|
||||
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL:</td>
|
||||
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||
</tr>`;
|
||||
|
||||
// Add downpayment/balance if partial
|
||||
if (amountPaid > 0) {
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="color: #059669;">Downpayment:</td>
|
||||
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
|
||||
</tr>
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">BALANCE DUE:</td>
|
||||
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Thank you message
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||
</tr>`;
|
||||
|
||||
return itemsHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render quote items to HTML table rows
|
||||
*/
|
||||
function renderQuoteItems(items, quote = null) {
|
||||
let itemsHTML = items.map(item => {
|
||||
let rateFormatted = item.rate;
|
||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="description">${item.description}</td>
|
||||
<td class="rate">${rateFormatted}</td>
|
||||
<td class="amount">${item.amount}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Add subtotal
|
||||
const subtotal = quote ? quote.subtotal : 0;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Subtotal</td>
|
||||
<td class="total-amount">$${formatMoney(subtotal)}</td>
|
||||
</tr>`;
|
||||
|
||||
// Add tax if not exempt
|
||||
if (quote && !quote.tax_exempt) {
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%)</td>
|
||||
<td class="total-amount">$${formatMoney(quote.tax_amount)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Add total
|
||||
const total = quote ? quote.total : 0;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL</td>
|
||||
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||
</tr>
|
||||
<tr class="footer-row">
|
||||
<td colspan="4" class="thank-you">This quote is valid for 14 days. We appreciate your business </td>
|
||||
</tr>`;
|
||||
|
||||
return itemsHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format address lines for template
|
||||
*/
|
||||
function formatAddressLines(line1, line2, line3, line4, customerName) {
|
||||
const addressLines = [];
|
||||
if (line1 && line1.trim().toLowerCase() !== (customerName || '').trim().toLowerCase()) {
|
||||
addressLines.push(line1);
|
||||
}
|
||||
if (line2) addressLines.push(line2);
|
||||
if (line3) addressLines.push(line3);
|
||||
if (line4) addressLines.push(line4);
|
||||
return addressLines.join('<br>');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setBrowser,
|
||||
getBrowser,
|
||||
generatePdfFromHtml,
|
||||
getLogoHtml,
|
||||
renderInvoiceItems,
|
||||
renderQuoteItems,
|
||||
formatAddressLines
|
||||
};
|
||||
222
src/services/qbo-service.js
Normal file
222
src/services/qbo-service.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// src/services/qbo-service.js
|
||||
/**
|
||||
* QuickBooks Online Service
|
||||
* Handles QBO API interactions
|
||||
*/
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
|
||||
|
||||
// QBO Item IDs
|
||||
const QBO_LABOR_ID = '5';
|
||||
const QBO_PARTS_ID = '9';
|
||||
|
||||
function getClientInfo() {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
return { oauthClient, companyId, baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export invoice to QBO
|
||||
*/
|
||||
async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||
const invoiceRes = await dbClient.query(`
|
||||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = $1
|
||||
`, [invoiceId]);
|
||||
|
||||
const invoice = invoiceRes.rows[0];
|
||||
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
||||
|
||||
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||
const items = itemsRes.rows;
|
||||
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
|
||||
// Get next DocNumber
|
||||
const maxNumResult = await dbClient.query(`
|
||||
SELECT GREATEST(
|
||||
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
|
||||
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
|
||||
) as max_num
|
||||
`);
|
||||
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
||||
|
||||
const lineItems = items.map(item => {
|
||||
const parseNum = (val) => {
|
||||
if (val === null || val === undefined) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||
};
|
||||
const rate = parseNum(item.rate);
|
||||
const qty = parseNum(item.quantity) || 1;
|
||||
const amount = rate * qty;
|
||||
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||
|
||||
return {
|
||||
"DetailType": "SalesItemLineDetail",
|
||||
"Amount": amount,
|
||||
"Description": item.description,
|
||||
"SalesItemLineDetail": {
|
||||
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||
"UnitPrice": rate,
|
||||
"Qty": qty
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const qboPayload = {
|
||||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||
"DocNumber": nextDocNumber,
|
||||
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||||
"Line": lineItems,
|
||||
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
|
||||
"EmailStatus": "NotSet",
|
||||
"BillEmail": { "Address": invoice.email || "" }
|
||||
};
|
||||
|
||||
let qboInvoice = null;
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`);
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(qboPayload)
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
|
||||
if (data.Fault?.Error?.[0]?.code === '6140') {
|
||||
console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
|
||||
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
|
||||
continue;
|
||||
}
|
||||
if (data.Fault) {
|
||||
const errMsg = data.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(data.Fault);
|
||||
console.error(`❌ QBO Export Fault:`, errMsg);
|
||||
throw new Error('QBO export failed: ' + errMsg);
|
||||
}
|
||||
qboInvoice = data.Invoice || data;
|
||||
if (qboInvoice.Id) break;
|
||||
|
||||
throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
|
||||
}
|
||||
|
||||
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
|
||||
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5',
|
||||
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, invoiceId]
|
||||
);
|
||||
|
||||
console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`);
|
||||
return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync invoice to QBO (update)
|
||||
*/
|
||||
async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||
const invoiceRes = await dbClient.query(`
|
||||
SELECT i.*, c.qbo_id as customer_qbo_id
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = $1
|
||||
`, [invoiceId]);
|
||||
|
||||
const invoice = invoiceRes.rows[0];
|
||||
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
|
||||
|
||||
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
|
||||
const qboRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||
const currentSyncToken = qboData.Invoice?.SyncToken;
|
||||
|
||||
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
|
||||
|
||||
const lineItems = itemsRes.rows.map(item => {
|
||||
const parseNum = (val) => {
|
||||
if (val === null || val === undefined) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||
};
|
||||
const rate = parseNum(item.rate);
|
||||
const qty = parseNum(item.quantity) || 1;
|
||||
const amount = rate * qty;
|
||||
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||
|
||||
return {
|
||||
"DetailType": "SalesItemLineDetail",
|
||||
"Amount": amount,
|
||||
"Description": item.description,
|
||||
"SalesItemLineDetail": {
|
||||
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||
"UnitPrice": rate,
|
||||
"Qty": qty
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const updatePayload = {
|
||||
"Id": invoice.qbo_id,
|
||||
"SyncToken": currentSyncToken,
|
||||
"sparse": true,
|
||||
"Line": lineItems,
|
||||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||||
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
|
||||
};
|
||||
|
||||
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
|
||||
|
||||
const updateRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatePayload)
|
||||
});
|
||||
|
||||
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
|
||||
|
||||
if (updateData.Fault) {
|
||||
const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
|
||||
console.error(`❌ QBO Sync Fault:`, errMsg);
|
||||
throw new Error('QBO sync failed: ' + errMsg);
|
||||
}
|
||||
|
||||
const updated = updateData.Invoice || updateData;
|
||||
|
||||
if (!updated.Id) {
|
||||
throw new Error('QBO update returned no ID');
|
||||
}
|
||||
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[updated.SyncToken, invoiceId]
|
||||
);
|
||||
|
||||
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
|
||||
return { success: true, sync_token: updated.SyncToken };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QBO_LABOR_ID,
|
||||
QBO_PARTS_ID,
|
||||
getClientInfo,
|
||||
exportInvoiceToQbo,
|
||||
syncInvoiceToQbo
|
||||
};
|
||||
174
src/services/recurring-service.js
Normal file
174
src/services/recurring-service.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Recurring Invoice Service
|
||||
* Checks daily for recurring invoices that are due and creates new copies.
|
||||
*
|
||||
* Logic:
|
||||
* - Runs every 24h (and once on startup after 60s delay)
|
||||
* - Finds invoices where is_recurring=true AND next_recurring_date <= today
|
||||
* - Creates a copy with updated invoice_date = next_recurring_date
|
||||
* - Advances next_recurring_date by the interval (monthly/yearly)
|
||||
* - Auto-exports to QBO if customer is linked
|
||||
*/
|
||||
const { pool } = require('../config/database');
|
||||
const { exportInvoiceToQbo } = require('./qbo-service');
|
||||
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const STARTUP_DELAY_MS = 60 * 1000; // 60 seconds after boot
|
||||
|
||||
/**
|
||||
* Calculate next date based on interval
|
||||
*/
|
||||
function advanceDate(dateStr, interval) {
|
||||
const d = new Date(dateStr);
|
||||
if (interval === 'monthly') {
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
} else if (interval === 'yearly') {
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
}
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all due recurring invoices
|
||||
*/
|
||||
async function processRecurringInvoices() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
console.log(`🔄 [RECURRING] Checking for due recurring invoices (today: ${today})...`);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Find all recurring invoices that are due
|
||||
const dueResult = await client.query(`
|
||||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.is_recurring = true
|
||||
AND i.next_recurring_date IS NOT NULL
|
||||
AND i.next_recurring_date <= $1
|
||||
`, [today]);
|
||||
|
||||
if (dueResult.rows.length === 0) {
|
||||
console.log('🔄 [RECURRING] No recurring invoices due.');
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
console.log(`🔄 [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`);
|
||||
|
||||
let created = 0;
|
||||
|
||||
for (const source of dueResult.rows) {
|
||||
await client.query('BEGIN');
|
||||
|
||||
try {
|
||||
// Load items from the source invoice
|
||||
const itemsResult = await client.query(
|
||||
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||||
[source.id]
|
||||
);
|
||||
|
||||
const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0];
|
||||
|
||||
// Create the new invoice (no invoice_number — QBO will assign one)
|
||||
const newInvoice = await client.query(
|
||||
`INSERT INTO invoices (
|
||||
invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||
bill_to_name, recurring_source_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
`DRAFT-${Date.now()}`, // Temporary, QBO export will assign real number
|
||||
source.customer_id,
|
||||
newInvoiceDate,
|
||||
source.terms,
|
||||
source.auth_code,
|
||||
source.tax_exempt,
|
||||
source.tax_rate,
|
||||
source.subtotal,
|
||||
source.tax_amount,
|
||||
source.total,
|
||||
source.bill_to_name,
|
||||
source.id
|
||||
]
|
||||
);
|
||||
|
||||
const newInvoiceId = newInvoice.rows[0].id;
|
||||
|
||||
// Copy items
|
||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||
const item = itemsResult.rows[i];
|
||||
await client.query(
|
||||
`INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[newInvoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
// Advance the source invoice's next_recurring_date
|
||||
const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval);
|
||||
await client.query(
|
||||
'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[nextDate, source.id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log(` ✅ Created recurring invoice from #${source.invoice_number || source.id} → new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`);
|
||||
|
||||
// Auto-export to QBO (outside transaction, non-blocking)
|
||||
try {
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const qboResult = await exportInvoiceToQbo(newInvoiceId, dbClient);
|
||||
if (qboResult.success) {
|
||||
console.log(` 📤 Auto-exported to QBO: #${qboResult.qbo_doc_number}`);
|
||||
} else if (qboResult.skipped) {
|
||||
console.log(` ℹ️ QBO export skipped: ${qboResult.reason}`);
|
||||
}
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
} catch (qboErr) {
|
||||
console.error(` ⚠️ QBO auto-export failed for recurring invoice ${newInvoiceId}:`, qboErr.message);
|
||||
}
|
||||
|
||||
created++;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(` ❌ Failed to create recurring invoice from #${source.invoice_number || source.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔄 [RECURRING] Done. Created ${created} invoice(s).`);
|
||||
return { created };
|
||||
} catch (error) {
|
||||
console.error('❌ [RECURRING] Error:', error.message);
|
||||
return { created: 0, error: error.message };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the recurring invoice scheduler
|
||||
*/
|
||||
function startRecurringScheduler() {
|
||||
// First check after startup delay
|
||||
setTimeout(() => {
|
||||
console.log('🔄 [RECURRING] Initial check...');
|
||||
processRecurringInvoices();
|
||||
}, STARTUP_DELAY_MS);
|
||||
|
||||
// Then every 24 hours
|
||||
setInterval(() => {
|
||||
processRecurringInvoices();
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
console.log(`🔄 [RECURRING] Scheduler started (checks every 24h, first check in ${STARTUP_DELAY_MS / 1000}s)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
processRecurringInvoices,
|
||||
startRecurringScheduler,
|
||||
advanceDate
|
||||
};
|
||||
173
src/services/stripe-service.js
Normal file
173
src/services/stripe-service.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// src/services/stripe-service.js
|
||||
/**
|
||||
* Stripe Payment Links Service
|
||||
* Creates payment links for invoices, checks payment status via API polling.
|
||||
*
|
||||
* No webhooks needed — the app is not internet-facing.
|
||||
* Status is checked on-demand via checkPaymentStatus().
|
||||
*/
|
||||
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
/**
|
||||
* Create a Stripe Payment Link for an invoice.
|
||||
*
|
||||
* @param {object} invoice - Invoice record from DB
|
||||
* @param {number} invoice.id
|
||||
* @param {string} invoice.invoice_number
|
||||
* @param {number} invoice.total - Total in dollars (e.g. 194.85)
|
||||
* @param {number} invoice.balance - Remaining balance (total - amount_paid)
|
||||
* @param {string} [invoice.customer_name]
|
||||
* @returns {object} { paymentLinkId, paymentLinkUrl }
|
||||
*/
|
||||
async function createPaymentLink(invoice) {
|
||||
const amountDue = parseFloat(invoice.balance ?? invoice.total);
|
||||
if (!amountDue || amountDue <= 0) {
|
||||
throw new Error('Invoice has no balance due.');
|
||||
}
|
||||
|
||||
const unitAmount = Math.round(amountDue * 100); // Convert dollars to cents
|
||||
const invoiceLabel = `Invoice #${invoice.invoice_number || invoice.id}`;
|
||||
|
||||
console.log(`💳 Creating Stripe Payment Link for ${invoiceLabel} — $${amountDue.toFixed(2)}...`);
|
||||
|
||||
const paymentLink = await stripe.paymentLinks.create({
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: 'usd',
|
||||
product_data: {
|
||||
name: invoiceLabel,
|
||||
description: invoice.customer_name
|
||||
? `Bay Area Affiliates, Inc. — ${invoice.customer_name}`
|
||||
: 'Bay Area Affiliates, Inc.'
|
||||
},
|
||||
unit_amount: unitAmount,
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
metadata: {
|
||||
invoice_id: String(invoice.id),
|
||||
invoice_number: String(invoice.invoice_number || ''),
|
||||
source: 'quote-invoice-system'
|
||||
},
|
||||
payment_method_types: ['card', 'us_bank_account'],
|
||||
// After payment, show a simple confirmation
|
||||
after_completion: {
|
||||
type: 'hosted_confirmation',
|
||||
hosted_confirmation: {
|
||||
custom_message: `Thank you! Your payment for ${invoiceLabel} has been received. Bay Area Affiliates, Inc. will send a confirmation.`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ Stripe Payment Link created: ${paymentLink.url}`);
|
||||
|
||||
return {
|
||||
paymentLinkId: paymentLink.id,
|
||||
paymentLinkUrl: paymentLink.url
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check payment status for a Stripe Payment Link.
|
||||
* Polls completed Checkout Sessions associated with the payment link.
|
||||
*
|
||||
* @param {string} paymentLinkId - Stripe Payment Link ID (plink_xxx)
|
||||
* @returns {object} { paid, status, details }
|
||||
*/
|
||||
async function checkPaymentStatus(paymentLinkId) {
|
||||
if (!paymentLinkId) {
|
||||
return { paid: false, status: 'no_link', details: null };
|
||||
}
|
||||
|
||||
console.log(`🔍 Checking Stripe payment status for ${paymentLinkId}...`);
|
||||
|
||||
// List checkout sessions created via this payment link
|
||||
const sessions = await stripe.checkout.sessions.list({
|
||||
payment_link: paymentLinkId,
|
||||
limit: 10,
|
||||
expand: ['data.payment_intent']
|
||||
});
|
||||
|
||||
// Find a completed/paid session
|
||||
const paidSession = sessions.data.find(s => s.payment_status === 'paid');
|
||||
|
||||
if (paidSession) {
|
||||
const pi = paidSession.payment_intent;
|
||||
const paymentMethod = pi?.payment_method_types?.[0] || 'unknown';
|
||||
const amountReceived = (pi?.amount_received || 0) / 100;
|
||||
const stripeFee = calculateStripeFee(amountReceived, paymentMethod);
|
||||
|
||||
console.log(`✅ Payment found! $${amountReceived.toFixed(2)} via ${paymentMethod}`);
|
||||
|
||||
return {
|
||||
paid: true,
|
||||
status: 'paid',
|
||||
details: {
|
||||
sessionId: paidSession.id,
|
||||
paymentIntentId: pi?.id,
|
||||
amountReceived,
|
||||
paymentMethod, // 'card' or 'us_bank_account'
|
||||
customerEmail: paidSession.customer_details?.email,
|
||||
paidAt: new Date(paidSession.created * 1000).toISOString(),
|
||||
stripeFee
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Check for pending ACH payments (processing state)
|
||||
const pendingSession = sessions.data.find(s => s.payment_status === 'unpaid' && s.status === 'complete');
|
||||
if (pendingSession) {
|
||||
return {
|
||||
paid: false,
|
||||
status: 'processing',
|
||||
details: { note: 'ACH payment is processing (may take 3-5 business days).' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
paid: false,
|
||||
status: sessions.data.length > 0 ? 'attempted' : 'pending',
|
||||
details: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a payment link (e.g. when invoice is voided or amount changes).
|
||||
*
|
||||
* @param {string} paymentLinkId
|
||||
*/
|
||||
async function deactivatePaymentLink(paymentLinkId) {
|
||||
if (!paymentLinkId) return;
|
||||
|
||||
try {
|
||||
await stripe.paymentLinks.update(paymentLinkId, { active: false });
|
||||
console.log(`🚫 Stripe Payment Link ${paymentLinkId} deactivated.`);
|
||||
} catch (e) {
|
||||
console.error(`⚠️ Could not deactivate payment link ${paymentLinkId}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated Stripe fee for reference/QBO booking.
|
||||
* Card: 2.9% + $0.30
|
||||
* ACH: 0.8%, capped at $5.00
|
||||
*
|
||||
* @param {number} amount - Amount in dollars
|
||||
* @param {string} method - 'card' or 'us_bank_account'
|
||||
* @returns {number} Estimated fee in dollars
|
||||
*/
|
||||
function calculateStripeFee(amount, method) {
|
||||
if (method === 'us_bank_account') {
|
||||
return Math.min(amount * 0.008, 5.00);
|
||||
}
|
||||
// Default: card
|
||||
return (amount * 0.029) + 0.30;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPaymentLink,
|
||||
checkPaymentStatus,
|
||||
deactivatePaymentLink,
|
||||
calculateStripeFee
|
||||
};
|
||||
38
src/utils/helpers.js
Normal file
38
src/utils/helpers.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Utility helper functions for the Quote & Invoice System
|
||||
*/
|
||||
|
||||
function formatDate(date) {
|
||||
const d = new Date(date);
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
function formatMoney(val) {
|
||||
return parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function parseNumericValue(val) {
|
||||
if (val === null || val === undefined) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||
}
|
||||
|
||||
function formatAddress(address) {
|
||||
if (!address) return '';
|
||||
const lines = [];
|
||||
if (address.line1) lines.push(address.line1);
|
||||
if (address.line2) lines.push(address.line2);
|
||||
if (address.line3) lines.push(address.line3);
|
||||
if (address.line4) lines.push(address.line4);
|
||||
return lines.join('<br>');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDate,
|
||||
formatMoney,
|
||||
parseNumericValue,
|
||||
formatAddress
|
||||
};
|
||||
37
src/utils/numberGenerators.js
Normal file
37
src/utils/numberGenerators.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Number generation utilities for quotes and invoices
|
||||
*/
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
async function getNextQuoteNumber() {
|
||||
const year = new Date().getFullYear();
|
||||
const result = await pool.query(
|
||||
'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1',
|
||||
[`${year}-%`]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return `${year}-001`;
|
||||
}
|
||||
|
||||
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]);
|
||||
const nextNumber = String(lastNumber + 1).padStart(3, '0');
|
||||
return `${year}-${nextNumber}`;
|
||||
}
|
||||
|
||||
async function getNextInvoiceNumber() {
|
||||
const result = await pool.query(
|
||||
'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\''
|
||||
);
|
||||
|
||||
if (result.rows.length === 0 || result.rows[0].max_number === null) {
|
||||
return '110508';
|
||||
}
|
||||
|
||||
return String(parseInt(result.rows[0].max_number) + 1);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextQuoteNumber,
|
||||
getNextInvoiceNumber
|
||||
};
|
||||
@@ -86,7 +86,11 @@
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-div {
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
margin-top: auto;
|
||||
}
|
||||
.info-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
@@ -110,7 +114,7 @@
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
margin: 40px 0 20px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -180,7 +184,6 @@
|
||||
font-weight: bold;
|
||||
padding-right: 20px !important;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
@@ -202,6 +205,10 @@
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -238,24 +245,26 @@
|
||||
{{CUSTOMER_CITY}}, {{CUSTOMER_STATE}} {{CUSTOMER_ZIP}}
|
||||
</div>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>INVOICE #</th>
|
||||
<th>ACCOUNT NO.</th>
|
||||
<th>DATE</th>
|
||||
<th>TERMS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{INVOICE_NUMBER}}</td>
|
||||
<td>{{ACCOUNT_NUMBER}}</td>
|
||||
<td>{{INVOICE_DATE}}</td>
|
||||
<td>{{TERMS}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="info-div">
|
||||
<table class="info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>INVOICE #</th>
|
||||
<th>ACCOUNT NO.</th>
|
||||
<th>DATE</th>
|
||||
<th>TERMS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{INVOICE_NUMBER}}</td>
|
||||
<td>{{ACCOUNT_NUMBER}}</td>
|
||||
<td>{{INVOICE_DATE}}</td>
|
||||
<td>{{TERMS}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{AUTHORIZATION}}
|
||||
|
||||
@@ -202,6 +202,10 @@
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user