Defer Odoo writes to transaction.on_commit to prevent orphans #521

Merged
tobru merged 3 commits from fix-org-creation-race into main 2026-09-18 15:02:50 +00:00
Owner

Inverts the create-organization flow so Django becomes the source of truth: BillingEntity and Organization rows are saved synchronously with NULL Odoo IDs, and the Odoo res.partner / sale.order writes are scheduled via transaction.on_commit. If Django rolls back, the on_commit callbacks are discarded and no Odoo orphan is created.

  • BillingEntity: split into create_django (no Odoo) and sync_to_odoo (idempotent). create_from_data kept as a thin wrapper.
  • Organization: create_organization no longer touches Odoo; new sync_to_odoo method handles sale.order create / default-sale-order lookup / GTC chatter (idempotent).
  • OrganizationCreateView wraps the scheduled sync in _safe_odoo_sync so Odoo failure no longer 500s the user; the dashboard shows a banner when Odoo IDs are still NULL.
  • OSB API mirrors the on_commit pattern but lets exceptions propagate so the existing 500-on-Odoo-failure contract with Exoscale is preserved.
  • sync_odoo gains --resync-missing to rerun sync_to_odoo on rows that the on_commit callback failed to populate; idempotency guards make re-runs safe.
Inverts the create-organization flow so Django becomes the source of truth: BillingEntity and Organization rows are saved synchronously with NULL Odoo IDs, and the Odoo res.partner / sale.order writes are scheduled via transaction.on_commit. If Django rolls back, the on_commit callbacks are discarded and no Odoo orphan is created. - BillingEntity: split into create_django (no Odoo) and sync_to_odoo (idempotent). create_from_data kept as a thin wrapper. - Organization: create_organization no longer touches Odoo; new sync_to_odoo method handles sale.order create / default-sale-order lookup / GTC chatter (idempotent). - OrganizationCreateView wraps the scheduled sync in _safe_odoo_sync so Odoo failure no longer 500s the user; the dashboard shows a banner when Odoo IDs are still NULL. - OSB API mirrors the on_commit pattern but lets exceptions propagate so the existing 500-on-Odoo-failure contract with Exoscale is preserved. - sync_odoo gains --resync-missing to rerun sync_to_odoo on rows that the on_commit callback failed to populate; idempotency guards make re-runs safe.
defer Odoo writes to transaction.on_commit to prevent orphans
All checks were successful
Tests / test (push) Successful in 52s
d14d826d9f
Inverts the create-organization flow so Django becomes the source of
truth: BillingEntity and Organization rows are saved synchronously with
NULL Odoo IDs, and the Odoo res.partner / sale.order writes are
scheduled via transaction.on_commit. If Django rolls back, the on_commit
callbacks are discarded and no Odoo orphan is created.

- BillingEntity: split into create_django (no Odoo) and sync_to_odoo
  (idempotent). create_from_data kept as a thin wrapper.
- Organization: create_organization no longer touches Odoo; new
  sync_to_odoo method handles sale.order create / default-sale-order
  lookup / GTC chatter (idempotent).
- OrganizationCreateView wraps the scheduled sync in _safe_odoo_sync so
  Odoo failure no longer 500s the user; the dashboard shows a banner
  when Odoo IDs are still NULL.
- OSB API mirrors the on_commit pattern but lets exceptions propagate so
  the existing 500-on-Odoo-failure contract with Exoscale is preserved.
- sync_odoo gains --resync-missing to rerun sync_to_odoo on rows that
  the on_commit callback failed to populate; idempotency guards make
  re-runs safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tobru force-pushed fix-org-creation-race from d14d826d9f
All checks were successful
Tests / test (push) Successful in 52s
to d2ffd0dbd2
All checks were successful
Tests / test (push) Successful in 1m37s
2026-09-18 14:27:57 +00:00
Compare
fix(billing): do not let Odoo trouble fail a request or flag legacy rows
All checks were successful
Tests / test (push) Successful in 1m33s
9e53c27eaf
Follow-up to the deferred-write change, all of it regressions on data
that already exists. Every row predating migration 0069 has
`pending_odoo_data` NULL, and the new code treated that as an error
rather than the normal permanent state it is.

- `sync_to_odoo()` raising on a payload-less billing entity reached the
  OSB API as a 500. Exoscale retries, takes the identical path, and
  500s again - forever, since no payload will ever appear and
  `--resync-missing` deliberately skips those rows. A service that
  used to be enablable became permanently unenablable. Odoo trouble no
  longer fails an OSB request at all: rejecting the enablement only
  makes Exoscale retry into the same wall.

- `ensure_billing_provisioned()` ran the partner sync before the sale
  order, so a partner failure skipped `provision_billing()` entirely -
  and with it the `origin.default_odoo_sale_order_id` short circuit,
  which never needed the billing entity. A shared-sale-order origin was
  broken by a billing entity it does not use. The two steps are now
  independent and neither failure stops the other.

- Provisioning now runs before the already-enabled short circuit, so a
  repeat OSB call is the retry that finishes an earlier attempt instead
  of returning 200 on an organization that would never be billed.

- `billing_sync_pending` was true for any approved organization missing
  a sale order, which is every legacy and hand-billed one: a permanent
  "Billing setup is still finalizing. Refresh in a moment" that nothing
  clears. It now means only that a stored payload is still outstanding.
  The broad check moves to `billing_provisioning_incomplete`, for staff.

- `--resync-missing` skips organizations with no billing entity rather
  than stamping an origin's shared sale order onto historical rows, and
  a run that produced no sale order no longer ends with "All Odoo
  references are in sync".

Two tests asserted things the harness guaranteed: under plain
`django_db` no `on_commit` callback can fire, so "Odoo failed" and
"rollback discarded the writes" both held with Odoo never called at all.
Both passed with Odoo mocked to succeed. They now use
`django_db(transaction=True)`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
refactor(billing): drop BillingEntity.create_from_data
All checks were successful
Tests / test (push) Successful in 1m41s
40e12d39ff
It has had no callers since the create view and the approval path moved
to `create_django`, and it is the exact shape this branch removes:
create the row and write to Odoo in one synchronous call. Left public,
it is what the next billing path reaches for - inside a transaction,
silently reintroducing the orphan, with its own tests still green.

Its five tests move to `create_django` + `sync_to_odoo`, which is what
production does. Three mocks in test_gtc_acceptance.py had already gone
quiet against it: an `assert_not_called()` that could no longer fail
whatever the view did, and a `return_value=` the view stopped consuming,
so the real row was used and the mock only left a stray BillingEntity
behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tobru changed title from WIP: Defer Odoo writes to transaction.on_commit to prevent orphans to Defer Odoo writes to transaction.on_commit to prevent orphans 2026-09-18 15:01:12 +00:00
tobru merged commit 78c2dca427 into main 2026-09-18 15:02:50 +00:00
tobru deleted branch fix-org-creation-race 2026-09-18 15:02:51 +00:00
Sign in to join this conversation.
No description provided.