NEC-142: Portal plan or seat changes on a fulfilled order may never reach the shard #202

Merged
nectenda-agent merged 6 commits from worktree-nec-142-portal-plan-or-seat-changes-on-a-fulfill into main 2026-09-28 14:07:23 +01:00
Collaborator

Task: NEC-142 — https://projectron.nerchure.com/tasks/142

Billing webhooks applied every change to the order row and discarded what the shard had to hear, so on a live order a seat or plan bought in the provider's portal was charged and never granted, and past-due notices, dispute suspensions and cancellations never reached the shard. Changes are now queued in shard_intents in the webhook's transaction and delivered by the hourly tick, oldest first per organisation. A refused change holds its organisation with no time limit (user decision): it is reported after a day, gauged, and can be abandoned by an operator with a reason via /api/admin/billing/intents; docs/support-runbook.md has the recovery steps. Changes from a superseded order are not sent, a drop to free falls back to any order still being paid for, drops clear the paid seat cap, and a scheduled change that crosses a webhook no longer overwrites the row. Rides along NEC-145: changes now go to the shard the directory says the account lives on, and wait while it is mid-move.

Spec: docs/changes/NEC-142-portal-changes-reach-shard/spec.md

Also fixes NEC-145: Billing changes go to the old shard after an account moves

Changelog

Seat and plan changes made in the billing portal now reach your organisation, as do payment-failure notices and cancellations, including after the organisation moves to another server.

Task: NEC-142 — https://projectron.nerchure.com/tasks/142 Billing webhooks applied every change to the order row and discarded what the shard had to hear, so on a live order a seat or plan bought in the provider's portal was charged and never granted, and past-due notices, dispute suspensions and cancellations never reached the shard. Changes are now queued in shard_intents in the webhook's transaction and delivered by the hourly tick, oldest first per organisation. A refused change holds its organisation with no time limit (user decision): it is reported after a day, gauged, and can be abandoned by an operator with a reason via /api/admin/billing/intents; docs/support-runbook.md has the recovery steps. Changes from a superseded order are not sent, a drop to free falls back to any order still being paid for, drops clear the paid seat cap, and a scheduled change that crosses a webhook no longer overwrites the row. Rides along NEC-145: changes now go to the shard the directory says the account lives on, and wait while it is mid-move. Spec: `docs/changes/NEC-142-portal-changes-reach-shard/spec.md` Also fixes NEC-145: Billing changes go to the old shard after an account moves ## Changelog Seat and plan changes made in the billing portal now reach your organisation, as do payment-failure notices and cancellations, including after the organisation moves to another server.
Task: NEC-142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GELrRCWpGqFZPAHK9bx28f
The webhook handler applied each change to the order row and discarded
the ShardIntent applyChange returned. The ticker only pushed orders that
were unfulfilled or had a date come due, so on a live order nothing a
webhook meant for the shard ever reached it: a seat or plan bought in
the provider's portal was charged and never granted, a failed payment
warned nobody, a dispute suspended nobody, and a cancellation arriving
before its scheduled date left the paid plan in place.

Intents are now written to a shard_intents table in the webhook's own
transaction and delivered by the ticker oldest first. An organisation
whose shard refuses one is held for the rest of the pass, including
its dated changes, so the shard never sees them out of order.
seats_changed now names the plan too, since the seat cap is only
written beside one. A gauge counts what is still owed.

Task: NEC-142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GELrRCWpGqFZPAHK9bx28f
Review of the first commit found three ways the new queue could hurt
a paying customer, and two decisions went to the user.

Change plan starts a second subscription and nothing cancels the
first, so the old one's `ended` would drop a paying organisation to
free. An intent, or a due date, from an order the organisation has
since replaced with a newer paid order is now settled as superseded
rather than sent.

A refused change holds its organisation with no time limit, by the
user's decision: most refusals clear by themselves or after a deploy.
Instead it is reported after a day of failure, the oldest owed change
has an age gauge, and an operator can abandon one with a reason
through a new admin route. The runbook says how to read each refusal.

Drops to free now send the seat count, so the shard clears the paid
plan's seat cap. And a scheduled change that crosses a webhook no
longer overwrites the row; the row's plan is queued to be re-sent.

Task: NEC-142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GELrRCWpGqFZPAHK9bx28f
An order's shard_id is written once at checkout, and move-account.mjs
tells the directory rather than the orders. So after a move every
billing change for that organisation went to the tombstoned source:
owed fulfilment, grace expiry, scheduled downgrades, and every change a
webhook queues, which on a 404 would hold the organisation's queue.

Delivery now resolves the account's current shard from the directory,
ranked as membershipsFor ranks it (never a moved copy; the source until
the target is activated), and moves the orders with it. It falls back
to the order's own shard when the directory has not seen the account.

Task: NEC-142
Also-fixes: NEC-145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GELrRCWpGqFZPAHK9bx28f
Reassigning `order` lost the non-null narrowing on accountId and
shardId, which failed the build.

Task: NEC-142
Also-fixes: NEC-145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GELrRCWpGqFZPAHK9bx28f
Never drop, suspend or reopen an organisation on the wrong order's word
All checks were successful
Release note / release-note (pull_request) Successful in 14s
CI / build (pull_request) Successful in 5m18s
CI / e2e (pull_request) Successful in 5m33s
CI / promote (pull_request) Has been skipped
CI / build (push) Successful in 5m7s
CI / e2e (push) Successful in 5m19s
CI / promote (push) Successful in 30s
a0cc6ae5b2
A second review found the supersede rule only looked one way. When the
newer order ends while an older subscription is still renewing, its drop
to free was sent, and the older order's renewals only ever clear a
notice, so nothing restored the plan. A drop to free is now sent as the
plan of any other order still taking money, and the row says so.

Lifting a suspension is never superseded, since a newer order sets a
plan and never a status. And it is only sent to an account the
directory lists as suspended: the shard allows closed -> active, so a
resolved dispute would otherwise reopen an organisation its owner
closed.

While the directory lists an account as migrating, nothing is sent.
The source takes a command on any status and answers 200, but an export
may already have left it, so the change would be recorded as delivered
and never reach the target. It waits, and a change waiting a day is
reported like one refused for a day.

The runbook table now matches what the shard actually returns: the 503
from a shard with no control token, the 401 for a mismatched one, and
no "reactivation" row, since closed -> active is allowed.

Task: NEC-142
Also-fixes: NEC-145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GELrRCWpGqFZPAHK9bx28f
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Nectenda/nectenda!202
No description provided.