- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints
Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
40 lines
1.5 KiB
Python
40 lines
1.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
from odoo import fields, models
|
|
|
|
|
|
class FusionBillingReconciliation(models.Model):
|
|
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
|
|
|
|
During phased cutover (NexaCloud first), Odoo computes invoices while the app
|
|
keeps charging. This row records the per-customer, per-period delta so we only
|
|
flip once deltas are within tolerance. See spec §10.
|
|
"""
|
|
|
|
_name = "fusion.billing.reconciliation"
|
|
_description = "Fusion Billing — Dual-Run Reconciliation"
|
|
_order = "period desc, service_id"
|
|
|
|
service_id = fields.Many2one(
|
|
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
|
)
|
|
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
|
|
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
|
|
odoo_amount = fields.Monetary()
|
|
external_amount = fields.Monetary(string="App-actual Amount")
|
|
delta = fields.Monetary(help="odoo_amount - external_amount.")
|
|
currency_id = fields.Many2one(
|
|
"res.currency", required=True,
|
|
default=lambda self: self.env.company.currency_id,
|
|
)
|
|
status = fields.Selection(
|
|
[
|
|
("match", "Within tolerance"),
|
|
("delta", "Delta — investigate"),
|
|
("resolved", "Resolved"),
|
|
],
|
|
default="delta", required=True, index=True,
|
|
)
|
|
note = fields.Text()
|