Centralize billing for all NexaSystems services (NexaCloud, NexaDesk, NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance, replacing Lago. The module adds only the metering + integration layer; native sale_subscription / account_accountant / payment_stripe do all the financial work (invoicing, HST, dunning, portal, credit notes, Stripe). Includes: - Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md): 6 locked decisions, architecture, data model, usage engine, Lago-shaped API, webhook control loop, NexaCloud pilot, phased dual-run migration. - Module scaffold: 7 fusion.billing.* models (service, account.link, metric, charge, usage, webhook, reconciliation), bearer-auth API controller shell, security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs pending the implementation plan. - CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription is a sale.order(is_subscription) + sale.subscription.plan (verified live). Task 0 verified: a single Stripe account is shared across NexaCloud and all Lago providers, so no Stripe account/card migration is required. 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 FusionBillingUsage(models.Model):
|
|
"""Aggregated usage rollup for a (subscription, metric, period).
|
|
|
|
Aggregate-push model: apps send periodic counters (not raw events). The
|
|
``idempotency_key`` makes re-sent counters safe — they never double-count.
|
|
A pre-invoice cron sums these and feeds billable quantity onto the subscription.
|
|
|
|
NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with
|
|
``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2.
|
|
"""
|
|
|
|
_name = "fusion.billing.usage"
|
|
_description = "Fusion Billing — Aggregated Usage (period rollup)"
|
|
_order = "period_start desc"
|
|
|
|
subscription_id = fields.Many2one(
|
|
"sale.order", required=True, ondelete="cascade", index=True,
|
|
string="Subscription", domain=[("is_subscription", "=", True)],
|
|
)
|
|
metric_id = fields.Many2one(
|
|
"fusion.billing.metric", required=True, ondelete="restrict", index=True,
|
|
)
|
|
period_start = fields.Datetime(required=True)
|
|
period_end = fields.Datetime(required=True)
|
|
quantity = fields.Float(default=0.0)
|
|
source = fields.Char(default="push")
|
|
idempotency_key = fields.Char(
|
|
index=True, help="Dedupe key so re-sent counters never double-count.",
|
|
)
|
|
|
|
_idempotency_uniq = models.Constraint(
|
|
"unique(idempotency_key)", "Usage idempotency key must be unique.",
|
|
)
|