Compare commits

...

92 Commits

Author SHA1 Message Date
88582cbc77 fic for ref number 2026-04-14 10:27:26 -05:00
6e45ce6cf9 better logging 2026-04-02 12:07:39 -05:00
0b738ba530 fix for qbo changes 2026-04-01 17:13:04 -05:00
96ed8c7141 fix for Downpayment label + fix for invoice modal 2026-04-01 17:06:31 -05:00
5e792ab96f click if sent 2026-03-25 18:01:45 -05:00
fe7a9f6dd4 edit date renewed 2026-03-25 17:40:57 -05:00
a9173acc8d sent date 2026-03-25 17:35:30 -05:00
453f8654b7 set status to sent if sent 2026-03-25 16:34:23 -05:00
acd5b7d605 new message for overdue 2026-03-25 15:42:51 -05:00
cc154141bd puppeteer handling change 2026-03-21 18:08:17 -05:00
81ab5df13f get the email 2026-03-20 15:13:03 -05:00
01ee278e03 backup db 2026-03-20 14:32:17 -05:00
041103be04 bcc 2026-03-20 14:18:04 -05:00
a18c47112b Polling stripe payments 2026-03-20 14:13:10 -05:00
a4a79f3eb2 pdf includes payment link 2026-03-20 13:58:52 -05:00
b4a442954f show Pay Link only in certain situations 2026-03-20 13:47:39 -05:00
fb09a0b7e1 new text 2026-03-19 17:13:33 -05:00
229e658831 stripe 2026-03-19 16:28:37 -05:00
5a7ba66c27 mjml EMail 2026-03-16 18:00:32 -05:00
b9f9df74c0 fix 2026-03-04 18:46:37 -06:00
d38195eae5 move recurring 2026-03-04 18:33:24 -06:00
e9d88b1400 recurring, tax exempt, badge 2026-03-04 18:21:40 -06:00
e333628f1c refactoring 2026-03-04 17:03:51 -06:00
27ecafea5f Merge branch 'main' into refactoring 2026-03-04 16:05:02 -06:00
15d33a116c moved routes 2026-03-04 16:00:40 -06:00
0fbb298e89 bug fixing 2026-03-04 15:19:40 -06:00
6d0f4c49be mark sent for partial 2026-03-02 10:53:45 -06:00
7226883a2e refactoring 1. step 2026-03-02 10:09:24 -06:00
198126c13e schema + auth 2026-02-28 15:01:44 -06:00
c17cc362e4 quotes 2026-02-27 11:55:35 -06:00
a9c190bbf6 quote 2026-02-27 11:52:20 -06:00
39de7f7340 quote 2026-02-27 11:46:31 -06:00
bdfd096e99 quote 2026-02-27 11:41:36 -06:00
cc19cfcfad format 2026-02-27 11:30:34 -06:00
66736ef09d update 2026-02-27 11:09:22 -06:00
10380f26c4 template fix 2026-02-27 11:00:29 -06:00
5c86bd56aa template change 2026-02-27 10:58:07 -06:00
8ce739d713 avoid breaks 2026-02-27 10:44:00 -06:00
b90a2a6340 change the name 2026-02-27 10:09:50 -06:00
667a4c2a48 bill_to_name 2026-02-27 09:56:43 -06:00
d47d52b3d1 syntax error fixed 2026-02-26 18:06:43 -06:00
8f68ed02c5 changes 2026-02-26 18:04:34 -06:00
54c43fd052 new css 2026-02-25 10:50:45 -06:00
503adf5bbc new styles 2026-02-25 10:46:34 -06:00
55b4cba35a fixes 2026-02-25 10:42:28 -06:00
053f01c5ec korrektur 2026-02-25 10:22:42 -06:00
cc41ed6ec9 new icons 2026-02-25 10:15:43 -06:00
326c45cca0 favicon, filter update 2026-02-25 09:59:12 -06:00
6b05917352 new colors 2026-02-25 09:40:55 -06:00
ab2f064de9 fix 2026-02-24 18:40:57 -06:00
b5ac7f0807 error handling 2026-02-24 18:36:52 -06:00
a8b82783b1 colors 2026-02-24 18:26:39 -06:00
0750fd86b4 colors 2026-02-24 18:24:09 -06:00
13f931978a colors 2026-02-24 18:22:03 -06:00
731ac9f5d9 overdue 2026-02-24 18:15:22 -06:00
b7db400e53 fix 2026-02-24 17:55:50 -06:00
851ca7a037 open, sent status 2026-02-24 17:46:27 -06:00
503dd4051f badge 2026-02-24 17:39:23 -06:00
73b869e2d9 sent,mark sent 2026-02-24 17:13:27 -06:00
ec3cd2b659 fix 2026-02-24 16:46:54 -06:00
29a37ad98a update 2026-02-24 16:35:03 -06:00
be834fa9a0 Korrektur durch effectiveAmount 2026-02-23 15:13:09 -06:00
a0555eddd4 window.customers = customers; 2026-02-23 15:07:09 -06:00
9a9cabdec6 modul umbau 2026-02-23 15:06:28 -06:00
9ebfd9b8c3 customer module + features 2026-02-23 14:09:52 -06:00
5e63adfee8 update delete endpoints 2026-02-20 18:03:33 -06:00
c44fc7f63e update sync endpoint 2026-02-20 12:16:34 -06:00
4e6429e9ac paid & deposited 2026-02-20 11:47:06 -06:00
cbfbcf9b06 QBO Query tool 2026-02-20 11:23:33 -06:00
8643aebcfc update qbo sync 2026-02-20 10:53:20 -06:00
7ba4eef5db update 2026-02-20 10:09:01 -06:00
444e8555f3 update 2026-02-19 22:24:53 -06:00
451f6f66c1 Korrektur zur Nummer ermittlung 2026-02-19 22:18:15 -06:00
410faee6d1 update 2026-02-19 22:02:22 -06:00
49aeff8cb6 update 2026-02-19 21:27:03 -06:00
171450400a update 2026-02-19 21:01:23 -06:00
a9465aa812 update 2026-02-19 15:01:51 -06:00
b24a360fba update 2026-02-19 14:45:19 -06:00
48fa86916b update 2026-02-18 10:09:57 -06:00
acb588425a payments - 1. version 2026-02-18 09:39:06 -06:00
2bb304babe update 2026-02-17 21:29:56 -06:00
a0c62d639e update 2026-02-17 20:53:00 -06:00
84b0836234 removed 2026-02-17 16:54:13 -06:00
2d5be21bf2 update 2026-02-17 16:51:30 -06:00
31f03b0d7c neuer Ansatz ... 2026-02-17 15:05:26 -06:00
03e0516c08 update 2026-02-17 14:41:58 -06:00
52dcdce8bb update 2026-02-17 13:33:39 -06:00
272f325d98 sfsdf 2026-02-17 13:13:05 -06:00
c34f0391b3 bcvbc 2026-02-17 10:18:31 -06:00
eb19b2785e update add report 2026-02-17 09:53:13 -06:00
df1be3b823 update 2026-02-17 09:25:04 -06:00
25da1a46a8 update 2026-02-16 18:42:20 -06:00
61 changed files with 14997 additions and 3667 deletions

