feat(billing): importer Test Connection guard + operator runbook
Add action_test_connection — a read-only connectivity/schema check that reports source row counts and imports nothing, the safe first step before a dry-run. Wire a "Test Connection" button on the wizard. Document the end-to-end run in the README: least-privilege read-only DB role SQL, the fusion_billing.nexacloud_dsn system parameter (libpq DSN = NexaCloud's URL minus +asyncpg), and the Test → dry-run → real-run flow. Refresh the stale SCAFFOLD status. 53/53 green on odoo-trial.
This commit is contained in:
@@ -7,9 +7,9 @@ home-grown Stripe billing into one customer ledger and one accounting system.
|
||||
|
||||
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
|
||||
>
|
||||
> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the
|
||||
> module installs. The usage engine, full inbound API, and webhook processor are stubs
|
||||
> to be implemented from the writing-plans output.
|
||||
> **Status:** Core engine (sub-project #1) and the **NexaCloud importer (sub-project #2a)**
|
||||
> are implemented and tested on odoo-trial Enterprise. 2b (usage wiring), 2c (control loop),
|
||||
> and 2d (reconciliation) are pending.
|
||||
|
||||
## Why this module is small
|
||||
|
||||
@@ -59,6 +59,42 @@ cost into margin reporting; reuse its daily-rollup aggregation pattern.
|
||||
|
||||
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
|
||||
|
||||
## Running the NexaCloud import (2a)
|
||||
|
||||
Exposed as **Fusion Billing → Import from NexaCloud** (a wizard). It runs entirely
|
||||
read-only against NexaCloud, and everything it creates in Odoo is shadow-safe (draft
|
||||
subscriptions, no payment token, charges with NULL `plan_id`) so it cannot charge or post
|
||||
during the dual-run.
|
||||
|
||||
**1. Create a least-privilege read-only role in the NexaCloud Postgres (LXC 201):**
|
||||
|
||||
```sql
|
||||
CREATE ROLE odoo_billing_ro WITH LOGIN PASSWORD '<choose-a-strong-password>';
|
||||
GRANT CONNECT ON DATABASE nexacloud TO odoo_billing_ro;
|
||||
GRANT USAGE ON SCHEMA public TO odoo_billing_ro;
|
||||
GRANT SELECT ON users, plans, subscriptions, deployments TO odoo_billing_ro;
|
||||
```
|
||||
|
||||
**2. Point Odoo at it** via the system parameter (Settings → Technical → System Parameters,
|
||||
or odoo-shell). psycopg2 wants a **libpq DSN** — i.e. NexaCloud's SQLAlchemy URL *without*
|
||||
`+asyncpg`:
|
||||
|
||||
```
|
||||
key: fusion_billing.nexacloud_dsn
|
||||
value: postgresql://odoo_billing_ro:<password>@<lxc201-host>:5432/nexacloud
|
||||
```
|
||||
|
||||
(Odoo on nexa / VM 315 must have a network route to the LXC 201 Postgres port.)
|
||||
|
||||
**3. Validate → dry-run → run for real:**
|
||||
|
||||
- **Test Connection** — confirms reachability + schema and reports row counts; imports nothing.
|
||||
- **Run Import** with **Dry run** ticked — computes the whole import inside a rolled-back
|
||||
savepoint and reports created / updated / **skipped** / **failed** counts; writes nothing.
|
||||
A red/amber banner flags any failures — investigate them before proceeding.
|
||||
- Untick **Dry run** and **Run Import** to persist the shadow copy. Re-running is safe and
|
||||
idempotent (upserts, never duplicates).
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
|
||||
@@ -243,3 +243,9 @@ class TestImporterReadGuard(TransactionCase):
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz._read_nexacloud_rows()
|
||||
|
||||
def test_test_connection_guards_missing_dsn(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz.action_test_connection()
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_test_connection" type="object"
|
||||
string="Test Connection" class="btn-secondary"/>
|
||||
<button name="action_run_import" type="object" string="Run Import"
|
||||
class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
|
||||
@@ -60,6 +60,22 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Read-only connectivity + schema check: connect, read the source tables, and
|
||||
report row counts WITHOUT importing anything. The safe first step before a
|
||||
dry-run — surfaces a bad DSN, no network route, or a schema drift up front."""
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
msg = "Connected. Read %s user(s), %s plan(s), %s subscription(s)." % (
|
||||
len(data.get("users", [])), len(data.get("plans", [])),
|
||||
len(data.get("subscriptions", [])))
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {"title": "NexaCloud connection OK", "message": msg,
|
||||
"type": "success", "sticky": False},
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
|
||||
Reference in New Issue
Block a user