bmc_hub/tests/test_also_period_billing.py

179 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Regression coverage for Marketplace period amounts; no external writes."""
from copy import deepcopy
from decimal import Decimal
import json
import pytest
from fastapi import HTTPException
from app.modules.also.backend.billing import period_amount_line, check_period_amounts, _period_text
from app.modules.orders.backend.router import OrdreLineInput
from app.products.backend.economic_documents import line_snapshot
CASES = [
(116, '67.08', '7028.25', '01.08.2026 - 29.08.2026', '28 af 31'),
(116, '67.08', '753.03', '29.08.2026 - 01.09.2026', '3 af 31'),
(108, '67.08', '6543.55', '01.08.2026 - 29.08.2026', '28 af 31'),
(108, '67.08', '701.09', '29.08.2026 - 01.09.2026', '3 af 31'),
(1, '27.90', '17.10', '13.08.2026 - 01.09.2026', '19 af 31'),
(1, '50.28', '19.46', '20.08.2026 - 01.09.2026', '12 af 31'),
]
def source(index=0):
quantity, price, amount, interval, _ = CASES[index]
return dict(line_key=f'also:{index+1}', source_type='also_cloud', source_id=index+1,
product_id=12, description='Microsoft abonnement\nPeriode: ' + interval,
quantity=quantity, unit_price=price, amount=amount,
discount_percentage=0, currency='DKK', meta={'billing_period': interval})
def test_norva_period_amounts_survive_request_model_and_catalog_snapshot():
total = Decimal(0)
product = dict(id=12, name='Microsoft abonnement', economic_product_number='MS-365',
economic_connection_id=1, economic_unit_number=1, status='active')
for index, (_, _, amount, _, days) in enumerate(CASES):
old = source(index)
before = deepcopy(old)
line = period_amount_line(old)
assert old == before
assert period_amount_line(line) == line
assert days + ' dage.' in line['description']
assert 'Reference:' not in line['description']
request_line = OrdreLineInput(**line).model_dump()
check_period_amounts([request_line], [old])
snapshot = line_snapshot(request_line, product, 1, 'DKK')
assert snapshot['quantity'] == '1.00'
assert snapshot['unit_price'] == amount
assert snapshot['net_total'] == amount
total += Decimal(snapshot['net_total'])
assert total == Decimal('15062.48')
def test_exact_customer_text_and_no_reference():
old = source()
old['description'] += '\nReference: sag-xx11.'
line = period_amount_line(old)
assert line['description'] == ('Microsoft abonnement\n116 licenser á 67,08 kr./måned. '
'Periode: 01.08.–28.08.2026, 28 af 31 dage.')
@pytest.mark.parametrize('interval,expected', [
('01.02.2024 - 01.03.2024', '29 af 29 dage.'),
('01.02.2026 - 01.03.2026', '28 af 28 dage.'),
('31.12.2026 - 02.01.2027', '31.12.2026–01.01.2027, 2 dage.'),
('31.08.2026 - 02.09.2026', '31.08.–01.09.2026, 2 dage.'),
])
def test_exclusive_period_end(interval, expected):
assert expected in _period_text(interval)
@pytest.mark.parametrize('changes', [dict(amount=None), dict(amount='NaN'), dict(amount='Infinity'),
dict(amount='-1'), dict(quantity=0), dict(quantity=None),
dict(unit_price=None), dict(discount_percentage=10)])
def test_invalid_source_blocks_instead_of_silent_fallback(changes):
with pytest.raises(HTTPException):
period_amount_line(dict(source(), **changes))
def test_zero_is_preserved():
line = period_amount_line(dict(source(), amount=0, unit_price=0))
assert line['unit_price'] == line['amount'] == 0
@pytest.mark.parametrize('changes', [dict(quantity=116), dict(unit_price=67.08),
dict(product_id=99), dict(currency='EUR'),
dict(discount_percentage=1), dict(source_type='manual'),
dict(description='changed')])
def test_stale_or_modified_export_is_blocked(changes):
line = dict(period_amount_line(source()), **changes)
with pytest.raises(HTTPException):
check_period_amounts([line], [source()])
def test_unsaved_also_line_cannot_bypass_approved_amount():
with pytest.raises(HTTPException):
check_period_amounts([period_amount_line(source())], [])
def test_other_line_types_are_unchanged():
line = dict(source(), source_type='manual')
assert period_amount_line(line) == line
check_period_amounts([line], [])
def test_repricing_skips_approved_also_period_amounts(monkeypatch):
from app.products.backend import economic_pricing
monkeypatch.setattr(economic_pricing, 'query', lambda *args, **kwargs: [])
assert economic_pricing.proposals({'id': 1, 'customer_id': 1, 'lines_json': [period_amount_line(source())]})['changes'] == []
@pytest.mark.parametrize('raw_line', [{}, {'Charge': '7028.25', 'Unit Price': '67.08'}])
def test_approval_stores_period_price_with_source_identity(monkeypatch, raw_line):
from app.modules.also.backend import service
raw = dict(id=1, import_job_id=5, matched_customer_id=8, matched_product_id=12,
matched_customer_name='Testkunde', product_name='Microsoft abonnement',
currency='DKK', billing_start='2026-08-01', billable_parameters=116,
unit_price=Decimal('67.08'), total_price=Decimal('7028.25'),
charge_interval='01.08.2026 - 29.08.2026', raw_line_json=raw_line)
writes = []
def query(sql, params=()):
if 'SELECT' in sql and 'FROM also_import_lines l' in sql:
return [raw]
writes.append((sql, params))
return []
def single(sql, params=()):
if 'INSERT INTO ordre_drafts' in sql:
writes.append((sql, params))
return {'id': 44}
return None
monkeypatch.setattr(service.AlsoService, '_assert_enabled', lambda self: None)
monkeypatch.setattr(service, 'execute_query', query)
monkeypatch.setattr(service, 'execute_query_single', single)
monkeypatch.setattr(service, 'with_invoice_fee', lambda lines, customer: lines)
result = service.AlsoService().approve_lines_to_drafts(5, [1], 3)
insert = next(params for sql, params in writes if 'INSERT INTO ordre_drafts' in sql)
line = json.loads(insert[2])[0]
assert line['quantity'] == 1
assert line['unit_price'] == line['amount'] == 7028.25
assert line['source_id'] == 1
assert line['meta']['also_license_quantity'] == '116'
assert line['meta']['also_monthly_unit_price'] == '67.08'
assert result['approved_lines'] == 1
def test_duplicate_source_line_cannot_double_the_charge():
line = period_amount_line(source())
with pytest.raises(HTTPException):
check_period_amounts([line, line], [source()])
def test_existing_pending_draft_is_presented_correctly_without_database_write(monkeypatch):
import asyncio
from app.core import database
from app.modules.orders.backend.router import get_ordre_draft
old = source()
row = dict(id=44, sync_status='pending', lines_json=[old], export_status_json={})
monkeypatch.setattr(database, 'execute_query_single', lambda *args: deepcopy(row))
def no_write(*args, **kwargs):
raise AssertionError('Reading a pending draft must not write to the database')
monkeypatch.setattr(database, 'execute_query', no_write)
result = asyncio.run(get_ordre_draft(44, None))
assert result['lines_json'][0]['quantity'] == 1
assert result['lines_json'][0]['unit_price'] == 7028.25
assert row['lines_json'][0] == old
@pytest.mark.parametrize('state', [dict(sync_status='exported', economic_order_number='123'),
dict(sync_status='uncertain'),
dict(sync_status='pending', export_idempotency_key='attempt-123')])
def test_exported_or_uncertain_history_is_not_rewritten(monkeypatch, state):
import asyncio
from app.core import database
from app.modules.orders.backend.router import get_ordre_draft
row = dict(id=44, lines_json=[source()], export_status_json={}, **state)
monkeypatch.setattr(database, 'execute_query_single', lambda *args: deepcopy(row))
result = asyncio.run(get_ordre_draft(44, None))
assert result['lines_json'] == row['lines_json']