View File

@@ -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
View File

@@ -1,2 +1,4 @@
.env
*.png
public/uploads/*.png
node_modules
qbo_token.json

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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!** 🚀

View File

@@ -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.

View File

@@ -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
View 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');
});

38
backup-invoice-db.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# /home/aknuth/scripts/backup-invoice-db.sh
# Daily PostgreSQL backup for quote-invoice-system → iDrive e2
set -euo pipefail
BACKUP_DIR="/tmp/invoice-backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="invoice_db_${TIMESTAMP}.sql.gz"
CONTAINER="quote_db"
DB_USER="quoteuser"
DB_NAME="quotes_db"
RCLONE_REMOTE="invoice-backup:invoice-postgresdb"
RETAIN_DAYS=30
echo "🗄️ [BACKUP] Starting invoice DB backup..."
# Create temp dir
mkdir -p "$BACKUP_DIR"
# Dump and compress
docker exec "$CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "${BACKUP_DIR}/${BACKUP_FILE}"
FILESIZE=$(du -h "${BACKUP_DIR}/${BACKUP_FILE}" | cut -f1)
echo "📦 Dump complete: ${BACKUP_FILE} (${FILESIZE})"
# Upload to iDrive e2
rclone copy "${BACKUP_DIR}/${BACKUP_FILE}" "$RCLONE_REMOTE/" --log-level INFO
echo "☁️ Uploaded to ${RCLONE_REMOTE}/${BACKUP_FILE}"
# Clean up local temp
rm -f "${BACKUP_DIR}/${BACKUP_FILE}"
# Remove remote backups older than 30 days
rclone delete "$RCLONE_REMOTE" --min-age "${RETAIN_DAYS}d" --log-level INFO
echo "🧹 Remote cleanup done (>${RETAIN_DAYS} days)"
echo "✅ [BACKUP] Invoice DB backup complete."

View File

@@ -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
View 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
View 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); });

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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&nbsp;</li><li><strong>Display: </strong>16" &nbsp;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

View File

@@ -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&nbsp;</li><li><strong>Display: </strong>16" &nbsp;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

File diff suppressed because it is too large Load Diff

54
public/css/styles.css Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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>
@@ -410,7 +437,10 @@
+ Add Item
</button>
</div>
<div id="invoice-items"></div>
<div id="invoice-items"
class="overflow-y-auto pr-1"
style="max-height: 40vh; min-height: 80px;">
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
@@ -432,18 +462,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
View 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;

View 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;

View File

@@ -0,0 +1,326 @@
// email-modal.js — ES Module
// Modal to review and send invoice emails via AWS SES
// With Stripe Payment Link integration
import { showSpinner, hideSpinner, formatDate } 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);
const customerName = currentInvoice.customer_name || 'Valued Customer';
// 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.';
}
// Detect overdue: unpaid + older than 30 days
const invoiceDateParsed = currentInvoice.invoice_date
? new Date(currentInvoice.invoice_date.split('T')[0])
: null;
const daysSinceInvoice = invoiceDateParsed
? Math.floor((new Date() - invoiceDateParsed) / 86400000)
: 0;
const isOverdue = !currentInvoice.paid_date && daysSinceInvoice > 30;
let defaultHtml = '';
if (isOverdue) {
// Reminder / Overdue template
defaultHtml = `
<p>Dear ${customerName},</p>
<p>We hope this message finds you well. Our records indicate that invoice <strong>#${invoiceNum}</strong> in the amount of <strong>$${totalDue}</strong>, dated ${formatDate(currentInvoice.invoice_date)}, remains unpaid.</p>
<p>This invoice is now <strong>${daysSinceInvoice} days past the invoice date</strong>. We kindly request prompt payment at your earliest convenience.</p>
<p>For your convenience, you can pay securely online using the payment link included below. We accept both Credit Card and ACH bank transfer.</p>
<p>If payment has already been sent, please disregard this notice. Should you have any questions or need to discuss payment arrangements, please do not hesitate to reply to this email.</p>
<p>Thank you for your attention to this matter. We value your business and look forward to continuing our partnership.</p>
<p>Best regards,</p>
<p><strong>Claudia Knuth</strong></p>
<p>Bay Area Affiliates, Inc.</p>
<p>accounting@bayarea-cc.com</p>
`;
} else {
// Standard template
defaultHtml = `
<p>Dear ${customerName},</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
};

View 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;

View 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
};

View 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;

134
public/js/utils/api.js Normal file
View File

@@ -0,0 +1,134 @@
/**
* 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'),
updateSentDates: (id, dates) => fetch(`/api/invoices/${id}/sent-dates`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sent_dates: dates })
}).then(r => r.json())
},
// 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;

View 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;

View 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
};

View 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;

View File

@@ -0,0 +1,680 @@
// 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 — show actual sent dates if available, otherwise scheduled
let sendDateDisplay = '—';
const sentDates = invoice.sent_dates || [];
if (sentDates.length > 0) {
// Show most recent sent date
const lastSent = sentDates[sentDates.length - 1];
sendDateDisplay = formatDate(lastSent);
if (sentDates.length > 1) {
// Tooltip with all dates
const allDates = sentDates.map(d => formatDate(d)).join('&#10;');
sendDateDisplay = `<span title="All send dates:&#10;${allDates}" class="cursor-help border-b border-dotted border-gray-400">${formatDate(lastSent)}</span>`;
sendDateDisplay += ` <span class="text-xs text-gray-400">(${sentDates.length}x)</span>`;
}
} else if (invoice.scheduled_send_date) {
// No actual sends yet — show scheduled date with indicators
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' && !overdue) {
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>`;
}
}
// Send Date cell — only clickable if actually sent
const sendDateClickable = sentDates.length > 0;
const sendDateCell = sendDateClickable
? `<span class="cursor-pointer hover:text-blue-600" onclick="window.invoiceView.editSentDates(${invoice.id})" title="Click to edit sent dates">${sendDateDisplay}</span>`
: sendDateDisplay;
// 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>`;
}
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
const stripeEmailBtn = (hasQbo && !paid && (invoice.email_status !== 'sent' || ((invoice.email_status === 'sent' && overdue))))
? `<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-600">
${sendDateCell}
</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();
}
}
async function editSentDates(invoiceId) {
const res = await fetch(`/api/invoices/${invoiceId}`);
const data = await res.json();
const invoice = data.invoice;
const sentDates = (invoice.sent_dates || []).map(d => d.split('T')[0]);
// Build modal
let modal = document.getElementById('sent-dates-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sent-dates-modal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center';
document.body.appendChild(modal);
}
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-2xl w-full max-w-sm mx-auto p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">Edit Sent Dates</h3>
<div id="sent-dates-list" class="space-y-2 mb-4"></div>
<button type="button" onclick="window.invoiceView._addSentDateRow()"
class="text-sm text-blue-600 hover:text-blue-800 mb-4">+ Add date</button>
<div class="flex justify-end space-x-3 pt-4 border-t">
<button onclick="document.getElementById('sent-dates-modal').classList.add('hidden')"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm">Cancel</button>
<button onclick="window.invoiceView._saveSentDates(${invoiceId})"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-semibold">Save</button>
</div>
</div>`;
const list = document.getElementById('sent-dates-list');
if (sentDates.length === 0) {
_addSentDateRow();
} else {
sentDates.forEach(d => _addSentDateRow(d));
}
modal.classList.remove('hidden');
}
function _addSentDateRow(value = '') {
const list = document.getElementById('sent-dates-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'flex items-center gap-2';
row.innerHTML = `
<input type="date" value="${value}" class="sent-date-input flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
<button type="button" onclick="this.parentElement.remove()" class="px-2 py-1 bg-red-100 text-red-600 rounded hover:bg-red-200 text-sm">×</button>`;
list.appendChild(row);
}
async function _saveSentDates(invoiceId) {
const inputs = document.querySelectorAll('#sent-dates-list .sent-date-input');
const dates = [];
for (const input of inputs) {
if (input.value) dates.push(input.value);
}
try {
const response = await fetch(`/api/invoices/${invoiceId}/sent-dates`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sent_dates: dates })
});
if (response.ok) {
document.getElementById('sent-dates-modal').classList.add('hidden');
loadInvoices();
} else {
const err = await response.json();
alert(`Error: ${err.error}`);
}
} catch (e) {
console.error('Error updating sent dates:', e);
alert('Network error.');
}
}
// ============================================================
// Expose
// ============================================================
window.invoiceView = {
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
loadInvoices, renderInvoiceView, setStatus, checkStripePayment, editSentDates ,_addSentDateRow, _saveSentDates
};

View 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
};

View 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
View 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

171
qbo_helper.js Normal file
View File

@@ -0,0 +1,171 @@
// 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;
let _lastSavedAccessToken = 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();
// ── NEU: Nur speichern wenn access_token sich tatsächlich geändert hat ──
if (token.access_token === _lastSavedAccessToken) {
return; // Token unverändert kein Save, kein Log
}
_lastSavedAccessToken = token.access_token;
const ts = new Date().toISOString().replace('T',' ').substring(0,19);
console.log(`[${ts}] 💾 Token changed saving (realmId: ${token.realmId || 'FEHLT'})`);
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(`[${ts}] 💾 Token saved to qbo_token.json`);
} catch (e) {
console.error(`❌ Fehler beim Speichern der Tokens: ${e.message}`);
}
}
async function makeQboApiCall(requestOptions) {
const client = getOAuthClient();
const ts = () => new Date().toISOString().replace('T',' ').substring(0,19);
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(`[${ts()}] 🔄 QBO Token Refresh...`);
const refreshTokenStr = currentToken.refresh_token;
try {
const authResponse = await client.refreshUsingToken(refreshTokenStr);
console.log(`[${ts()}] ✅ Token refreshed via refreshUsingToken()`);
saveTokens(); // saveTokens prüft selbst ob sich was geändert hat
return authResponse;
} catch (e) {
const errMsg = e.originalMessage || e.message || String(e);
console.error(`[${ts()}] ❌ Refresh failed: ${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(`[${ts()}] ⚠️ QBO Token-Fehler (${errorCode}) Refresh & Retry...`);
await doRefresh();
return await client.makeApiCall(requestOptions);
}
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
}
// ── Kein saveTokens() hier Token hat sich nicht geändert ──
return response;
} catch (e) {
const isAuthError =
e.response?.status === 401 ||
(e.authResponse?.response?.status === 401) ||
e.message?.includes('AuthenticationFailed');
if (isAuthError) {
console.log(`[${ts()}] ⚠️ 401 Refresh & Retry...`);
await doRefresh();
return await client.makeApiCall(requestOptions);
}
throw e;
}
}
module.exports = {
getOAuthClient,
makeQboApiCall,
saveTokens,
resetOAuthClient
};

49
qbo_query.js Normal file
View 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
View 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

1121
server.js

File diff suppressed because it is too large Load Diff

59
set_qbo_token.js Normal file
View 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
View 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
View 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
};

149
src/index.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* Quote & Invoice System - Main Entry Point
* Modularized Backend
*/
// ── Global timestamp logger must be first line before any require ──
const _ts = () => new Date().toISOString().replace('T', ' ').substring(0, 19);
const _origLog = console.log.bind(console);
const _origWarn = console.warn.bind(console);
const _origError = console.error.bind(console);
console.log = (...a) => _origLog(`[${_ts()}]`, ...a);
console.warn = (...a) => _origWarn(`[${_ts()}]`, ...a);
console.error = (...a) => _origError(`[${_ts()}]`, ...a);
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 { startStripePolling } = require('./services/stripe-poll-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' WURDE ENTFERNT!
],
protocolTimeout: 180000,
timeout: 180000
});
console.log('[BROWSER] Browser launched and ready');
// Pass browser to PDF service
setBrowser(browser);
// Restart browser if it crashes (mit Atempause!)
browser.on('disconnected', () => {
console.log('[BROWSER] Browser disconnected. Waiting 5 seconds before restarting...');
browser = null;
setBrowser(null);
// 5 Sekunden warten, bevor ein Neustart versucht wird
setTimeout(() => {
initBrowser();
}, 5000);
});
}
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();
startStripePolling();
}
// 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
View 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;

