fix(api): keep premium through cancellation and refund pix exactly (#1995)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-25 00:57:09 +02:00
1 parent 29e244d4eb
commit 21e806b991
8 files changed
+91 -22

No files matched your search

@@ -582,6 +582,15 @@ export class StripeCheckoutWebhookHandler {
if (chargeId) {
await this.refundChargeForDuplicateSubscription(chargeId, session.id);
}
const latestUser = await this.userRepository.findUnique(payment.userId);
if (latestUser && latestUser.stripeSubscriptionId === subscriptionId) {
const restoredUser = await this.userRepository.patchUpsert(
payment.userId,
{stripe_subscription_id: existingSubscriptionId},
latestUser.toRow(),
);
await this.dispatchUser(restoredUser);
}
await this.userRepository.updatePayment({
...payment.toRow(),
stripe_customer_id: customerId,
@@ -17,7 +17,7 @@ import {createRequestCache} from '../../middleware/RequestCacheMiddleware';
import {addGiftCodeDuration} from '../../models/GiftCode';
import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {createPremiumClearPatch} from '../../user/UserHelpers';
import {createPremiumClearPatch, getEffectivePremiumUntil} from '../../user/UserHelpers';
import {mapUserToPrivateResponse} from '../../user/UserMappers';
export class StripePremiumService {
@@ -230,6 +230,10 @@ export class StripePremiumService {
if (user.premiumType === UserPremiumTypes.LIFETIME) {
return false;
}
const effective = getEffectivePremiumUntil(user);
if (effective != null && Date.now() <= effective.getTime()) {
return false;
}
const updatedUser = await this.userRepository.patchUpsert(userId, createPremiumClearPatch(), user.toRow());
await this.dispatchUser(updatedUser);
Logger.debug({userId}, 'Premium grace period ended early');
@@ -227,6 +227,7 @@ export class StripeRefundService {
{error, chargeId: target.chargeId, paymentIntentId: target.paymentIntentId},
'Failed to resolve payment method type for self-serve refund',
);
throw new StripeError('Could not determine the payment method for this refund');
}
return null;
}
@@ -148,10 +148,12 @@ export class StripeSubscriptionService {
premium_grace_ends_at: null,
};
if (user.premiumType !== UserPremiumTypes.LIFETIME) {
const hasActiveGift =
user.premiumGiftExtensionEndsAt != null && user.premiumGiftExtensionEndsAt.getTime() > Date.now();
Object.assign(patch, {
premium_type: UserPremiumTypes.NONE,
premium_since: null,
premium_until: null,
premium_type: hasActiveGift ? user.premiumType : UserPremiumTypes.NONE,
premium_since: hasActiveGift ? user.premiumSince : null,
premium_until: new Date(),
});
}
const updatedUser = await this.userRepository.patchUpsert(userId, patch, user.toRow());
@@ -588,15 +588,20 @@ export class StripeSubscriptionWebhookHandler {
stripe_subscription_id: null,
premium_billing_cycle: null,
};
if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) {
if (targetUser.premiumType === UserPremiumTypes.SUBSCRIPTION) {
const subscriptionEndedAt = subscription.ended_at ? new Date(subscription.ended_at * 1000) : new Date();
const cancelledBeforePeriodEnd =
targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime();
if (cancelledBeforePeriodEnd) {
updates.premium_until = subscriptionEndedAt;
updates.premium_grace_ends_at = null;
} else {
updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS);
const alreadyAppliedEarlyCancellation =
targetUser.premiumUntil?.getTime() === subscriptionEndedAt.getTime() &&
targetUser.premiumGraceEndsAt?.getTime() === subscriptionEndedAt.getTime();
if (!alreadyAppliedEarlyCancellation) {
const cancelledBeforePeriodEnd =
targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime();
if (cancelledBeforePeriodEnd) {
updates.premium_until = subscriptionEndedAt;
updates.premium_grace_ends_at = subscriptionEndedAt;
} else {
updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS);
}
}
}
const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow());
@@ -490,14 +490,16 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
},
(await userRepository.findUnique(userId))!.toRow(),
);
const endedAt = Math.floor(Date.now() / 1000);
const endedAt = Math.floor(Date.now() / 1000) - 60;
const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const afterUser = await userRepository.findUnique(userId);
expect(afterUser?.premiumUntil?.getTime()).toBe(endedAt * 1000);
expect(afterUser?.premiumGraceEndsAt).toBeNull();
expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(endedAt * 1000);
expect(afterUser?.stripeSubscriptionId).toBeNull();
const {checkHasActivePaidPremium} = await import('../../user/UserHelpers');
expect(checkHasActivePaidPremium(afterUser!)).toBe(false);
});
test('grants standard grace when the subscription is cancelled at the end of its paid period', async () => {
const account = await createTestAccount(harness);
@@ -523,6 +525,8 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(afterUser?.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
expect(afterUser?.premiumGraceEndsAt).not.toBeNull();
expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + 3 * 24 * 60 * 60 * 1000);
const {checkHasActivePaidPremium} = await import('../../user/UserHelpers');
expect(checkHasActivePaidPremium(afterUser!)).toBe(true);
});
test('processes donation subscription deletion', async () => {
const subscriptionId = 'sub_donor_delete_test';
@@ -128,19 +128,39 @@ function chooseEffectiveSubscription(user: User, subscriptions: Array<Stripe.Sub
return sorted[0]!;
}
function trackMostRecentTerminalSubscription(
current: Stripe.Subscription | null,
candidate: Stripe.Subscription,
): Stripe.Subscription | null {
if (!candidate.ended_at) {
return current;
}
if (!current || (current.ended_at ?? 0) < candidate.ended_at) {
return candidate;
}
return current;
}
async function getEffectiveActiveStripeSubscription(
stripe: Stripe,
user: User,
): Promise<{
subscription: Stripe.Subscription | null;
activeCount: number;
mostRecentTerminalSubscription: Stripe.Subscription | null;
}> {
const subscriptionsById = new Map<string, Stripe.Subscription>();
let mostRecentTerminalSubscription: Stripe.Subscription | null = null;
if (user.stripeSubscriptionId) {
try {
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
if (canProvisionPremiumFromSubscriptionStatus(subscription.status)) {
subscriptionsById.set(subscription.id, subscription);
} else {
mostRecentTerminalSubscription = trackMostRecentTerminalSubscription(
mostRecentTerminalSubscription,
subscription,
);
}
} catch (error) {
Logger.warn(
@@ -162,6 +182,10 @@ async function getEffectiveActiveStripeSubscription(
});
for (const subscription of subscriptions.data) {
if (!canProvisionPremiumFromSubscriptionStatus(subscription.status)) {
mostRecentTerminalSubscription = trackMostRecentTerminalSubscription(
mostRecentTerminalSubscription,
subscription,
);
continue;
}
subscriptionsById.set(subscription.id, subscription);
@@ -182,11 +206,13 @@ async function getEffectiveActiveStripeSubscription(
return {
subscription: null,
activeCount: 0,
mostRecentTerminalSubscription,
};
}
return {
subscription: chooseEffectiveSubscription(user, activeSubscriptions),
activeCount: activeSubscriptions.length,
mostRecentTerminalSubscription,
};
}
@@ -203,15 +229,29 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
if (!user.stripeSubscriptionId && !user.stripeCustomerId) {
return {status: 'skipped', patchedFields: []};
}
const {subscription, activeCount} = await getEffectiveActiveStripeSubscription(stripe, user);
const {subscription, activeCount, mostRecentTerminalSubscription} = await getEffectiveActiveStripeSubscription(
stripe,
user,
);
if (!subscription) {
const hasStalePremium = user.premiumType === UserPremiumTypes.SUBSCRIPTION;
const hasNonStripePremium = Config.instance.selfHosted || (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0;
if (hasStalePremium && !hasNonStripePremium) {
const effectivePremiumUntil = getEffectivePremiumUntil(user);
const patch: Partial<UserRow> = {};
let effectivePremiumUntil = getEffectivePremiumUntil(user);
if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) {
const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000);
if (subscriptionEndedAt.getTime() < user.premiumUntil.getTime()) {
patch.premium_until = subscriptionEndedAt;
patch.premium_grace_ends_at = subscriptionEndedAt;
effectivePremiumUntil = getEffectivePremiumUntil({
premiumUntil: subscriptionEndedAt,
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
});
}
}
const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime();
if (hasFutureLocalEntitlement) {
const patch: Partial<UserRow> = {};
if (user.premiumWillCancel !== true) {
patch.premium_will_cancel = true;
}
@@ -112,9 +112,11 @@ export const useNagbarConditions = (): NagbarConditions => {
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
const graceEndDate = user.premiumGraceEndsAt
? new Date(user.premiumGraceEndsAt)
: new Date(expiryDate.getTime() + gracePeriodMs);
const isInGracePeriod = now > expiryDate && now <= graceEndDate;
return isInGracePeriod;
return isInGracePeriod && !nagbarState.premiumGracePeriodDismissed;
})();
const canShowPremiumExpired = (() => {
if (isSelfHosted) return false;
@@ -125,11 +127,13 @@ export const useNagbarConditions = (): NagbarConditions => {
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const expiredStateDurationMs = 30 * MS_PER_DAY;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
const expiredStateEndDate = new Date(expiryDate.getTime() + expiredStateDurationMs);
const graceEndDate = user.premiumGraceEndsAt
? new Date(user.premiumGraceEndsAt)
: new Date(expiryDate.getTime() + gracePeriodMs);
const expiredStateEndDate = new Date(graceEndDate.getTime() + expiredStateDurationMs);
const isExpired = now > graceEndDate;
const showExpiredState = isExpired && now <= expiredStateEndDate;
return showExpiredState;
return showExpiredState && !nagbarState.premiumExpiredDismissed;
})();
const canShowGiftInventory = (() => {
if (isSelfHosted) return false;