feat(billing): admin plan grants with duration/reason, 4-way checkout terms - #200
Merged
Conversation
… SEMIANNUAL Replaces the binary YEARLY-or-else-add-1-month branching in resolvePeriodEndMs with a 4-way lookup (MONTHS_BY_BILLING_INTERVAL) driving addCalendarMonths from @claw/shared-utilities. Unifies YEARLY onto the same calendar-clamping logic MONTHLY already used, fixing a latent bug where a 29-February start silently rolled into March instead of clamping to 28 February. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ance Extracts assignDefaultPlan to preserve unattributed/unlimited signup-plan semantics now that assignUserToPlan requires an admin grant's duration and reason.
Migrates resolvePlanPrice, usePricingToggle/usePublicPricing, PlanTierCard and PricingSection from a boolean isYearly toggle to the BillingInterval enum (MONTHLY/QUARTERLY/SEMIANNUAL/YEARLY), and swaps PricingSection's hand-rolled two-button toggle for the existing generic BillingIntervalToggle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… for admin grants
Selecting a plan in the admin users table now opens AssignPlanDialog (Task 15) instead of assigning immediately: useUserTableState composes a new useAssignPlanDialogState sub-hook to stay under the 50-line hook guideline, and UserTable's onAssignPlan prop grows to (userId, planId, durationMonths, grantReason). plansRepository.assignUser and useAdminUserMutations' handleAssignPlan/useAssignPlanMutation carry the new arguments through to the API; grantReason is deliberately omitted from the structured log line since it may hold free-text about a user's account. Adds the dialog's 8 admin.assignPlan* i18n keys, with real translations, to all 13 locales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tor, docs delta - CRITICAL: add plan-quarterly-semiannual-pricing.seeder.cjs to backfill QUARTERLY/SEMIANNUAL PlanPriceVersion rows on already-seeded installs (plan-catalog v2's run-once guard meant they never ran there); also null-guards upsertPrices against a null monthlyMinor. - IMPORTANT: plan-price-editor.tsx interval picker now maps BILLING_INTERVAL_ORDER (4 options) instead of hardcoding MONTHLY/YEARLY. - IMPORTANT: substantive docs delta — auth-service CLAUDE.md (grant split + backfill seeder), payment-service CLAUDE.md (resolvePeriodEndMs 4-way fix), docs/business/plan-allowances.md (checkout-term discount formula). - MINOR: billing-plan-card.tsx shows the discountBadge for QUARTERLY/ SEMIANNUAL, mutually exclusive with the existing yearly-saving line. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PLAN_GRANT_DURATION_INVALIDon typos like 240) and a required reason (PLAN_GRANT_REASON_REQUIRED), attributed to the admin who made the grant and reused the existing lazy-expiry mechanism (findEffectiveForUser) with no new expiry sweep.PlansRepository.assignDefaultPlanto preserve the unattributed/unlimited semantics of a brand-new signup's default plan grant, now thatassignUserToPlanexclusively means an attributed, time-limited admin grant.plan-quarterly-semiannual-pricing.seeder.cjs) so the two new price rows actually reach already-seeded installs, not just fresh databases — this must run before the 4-way checkout terms are selectable anywhere but a brand-new install.Release notes
Admins can now grant a user a plan for a specific number of months (1-60) with a required reason, visible as an audit trail — this replaces the old "assign forever, no record of why" behavior. Anyone checking out can now choose to pay for 1, 3, 6, or 12 months up front, with a 10% discount on the 3- and 6-month options (12 months keeps its existing ~16.7% discount). The admin plan-price editor can now publish prices for all four terms, not just monthly/yearly.
Remaining work
/billingupgrade page and the public marketing pricing page both show the discount now; a few cosmetic/deferred items were parked in the final review (documented in the PR's linked plan/spec) as non-blocking: the public-pricing fallback catalog (offline-only path) still lists just 2 terms, and the admin assign-plan dialog doesn't hide its duration/reason fields when the target plan happens to be a trial plan (the server already silently ignores both fields on that path).Test plan
npx tsgo --noEmit && npm run lint && npm run test(782/782 tests)npx tsgo --noEmit && npm run lint && npm run test && npm run buildnpx tsgo --noEmit && npm run lint && npm run test && npm run build(2609/2609 tests)docs/superpowers/specs/2026-09-01-admin-plan-grants-and-multi-month-checkout-design.md,docs/superpowers/plans/2026-09-01-admin-plan-grants-and-multi-month-checkout.md