2026-02-17 08:29:05 +01:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
2026-09-07 19:05:58 +02:00
|
|
|
|
from typing import Optional, Sequence
|
2026-02-17 08:29:05 +01:00
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.database import execute_query, get_db_connection
|
2026-08-28 20:49:55 +02:00
|
|
|
|
from app.services.subscription_billing_calendar import (
|
|
|
|
|
|
advance_billing_periods,
|
|
|
|
|
|
billing_date_for_period,
|
|
|
|
|
|
prorated_30_day_factor,
|
|
|
|
|
|
)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 19:05:58 +02:00
|
|
|
|
async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None):
|
2026-02-17 08:29:05 +01:00
|
|
|
|
"""
|
2026-03-23 20:35:15 +01:00
|
|
|
|
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
|
2026-02-17 08:29:05 +01:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
logger.info("💰 Processing subscription invoices...")
|
2026-08-28 20:49:55 +02:00
|
|
|
|
from app.subscriptions.backend.router import apply_due_subscription_changes, expire_ended_subscriptions
|
|
|
|
|
|
applied_changes = apply_due_subscription_changes()
|
|
|
|
|
|
expired_subscriptions = expire_ended_subscriptions()
|
|
|
|
|
|
if applied_changes:
|
|
|
|
|
|
logger.info("✅ Applied %s scheduled subscription change request(s)", applied_changes)
|
|
|
|
|
|
if expired_subscriptions:
|
|
|
|
|
|
logger.info("✅ Expired %s ended subscription(s)", expired_subscriptions)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
|
|
# 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,
|
2026-08-28 20:49:55 +02:00
|
|
|
|
s.billing_schedule_type,
|
|
|
|
|
|
s.billing_day,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
s.billing_direction,
|
|
|
|
|
|
s.advance_months,
|
2026-08-28 20:49:55 +02:00
|
|
|
|
s.billing_lead_months,
|
|
|
|
|
|
s.first_full_period_start,
|
|
|
|
|
|
s.proration_basis,
|
2026-02-17 08:29:05 +01:00
|
|
|
|
s.price,
|
|
|
|
|
|
s.next_invoice_date,
|
|
|
|
|
|
s.period_start,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
s.invoice_merge_key,
|
|
|
|
|
|
s.billing_blocked,
|
|
|
|
|
|
s.billing_block_reason,
|
2026-02-17 08:29:05 +01:00
|
|
|
|
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,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
'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
|
2026-02-17 08:29:05 +01:00
|
|
|
|
) ORDER BY si.id
|
|
|
|
|
|
)
|
|
|
|
|
|
FROM sag_subscription_items si
|
|
|
|
|
|
WHERE si.subscription_id = s.id
|
|
|
|
|
|
),
|
|
|
|
|
|
'[]'::json
|
|
|
|
|
|
) as line_items
|
2026-08-28 20:49:55 +02:00
|
|
|
|
,COALESCE(
|
|
|
|
|
|
(
|
|
|
|
|
|
SELECT json_agg(json_build_object(
|
|
|
|
|
|
'id', fi.id,
|
|
|
|
|
|
'description', fi.description,
|
|
|
|
|
|
'quantity', fi.quantity,
|
|
|
|
|
|
'unit_price', fi.unit_price,
|
|
|
|
|
|
'line_total', fi.line_total,
|
|
|
|
|
|
'product_id', fi.product_id
|
|
|
|
|
|
) ORDER BY fi.line_no, fi.id)
|
|
|
|
|
|
FROM sag_subscription_first_invoice_items fi
|
|
|
|
|
|
WHERE fi.subscription_id = s.id AND fi.billed_at IS NULL
|
|
|
|
|
|
),
|
|
|
|
|
|
'[]'::json
|
|
|
|
|
|
) AS first_invoice_items
|
2026-02-17 08:29:05 +01:00
|
|
|
|
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
|
2026-08-28 20:49:55 +02:00
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
|
SELECT 1 FROM subscription_billing_runs br
|
|
|
|
|
|
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
|
|
|
|
|
|
)
|
2026-09-07 19:05:58 +02:00
|
|
|
|
"""
|
|
|
|
|
|
params = []
|
|
|
|
|
|
if subscription_ids is not None:
|
|
|
|
|
|
selected_ids = sorted({int(item) for item in subscription_ids})
|
|
|
|
|
|
if not selected_ids:
|
|
|
|
|
|
return
|
|
|
|
|
|
query += " AND s.id = ANY(%s)"
|
|
|
|
|
|
params.append(selected_ids)
|
|
|
|
|
|
query += """
|
2026-02-17 08:29:05 +01:00
|
|
|
|
ORDER BY s.next_invoice_date, s.id
|
|
|
|
|
|
"""
|
2026-09-07 19:05:58 +02:00
|
|
|
|
|
|
|
|
|
|
subscriptions = execute_query(query, tuple(params))
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
|
|
if not subscriptions:
|
|
|
|
|
|
logger.info("✅ No subscriptions due for invoicing")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
|
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
|
blocked_count = 0
|
2026-02-17 08:29:05 +01:00
|
|
|
|
processed_count = 0
|
|
|
|
|
|
error_count = 0
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
|
|
|
|
|
grouped_subscriptions = {}
|
2026-02-17 08:29:05 +01:00
|
|
|
|
for sub in subscriptions:
|
2026-03-23 20:35:15 +01:00
|
|
|
|
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():
|
2026-02-17 08:29:05 +01:00
|
|
|
|
try:
|
2026-03-23 20:35:15 +01:00
|
|
|
|
count = await _process_subscription_group(group)
|
|
|
|
|
|
processed_count += count
|
2026-02-17 08:29:05 +01:00
|
|
|
|
except Exception as e:
|
2026-03-23 20:35:15 +01:00
|
|
|
|
logger.error("❌ Failed processing subscription group: %s", e, exc_info=True)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
error_count += 1
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"✅ Subscription processing complete: %s processed, %s blocked, %s errors",
|
|
|
|
|
|
processed_count,
|
|
|
|
|
|
blocked_count,
|
|
|
|
|
|
error_count,
|
|
|
|
|
|
)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
|
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}"
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
conn = get_db_connection()
|
|
|
|
|
|
cursor = conn.cursor()
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
try:
|
|
|
|
|
|
ordre_lines = []
|
2026-03-23 20:35:15 +01:00
|
|
|
|
source_subscription_ids = []
|
|
|
|
|
|
coverage_start = None
|
|
|
|
|
|
coverage_end = None
|
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
|
# Claim every subscription period. A competing worker will make this
|
|
|
|
|
|
# transaction roll back before an order draft can be duplicated.
|
|
|
|
|
|
claimed_run_ids = []
|
|
|
|
|
|
for sub in subscriptions:
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"""INSERT INTO subscription_billing_runs (subscription_id, period_start)
|
|
|
|
|
|
VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING id""",
|
|
|
|
|
|
(int(sub['id']), sub.get('period_start') or sub.get('next_invoice_date')),
|
|
|
|
|
|
)
|
|
|
|
|
|
claimed = cursor.fetchone()
|
|
|
|
|
|
if not claimed:
|
|
|
|
|
|
conn.rollback()
|
|
|
|
|
|
logger.info("Subscription period already claimed by another worker")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
claimed_run_ids.append(int(claimed[0]))
|
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
|
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')
|
2026-08-28 20:49:55 +02:00
|
|
|
|
first_full_period_start = sub.get('first_full_period_start')
|
|
|
|
|
|
if isinstance(first_full_period_start, str):
|
|
|
|
|
|
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
|
|
|
|
|
|
advance_periods = max(1, int(sub.get('advance_months') or 1))
|
|
|
|
|
|
has_short_opening_period = bool(first_full_period_start and period_start < first_full_period_start)
|
|
|
|
|
|
full_period_start = first_full_period_start if has_short_opening_period else period_start
|
|
|
|
|
|
period_end = advance_billing_periods(full_period_start, sub['billing_interval'], advance_periods)
|
2026-03-23 20:35:15 +01:00
|
|
|
|
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'))
|
2026-08-28 20:49:55 +02:00
|
|
|
|
if has_short_opening_period:
|
|
|
|
|
|
factor = prorated_30_day_factor(period_start, first_full_period_start)
|
|
|
|
|
|
if factor > 0:
|
|
|
|
|
|
prorated_unit_price = round(float(item.get('unit_price', 0)) * factor, 2)
|
|
|
|
|
|
ordre_lines.append({
|
|
|
|
|
|
"product": {
|
|
|
|
|
|
"productNumber": product_number,
|
|
|
|
|
|
"description": f"{item.get('description', '')} – skæv periode {period_start} til {first_full_period_start} (30 dage)"
|
|
|
|
|
|
},
|
|
|
|
|
|
"quantity": float(item.get('quantity', 1)),
|
|
|
|
|
|
"unitNetPrice": prorated_unit_price,
|
|
|
|
|
|
"totalNetAmount": round(float(item.get('quantity', 1)) * prorated_unit_price, 2),
|
|
|
|
|
|
"discountPercentage": 0,
|
|
|
|
|
|
"metadata": {
|
|
|
|
|
|
"subscription_id": subscription_id,
|
|
|
|
|
|
"proration_basis": "30_day",
|
|
|
|
|
|
"proration_factor": factor,
|
|
|
|
|
|
"period_from": str(period_start),
|
|
|
|
|
|
"period_to": str(first_full_period_start),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
full_unit_price = float(item.get('unit_price', 0)) * advance_periods
|
2026-03-23 20:35:15 +01:00
|
|
|
|
ordre_lines.append({
|
|
|
|
|
|
"product": {
|
|
|
|
|
|
"productNumber": product_number,
|
2026-08-28 20:49:55 +02:00
|
|
|
|
"description": item.get('description', '') + (f" – {advance_periods} perioder" if advance_periods > 1 else '')
|
|
|
|
|
|
},
|
|
|
|
|
|
"quantity": float(item.get('quantity', 1)),
|
|
|
|
|
|
"unitNetPrice": full_unit_price,
|
|
|
|
|
|
"totalNetAmount": float(item.get('quantity', 1)) * full_unit_price,
|
|
|
|
|
|
"discountPercentage": 0,
|
|
|
|
|
|
"metadata": {
|
|
|
|
|
|
"subscription_id": subscription_id,
|
|
|
|
|
|
"asset_id": item.get('asset_id'),
|
|
|
|
|
|
"period_from": str(full_period_start),
|
|
|
|
|
|
"period_to": str(item.get('period_to') or period_end),
|
|
|
|
|
|
"advance_periods": advance_periods,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
first_invoice_items = sub.get('first_invoice_items', [])
|
|
|
|
|
|
if isinstance(first_invoice_items, str):
|
|
|
|
|
|
first_invoice_items = json.loads(first_invoice_items)
|
|
|
|
|
|
for item in first_invoice_items:
|
|
|
|
|
|
ordre_lines.append({
|
|
|
|
|
|
"product": {
|
|
|
|
|
|
"productNumber": str(item.get('product_id') or 'ENGANG'),
|
2026-03-23 20:35:15 +01:00
|
|
|
|
"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,
|
2026-08-28 20:49:55 +02:00
|
|
|
|
"first_invoice_item_id": item.get('id'),
|
|
|
|
|
|
"one_time": True,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
insert_query = """
|
|
|
|
|
|
INSERT INTO ordre_drafts (
|
|
|
|
|
|
title,
|
|
|
|
|
|
customer_id,
|
|
|
|
|
|
lines_json,
|
|
|
|
|
|
notes,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
coverage_start,
|
|
|
|
|
|
coverage_end,
|
|
|
|
|
|
billing_direction,
|
|
|
|
|
|
source_subscription_ids,
|
|
|
|
|
|
invoice_aggregate_key,
|
2026-02-17 08:29:05 +01:00
|
|
|
|
layout_number,
|
|
|
|
|
|
created_by_user_id,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
sync_status,
|
2026-02-17 08:29:05 +01:00
|
|
|
|
export_status_json,
|
|
|
|
|
|
updated_at
|
2026-03-23 20:35:15 +01:00
|
|
|
|
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
RETURNING id
|
|
|
|
|
|
"""
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
cursor.execute(insert_query, (
|
|
|
|
|
|
title,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
customer_id,
|
2026-02-17 08:29:05 +01:00
|
|
|
|
json.dumps(ordre_lines, ensure_ascii=False),
|
|
|
|
|
|
notes,
|
2026-03-23 20:35:15 +01:00
|
|
|
|
coverage_start,
|
|
|
|
|
|
coverage_end,
|
|
|
|
|
|
billing_direction,
|
|
|
|
|
|
source_subscription_ids,
|
|
|
|
|
|
invoice_aggregate_key,
|
2026-02-17 08:29:05 +01:00
|
|
|
|
1, # Default layout
|
|
|
|
|
|
None, # System-created
|
2026-03-23 20:35:15 +01:00
|
|
|
|
'pending',
|
|
|
|
|
|
json.dumps({"source": "subscription", "subscription_ids": source_subscription_ids}, ensure_ascii=False)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
))
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
ordre_id = cursor.fetchone()[0]
|
2026-08-28 20:49:55 +02:00
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"UPDATE subscription_billing_runs SET ordre_draft_id = %s WHERE id = ANY(%s)",
|
|
|
|
|
|
(ordre_id, claimed_run_ids),
|
|
|
|
|
|
)
|
|
|
|
|
|
for sub, run_id in zip(subscriptions, claimed_run_ids):
|
|
|
|
|
|
first_items = sub.get('first_invoice_items', [])
|
|
|
|
|
|
if isinstance(first_items, str):
|
|
|
|
|
|
first_items = json.loads(first_items)
|
|
|
|
|
|
first_item_ids = [int(item['id']) for item in first_items if item.get('id')]
|
|
|
|
|
|
if first_item_ids:
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"""UPDATE sag_subscription_first_invoice_items
|
|
|
|
|
|
SET billed_at = CURRENT_TIMESTAMP, billing_run_id = %s, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE subscription_id = %s AND id = ANY(%s) AND billed_at IS NULL""",
|
|
|
|
|
|
(run_id, int(sub['id']), first_item_ids),
|
|
|
|
|
|
)
|
2026-03-23 20:35:15 +01:00
|
|
|
|
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')
|
2026-08-28 20:49:55 +02:00
|
|
|
|
first_full_period_start = sub.get('first_full_period_start')
|
|
|
|
|
|
if isinstance(first_full_period_start, str):
|
|
|
|
|
|
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
|
|
|
|
|
|
full_period_start = first_full_period_start if first_full_period_start and current_period_start < first_full_period_start else current_period_start
|
|
|
|
|
|
new_period_start = advance_billing_periods(
|
|
|
|
|
|
full_period_start, sub['billing_interval'], max(1, int(sub.get('advance_months') or 1))
|
|
|
|
|
|
)
|
|
|
|
|
|
new_next_invoice_date = billing_date_for_period(
|
|
|
|
|
|
new_period_start,
|
|
|
|
|
|
int(sub.get('billing_lead_months') or 0),
|
|
|
|
|
|
sub.get('billing_schedule_type') or 'fixed_day',
|
|
|
|
|
|
int(sub.get('billing_day') or 1),
|
|
|
|
|
|
)
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
conn.commit()
|
2026-03-23 20:35:15 +01:00
|
|
|
|
return len(source_subscription_ids)
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
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
|