feat(api): retire prices safely and add a self-serve switch (#2637)

This commit is contained in:
Hampus
2026-09-10 17:28:16 +02:00
committed by GitHub
parent d79cd99050
commit 4a93b677af
77 changed files with 9242 additions and 2542 deletions
@@ -153,6 +153,8 @@ message NagbarDismissals {
map<string, bool> pending_bulk_deletion = 15;
map<string, bool> invites_disabled = 16;
map<string, bool> guild_mfa_requirement = 17;
map<string, bool> price_announcement = 18;
map<string, bool> legacy_price_opt_in = 19;
}
message DismissedUpsells {
@@ -100,14 +100,26 @@ export const CurrentSubscriptionPriceResponse = z
export type CurrentSubscriptionPriceResponse = z.infer<typeof CurrentSubscriptionPriceResponse>;
export const PendingSubscriptionChangeKind = z.enum(['billing_cycle', 'price']);
export type PendingSubscriptionChangeKind = z.infer<typeof PendingSubscriptionChangeKind>;
export const PendingSubscriptionChangeResponse = z
.object({
schedule_id: z.string().describe('Stripe subscription schedule ID managing the pending change'),
change_kind: PendingSubscriptionChangeKind.describe(
'Whether the pending change moves the billing cycle or only the price within the same cycle',
),
current_billing_cycle: z.enum(['monthly', 'yearly']).nullable().describe('Current recurring billing cycle'),
target_billing_cycle: z.enum(['monthly', 'yearly']).describe('Recurring billing cycle that will start later'),
effective_at: z.string().describe('ISO timestamp when the pending change takes effect'),
current_price_id: z.string().nullable().describe('Current Stripe price ID, when known'),
target_price_id: z.string().nullable().describe('Stripe price ID that will be used after the change'),
target_amount_minor: z
.number()
.int()
.nullable()
.describe('Unit amount of the price that will be used after the change, in the currency minor unit'),
currency: z.enum(['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY']).nullable().describe('Currency for the pending change'),
initial_amount_minor: z
.number()
@@ -138,6 +150,81 @@ export const ChangeSubscriptionRequest = z.object({
export type ChangeSubscriptionRequest = z.infer<typeof ChangeSubscriptionRequest>;
export const ListPriceSwitchIneligibilityReason = z.enum([
'feature_unavailable',
'no_active_subscription',
'subscription_not_chargeable',
'unsupported_subscription',
'no_list_price',
'already_on_list_price',
'not_a_price_decrease',
'subscription_cancelling',
'cancellation_managed_by_schedule',
'conflicting_pending_change',
'missing_period_end',
'switch_in_progress',
]);
export type ListPriceSwitchIneligibilityReason = z.infer<typeof ListPriceSwitchIneligibilityReason>;
export const ListPriceSwitchState = z.object({
available: z
.boolean()
.describe('Whether the authenticated user can move their subscription down to the current list price right now'),
reason: ListPriceSwitchIneligibilityReason.nullable().describe(
'Why the switch is unavailable, when available is false',
),
pending: z.boolean().describe('Whether a switch to the current list price is already scheduled'),
current_price_id: z.string().nullable().describe('Stripe price ID the subscription is billed against today'),
current_amount_minor: z
.number()
.int()
.nullable()
.describe('Amount the subscription is billed today, in the currency minor unit'),
list_price_id: z.string().nullable().describe('Current list Stripe price ID for the same cycle and currency'),
list_amount_minor: z
.number()
.int()
.nullable()
.describe('Current list price for the same cycle and currency, in the currency minor unit'),
currency: z
.enum(['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY'])
.nullable()
.describe('Currency of both the current and the list amount'),
billing_cycle: z.enum(['monthly', 'yearly']).nullable().describe('Recurring billing cycle the switch applies to'),
effective_at: z
.string()
.nullable()
.describe('ISO timestamp the switch takes effect, which is the end of the current billing period'),
});
export type ListPriceSwitchState = z.infer<typeof ListPriceSwitchState>;
export const SwitchToListPriceResponse = z.discriminatedUnion('status', [
z.object({
status: z.literal('scheduled').describe('The switch was scheduled for the end of the current billing period'),
effective_at: z.string().describe('ISO timestamp the switch takes effect'),
target_price_id: z.string().describe('Stripe price ID the subscription will be billed against after the switch'),
target_amount_minor: z.number().int().describe('Amount billed after the switch, in the currency minor unit'),
current_amount_minor: z.number().int().describe('Amount billed before the switch, in the currency minor unit'),
currency: z.enum(['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY']).describe('Currency of both amounts'),
}),
z.object({
status: z.literal('already_scheduled').describe('The switch was already scheduled by an earlier request'),
effective_at: z.string().describe('ISO timestamp the switch takes effect'),
target_price_id: z.string().describe('Stripe price ID the subscription will be billed against after the switch'),
target_amount_minor: z.number().int().describe('Amount billed after the switch, in the currency minor unit'),
current_amount_minor: z.number().int().describe('Amount billed before the switch, in the currency minor unit'),
currency: z.enum(['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY']).describe('Currency of both amounts'),
}),
z.object({
status: z.literal('ineligible').describe('The subscription cannot be moved to the current list price'),
reason: ListPriceSwitchIneligibilityReason.describe('Why the switch was refused'),
}),
]);
export type SwitchToListPriceResponse = z.infer<typeof SwitchToListPriceResponse>;
const PremiumBillingCycle = z.enum(['monthly', 'yearly']).nullable();
const PremiumActualState = z.object({
premium_type: UserPremiumTypesSchema.nullable().describe('Actual subscription type before local perk disabling'),
@@ -260,6 +347,7 @@ const PremiumBillingState = z.object({
stripe_customer_id: z.string().nullable(),
current_subscription_price: CurrentSubscriptionPriceResponse.nullable(),
pending_subscription_change: PendingSubscriptionChangeResponse.nullable(),
list_price_switch: ListPriceSwitchState,
subscription: PremiumBillingSubscriptionResponse.nullable(),
invoices: z.array(PremiumBillingInvoiceResponse),
invoices_has_more: z.boolean(),
File diff suppressed because one or more lines are too long