bmc_hub/app/modules/also/backend/billing.py

97 lines
5.0 KiB
Python
Raw Normal View History

"""Keep ALSO's approved period amount authoritative on order lines."""
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from datetime import datetime, timedelta
import calendar
import re
from fastapi import HTTPException
def _number(value):
try:
result = Decimal(str(value))
if not result.is_finite():
raise InvalidOperation
return result
except (InvalidOperation, TypeError, ValueError):
raise HTTPException(409, 'ALSO-linjen mangler et gyldigt godkendt periodebeløb')
def period_amount_line(line):
"""Return a copy; never rewrite saved history or normalize other sources."""
if line.get('source_type') != 'also_cloud':
return dict(line)
amount = _number(line.get('amount'))
if amount < 0 or amount != amount.quantize(Decimal('.01'), rounding=ROUND_HALF_UP):
raise HTTPException(409, 'ALSO-periodebeløbet kræver afklaring før eksport')
if _number(line.get('discount_percentage', 0)) != 0:
raise HTTPException(409, 'ALSO-periodebeløbet må ikke kombineres med linjerabat')
result = dict(line)
meta = dict(line.get('meta') or {})
if meta.get('also_period_amount_version') != 1:
quantity = _number(line.get('quantity'))
monthly_price = _number(line.get('unit_price'))
if quantity <= 0 or monthly_price < 0:
raise HTTPException(409, 'ALSO-linjen mangler gyldigt licensantal eller månedspris')
meta.update(also_license_quantity=str(quantity), also_monthly_unit_price=str(monthly_price),
also_period_amount_version=1)
quantity_text = format(quantity, 'f').rstrip('0').rstrip('.') if '.' in str(quantity) else str(quantity)
price_text = f'{monthly_price:.2f}'.replace('.', ',')
license_text = 'licens' if quantity == 1 else 'licenser'
description = result.get('description') or 'Cloud abonnement'
title, separator, rest = description.partition('\n')
currency_text = 'kr.' if result.get('currency', 'DKK') == 'DKK' else result['currency']
explanation = f'{quantity_text} {license_text} á {price_text} {currency_text}/måned.'
interval = meta.get('billing_period')
period_text = _period_text(interval)
result['description'] = title + '\n' + explanation + (
' ' + period_text if period_text else ('\n' + rest if separator else ''))
result['description'] = re.sub(r'\s*Reference:\s*[^\n]*', '', result.get('description', ''), flags=re.IGNORECASE).strip()
result.update(quantity=1.0, unit_price=float(amount), amount=float(amount),
discount_percentage=0.0, meta=meta)
return result
def _period_text(interval):
"""ALSO Marketplace intervals have an exclusive end date."""
match = re.fullmatch(r'\s*(\d{2})[./-](\d{2})[./-](\d{4})\s*-\s*(\d{2})[./-](\d{2})[./-](\d{4})\s*', str(interval or ''))
if not match:
return None
try:
day, month, year, end_day, end_month, end_year = map(int, match.groups())
start = datetime(year, month, day).date()
end = datetime(end_year, end_month, end_day).date()
except ValueError:
raise HTTPException(409, 'ALSO-linjen har en ugyldig periode')
if end <= start:
raise HTTPException(409, 'ALSO-periodens slutdato skal være efter startdatoen')
last = end - timedelta(days=1)
start_text = start.strftime('%d.%m.') if start.year == last.year else start.strftime('%d.%m.%Y')
text = f'Periode: {start_text}–{last:%d.%m.%Y}, {(end-start).days}'
if (start.year, start.month) == (last.year, last.month):
text += f' af {calendar.monthrange(start.year, start.month)[1]}'
return text + ' dage.'
def check_period_amounts(lines, saved_lines):
"""Reject stale browsers and altered amounts before either export backend runs."""
saved = {str(line.get('line_key')): line for line in saved_lines}
seen = set()
for line in lines:
old = saved.get(str(line.get('line_key')))
if line.get('source_type') != 'also_cloud' and (not old or old.get('source_type') != 'also_cloud'):
continue
if not old or old.get('source_type') != 'also_cloud' or line.get('source_type') != 'also_cloud':
raise HTTPException(409, 'ALSO-eksport kræver den gemte godkendte ordrekladde')
key = str(line.get('line_key'))
if key in seen or line.get('source_id') != old.get('source_id'):
raise HTTPException(409, 'ALSO-kildelinjen mangler entydig identitet eller er valgt flere gange')
seen.add(key)
expected = period_amount_line(old)
if (line.get('product_id') != old.get('product_id')
or line.get('description') != expected.get('description')
or line.get('currency', 'DKK') != old.get('currency', 'DKK')
or any(_number(line.get(key, 0)) != _number(expected[key])
for key in ('quantity', 'unit_price', 'discount_percentage'))):
raise HTTPException(409, 'ALSO-linjen matcher ikke det godkendte periodebeløb. Genindlæs kladden og kontrollér linjerne før eksport.')