feat(products): add rate overrides on rate phases - #5842
Conversation
06f3a43 to
c7f4515
Compare
c7f4515 to
26cfb0b
Compare
26cfb0b to
a250989
Compare
a250989 to
1af88e9
Compare
1af88e9 to
b3b9122
Compare
b3b9122 to
8c539e6
Compare
8c539e6 to
05b7f68
Compare
05b7f68 to
1d84df3
Compare
1d84df3 to
f0caaab
Compare
f0caaab to
7fee236
Compare
7fee236 to
d82dc0d
Compare
d82dc0d to
74dabb0
Compare
74dabb0 to
a3daa0c
Compare
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the new phase APIs bypass feature gating and leave rate-override lifecycle gaps.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the new rate-phase APIs have feature-guard, sequence-integrity, override-lifecycle, and query-loading gaps.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the new REST update path can silently remove pricing or return a 500 for malformed input.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — several explicit REST inputs currently succeed with unintended results.
|
|
Automated pre-review (advisory, not a required check) — verdict: PASS · CI green PASS — The rate-phase REST and GraphQL paths are organization-scoped, preserve sequence invariants transactionally, roll back invalid nested overrides, and have focused service/request/mutation coverage. Generated GraphQL schemas match the new API surface, with no unrelated semantic changes or secrets found. |
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — malformed override input can raise, and concurrent phase edits can break sequence invariants.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green Phase mutations are unsafe under concurrent requests.
|
|
Automated pre-review (advisory, not a required check) — verdict: PASS · CI green PASS — The REST and GraphQL rate-phase operations are organization-scoped, preserve sequence and override lifecycle invariants transactionally, and have focused service, request, and mutation coverage. |
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — Creating a
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD —
|
|
Automated pre-review (advisory, not a required check) — verdict: PASS · CI green PASS — Rate override creation, replacement, clearing, validation, and serialization are consistently wired across REST, GraphQL, and phase lifecycle services. Focused specs cover nested creation, validation failures, rollback, and soft deletion of superseded overrides. |
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — an invalid graduated-percentage override can raise a 500 instead of returning validation errors.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — concurrent override replacements can leave a kept, unattached override.
|
## Context Creating a rate override happens in the context of a specific rate card, which determines whether a pricing-unit conversion rate is required. Overrides are attached to the rate phases of a plan's rate card entry. ## Description Add RateOverrides::CreateService, which builds a RateOverride from the given pricing parameters. It requires a pricing_unit_conversion_rate when the rate card carries a pricing unit, and surfaces rate-property validation errors from the model. Wiring overrides into the rate phase replace flow comes next.
A rate phase can override the rate its card would otherwise apply. The replace flow is where the override content is submitted alongside each phase, so it must create the overrides and attach them to the phases it builds. Extend RatePhases::ReplaceService to accept an optional rate_override per phase. Each override is created through RateOverrides::CreateService in the context of the entry's rate card and linked to its phase; a failed override validation propagates and rolls back the whole replace. The overrides of the phases being replaced are discarded alongside them.
The rate phase replace endpoint accepts an optional rate override per phase, and the list/replace responses need to surface the resulting override. Permit a nested rate_override in the rate phase replace payload and serialize it back on each phase (null when the phase inherits the card's active rate) via a new RateOverrideSerializer.
The GraphQL replace-sequence mutation must accept an optional override per phase and return the resulting override, mirroring the REST contract. Add the RateOverride type and RateOverrideInput, reusing the rate card rate model and billing-interval enums. Accept a rate_override on each PhaseInput and expose it on the RatePhase type (null when the phase inherits the card's active rate).
## Context A rate on an advance card rejects a positive min_amount_cents, because a spend floor true-ups against a closed period and advance billing has none. The same value submitted through a phase rate override was stored without complaint: overrides run the rate-model compatibility matrix, but the min_amount timing rule is a model validation on RateCardRate with no equivalent on RateOverride (QA-20). Two paths to the same priced object, one guard on only one of them. ## Description Apply the rule in the override creation service, next to the compatibility check, returning the same field and code as the rate layer: min_amount_cents not_allowed_for_billing_timing. Phase create, update and replace all build overrides through this service, so every path is covered.
An override request naming a structural card field, billing_timing, currency or proration, returned 200 with the field silently dropped by strong params — while the pricing part of the same request was applied. A plan author could believe a phase is advance-billed, EUR or prorated when the card still decides all three (QA-21). These are not unknown keys: they are real card fields, so dropping them hides a contract misunderstanding. Let the structural card fields through the controllers so the override creation service can reject them explicitly: each returns not_overridable on its own key, before anything is written, making the request atomic. Genuinely unknown keys keep the API-wide tolerant behaviour, and GraphQL is unaffected since typed inputs already reject unknown arguments.
Review of the v2 error-code inventory flagged non_terminal_indefinite as the most cryptic code in the API: decoding it requires knowing both jargon terms. non_contiguous_position had the same problem to a lesser degree. Both name the internal invariant instead of telling the caller what to change. Rename non_terminal_indefinite to indefinite_phase_must_be_last and non_contiguous_position to positions_must_be_contiguous, joining the must_be_* family of requirement-statement codes.
Pre-review of the rate phase APIs surfaced four gaps: the GraphQL mutations skipped the product catalog guard, a blank cycle count slipped past the sequence checks and persisted as an indefinite phase mid-sequence, override replacement leaked superseded records, and the phase and override associations loaded one query per record. Gate the three rate phase mutations with RequiresProductCatalog like the sibling catalog mutations. Normalize a blank billing_interval_cycle_count to nil before the sequence checks on create and update. Replace or clear a phase override inside a transaction and discard the superseded record, by id after the save: discarding the loaded association target writes the old foreign key back through its has_one inverse and silently undoes the replacement. Discard a phase's override when the phase is destroyed. Preload the override on the REST index and dataload the phase and override GraphQL associations. Carry an explicit null rate_override through strong parameters so an override can be cleared over REST.
Pre-review flagged three explicit inputs that succeeded with unintended results: an empty rate_override object cleared the existing override instead of failing validation, and a request without the rate_phase wrapper crashed with a 500 instead of the parameter-missing response. Treat only an omitted or null rate_override as a skip or clear so an empty object flows into the create service and fails on its missing fields. Require the rate_phase wrapper before inspecting it for the explicit-null carry-through. Cover the empty object at the service and request layers and the missing wrapper at the request layer.
## Context Companion to the rate card rate change: rateProperties on the rate override input and object was a raw JSON scalar. ## Description Reuse the generic Properties and PropertiesInput types on the rate override GraphQL surface. No service change: the override create service already converts its params deeply, and REST is untouched.
## Context Update was the one sequence mutation outside the parent lock: making the tail indefinite could race a concurrent insert and persist a non-terminal indefinite phase. ## Description Run the guards, terminal check and write of the update service under the same parent lock as create, destroy and replace, with a spec pinning the lock.
## Context The graduated percentage validator crash is fixed at the shared validator level; this pins the behaviour through the override surface.
Context
A phase can replace the card's active rate with its own pricing: the rate override.
Description
RateOverrides::CreateService— creates the override with the same model/item/timing compatibility matrix as catalog rates; spend floors stay arrears-only; structural card fields (currency, billing timing, proration, …) are rejected withnot_overridableinstead of being silently dropped.rate_overrideinput; explicit null clears).rate_overrideon phases, preloaded/dataloaded.