1225
src/routes/invoices.js Normal file

File diff suppressed because it is too large Load Diff

29
src/routes/payments.js Normal file
View 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;

606
src/routes/qbo.js Normal file
View File

@@ -0,0 +1,606 @@
/**
* 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 || '').substring(0, 21) || undefined,
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();
// ── Batch-fetch all invoices from QBO (max 50 per query) ──────────
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;
(data.QueryResponse?.Invoice || []).forEach(inv => qboInvoices.set(inv.Id, inv));
}
console.log(`🔍 QBO Sync: ${openInvoices.length} invoices checked, ${qboInvoices.size} loaded from QBO`);
// ── Collect all unique Payment IDs that need to be fetched ────────
// Instead of fetching each payment one-by-one, collect all IDs first
// then batch-fetch them in one query per 30 IDs
const paymentIdsToFetch = new Set();
for (const localInv of openInvoices) {
const qboInv = qboInvoices.get(localInv.qbo_id);
if (!qboInv || parseFloat(qboInv.Balance) !== 0) continue;
if (qboInv.LinkedTxn) {
for (const txn of qboInv.LinkedTxn) {
if (txn.TxnType === 'Payment') paymentIdsToFetch.add(txn.TxnId);
}
}
}
// Batch-fetch all payments in groups of 30
const UNDEPOSITED_FUNDS_ID = '221';
const paymentDepositMap = new Map(); // paymentId -> isDeposited (bool)
if (paymentIdsToFetch.size > 0) {
console.log(`💳 Fetching ${paymentIdsToFetch.size} unique payment(s) from QBO...`);
const pmIds = [...paymentIdsToFetch];
const pmBatchSize = 30;
for (let i = 0; i < pmIds.length; i += pmBatchSize) {
const batch = pmIds.slice(i, i + pmBatchSize);
const pmQuery = `SELECT Id, DepositToAccountRef FROM Payment WHERE Id IN (${batch.map(id => `'${id}'`).join(',')})`;
try {
const pmRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(pmQuery)}`,
method: 'GET'
});
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
for (const pm of (pmData.QueryResponse?.Payment || [])) {
const isDeposited = pm.DepositToAccountRef?.value !== UNDEPOSITED_FUNDS_ID;
paymentDepositMap.set(pm.Id, isDeposited);
}
} catch (e) {
console.log(`⚠️ Payment batch fetch error (non-fatal): ${e.message}`);
}
}
console.log(`💳 Payment deposit status loaded for ${paymentDepositMap.size} payment(s)`);
}
// ── Process invoices ───────────────────────────────────────────────
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) {
// Determine Paid vs Deposited using pre-fetched map
let status = 'Paid';
if (qboInv.LinkedTxn) {
for (const txn of qboInv.LinkedTxn) {
if (txn.TxnType === 'Payment' && paymentDepositMap.get(txn.TxnId) === true) {
status = 'Deposited';
break;
}
}
}
if (!localInv.paid_date || localInv.payment_status !== status) {
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)} synced`);
}
} else if (qboBalance > 0 && qboBalance < qboTotal) {
const qboPaid = qboTotal - qboBalance;
const diff = qboPaid - localPaid;
if (localInv.payment_status !== 'Partial') {
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)} / $${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 complete: ${updated} updated, ${newPayments} new 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.log(`❌ Sync Error: ${error.message}`);
res.status(500).json({ error: 'Sync failed: ' + error.message });
} finally {
dbClient.release();
}
});
module.exports = router;

370
src/routes/quotes.js Normal file
View 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
View 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;

View File

@@ -0,0 +1,135 @@
// 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,
bcc: 'accounting@bayarea-cc.com',
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 };

224
src/services/pdf-service.js Normal file
View File

@@ -0,0 +1,224 @@
/**
* 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();
try {
// Erhöhtes Timeout: 5 Sekunden sind unter Docker manchmal zu wenig.
// Besser auf 15 Sekunden (15000) setzen, um den Fehler von vornherein zu vermeiden.
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
const pdf = await page.pdf({
format,
printBackground,
margin
});
return pdf;
} finally {
// Dieser Block wird IMMER ausgeführt, selbst wenn oben ein Fehler fliegt.
// Der Tab wird also zu 100% wieder geschlossen.
if (page) {
await page.close();
}
}
}
/**
* 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
// Add downpayment/balance if partial
if (amountPaid > 0) {
const isFullyPaid = balanceDue <= 0.01; // allow for rounding
const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:';
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="color: #059669;">${paymentLabel}</td>
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
</tr>`;
// Only show BALANCE DUE row if there's actually a remaining balance
if (!isFullyPaid) {
itemsHTML += `
<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
View 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
};

View 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
};

View File

@@ -0,0 +1,238 @@
// src/services/stripe-poll-service.js
/**
* Stripe Payment Polling Service
* Periodically checks all open Stripe payment links for completed payments.
*
* Similar pattern to recurring-service.js:
* - Runs every 4 hours (and once on startup after 2 min delay)
* - Finds invoices with active Stripe links that aren't paid yet
* - Checks each via Stripe API
* - Records payment + QBO booking if paid
*/
const { pool } = require('../config/database');
const { checkPaymentStatus, deactivatePaymentLink, calculateStripeFee } = require('./stripe-service');
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
const POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
const STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 minutes after boot
/**
* Check all invoices with open Stripe payment links
*/
async function pollStripePayments() {
console.log('💳 [STRIPE-POLL] Checking for completed Stripe payments...');
const dbClient = await pool.connect();
try {
// Find all invoices with active (unpaid) Stripe links
const result = await dbClient.query(`
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.stripe_payment_link_id IS NOT NULL
AND i.stripe_payment_status NOT IN ('paid')
AND i.paid_date IS NULL
`);
const openInvoices = result.rows;
if (openInvoices.length === 0) {
console.log('💳 [STRIPE-POLL] No open Stripe payment links to check.');
return;
}
console.log(`💳 [STRIPE-POLL] Checking ${openInvoices.length} invoice(s)...`);
let paidCount = 0;
let processingCount = 0;
let errorCount = 0;
for (const invoice of openInvoices) {
try {
const status = await checkPaymentStatus(invoice.stripe_payment_link_id);
// Update status if changed
if (status.status !== invoice.stripe_payment_status) {
await dbClient.query(
'UPDATE invoices SET stripe_payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[status.status, invoice.id]
);
}
if (status.status === 'processing') {
processingCount++;
console.log(` ⏳ #${invoice.invoice_number}: ACH processing`);
continue;
}
if (!status.paid) continue;
// === PAID — process it ===
const amountReceived = status.details.amountReceived;
const paymentMethod = status.details.paymentMethod;
const stripeFee = status.details.stripeFee;
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
await dbClient.query('BEGIN');
// 1. Record local payment
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, reference_number, notes, created_at)
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
RETURNING id`,
[
`Stripe ${methodLabel}`,
amountReceived,
invoice.customer_id,
status.details.paymentIntentId || status.details.sessionId,
`Stripe ${methodLabel} — Fee: $${stripeFee.toFixed(2)} (auto-polled)`
]
);
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[payResult.rows[0].id, invoice.id, amountReceived]
);
// 2. Check if fully paid
const newTotalPaid = invoice.amount_paid + amountReceived;
const invoiceTotal = parseFloat(invoice.total) || 0;
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01);
await dbClient.query(
`UPDATE invoices SET
stripe_payment_status = 'paid',
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
payment_status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[fullyPaid ? 'Stripe' : 'Partial', invoice.id]
);
// 3. Deactivate link
await deactivatePaymentLink(invoice.stripe_payment_link_id);
// 4. QBO booking
if (invoice.qbo_id && invoice.customer_qbo_id) {
try {
await recordStripePaymentInQbo(invoice, amountReceived, methodLabel, stripeFee,
status.details.paymentIntentId || '');
} catch (qboErr) {
console.error(` ⚠️ QBO booking failed for #${invoice.invoice_number}:`, qboErr.message);
}
}
await dbClient.query('COMMIT');
paidCount++;
console.log(` ✅ #${invoice.invoice_number}: $${amountReceived.toFixed(2)} via Stripe ${methodLabel} (Fee: $${stripeFee.toFixed(2)})`);
} catch (err) {
await dbClient.query('ROLLBACK').catch(() => {});
errorCount++;
console.error(` ❌ #${invoice.invoice_number}: ${err.message}`);
}
}
console.log(`💳 [STRIPE-POLL] Done: ${paidCount} paid, ${processingCount} processing, ${errorCount} errors (of ${openInvoices.length} checked)`);
} catch (error) {
console.error('💳 [STRIPE-POLL] Fatal error:', error.message);
} finally {
dbClient.release();
}
}
/**
* Record Stripe payment in QBO (same logic as in invoices.js check-payment route)
*/
async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference) {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
// 1. QBO Payment
const paymentPayload = {
CustomerRef: { value: invoice.customer_qbo_id },
TotalAmt: amount,
TxnDate: new Date().toISOString().split('T')[0],
PaymentRefNum: reference ? reference.substring(0, 21) : 'Stripe',
PrivateNote: `Stripe ${methodLabel} — auto-polled`,
Line: [{
Amount: amount,
LinkedTxn: [{
TxnId: invoice.qbo_id,
TxnType: 'Invoice'
}]
}],
DepositToAccountRef: { value: '221' }
};
const paymentRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paymentPayload)
});
const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json;
if (paymentData.Fault) {
const errMsg = paymentData.Fault.Error?.map(e => `${e.Message}: ${e.Detail}`).join('; ');
throw new Error('QBO Payment failed: ' + errMsg);
}
console.log(` 📗 QBO Payment: ID ${paymentData.Payment?.Id}`);
// 2. QBO Expense for fee
if (stripeFee > 0) {
const expensePayload = {
AccountRef: { value: '244', name: 'PlainsCapital Bank' },
TxnDate: new Date().toISOString().split('T')[0],
PaymentType: 'Check',
PrivateNote: `Stripe fee for Invoice #${invoice.invoice_number} (${methodLabel}) — auto-polled`,
Line: [{
DetailType: 'AccountBasedExpenseLineDetail',
Amount: stripeFee,
AccountBasedExpenseLineDetail: {
AccountRef: { value: '1150040001', name: 'Payment Processing Fees' }
},
Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}`
}]
};
const expenseRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/purchase`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(expensePayload)
});
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
if (expenseData.Fault) {
console.error(` ⚠️ QBO Fee booking failed:`, JSON.stringify(expenseData.Fault));
} else {
console.log(` 📗 QBO Fee: ID ${expenseData.Purchase?.Id} ($${stripeFee.toFixed(2)})`);
}
}
}
/**
* Start the polling scheduler
*/
function startStripePolling() {
// First check after startup delay
setTimeout(() => {
pollStripePayments();
}, STARTUP_DELAY_MS);
// Then every 4 hours
setInterval(() => {
pollStripePayments();
}, POLL_INTERVAL_MS);
console.log(`💳 [STRIPE-POLL] Scheduler started (every ${POLL_INTERVAL_MS / 3600000}h, first check in ${STARTUP_DELAY_MS / 1000}s)`);
}
module.exports = { startStripePolling, pollStripePayments };

View 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
View 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
};

View 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
};

View File

@@ -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}}
@@ -273,6 +282,7 @@
{{ITEMS}}
</tbody>
</table>
{{PAYMENT_LINK}}
</div>
</body>
</html>

View File

@@ -202,6 +202,10 @@
position: absolute;
bottom: 0;
}
tr {
page-break-inside: avoid;
break-inside: avoid;
}
</style>
</head>
<body>