Skip to content

feat(api): add the /api/v2 namespace and pricing-engine guards - #5779

Merged
rsempe merged 3 commits into
mainfrom
api-v2-foundation
Aug 24, 2026
Merged

feat(api): add the /api/v2 namespace and pricing-engine guards#5779
rsempe merged 3 commits into
mainfrom
api-v2-foundation

Conversation

@rsempe

@rsempe rsempe commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Context

An organization is either on the product catalog or on legacy v1 pricing, never both. The engine is an organization-level property, so it must not be a per-plan payload choice.

Description

The rule is enforced where it cannot be bypassed, as payload shape rather than blanket endpoint blocks:

  • Plans::CreateService and UpdateService reject the legacy pricing fields (interval, amount_cents, pay_in_advance, charges, fixed_charges) for catalog organizations and plans with legacy_billing_disabled; everything else (name, description, currency…) stays editable, and catalog plans stay deletable.
  • Charge and FixedCharge validate that their plan is not a catalog plan — the deepest layer, closing internal paths such as subscription plan overrides that no endpoint gate can see.
  • The ForbidsLegacyBilling door-level 403 remains only on the pure-legacy charge, fixed-charge and charge-filter endpoints and mutations, where every action is illegal for a catalog org.
  • RequiresProductCatalog gates the catalog surface on both APIs: v2 REST endpoints, catalog GraphQL mutations and resolvers (403 feature_unavailable without the flag).
  • Each engine keeps its own strict GraphQL write shape, mirroring the REST v1/v2 split: CreatePlanInput/UpdatePlanInput stay unchanged for legacy, and catalog organizations get dedicated createCatalogPlan/updateCatalogPlan mutations with a catalog-only payload exposing the plan currency as currency. destroyPlan is shared. Rate cards attach to catalog plans only (legacy_plan otherwise).
  • Add the /api/v2 namespace, falling back to v1 for every path it does not define, kept exhaustive by a routing parity spec, plus a v2 plans endpoint with the same catalog-only payload.
  • Derive Plan#pricing_type from the organization flag and remove it from the v1 payload.

Defaulting new organizations to the catalog moves to #5871, alongside the v2 subscription surface: enabling it here would let a fresh organization create catalog plans while its subscriptions still go through the ungated v1 endpoint into a runtime that cannot bill them.

@rsempe
rsempe marked this pull request as draft June 24, 2026 14:23
@rsempe
rsempe force-pushed the api-v2-foundation branch from 4f729da to e658efc Compare June 25, 2026 10:38
@rsempe
rsempe force-pushed the api-v2-foundation branch from e658efc to fb040e8 Compare June 25, 2026 13:30
@rsempe
rsempe force-pushed the api-v2-foundation branch from fb040e8 to 6242c7c Compare June 25, 2026 13:57
@rsempe
rsempe force-pushed the api-v2-foundation branch from 6242c7c to b06f00e Compare June 25, 2026 14:35
@rsempe
rsempe force-pushed the api-v2-foundation branch from b06f00e to 7f6de41 Compare June 30, 2026 10:43
@rsempe
rsempe force-pushed the api-v2-foundation branch from 7f6de41 to 79ea106 Compare June 30, 2026 11:21
@rsempe
rsempe force-pushed the api-v2-foundation branch from 79ea106 to c5663bb Compare June 30, 2026 12:35
@rsempe
rsempe force-pushed the api-v2-foundation branch from c5663bb to 4f42553 Compare June 30, 2026 14:27
@rsempe
rsempe force-pushed the api-v2-foundation branch from 4f42553 to a0fd435 Compare July 2, 2026 07:07
@rsempe
rsempe force-pushed the api-v2-foundation branch from a0fd435 to 2161688 Compare July 2, 2026 07:14
@rsempe
rsempe force-pushed the api-v2-foundation branch from 2161688 to 5154dae Compare July 2, 2026 07:25
@rsempe
rsempe force-pushed the api-v2-foundation branch from 5154dae to 4da302f Compare July 2, 2026 07:53
@rsempe
rsempe force-pushed the api-v2-foundation branch from 4da302f to 6492c0d Compare July 2, 2026 08:01
@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — the REST billing split is not enforced by the sibling GraphQL API.

  • Neither new GraphQL guard is included by any production mutation or resolver; only synthetic concern specs use them. Catalog organizations can still execute legacy plan/charge/fixed-charge/filter/subscription mutations, while non-catalog organizations can still read and mutate catalog resources. Wire the guards into all corresponding GraphQL call sites and add real-schema coverage for both directions.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — the route set cannot load, and the billing-mode boundary still has write-path bypasses.

  • Remove or rename the overlapping v2 plan fallback routes: config/routes.rb defines named v2 plan routes, then draw(:shared_api) defines api_v2_plans/api_v2_plan again, which Rails rejects as duplicate route names.
  • Apply ForbidsLegacyBilling to the omitted legacy GraphQL writes (ChargeFilters::{Create,Update,Destroy} and the subscription charge, fixed-charge, and charge-filter override mutations) and cover those real mutation paths.
  • Restrict v2/GraphQL plan and applied-rate-card operations to product_catalog plans; their current unscoped plan lookups allow a catalog-enabled organization to update a pre-migration legacy plan or attach rate cards to it, and the existing request setup even exercises a default legacy plan.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — legacy billing still has an unguarded GraphQL path, and false-valued legacy input bypasses catalog-plan validation.

  • Apply the legacy-billing guard to the subscription-specific charge/filter GraphQL mutations (create/update/destroy filter, update charge, and update fixed charge) and cover them; their REST siblings are guarded but these remain callable.
  • Detect explicitly supplied legacy plan fields by key in Plans::CreateService; pay_in_advance: false currently passes the .present? check and is stored on a product-catalog plan. Add regression coverage for this case.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — the catalog boundary is still bypassable and the v2 fallback misroutes nested plan endpoints.

  • Add ForbidsLegacyBilling to the five subscription-side GraphQL pricing mutations (create/update/destroy charge filter, update charge, and update fixed charge) and cover them; unlike their REST siblings, they still mutate legacy subscription pricing for catalog-enabled organizations.
  • Make native v2 plan member routes unambiguous and add nested recognition/request coverage: because :code uses /.*/ and those routes precede the fallback, paths such as GET /api/v2/plans/foo/charges and PUT /api/v2/plans/foo/charges/bar are swallowed by Api::V2::PlansController instead of reaching the v1 controllers.
  • Detect supplied legacy fields by key on catalog plan creation: present? lets pay_in_advance: false through, persisting a legacy pricing value on a catalog plan. Add a regression example for this false-value case.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — legacy pricing still has a sibling write path through subscription plan overrides.

  • Gate Plans::OverrideService and the units-only plan_overrides branches in subscription create/update: they bypass the new concerns and plan create/update checks, allowing legacy repricing after catalog enablement and even amount_cents on a product_catalog child plan. Add service and API regression coverage.
  • Restrict the new REST and GraphQL catalog-plan show/update lookups to product-catalog plans. They currently accept a migrated organization's pre-existing legacy plans and expose/update them through the catalog surface; cover legacy IDs/codes.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: PASS · CI green

