diff --git a/fluxer_api/src/api/stripe/services/StripePremiumService.ts b/fluxer_api/src/api/stripe/services/StripePremiumService.ts index 18350ded2..71ecc8e4c 100644 --- a/fluxer_api/src/api/stripe/services/StripePremiumService.ts +++ b/fluxer_api/src/api/stripe/services/StripePremiumService.ts @@ -14,7 +14,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {addGiftCodeDuration} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; import type {IUserRepository} from '@app/api/user/IUserRepository'; -import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers'; +import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; @@ -62,6 +62,7 @@ export class StripePremiumService { premium_will_cancel: false, premium_billing_cycle: billingCycle, premium_grace_ends_at: null, + premium_flags: clearPerksSanitizedFlag(user.premiumFlags), }, user.toRow(), ); @@ -90,6 +91,7 @@ export class StripePremiumService { premium_since: this.resolvePremiumSince(user.premiumSince, premiumSinceAnchor, now), premium_until: null, premium_lifetime_sequence: visionarySequence, + premium_flags: clearPerksSanitizedFlag(user.premiumFlags), has_ever_purchased: hasEverPurchased, premium_will_cancel: false, premium_billing_cycle: null, @@ -127,6 +129,7 @@ export class StripePremiumService { }; if ((user.premiumType ?? 0) <= 0) { patch.premium_type = premiumType; + patch.premium_flags = clearPerksSanitizedFlag(user.premiumFlags); patch.premium_since = this.resolvePremiumSince(user.premiumSince, null, now); } if (hasEverPurchased && !user.hasEverPurchased) { diff --git a/fluxer_api/src/api/user/UserHelpers.ts b/fluxer_api/src/api/user/UserHelpers.ts index 9f503798b..05d726348 100644 --- a/fluxer_api/src/api/user/UserHelpers.ts +++ b/fluxer_api/src/api/user/UserHelpers.ts @@ -275,6 +275,10 @@ export function createPremiumClearPatch(): Partial { return mapExpiredPremiumFields(() => null) as Partial; } +export function clearPerksSanitizedFlag(premiumFlags: number): number { + return premiumFlags & ~PremiumFlags.PERKS_SANITIZED; +} + const PROFILE_SUBSTRING_EXEMPT_FLAGS = UserFlags.STAFF; export function isProfileSubstringExempt(user: Pick): boolean { diff --git a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts index 34bdcb57e..aa34363ed 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts @@ -13,7 +13,7 @@ import { getSubscriptionPremiumPeriodEnd, getSubscriptionStartDate, } from '@app/api/stripe/StripeSubscriptionPeriod'; -import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers'; +import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; @@ -73,6 +73,10 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip if (user.stripeSubscriptionId !== subscription.id) { patch.stripe_subscription_id = subscription.id; } + const clearedPremiumFlags = clearPerksSanitizedFlag(user.premiumFlags); + if (user.premiumFlags !== clearedPremiumFlags) { + patch.premium_flags = clearedPremiumFlags; + } if (subscriptionCustomerId && user.stripeCustomerId !== subscriptionCustomerId) { patch.stripe_customer_id = subscriptionCustomerId; } diff --git a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts index 49a5e4201..b43cc7827 100644 --- a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts +++ b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts @@ -10,6 +10,7 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/ProcessPremiumStateReconciliationQueue'; import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {PremiumFlags} from '@fluxer/constants/src/UserConstants'; import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; import type Stripe from 'stripe'; import {afterEach, describe, expect, test} from 'vitest'; @@ -48,6 +49,28 @@ function createCancelledSubscription(endedAtMs: number): Stripe.Subscription { } as unknown as Stripe.Subscription; } +function createActiveSubscription(periodEndMs: number): Stripe.Subscription { + return { + id: 'sub_test', + status: 'active', + customer: 'cus_test', + ended_at: null, + canceled_at: null, + cancel_at: null, + cancel_at_period_end: false, + trial_end: null, + start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000), + items: { + data: [ + { + current_period_end: Math.floor(periodEndMs / 1000), + price: {recurring: {interval: 'month'}}, + }, + ], + }, + } as unknown as Stripe.Subscription; +} + function createPaidInvoice(periodEndMs: number): Stripe.Invoice { return { id: 'in_test', @@ -299,4 +322,27 @@ describe('processPremiumStateReconciliationQueue', () => { expect(patches[0].premium_until).toBeNull(); expect(patches[0].premium_since).toBeNull(); }); + test('clears the perks-sanitized latch once the subscription is active again', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const periodEndMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000; + const user = createPremiumUser({ + premium_until: new Date(periodEndMs), + premium_flags: PremiumFlags.PERKS_SANITIZED, + }); + const {userRepository, patches, extras} = createCapturingDeps(user); + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: createStripeStub(createActiveSubscription(periodEndMs), []), + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches).toHaveLength(1); + expect(patches[0].premium_flags).toBe(0); + }); });