mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api): keep premium through cancellation and refund pix exactly (#1995)
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user