PASS — The product-catalog and legacy-billing gates are consistently applied across REST, GraphQL, services, and model backstops, with focused coverage for routing parity, field translation, and attachment cleanup.

Non-blocking: the new charge and fixed-charge validation examples should follow the project's consolidated model-validation spec structure.

Comment thread config/routes.rb Outdated
rsempe added 3 commits August 24, 2026 22:29
The product-catalog work introduces a second API version. A v2 client
should use /api/v2 for its whole integration without mixing in v1, even for
resources that have not been reimplemented yet.

Extract the shared API resource definitions into a draw(:shared_api) file
used by both namespaces. The v1 namespace draws them as today; a new v2
namespace draws the same set with module: :v1, so every /api/v2 path is
served by the existing Api::V1 controller until a real Api::V2 controller
replaces it. Add a routing parity spec asserting every v1 endpoint is
reachable under v2, plus a request smoke test.
An organization is either on the product catalog (billing v2) or on legacy
v1 pricing, never both. The engine is decided by the organization-level
product_catalog feature flag, so it must not be a per-plan payload choice
and both API surfaces must enforce the split.

Wire the gating concerns into the API surfaces: v2 catalog endpoints
require the organization flag (403 feature_unavailable without it) and v1
pricing write endpoints (plans charges, fixed charges, filters) are blocked
for catalog organizations (403 legacy_billing_disabled), while v1 reads
remain available for migration purposes.

Derive Plan#pricing_type from the organization flag instead of accepting it
in the payload, and remove it from the v1 plans endpoint. Add a v2 plans
endpoint (create, update, show) with a catalog-only payload: no interval,
amount_cents or pay_in_advance, since billing cadence lives on rate cards.

New organizations enable the product_catalog feature flag on creation, so
they use the catalog by default.
## Context

GET /api/v2/plans fell through to the v1 fallback: it returned the
heavy v1 payload and listed legacy plans that the v2 show action then
rejects with a 404. The v2 surface exposed two different response
shapes for the same resource.

## Description

Add an index action to the v2 plans controller, scoped to
product-catalog plans and serialized with the lean catalog shape used
by show, create and update. The endpoint is gated by the product
catalog flag like the rest of the catalog surface, and the fallback
spec now demonstrates the v1 fallback on a resource without a v2
controller. The applied rate cards count goes through the preloaded
association to avoid one count query per plan.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants