From 21e806b9916859ae349a3b8a8e43c766b48208d2 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 25 Aug 2026 00:57:09 +0200 Subject: [PATCH] fix(api): keep premium through cancellation and refund pix exactly (#1995) --- .../services/StripeCheckoutWebhookHandler.ts | 9 ++++ .../stripe/services/StripePremiumService.ts | 6 ++- .../stripe/services/StripeRefundService.ts | 1 + .../services/StripeSubscriptionService.ts | 8 ++-- .../StripeSubscriptionWebhookHandler.ts | 21 +++++---- .../tests/StripeWebhookSubscription.test.ts | 8 +++- .../ProcessPremiumStateReconciliationQueue.ts | 46 +++++++++++++++++-- .../layout/app_layout/AppLayoutHooks.ts | 14 ++++-- 8 files changed, 91 insertions(+), 22 deletions(-) diff --git a/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts index 1715c707b..e05d6bfda 100644 --- a/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts @@ -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, diff --git a/fluxer_api/src/api/stripe/services/StripePremiumService.ts b/fluxer_api/src/api/stripe/services/StripePremiumService.ts index 555211614..de4d0bf79 100644 --- a/fluxer_api/src/api/stripe/services/StripePremiumService.ts +++ b/fluxer_api/src/api/stripe/services/StripePremiumService.ts @@ -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'); diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index 794d5bb6e..3ee9be586 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -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; } diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts index c2f4f02ba..590a8c55b 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts @@ -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()); diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts index 1085eb2d1..44459acf0 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts @@ -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()); diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts index 043957ff9..c5bb884f4 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts @@ -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'; diff --git a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts index 5d374ae5f..6a07be309 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts @@ -128,19 +128,39 @@ function chooseEffectiveSubscription(user: User, subscriptions: Array { const subscriptionsById = new Map(); + 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 = {}; + 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 = {}; if (user.premiumWillCancel !== true) { patch.premium_will_cancel = true; } diff --git a/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts b/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts index 707846399..9eaff77f8 100644 --- a/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts +++ b/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts @@ -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;