""" Subscription Invoice Processing Job Processes active subscriptions when next_invoice_date is reached Creates ordre drafts and advances subscription periods Runs daily at 04:00 """ import logging from datetime import datetime, date import json from dateutil.relativedelta import relativedelta from app.core.database import execute_query, get_db_connection logger = logging.getLogger(__name__) async def process_subscriptions(): """ Main job: Process subscriptions due for invoicing. - Find active subscriptions where next_invoice_date <= today - Skip subscriptions blocked for invoicing (missing asset/serial) - Aggregate eligible subscriptions into one ordre_draft per customer + merge key + due date + billing direction - Advance period_start and next_invoice_date for processed subscriptions """ try: logger.info("💰 Processing subscription invoices...") # Find subscriptions due for invoicing query = """ SELECT s.id, s.sag_id, sg.titel AS sag_name, s.customer_id, c.name AS customer_name, s.product_name, s.billing_interval, s.billing_direction, s.advance_months, s.price, s.next_invoice_date, s.period_start, s.invoice_merge_key, s.billing_blocked, s.billing_block_reason, COALESCE( ( SELECT json_agg( json_build_object( 'id', si.id, 'description', si.description, 'quantity', si.quantity, 'unit_price', si.unit_price, 'line_total', si.line_total, 'product_id', si.product_id, 'asset_id', si.asset_id, 'billing_blocked', si.billing_blocked, 'billing_block_reason', si.billing_block_reason, 'period_from', si.period_from, 'period_to', si.period_to ) ORDER BY si.id ) FROM sag_subscription_items si WHERE si.subscription_id = s.id ), '[]'::json ) as line_items FROM sag_subscriptions s LEFT JOIN sag_sager sg ON sg.id = s.sag_id LEFT JOIN customers c ON c.id = s.customer_id WHERE s.status = 'active' AND s.next_invoice_date <= CURRENT_DATE ORDER BY s.next_invoice_date, s.id """ subscriptions = execute_query(query) if not subscriptions: logger.info("✅ No subscriptions due for invoicing") return logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process") blocked_count = 0 processed_count = 0 error_count = 0 grouped_subscriptions = {} for sub in subscriptions: if sub.get('billing_blocked'): blocked_count += 1 logger.warning( "⚠️ Subscription %s skipped due to billing block: %s", sub.get('id'), sub.get('billing_block_reason') or 'unknown reason' ) continue group_key = ( int(sub['customer_id']), str(sub.get('invoice_merge_key') or f"cust-{sub['customer_id']}"), str(sub.get('next_invoice_date')), str(sub.get('billing_direction') or 'forward'), ) grouped_subscriptions.setdefault(group_key, []).append(sub) for group in grouped_subscriptions.values(): try: count = await _process_subscription_group(group) processed_count += count except Exception as e: logger.error("❌ Failed processing subscription group: %s", e, exc_info=True) error_count += 1 logger.info( "✅ Subscription processing complete: %s processed, %s blocked, %s errors", processed_count, blocked_count, error_count, ) except Exception as e: logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True) async def _process_subscription_group(subscriptions: list[dict]) -> int: """Create one aggregated ordre draft for a group of subscriptions and advance all periods.""" if not subscriptions: return 0 first = subscriptions[0] customer_id = first['customer_id'] customer_name = first.get('customer_name') or f"Customer #{customer_id}" billing_direction = first.get('billing_direction') or 'forward' invoice_aggregate_key = first.get('invoice_merge_key') or f"cust-{customer_id}" conn = get_db_connection() cursor = conn.cursor() try: ordre_lines = [] source_subscription_ids = [] coverage_start = None coverage_end = None for sub in subscriptions: subscription_id = int(sub['id']) source_subscription_ids.append(subscription_id) line_items = sub.get('line_items', []) if isinstance(line_items, str): line_items = json.loads(line_items) period_start = sub.get('period_start') or sub.get('next_invoice_date') period_end = _calculate_next_period_start(period_start, sub['billing_interval']) if coverage_start is None or period_start < coverage_start: coverage_start = period_start if coverage_end is None or period_end > coverage_end: coverage_end = period_end for item in line_items: if item.get('billing_blocked'): logger.warning( "⚠️ Skipping blocked subscription item %s on subscription %s", item.get('id'), subscription_id, ) continue product_number = str(item.get('product_id', 'SUB')) ordre_lines.append({ "product": { "productNumber": product_number, "description": item.get('description', '') }, "quantity": float(item.get('quantity', 1)), "unitNetPrice": float(item.get('unit_price', 0)), "totalNetAmount": float(item.get('line_total', 0)), "discountPercentage": 0, "metadata": { "subscription_id": subscription_id, "asset_id": item.get('asset_id'), "period_from": str(item.get('period_from') or period_start), "period_to": str(item.get('period_to') or period_end), } }) if not ordre_lines: logger.warning("⚠️ No invoiceable lines in subscription group for customer %s", customer_id) return 0 title = f"Abonnementer: {customer_name}" notes = ( f"Aggregated abonnement faktura\n" f"Kunde: {customer_name}\n" f"Coverage: {coverage_start} til {coverage_end}\n" f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}" ) insert_query = """ INSERT INTO ordre_drafts ( title, customer_id, lines_json, notes, coverage_start, coverage_end, billing_direction, source_subscription_ids, invoice_aggregate_key, layout_number, created_by_user_id, sync_status, export_status_json, updated_at ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) RETURNING id """ cursor.execute(insert_query, ( title, customer_id, json.dumps(ordre_lines, ensure_ascii=False), notes, coverage_start, coverage_end, billing_direction, source_subscription_ids, invoice_aggregate_key, 1, # Default layout None, # System-created 'pending', json.dumps({"source": "subscription", "subscription_ids": source_subscription_ids}, ensure_ascii=False) )) ordre_id = cursor.fetchone()[0] logger.info( "✅ Created aggregated ordre draft #%s for %s subscription(s)", ordre_id, len(source_subscription_ids), ) for sub in subscriptions: subscription_id = int(sub['id']) current_period_start = sub.get('period_start') or sub.get('next_invoice_date') new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval']) new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval']) cursor.execute( """ UPDATE sag_subscriptions SET period_start = %s, next_invoice_date = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (new_period_start, new_next_invoice_date, subscription_id) ) conn.commit() return len(source_subscription_ids) except Exception as e: conn.rollback() raise e finally: cursor.close() conn.close() def _calculate_next_period_start(current_date, billing_interval: str) -> date: """Calculate next period start date based on billing interval""" # Parse current_date if it's a string if isinstance(current_date, str): current_date = datetime.strptime(current_date, '%Y-%m-%d').date() elif isinstance(current_date, datetime): current_date = current_date.date() # Calculate delta based on interval if billing_interval == 'daily': delta = relativedelta(days=1) elif billing_interval == 'biweekly': delta = relativedelta(weeks=2) elif billing_interval == 'monthly': delta = relativedelta(months=1) elif billing_interval == 'quarterly': delta = relativedelta(months=3) elif billing_interval == 'yearly': delta = relativedelta(years=1) else: # Default to monthly if unknown logger.warning(f"Unknown billing interval '{billing_interval}', defaulting to monthly") delta = relativedelta(months=1) next_date = current_date + delta return next_date