feat: Add Abonnements Matrix feature with e-conomic invoice aggregation

- New SubscriptionMatrixService for billing matrix generation
- Products grouped by product number with monthly aggregation
- Support for archived, draft, sent, booked, paid, unpaid invoices
- Fixed amount calculation with fallback logic (grossAmount, unitNetPrice)
- Status mapping based on invoice type (draft, invoiced, paid)
- Frontend tab on customer detail page with dynamic table rendering
- Fixed Blåhund customer economic number linking
This commit is contained in:
Christian 2026-01-25 14:46:00 +01:00
parent 3dcd04396e
commit 6b7b63f7d7
5 changed files with 737 additions and 0 deletions

View File

@ -1355,3 +1355,51 @@ async def get_subscription_comment(customer_id: int):
except Exception as e:
logger.error(f"❌ Error fetching subscription comment: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/subscriptions/billing-matrix")
async def get_subscription_billing_matrix(
customer_id: int,
months: int = Query(default=12, ge=1, le=60, description="Number of months to show")
):
"""
Get subscription billing matrix showing monthly invoiced amounts per product
Rows: Products from e-conomic invoices
Columns: Months
Cells: Amount, status (paid/invoiced/missing), invoice reference
Data source: e-conomic sales invoices only
"""
try:
# Verify customer exists
customer = execute_query_single(
"SELECT id, economic_customer_number FROM customers WHERE id = %s",
(customer_id,)
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
if not customer.get('economic_customer_number'):
logger.warning(f"⚠️ Customer {customer_id} has no e-conomic number")
return {
"customer_id": customer_id,
"error": "Customer not linked to e-conomic",
"products": []
}
# Generate matrix
from app.services.subscription_matrix import get_subscription_matrix_service
matrix_service = get_subscription_matrix_service()
matrix = await matrix_service.generate_billing_matrix(customer_id, months)
logger.info(f"✅ Generated billing matrix for customer {customer_id}")
return matrix
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error generating billing matrix: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -301,6 +301,11 @@
<i class="bi bi-arrow-repeat"></i>Abonnnents tjek
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#billing-matrix">
<i class="bi bi-table"></i>Abonnements Matrix
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#hardware">
<i class="bi bi-hdd"></i>Hardware
@ -490,6 +495,42 @@
</div>
</div>
<!-- Billing Matrix Tab -->
<div class="tab-pane fade" id="billing-matrix">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">
<i class="bi bi-table me-2"></i>Abonnements-matrix
<small class="text-muted fw-normal">(fra e-conomic)</small>
</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent fakturaer fra e-conomic">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
</div>
<div id="billingMatrixContainer" style="display: none;">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="billingMatrixTable">
<thead class="table-light">
<tr id="matrixHeaderRow">
<th style="min-width: 200px;">Vare</th>
<!-- Months will be added dynamically -->
</tr>
</thead>
<tbody id="matrixBodyRows">
<!-- Rows will be added dynamically -->
</tbody>
</table>
</div>
</div>
<div id="billingMatrixLoading" class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary"></div>
<p class="text-muted mt-2">Henter fakturamatrix fra e-conomic...</p>
</div>
<div id="billingMatrixEmpty" style="display: none;" class="text-center py-5">
<p class="text-muted">Ingen fakturaer fundet for denne kunde</p>
</div>
</div>
<!-- Hardware Tab -->
<div class="tab-pane fade" id="hardware">
<h5 class="fw-bold mb-4">Hardware</h5>
@ -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 = `<div class="alert alert-danger"><i class="bi bi-exclamation-circle me-2"></i>${error.message}</div>`;
}
}
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 = '<th style="min-width: 200px;">Vare</th>' +
months.map(month => {
const date = new Date(month + '-01');
const label = date.toLocaleDateString('da-DK', { month: 'short', year: '2-digit' });
return `<th style="min-width: 100px; text-align: center; font-size: 0.9rem;">${label}</th>`;
}).join('') +
'<th style="min-width: 80px; text-align: right;">I alt</th>';
// 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 '<td class="text-muted text-center" style="font-size: 0.85rem;">-</td>';
}
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 `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
<div class="d-flex flex-column align-items-center">
<div class="fw-500">${formatDKK(amount)}</div>
<div>${statusBadge}</div>
</div>
</td>`;
}).join('');
return `<tr>
<td class="fw-500">${escapeHtml(product.product_name)}</td>
${monthCells}
<td class="text-right fw-bold" style="text-align: right;">${formatDKK(product.total_amount)}</td>
</tr>`;
}).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 `<span class="badge bg-${badge.color}" style="font-size: 0.75rem; margin-top: 2px;">
<i class="bi bi-${badge.icon}" style="font-size: 0.65rem;"></i> ${badge.label}
</span>`;
}
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();
}
});
}
});
</script>
<!-- Data Consistency Comparison Modal -->

View File

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

View File

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

47
create_test_invoice.py Normal file
View File

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