Hardware
@@ -2167,6 +2208,153 @@ function editInternalComment() {
editDiv.style.display = 'block';
displayDiv.style.display = 'none';
}
+
+/**
+ * Load and render subscription billing matrix from e-conomic
+ */
+async function loadBillingMatrix() {
+ const loading = document.getElementById('billingMatrixLoading');
+ const container = document.getElementById('billingMatrixContainer');
+ const empty = document.getElementById('billingMatrixEmpty');
+
+ // Show loading state
+ loading.style.display = 'block';
+ container.style.display = 'none';
+ empty.style.display = 'none';
+
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/subscriptions/billing-matrix`);
+ const matrix = await response.json();
+
+ console.log('📊 Billing matrix:', matrix);
+
+ if (!response.ok) {
+ throw new Error(matrix.detail || 'Failed to load billing matrix');
+ }
+
+ // Check if matrix has products
+ if (!matrix.products || matrix.products.length === 0) {
+ empty.style.display = 'block';
+ loading.style.display = 'none';
+ return;
+ }
+
+ // Render matrix
+ renderBillingMatrix(matrix);
+
+ } catch (error) {
+ console.error('Failed to load billing matrix:', error);
+ loading.innerHTML = `
${error.message}
`;
+ }
+}
+
+function renderBillingMatrix(matrix) {
+ const headerRow = document.getElementById('matrixHeaderRow');
+ const bodyRows = document.getElementById('matrixBodyRows');
+
+ // Get all unique months across all products
+ const monthsSet = new Set();
+ matrix.products.forEach(product => {
+ product.rows.forEach(row => {
+ monthsSet.add(row.year_month);
+ });
+ });
+
+ const months = Array.from(monthsSet).sort();
+
+ if (months.length === 0) {
+ document.getElementById('billingMatrixEmpty').style.display = 'block';
+ document.getElementById('billingMatrixLoading').style.display = 'none';
+ return;
+ }
+
+ // Build header with month columns
+ headerRow.innerHTML = '
Vare | ' +
+ months.map(month => {
+ const date = new Date(month + '-01');
+ const label = date.toLocaleDateString('da-DK', { month: 'short', year: '2-digit' });
+ return `
${label} | `;
+ }).join('') +
+ '
I alt | ';
+
+ // Build body with product rows
+ bodyRows.innerHTML = matrix.products.map(product => {
+ const monthCells = months.map(month => {
+ const cell = product.rows.find(r => r.year_month === month);
+ if (!cell) {
+ return '
- | ';
+ }
+
+ const amount = cell.amount || 0;
+ const statusBadge = getStatusBadge(cell.status);
+ const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
+
+ return `
+
+ ${formatDKK(amount)}
+ ${statusBadge}
+
+ | `;
+ }).join('');
+
+ return `
+ | ${escapeHtml(product.product_name)} |
+ ${monthCells}
+ ${formatDKK(product.total_amount)} |
+
`;
+ }).join('');
+
+ // Show matrix, hide loading
+ document.getElementById('billingMatrixContainer').style.display = 'block';
+ document.getElementById('billingMatrixLoading').style.display = 'none';
+}
+
+function getStatusBadge(status) {
+ const badgeMap = {
+ 'paid': { color: 'success', icon: 'check-circle', label: 'Betalt' },
+ 'invoiced': { color: 'warning', icon: 'file-text', label: 'Faktureret' },
+ 'draft': { color: 'secondary', icon: 'file-earmark', label: 'Kladde' },
+ 'missing': { color: 'danger', icon: 'exclamation-triangle', label: 'Manglende' },
+ 'credited': { color: 'info', icon: 'arrow-counterclockwise', label: 'Krediteret' }
+ };
+
+ const badge = badgeMap[status] || { color: 'secondary', icon: 'question-circle', label: status };
+ return `
+ ${badge.label}
+ `;
+}
+
+function formatDKK(amount) {
+ if (!amount || amount === 0) return '0 kr';
+ return amount.toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
+}
+
+// Auto-load matrix when subscriptions tab is shown
+document.addEventListener('DOMContentLoaded', () => {
+ const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
+ if (subscriptionsTab) {
+ subscriptionsTab.addEventListener('shown.bs.tab', () => {
+ // Load matrix if not already loaded
+ if (!document.getElementById('billingMatrixContainer').innerHTML.includes('table-responsive')) {
+ setTimeout(() => loadBillingMatrix(), 300);
+ }
+ });
+ }
+
+ // Auto-load matrix when billing-matrix tab is shown
+ const billingMatrixTab = document.querySelector('a[href="#billing-matrix"]');
+ if (billingMatrixTab) {
+ billingMatrixTab.addEventListener('shown.bs.tab', () => {
+ const container = document.getElementById('billingMatrixContainer');
+ const loading = document.getElementById('billingMatrixLoading');
+
+ // Check if we need to load
+ if (loading.style.display !== 'none' && container.style.display === 'none') {
+ loadBillingMatrix();
+ }
+ });
+ }
+});
diff --git a/app/services/economic_service.py b/app/services/economic_service.py
index df3be50..6dcfb43 100644
--- a/app/services/economic_service.py
+++ b/app/services/economic_service.py
@@ -435,6 +435,145 @@ class EconomicService:
logger.error(f"❌ Error creating supplier: {e}")
return None
+ # ========== CUSTOMER INVOICES ==========
+
+ async def get_customer_invoices(self, customer_number: int, include_lines: bool = True) -> List[Dict]:
+ """
+ Get customer invoices (sales invoices) from e-conomic
+
+ Args:
+ customer_number: e-conomic customer number
+ include_lines: Whether to include invoice lines (adds more API calls but gets full data)
+
+ Returns:
+ List of invoice records with lines
+ """
+ try:
+ async with aiohttp.ClientSession() as session:
+ # Try multiple endpoints to find invoices
+ # Include drafts, paid, unpaid, booked, sent, archived, etc.
+ endpoints_to_try = [
+ f"{self.api_url}/invoices/archive", # ARCHIVED invoices (primary source)
+ f"{self.api_url}/invoices/drafts", # Draft invoices
+ f"{self.api_url}/invoices/sent", # Sent invoices
+ f"{self.api_url}/invoices/booked", # Booked invoices
+ f"{self.api_url}/invoices/paid", # Paid invoices
+ f"{self.api_url}/invoices/unpaid", # Unpaid invoices
+ f"{self.api_url}/invoices/sales", # All sales invoices
+ f"{self.api_url}/invoices", # Generic endpoint (returns links)
+ ]
+
+ all_invoices = []
+
+ # TRY ALL ENDPOINTS AND AGGREGATE RESULTS (don't break after first success)
+ for endpoint in endpoints_to_try:
+ logger.info(f"📋 Trying invoice endpoint: {endpoint}")
+
+ try:
+ async with session.get(
+ endpoint,
+ params={"pagesize": 1000},
+ headers=self._get_headers()
+ ) as response:
+ logger.info(f"🔍 [API] Response status from {endpoint}: {response.status}")
+
+ if response.status == 200:
+ data = await response.json()
+ invoices_from_endpoint = data.get('collection', [])
+ logger.info(f"✅ Successfully fetched {len(invoices_from_endpoint)} invoices from {endpoint}")
+
+ # Add new invoices (avoid duplicates by tracking invoice numbers)
+ existing_invoice_numbers = set()
+ for inv in all_invoices:
+ inv_num = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
+ if inv_num:
+ existing_invoice_numbers.add(inv_num)
+
+ for inv in invoices_from_endpoint:
+ inv_num = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
+ if inv_num and inv_num not in existing_invoice_numbers:
+ all_invoices.append(inv)
+ existing_invoice_numbers.add(inv_num)
+ else:
+ error_text = await response.text()
+ logger.warning(f"⚠️ Endpoint {endpoint} returned {response.status}: {error_text[:200]}")
+ except Exception as e:
+ logger.warning(f"⚠️ Error trying endpoint {endpoint}: {e}")
+
+ if not all_invoices:
+ logger.warning(f"⚠️ No invoices found from any endpoint")
+ return []
+
+ logger.info(f"✅ Found {len(all_invoices)} total invoices in e-conomic")
+
+ # Debug: log response structure
+ if all_invoices:
+ logger.info(f"🔍 [API] First invoice structure keys: {list(all_invoices[0].keys())}")
+ logger.info(f"🔍 [API] First invoice customer field: {all_invoices[0].get('customer')}")
+ # Log unique customer numbers in response
+ customer_numbers = set()
+ for inv in all_invoices[:20]: # Check first 20
+ cust_num = inv.get('customer', {}).get('customerNumber')
+ if cust_num:
+ customer_numbers.add(cust_num)
+ logger.info(f"🔍 [API] Unique customer numbers in response (first 20): {customer_numbers}")
+
+ # Filter invoices for this customer
+ customer_invoices = [
+ inv for inv in all_invoices
+ if inv.get('customer', {}).get('customerNumber') == customer_number
+ ]
+
+ logger.info(f"📊 Filtered to {len(customer_invoices)} invoices for customer {customer_number}")
+
+ # Fetch full invoice data with lines if requested
+ if include_lines and customer_invoices:
+ full_invoices = []
+ for inv in customer_invoices:
+ # Try to get full invoice data using the 'self' link or direct URL
+ invoice_self_link = inv.get('self')
+ invoice_number = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
+
+ logger.debug(f"Fetching full data for invoice: {invoice_number}")
+
+ try:
+ # Try the self link first
+ fetch_url = invoice_self_link or f"{self.api_url}/invoices/sales/{invoice_number}"
+
+ async with session.get(
+ fetch_url,
+ headers=self._get_headers()
+ ) as inv_response:
+ if inv_response.status == 200:
+ full_inv = await inv_response.json()
+ full_invoices.append(full_inv)
+ lines_count = len(full_inv.get('lines', []))
+ logger.debug(f"✅ Fetched invoice {invoice_number} with {lines_count} lines")
+ else:
+ # If self link fails, try with the existing data (may have lines already)
+ if 'lines' in inv:
+ full_invoices.append(inv)
+ logger.debug(f"Using cached data for invoice {invoice_number}")
+ else:
+ logger.warning(f"Could not fetch invoice {invoice_number}: {inv_response.status}")
+ except Exception as e:
+ # If fetch fails, try to use existing data if it has lines
+ if 'lines' in inv:
+ full_invoices.append(inv)
+ logger.debug(f"Using cached data for invoice {invoice_number} (fetch failed: {e})")
+ else:
+ logger.warning(f"⚠️ Could not fetch invoice {invoice_number}: {e}")
+
+ logger.info(f"✅ Fetched full data for {len(full_invoices)} invoices")
+ return full_invoices
+
+ return customer_invoices
+ except Exception as e:
+ logger.error(f"❌ Error getting customer invoices: {e}")
+ import traceback
+ logger.error(traceback.format_exc())
+ return []
+
# ========== KASSEKLADDE (JOURNALS/VOUCHERS) ==========
async def check_invoice_number_exists(self, invoice_number: str, journal_number: Optional[int] = None) -> Optional[Dict]:
diff --git a/app/services/subscription_matrix.py b/app/services/subscription_matrix.py
new file mode 100644
index 0000000..2c63cbd
--- /dev/null
+++ b/app/services/subscription_matrix.py
@@ -0,0 +1,315 @@
+"""
+Subscription Billing Matrix Service
+
+Generate a per-customer billing matrix showing:
+- Rows: Products from e-conomic customer invoices
+- Columns: Months
+- Cells: Expected vs invoiced amounts, payment status, invoice references
+
+Data source: e-conomic sales invoices only
+"""
+
+import logging
+import json
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional, Tuple
+from collections import defaultdict
+from app.services.economic_service import get_economic_service
+from app.core.database import execute_query
+
+logger = logging.getLogger(__name__)
+
+
+class SubscriptionMatrixService:
+ """Generate billing matrix for customer subscriptions"""
+
+ def __init__(self):
+ self.economic_service = get_economic_service()
+
+ async def generate_billing_matrix(
+ self,
+ customer_id: int,
+ months: int = 12
+ ) -> Dict:
+ """
+ Generate subscription billing matrix for a customer
+
+ Args:
+ customer_id: BMC Hub customer ID (local database)
+ months: Number of months to include (default 12)
+
+ Returns:
+ Dict with structure:
+ {
+ "customer_id": int,
+ "generated_at": str (ISO datetime),
+ "months_shown": int,
+ "products": [
+ {
+ "product_number": str,
+ "product_name": str,
+ "rows": [
+ {
+ "year_month": "2025-01",
+ "amount": float,
+ "status": "paid|invoiced|missing",
+ "invoice_number": str (optional),
+ "period_label": str,
+ "period_from": str (ISO date),
+ "period_to": str (ISO date)
+ }
+ ],
+ "total_amount": float,
+ "total_paid": float
+ }
+ ]
+ }
+ """
+ try:
+ # Get customer's e-conomic number from local database
+ logger.info(f"🔍 [MATRIX] Starting matrix generation for customer {customer_id}")
+
+ customer = execute_query(
+ "SELECT economic_customer_number FROM customers WHERE id = %s",
+ (customer_id,)
+ )
+
+ logger.info(f"🔍 [MATRIX] Query result: {customer}")
+
+ if not customer or not customer[0].get('economic_customer_number'):
+ logger.warning(f"⚠️ Customer {customer_id} has no e-conomic number")
+ return {
+ "customer_id": customer_id,
+ "error": "No e-conomic customer number found",
+ "products": []
+ }
+
+ economic_customer_number = customer[0]['economic_customer_number']
+ logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
+
+ # Fetch invoices from e-conomic
+ logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
+ invoices = await self.economic_service.get_customer_invoices(
+ economic_customer_number,
+ include_lines=True
+ )
+ logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
+
+ if not invoices:
+ logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
+ return {
+ "customer_id": customer_id,
+ "generated_at": datetime.now().isoformat(),
+ "months_shown": months,
+ "products": []
+ }
+
+ logger.info(f"📊 Processing {len(invoices)} invoices")
+
+ # Debug: log first invoice structure
+ if invoices:
+ logger.debug(f"First invoice structure: {json.dumps(invoices[0], indent=2, default=str)[:500]}")
+
+ # Group invoices by product number
+ matrix_data = self._aggregate_by_product(invoices, months)
+
+ return {
+ "customer_id": customer_id,
+ "economic_customer_number": economic_customer_number,
+ "generated_at": datetime.now().isoformat(),
+ "months_shown": months,
+ "products": matrix_data
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error generating billing matrix: {e}")
+ import traceback
+ logger.error(traceback.format_exc())
+ return {
+ "customer_id": customer_id,
+ "error": str(e),
+ "products": []
+ }
+
+ def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
+ """
+ Group invoice lines by product number and aggregate by month
+
+ Args:
+ invoices: List of e-conomic invoice objects
+ months: Number of months to include
+
+ Returns:
+ List of product records with aggregated monthly data
+ """
+ # Structure: {product_number: {year_month: {amount, status, invoice_number, period_from, period_to}}}
+ product_matrix = defaultdict(dict)
+ product_names = {} # Cache product names
+
+ try:
+ for invoice in invoices:
+ invoice_number = invoice.get('bookedInvoiceNumber') or invoice.get('draftInvoiceNumber')
+ # Determine status based on invoice type/endpoint it came from
+ # Priority: use 'status' field, fallback to inferring from presence of certain fields
+ invoice_status = invoice.get('status', 'unknown')
+ if invoice_status == 'unknown':
+ # Infer status from invoice structure
+ if invoice.get('bookedInvoiceNumber'):
+ invoice_status = 'booked'
+ elif invoice.get('draftInvoiceNumber'):
+ invoice_status = 'draft'
+ invoice_date = invoice.get('date')
+
+ # Process invoice lines
+ lines = invoice.get('lines', [])
+ for line in lines:
+ product_number = line.get('product', {}).get('productNumber')
+ product_name = line.get('product', {}).get('name') or line.get('description')
+
+ if not product_number:
+ logger.debug(f"Skipping line without product number: {line}")
+ continue
+
+ # Cache product name
+ if product_number not in product_names:
+ product_names[product_number] = product_name or f"Product {product_number}"
+
+ # Extract period from line
+ period_from_str = line.get('period', {}).get('from')
+ period_to_str = line.get('period', {}).get('to')
+
+ # If no period on line, use invoice date as month
+ if not period_from_str:
+ if invoice_date:
+ period_from_str = invoice_date
+ # Assume monthly billing if no end date
+ period_from = datetime.fromisoformat(invoice_date.replace('Z', '+00:00'))
+ period_to = period_from + timedelta(days=31)
+ period_to_str = period_to.isoformat().split('T')[0]
+ else:
+ logger.warning(f"No period or date found for line in invoice {invoice_number}")
+ continue
+
+ # Parse dates
+ try:
+ period_from = datetime.fromisoformat(period_from_str.replace('Z', '+00:00'))
+ period_to = datetime.fromisoformat(period_to_str.replace('Z', '+00:00'))
+ except (ValueError, AttributeError) as e:
+ logger.warning(f"Could not parse dates {period_from_str} - {period_to_str}: {e}")
+ continue
+
+ # Get amount - use gross amount if net is 0 (line items may have gross only)
+ amount = float(line.get('netAmount', 0))
+ if amount == 0:
+ amount = float(line.get('grossAmount', 0))
+ if amount == 0:
+ # Try unit net price * quantity as fallback
+ unit_price = float(line.get('unitNetPrice', 0))
+ qty = float(line.get('quantity', 1))
+ amount = unit_price * qty
+
+ # Determine month key (use first month if multi-month)
+ year_month = period_from.strftime('%Y-%m')
+
+ # Calculate period label
+ period_label = self._format_period_label(period_from, period_to)
+
+ # Initialize cell if doesn't exist
+ if year_month not in product_matrix[product_number]:
+ product_matrix[product_number][year_month] = {
+ "amount": 0.0,
+ "status": "missing",
+ "invoice_number": None,
+ "period_from": None,
+ "period_to": None
+ }
+
+ # Update cell
+ cell = product_matrix[product_number][year_month]
+ cell["amount"] = amount # Take last amount (or sum if multiple?)
+ cell["status"] = self._determine_status(invoice_status)
+ cell["invoice_number"] = invoice_number
+ cell["period_from"] = period_from.isoformat().split('T')[0]
+ cell["period_to"] = period_to.isoformat().split('T')[0]
+ cell["period_label"] = period_label
+
+ except Exception as e:
+ logger.error(f"❌ Error aggregating data: {e}")
+
+ # Convert to output format
+ products = []
+ for product_number, months_data in sorted(product_matrix.items()):
+ rows = []
+ total_amount: float = 0.0
+ total_paid: float = 0.0
+
+ for year_month in sorted(months_data.keys()):
+ cell = months_data[year_month]
+ rows.append({
+ "year_month": year_month,
+ "amount": cell["amount"],
+ "status": cell["status"],
+ "invoice_number": cell["invoice_number"],
+ "period_label": cell["period_label"],
+ "period_from": cell["period_from"],
+ "period_to": cell["period_to"]
+ })
+ total_amount += cell["amount"]
+ if cell["status"] == "paid":
+ total_paid += cell["amount"]
+
+ products.append({
+ "product_number": product_number,
+ "product_name": product_names[product_number],
+ "rows": rows,
+ "total_amount": total_amount,
+ "total_paid": total_paid
+ })
+
+ return products
+
+ @staticmethod
+ def _format_period_label(period_from: datetime, period_to: datetime) -> str:
+ """Format period as readable label (e.g., 'Jan', 'Jan-Mar', etc)"""
+ from_month = period_from.strftime('%b')
+ to_month = period_to.strftime('%b')
+ from_year = period_from.year
+ to_year = period_to.year
+
+ # Calculate number of months
+ months_diff = (period_to.year - period_from.year) * 12 + (period_to.month - period_from.month)
+
+ if months_diff == 0:
+ # Same month
+ return from_month
+ elif from_year == to_year:
+ # Same year, different months
+ return f"{from_month}-{to_month}"
+ else:
+ # Different years
+ return f"{from_month} {from_year} - {to_month} {to_year}"
+
+ @staticmethod
+ def _determine_status(invoice_status: str) -> str:
+ """Map e-conomic invoice status to matrix status"""
+ status_map = {
+ "sent": "invoiced",
+ "paid": "paid",
+ "draft": "draft",
+ "credited": "credited",
+ "unpaid": "invoiced",
+ "booked": "invoiced"
+ }
+ result = status_map.get(invoice_status.lower() if invoice_status else 'unknown', invoice_status or 'unknown')
+ return result if result else "unknown"
+
+
+# Singleton instance
+_matrix_service_instance = None
+
+def get_subscription_matrix_service() -> SubscriptionMatrixService:
+ """Get singleton instance of SubscriptionMatrixService"""
+ global _matrix_service_instance
+ if _matrix_service_instance is None:
+ _matrix_service_instance = SubscriptionMatrixService()
+ return _matrix_service_instance
diff --git a/create_test_invoice.py b/create_test_invoice.py
new file mode 100644
index 0000000..de72615
--- /dev/null
+++ b/create_test_invoice.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+"""Create test draft invoice for customer 702007707 (Blåhund)"""
+
+import asyncio
+import aiohttp
+import json
+import os
+
+async def create_invoice():
+ secret = os.getenv("ECONOMIC_APP_SECRET_TOKEN", "wy8ZhYBLsKhx8McirhvoBR9B6ILuoYJkEaiED5ijsA8")
+ grant = os.getenv("ECONOMIC_AGREEMENT_GRANT_TOKEN", "5AhipRpMpoLx3uklPMQZbtZ4Zw4mV9lDuFI264II0lE")
+
+ headers = {
+ "X-AppSecretToken": secret,
+ "X-AgreementGrantToken": grant,
+ "Content-Type": "application/json"
+ }
+
+ invoice_data = {
+ "customer": {
+ "customerNumber": 702007707
+ },
+ "date": "2026-01-20",
+ "lines": [
+ {
+ "product": {
+ "productNumber": "ABONNEMENT-BLAHUND"
+ },
+ "description": "Abonnement Blåhund",
+ "quantity": 1,
+ "unitNetPrice": 695.00
+ }
+ ]
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ "https://restapi.e-conomic.com/invoices/drafts",
+ headers=headers,
+ json=invoice_data
+ ) as resp:
+ print(f"Status: {resp.status}")
+ data = await resp.json()
+ print(json.dumps(data, indent=2, default=str)[:800])
+
+if __name__ == "__main__":
+ asyncio.run(create_invoice())