From 7a4aa42d4b5fa6b7cd67ed7fa97350117c4e9601 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 04:21:54 +0200 Subject: [PATCH] feat(premium): native Nordic prices and subscribing during grace (#3274) --- fluxer_api/src/api/Config.ts | 4 + fluxer_api/src/api/config/APIConfig.ts | 4 + .../services/DonationCheckoutService.ts | 2 + .../donation/tests/DonationCheckout.test.ts | 2 + .../store_billing/StoreEntitlementWriter.ts | 5 +- .../src/api/stripe/BillingConfigCache.ts | 7 + fluxer_api/src/api/stripe/ProductRegistry.ts | 1 + .../src/api/stripe/StripeGiftTrialMetadata.ts | 47 ++ fluxer_api/src/api/stripe/StripeService.ts | 2 +- .../api/stripe/StripeSubscriptionPeriod.ts | 24 + .../stripe/services/StripeCheckoutService.ts | 13 + .../services/StripeCheckoutWebhookHandler.ts | 17 +- .../services/StripeGiftReversalHandler.ts | 169 +++- .../api/stripe/services/StripeGiftService.ts | 36 +- .../stripe/services/StripePremiumService.ts | 55 +- .../services/StripeSubscriptionService.ts | 27 +- .../StripeSubscriptionWebhookHandler.ts | 15 + .../stripe/tests/BillingConfigCache.test.ts | 6 +- .../tests/OperatorBillingCatalog.test.ts | 14 +- .../tests/ProductRegistryLegacyPrices.test.ts | 4 +- .../tests/StripeGiftSubscriptionShift.test.ts | 753 ++++++++++++++++++ .../tests/StripeNordicCurrencies.test.ts | 180 ++++- .../stripe/tests/StripePremiumService.test.ts | 26 + .../tests/StripeWebhookSubscription.test.ts | 6 +- .../test/msw/handlers/StripeApiHandlers.ts | 34 +- fluxer_api/src/api/user/GiftExtensionShift.ts | 27 + .../repositories/IUserAccountRepository.ts | 1 + .../repositories/UserAccountRepository.ts | 1 + .../api/user/repositories/UserRepository.ts | 1 + .../account/UserAccountRepository.ts | 1 + .../account/crud/UserDataRepository.ts | 16 +- fluxer_api/src/api/utils/CurrencyUtils.ts | 29 +- .../src/api/utils/tests/CurrencyUtils.test.ts | 61 +- .../ProcessPremiumStateReconciliationQueue.ts | 11 + .../dialogs/components/PlutoniumContent.tsx | 51 +- .../components/plutonium/SubscriptionCard.tsx | 151 +++- .../plutonium/hooks/useCheckoutActions.tsx | 84 +- .../plutonium/hooks/useSubscriptionStatus.ts | 18 +- .../src/features/i18n/locales/ar/messages.po | 295 ++++--- .../locales/auto-i18n-reviewed-unchanged.json | 24 + .../src/features/i18n/locales/bg/messages.po | 295 ++++--- .../src/features/i18n/locales/cs/messages.po | 295 ++++--- .../src/features/i18n/locales/da/messages.po | 295 ++++--- .../src/features/i18n/locales/de/messages.po | 295 ++++--- .../src/features/i18n/locales/el/messages.po | 295 ++++--- .../features/i18n/locales/en-GB/messages.po | 300 ++++--- .../features/i18n/locales/en-US/messages.po | 295 ++++--- .../features/i18n/locales/es-419/messages.po | 295 ++++--- .../features/i18n/locales/es-ES/messages.po | 295 ++++--- .../src/features/i18n/locales/fi/messages.po | 295 ++++--- .../src/features/i18n/locales/fr/messages.po | 295 ++++--- .../src/features/i18n/locales/he/messages.po | 295 ++++--- .../src/features/i18n/locales/hi/messages.po | 295 ++++--- .../src/features/i18n/locales/hr/messages.po | 295 ++++--- .../src/features/i18n/locales/hu/messages.po | 295 ++++--- .../src/features/i18n/locales/id/messages.po | 295 ++++--- .../src/features/i18n/locales/it/messages.po | 295 ++++--- .../src/features/i18n/locales/ja/messages.po | 295 ++++--- .../src/features/i18n/locales/ko/messages.po | 295 ++++--- .../src/features/i18n/locales/lt/messages.po | 295 ++++--- .../src/features/i18n/locales/nl/messages.po | 295 ++++--- .../src/features/i18n/locales/no/messages.po | 295 ++++--- .../src/features/i18n/locales/pl/messages.po | 295 ++++--- .../features/i18n/locales/pt-BR/messages.po | 295 ++++--- .../src/features/i18n/locales/ro/messages.po | 295 ++++--- .../src/features/i18n/locales/ru/messages.po | 295 ++++--- .../features/i18n/locales/sv-SE/messages.po | 295 ++++--- .../src/features/i18n/locales/th/messages.po | 295 ++++--- .../src/features/i18n/locales/tr/messages.po | 295 ++++--- .../src/features/i18n/locales/uk/messages.po | 295 ++++--- .../src/features/i18n/locales/vi/messages.po | 295 ++++--- .../features/i18n/locales/zh-CN/messages.po | 295 ++++--- .../features/i18n/locales/zh-TW/messages.po | 295 ++++--- .../components/modals/GiftPlutoniumModal.tsx | 2 +- .../plutonium_page/PlutoniumPage.tsx | 85 +- .../plutonium_page/PlutoniumPageMessages.ts | 13 + .../PremiumSubscriptionClassification.test.ts | 154 ++++ .../PremiumSubscriptionClassification.ts | 61 ++ .../src/content/docs/http-api/premium.mdx | 3 + .../content/docs/operator/configuration.mdx | 4 +- .../__tests__/EnvironmentOverrides.test.ts | 16 + .../SelfHostingComposeEnvironment.test.ts | 2 +- .../src/config_loader/EnvironmentOverrides.ts | 4 + .../constants/src/EuropeanEconomicArea.ts | 1 + 84 files changed, 7720 insertions(+), 4589 deletions(-) create mode 100644 fluxer_api/src/api/stripe/StripeGiftTrialMetadata.ts create mode 100644 fluxer_api/src/api/stripe/tests/StripeGiftSubscriptionShift.test.ts create mode 100644 fluxer_api/src/api/user/GiftExtensionShift.ts create mode 100644 fluxer_app/src/features/premium/utils/PremiumSubscriptionClassification.test.ts create mode 100644 fluxer_app/src/features/premium/utils/PremiumSubscriptionClassification.ts diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index efeaac18d..6181d04c6 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -330,6 +330,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { monthlyBrl: master.integrations.stripe.prices.monthly_brl, monthlyDkk: master.integrations.stripe.prices.monthly_dkk, monthlyInr: master.integrations.stripe.prices.monthly_inr, + monthlyIsk: master.integrations.stripe.prices.monthly_isk, monthlyNok: master.integrations.stripe.prices.monthly_nok, monthlyPln: master.integrations.stripe.prices.monthly_pln, monthlySek: master.integrations.stripe.prices.monthly_sek, @@ -339,6 +340,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { yearlyBrl: master.integrations.stripe.prices.yearly_brl, yearlyDkk: master.integrations.stripe.prices.yearly_dkk, yearlyInr: master.integrations.stripe.prices.yearly_inr, + yearlyIsk: master.integrations.stripe.prices.yearly_isk, yearlyNok: master.integrations.stripe.prices.yearly_nok, yearlyPln: master.integrations.stripe.prices.yearly_pln, yearlySek: master.integrations.stripe.prices.yearly_sek, @@ -351,6 +353,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk, gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok, gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok, + gift1MonthIsk: master.integrations.stripe.prices.gift_1_month_isk, + gift1YearIsk: master.integrations.stripe.prices.gift_1_year_isk, gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl, gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr, gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln, diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 2998de767..c898785d7 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -203,6 +203,7 @@ export interface APIConfig { monthlyBrl?: string; monthlyDkk?: string; monthlyInr?: string; + monthlyIsk?: string; monthlyNok?: string; monthlyPln?: string; monthlySek?: string; @@ -212,6 +213,7 @@ export interface APIConfig { yearlyBrl?: string; yearlyDkk?: string; yearlyInr?: string; + yearlyIsk?: string; yearlyNok?: string; yearlyPln?: string; yearlySek?: string; @@ -224,6 +226,8 @@ export interface APIConfig { gift1YearDkk?: string; gift1MonthNok?: string; gift1YearNok?: string; + gift1MonthIsk?: string; + gift1YearIsk?: string; gift1MonthBrl?: string; gift1MonthInr?: string; gift1MonthPln?: string; diff --git a/fluxer_api/src/api/donation/services/DonationCheckoutService.ts b/fluxer_api/src/api/donation/services/DonationCheckoutService.ts index 684ff5a95..a10d3c6bb 100644 --- a/fluxer_api/src/api/donation/services/DonationCheckoutService.ts +++ b/fluxer_api/src/api/donation/services/DonationCheckoutService.ts @@ -6,6 +6,7 @@ import type {IDonationRepository} from '@app/api/donation/IDonationRepository'; import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService'; import {Logger} from '@app/api/Logger'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; +import {shouldDisableAdaptivePricing} from '@app/api/utils/CurrencyUtils'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {DonationAmountInvalidError} from '@fluxer/errors/src/domains/donation/DonationAmountInvalidError'; @@ -173,6 +174,7 @@ export class DonationCheckoutService { enabled: true, }, ...(isBusiness ? {billing_address_collection: 'required' as const} : {}), + ...(shouldDisableAdaptivePricing(params.currency) ? {adaptive_pricing: {enabled: false}} : {}), ...(mode === 'payment' ? { customer_creation: 'always' as const, diff --git a/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts b/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts index 4aa24e843..437476fa3 100644 --- a/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts +++ b/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts @@ -131,6 +131,8 @@ describe('POST /donations/checkout', () => { } | undefined; expect(lineItem?.price_data?.currency).toBe(currency); + const nordic = ['sek', 'dkk', 'nok'].includes(currency); + expect(session?.adaptive_pricing).toEqual(nordic ? {enabled: 'false'} : undefined); }); test('accepts monthly interval', async () => { const response = await createDonationCheckoutBuilder(harness) diff --git a/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts b/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts index 381acfd3e..e95202921 100644 --- a/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts +++ b/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts @@ -10,6 +10,7 @@ import {Logger} from '@app/api/Logger'; import type {User} from '@app/api/models/User'; import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers'; import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import {shiftGiftExtensionEnd} from '@app/api/user/GiftExtensionShift'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; @@ -77,9 +78,7 @@ function buildGrantPatch(user: User, row: StorePurchaseRow, now: Date): Partial< user.premiumGraceEndsAt.getTime() > now.getTime(); const anchorMs = inStoreGrace ? oldUntil.getTime() : Math.max(now.getTime(), oldUntil?.getTime() ?? 0); const giftEnd = user.premiumGiftExtensionEndsAt; - const shiftMs = expiresAt.getTime() - anchorMs; - const shiftedGiftEnd = - giftEnd && shiftMs > 0 && giftEnd.getTime() > anchorMs ? new Date(giftEnd.getTime() + shiftMs) : giftEnd; + const shiftedGiftEnd = shiftGiftExtensionEnd(giftEnd, anchorMs, expiresAt); const startedAt = row.started_at ?? now; const premiumSince = user.premiumSince && user.premiumSince <= startedAt ? user.premiumSince : startedAt; setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION); diff --git a/fluxer_api/src/api/stripe/BillingConfigCache.ts b/fluxer_api/src/api/stripe/BillingConfigCache.ts index 500e81619..dbf49d1c2 100644 --- a/fluxer_api/src/api/stripe/BillingConfigCache.ts +++ b/fluxer_api/src/api/stripe/BillingConfigCache.ts @@ -74,6 +74,13 @@ const ENV_CATALOG: ReadonlyArray<{ gift_1_month: 'gift1MonthInr', gift_1_year: 'gift1YearInr', }, + { + currency: 'ISK', + monthly: 'monthlyIsk', + yearly: 'yearlyIsk', + gift_1_month: 'gift1MonthIsk', + gift_1_year: 'gift1YearIsk', + }, { currency: 'NOK', monthly: 'monthlyNok', diff --git a/fluxer_api/src/api/stripe/ProductRegistry.ts b/fluxer_api/src/api/stripe/ProductRegistry.ts index d505752cf..5c56dcdfb 100644 --- a/fluxer_api/src/api/stripe/ProductRegistry.ts +++ b/fluxer_api/src/api/stripe/ProductRegistry.ts @@ -64,6 +64,7 @@ const LEGACY_SLOT_CURRENCIES: Record = { brl: 'BRL', dkk: 'DKK', inr: 'INR', + isk: 'ISK', nok: 'NOK', pln: 'PLN', sek: 'SEK', diff --git a/fluxer_api/src/api/stripe/StripeGiftTrialMetadata.ts b/fluxer_api/src/api/stripe/StripeGiftTrialMetadata.ts new file mode 100644 index 000000000..6054e6cf6 --- /dev/null +++ b/fluxer_api/src/api/stripe/StripeGiftTrialMetadata.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type Stripe from 'stripe'; + +const GIFT_TRIAL_KEY_PREFIX = 'gtrial_'; +const GIFT_TRIAL_PAID_UNTIL_KEY = `${GIFT_TRIAL_KEY_PREFIX}paid_until`; + +export function getGiftTrialMetadataKey(giftCode: string): string { + return `${GIFT_TRIAL_KEY_PREFIX}${giftCode}`; +} + +function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean { + return subscription.trial_end != null && subscription.trial_end * 1000 > nowMs; +} + +export function buildGiftTrialMetadata( + subscription: Stripe.Subscription, + giftCode: string, + baseUnix: number, + newTrialEndUnix: number, + nowMs: number, +): Record { + const metadata: Record = {}; + if (!isGiftTrialRunning(subscription, nowMs)) { + for (const key of Object.keys(subscription.metadata ?? {})) { + if (key.startsWith(GIFT_TRIAL_KEY_PREFIX)) { + metadata[key] = ''; + } + } + metadata[GIFT_TRIAL_PAID_UNTIL_KEY] = String(baseUnix); + } + metadata[getGiftTrialMetadataKey(giftCode)] = String(newTrialEndUnix - baseUnix); + return metadata; +} + +export function getGiftTrialSeconds(subscription: Stripe.Subscription, giftCode: string): number | null { + const seconds = Number(subscription.metadata?.[getGiftTrialMetadataKey(giftCode)]); + return Number.isFinite(seconds) && seconds > 0 ? seconds : null; +} + +export function getGiftTrialPaidUntil(subscription: Stripe.Subscription, nowMs: number): Date | null { + if (!isGiftTrialRunning(subscription, nowMs)) { + return null; + } + const paidUntilUnix = Number(subscription.metadata?.[GIFT_TRIAL_PAID_UNTIL_KEY]); + return Number.isFinite(paidUntilUnix) && paidUntilUnix > 0 ? new Date(paidUntilUnix * 1000) : null; +} diff --git a/fluxer_api/src/api/stripe/StripeService.ts b/fluxer_api/src/api/stripe/StripeService.ts index 888156b32..604cbceb2 100644 --- a/fluxer_api/src/api/stripe/StripeService.ts +++ b/fluxer_api/src/api/stripe/StripeService.ts @@ -192,7 +192,7 @@ export class StripeService { durationType: GiftCodeDurationType, durationQuantity: number, idempotencyKey: string, - ): Promise { + ): Promise { return this.subscriptionService.extendSubscriptionWithGiftTrialDuration( user, durationType, diff --git a/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts b/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts index 0d8c22960..461c4dd97 100644 --- a/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts +++ b/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts @@ -63,6 +63,19 @@ function getSubscriptionCurrentPeriodEnd(subscription: Stripe.Subscription): Dat return latestPeriodEnd == null ? null : new Date(latestPeriodEnd * 1000); } +export function getSubscriptionCurrentPeriodStart(subscription: Stripe.Subscription): Date | null { + let latestPeriodStart: number | null = null; + for (const item of subscription.items?.data ?? []) { + if (item.current_period_start == null) { + continue; + } + if (latestPeriodStart == null || item.current_period_start > latestPeriodStart) { + latestPeriodStart = item.current_period_start; + } + } + return latestPeriodStart == null ? null : new Date(latestPeriodStart * 1000); +} + export function getSubscriptionPremiumPeriodEnd(subscription: Stripe.Subscription): Date | null { if (subscription.cancel_at) { return new Date(subscription.cancel_at * 1000); @@ -79,6 +92,17 @@ export function getSubscriptionStartDate(subscription: Stripe.Subscription): Dat return new Date((subscription.start_date ?? subscription.created) * 1000); } +export function getInvoiceLatestLinePeriodStart(invoice: Stripe.Invoice): Date | null { + let latestPeriodStartUnix: number | null = null; + for (const line of invoice.lines?.data ?? []) { + const periodStart = line.period?.start ?? null; + if (periodStart && (latestPeriodStartUnix === null || periodStart > latestPeriodStartUnix)) { + latestPeriodStartUnix = periodStart; + } + } + return latestPeriodStartUnix ? new Date(latestPeriodStartUnix * 1000) : null; +} + export function getInvoiceLatestLinePeriodEnd(invoice: Stripe.Invoice): Date | null { const lines = invoice.lines?.data ?? []; let latestPeriodEndUnix: number | null = null; diff --git a/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts b/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts index 29ea957a2..498bb6166 100644 --- a/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts +++ b/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts @@ -19,16 +19,19 @@ import { } from '@app/api/stripe/StripeSubscriptionAccessPolicy'; import { getPrimarySubscriptionItem, + getSubscriptionCurrentPeriodStart, getSubscriptionPremiumPeriodEnd, getSubscriptionStartDate, } from '@app/api/stripe/StripeSubscriptionPeriod'; import {extractId} from '@app/api/stripe/StripeUtils'; +import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import { type Currency, getCurrencyPreferences, getGiftCurrencyPreferences, isLocalizedCurrency, + shouldDisableAdaptivePricing, } from '@app/api/utils/CurrencyUtils'; import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea'; import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; @@ -232,6 +235,7 @@ export class StripeCheckoutService { }, billing_address_collection: isBusiness ? 'required' : 'auto', allow_promotion_codes: true, + ...(shouldDisableAdaptivePricing(productInfo.currency) ? {adaptive_pricing: {enabled: false}} : {}), ...(checkoutMode === 'subscription' ? { subscription_data: { @@ -539,6 +543,15 @@ export class StripeCheckoutService { } if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) { patch.premium_until = premiumUntil; + const giftEnd = shiftGiftExtensionPastPremiumUntil( + {premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt}, + premiumUntil, + new Date(), + getSubscriptionCurrentPeriodStart(subscription), + ); + if (giftEnd !== user.premiumGiftExtensionEndsAt) { + patch.premium_gift_extension_ends_at = giftEnd; + } } if (user.premiumWillCancel !== premiumWillCancel) { patch.premium_will_cancel = premiumWillCancel; diff --git a/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts index dd5c32312..20925e113 100644 --- a/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts @@ -14,6 +14,7 @@ import type {ProductInfo, ProductRegistry} from '@app/api/stripe/ProductRegistry import { getFirstInvoicePaymentIntentId, getPrimarySubscriptionItem, + getSubscriptionCurrentPeriodStart, getSubscriptionItemPeriodEnd, getSubscriptionPremiumPeriodEnd, } from '@app/api/stripe/StripeSubscriptionPeriod'; @@ -370,14 +371,16 @@ export class StripeCheckoutWebhookHandler { return 'granted'; } if (this.productRegistry.isRecurringSubscription(productInfo)) { - const periodEnd = await this.resolveCheckoutSubscriptionPeriodEnd(session, productInfo); - recovery.expectedPremiumUntil = periodEnd; + const period = await this.resolveCheckoutSubscriptionPeriod(session, productInfo); + recovery.expectedPremiumUntil = period.end; await this.premiumService.setPremiumFromSubscriptionPeriod( payment.userId, productInfo.premiumType, - periodEnd, + period.end, productInfo.billingCycle || null, true, + null, + period.start, ); return 'granted'; } @@ -522,17 +525,17 @@ export class StripeCheckoutWebhookHandler { } } - private async resolveCheckoutSubscriptionPeriodEnd( + private async resolveCheckoutSubscriptionPeriod( session: Stripe.Checkout.Session, productInfo: ProductInfo, - ): Promise { + ): Promise<{start: Date | null; end: Date}> { const subscriptionId = extractId(session.subscription); if (subscriptionId && this.stripe) { try { const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const candidate = getSubscriptionPremiumPeriodEnd(subscription); if (candidate) { - return candidate; + return {start: getSubscriptionCurrentPeriodStart(subscription), end: candidate}; } } catch (error) { Logger.warn( @@ -546,7 +549,7 @@ export class StripeCheckoutWebhookHandler { {sessionId: session.id, subscriptionId, durationMonths: productInfo.durationMonths}, 'Using product duration fallback for subscription period_end on checkout fulfilment', ); - return fallback; + return {start: null, end: fallback}; } private didCheckoutPremiumGrantApply( diff --git a/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts b/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts index 618a58b0b..a06586240 100644 --- a/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts @@ -5,19 +5,37 @@ import type {UserRow} from '@app/api/database/types/UserTypes'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; import {Logger} from '@app/api/Logger'; +import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import {addGiftCodeDuration, type GiftCode} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {getStripeClient} from '@app/api/stripe/StripeClient'; +import { + getGiftTrialMetadataKey, + getGiftTrialPaidUntil, + getGiftTrialSeconds, +} from '@app/api/stripe/StripeGiftTrialMetadata'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError'; +import Stripe from 'stripe'; interface RemainingGiftEntitlement { hasLifetimeGift: boolean; giftExtensionEndsAt: Date | null; } +interface GiftTrialCut { + subscriptionId: string; + trialEnd: Date | null; + cancel: boolean; + premiumUntil: Date; +} + +const MIN_REMAINING_TRIAL_MS = 60_000; + export class StripeGiftReversalHandler { constructor( private userRepository: IUserRepository, @@ -48,20 +66,26 @@ export class StripeGiftReversalHandler { let newGiftEnd: Date | null; let needsAdjustment: boolean; let reapplyIfMarked = true; + let trialCut: GiftTrialCut | null = null; if (await this.storeEntitlementService?.getActiveStoreEntitlement(redeemer.id)) { const reduced = this.reduceStackedGiftExtension(redeemer, giftCode, redeemedGifts, new Date()); newGiftEnd = reduced.giftExtensionEndsAt; needsAdjustment = reduced.changed; reapplyIfMarked = false; } else { - newGiftEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt; + newGiftEnd = this.removeGiftKeepingPaidShift(currentGiftEnd, giftCode, redeemedGifts); needsAdjustment = - (newGiftEnd?.getTime() ?? 0) !== (currentGiftEnd?.getTime() ?? 0) && - (currentGiftEnd == null || newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime()); + currentGiftEnd != null && (newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime()); + trialCut = await this.planGiftTrialCut(redeemer, giftCode, new Date()); } if ( - needsAdjustment && - (await this.commitReversal(redeemer, giftCode, {premium_gift_extension_ends_at: newGiftEnd}, {reapplyIfMarked})) + (needsAdjustment || trialCut) && + (await this.commitReversal( + redeemer, + giftCode, + needsAdjustment ? {premium_gift_extension_ends_at: newGiftEnd} : {}, + {reapplyIfMarked, trialCut}, + )) ) { Logger.info( { @@ -69,8 +93,10 @@ export class StripeGiftReversalHandler { redeemerId: redeemer.id, chargeId: context.chargeId, reason: context.reason, - adjustedGiftEnd: newGiftEnd?.toISOString() ?? null, + adjustedGiftEnd: needsAdjustment ? (newGiftEnd?.toISOString() ?? null) : undefined, previousGiftEnd: currentGiftEnd?.toISOString() ?? null, + shortenedTrialEnd: trialCut ? (trialCut.trialEnd?.toISOString() ?? 'now') : undefined, + cancelledSubscription: trialCut?.cancel, }, 'Reduced gift extension after gift reversal for user with Stripe identity', ); @@ -223,6 +249,20 @@ export class StripeGiftReversalHandler { return reduced.giftExtensionEndsAt; } + private removeGiftKeepingPaidShift( + currentGiftEnd: Date | null, + giftCode: GiftCode, + redeemedGifts: Array, + ): Date | null { + const remainingEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt; + if (!currentGiftEnd || !remainingEnd) { + return null; + } + const rebuiltEnd = this.computeRemainingGiftEntitlement(redeemedGifts, null).giftExtensionEndsAt; + const paidShiftMs = rebuiltEnd ? Math.max(0, currentGiftEnd.getTime() - rebuiltEnd.getTime()) : 0; + return new Date(remainingEnd.getTime() + paidShiftMs); + } + private reduceStackedGiftExtension( redeemer: User, giftCode: GiftCode, @@ -287,15 +327,91 @@ export class StripeGiftReversalHandler { await this.dispatchUser(updatedUser); } + private async planGiftTrialCut(redeemer: User, giftCode: GiftCode, now: Date): Promise { + const stripe = getStripeClient(); + if (!stripe || !redeemer.stripeSubscriptionId) { + return null; + } + let subscription: Stripe.Subscription; + try { + subscription = await stripe.subscriptions.retrieve(redeemer.stripeSubscriptionId); + } catch (error) { + if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') { + return null; + } + throw error; + } + if (subscription.status === 'canceled' || subscription.status === 'incomplete_expired') { + return null; + } + if (!subscription.trial_end || subscription.trial_end * 1000 <= now.getTime()) { + return null; + } + const giftSeconds = getGiftTrialSeconds(subscription, giftCode.code); + if (giftSeconds === null) { + return null; + } + const cutEndMs = Math.max( + subscription.trial_end * 1000 - giftSeconds * 1000, + getGiftTrialPaidUntil(subscription, now.getTime())?.getTime() ?? 0, + ); + const endsNow = cutEndMs <= now.getTime() + MIN_REMAINING_TRIAL_MS; + const premiumUntilMs = Math.min( + endsNow ? now.getTime() : cutEndMs, + redeemer.premiumUntil?.getTime() ?? Number.POSITIVE_INFINITY, + ); + return { + subscriptionId: subscription.id, + trialEnd: endsNow ? null : new Date(cutEndMs), + cancel: endsNow && (subscription.cancel_at_period_end || subscription.cancel_at != null), + premiumUntil: new Date(premiumUntilMs), + }; + } + + private async applyGiftTrialCut(redeemer: User, giftCode: GiftCode, cut: GiftTrialCut): Promise { + const stripe = getStripeClient(); + if (!stripe) { + throw new StripeError('Stripe client not available for gift trial reversal'); + } + const idempotencyKey = `gift_trial_reverse:${redeemer.id}:${giftCode.code}`; + const subscription = cut.cancel + ? await stripe.subscriptions.cancel( + cut.subscriptionId, + {invoice_now: false, prorate: false, cancellation_details: {comment: 'gift_reversal'}}, + {idempotencyKey}, + ) + : await stripe.subscriptions.update( + cut.subscriptionId, + { + trial_end: cut.trialEnd ? Math.floor(cut.trialEnd.getTime() / 1000) : 'now', + proration_behavior: 'none', + metadata: {[getGiftTrialMetadataKey(giftCode.code)]: ''}, + }, + {idempotencyKey}, + ); + try { + await getBillingRepository().subscriptions.upsertFromStripe(subscription, { + knownUserId: redeemer.id, + snapshotCapturedAt: new Date(), + }); + } catch (mirrorErr) { + Logger.error( + {mirrorErr, subId: subscription.id}, + 'Mirror upsert failed after Stripe write; reconciler will heal', + ); + } + } + private async commitReversal( redeemer: User, giftCode: GiftCode, patch: Partial, - {reapplyIfMarked}: {reapplyIfMarked: boolean}, + {reapplyIfMarked, trialCut = null}: {reapplyIfMarked: boolean; trialCut?: GiftTrialCut | null}, ): Promise { - const seconds = this.computeRemovedSeconds(redeemer, patch, Date.now()); + const fullPatch: Partial = trialCut ? {...patch, premium_until: trialCut.premiumUntil} : patch; + const seconds = this.computeRemovedSeconds(redeemer, fullPatch, Date.now()); if (!(await this.userRepository.markGiftPremiumReversed(giftCode, seconds))) { - if (!reapplyIfMarked) { + if (!reapplyIfMarked || Object.keys(patch).length === 0) { Logger.info( {giftCode: giftCode.code, redeemerId: redeemer.id}, 'Skipped a gift premium reversal that was already applied', @@ -305,17 +421,20 @@ export class StripeGiftReversalHandler { await this.dispatchUser(await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow())); return true; } + if (trialCut) { + try { + await this.applyGiftTrialCut(redeemer, giftCode, trialCut); + } catch (error) { + await this.releaseReversalClaim(redeemer, giftCode, seconds); + throw error; + } + } let updatedUser: User; try { - updatedUser = await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow()); + updatedUser = await this.userRepository.patchUpsert(redeemer.id, fullPatch, redeemer.toRow()); } catch (error) { - try { - await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds); - } catch (clearError) { - Logger.error( - {giftCode: giftCode.code, redeemerId: redeemer.id, clearError}, - 'Failed to release gift reversal marker', - ); + if (!trialCut) { + await this.releaseReversalClaim(redeemer, giftCode, seconds); } throw error; } @@ -323,6 +442,17 @@ export class StripeGiftReversalHandler { return true; } + private async releaseReversalClaim(redeemer: User, giftCode: GiftCode, seconds: number): Promise { + try { + await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds); + } catch (clearError) { + Logger.error( + {giftCode: giftCode.code, redeemerId: redeemer.id, clearError}, + 'Failed to release gift reversal marker', + ); + } + } + private computeRemovedSeconds(redeemer: User, patch: Partial, nowMs: number): number { const nextUntil = 'premium_until' in patch ? patch.premium_until : redeemer.premiumUntil; const nextGiftEnd = @@ -337,7 +467,10 @@ export class StripeGiftReversalHandler { return Math.max(0, Math.ceil((beforeMs - afterMs) / 1000)); } - computeRemainingGiftEntitlement(redeemedGifts: Array, excludedCode: string): RemainingGiftEntitlement { + computeRemainingGiftEntitlement( + redeemedGifts: Array, + excludedCode: string | null, + ): RemainingGiftEntitlement { const sortedGifts = redeemedGifts .filter( (giftCode) => diff --git a/fluxer_api/src/api/stripe/services/StripeGiftService.ts b/fluxer_api/src/api/stripe/services/StripeGiftService.ts index d49bdfb47..9daa22a2c 100644 --- a/fluxer_api/src/api/stripe/services/StripeGiftService.ts +++ b/fluxer_api/src/api/stripe/services/StripeGiftService.ts @@ -140,23 +140,19 @@ export class StripeGiftService { ); await this.cancelStripeSubscriptionImmediately(user); } - let stackedOntoStripeSubscription = false; - if (premiumType === UserPremiumTypes.SUBSCRIPTION) { - stackedOntoStripeSubscription = await this.tryStackSubscriptionGiftOntoStripeSubscription( - user, - giftCode, - code, - ); - } + const stackedSubscription = + premiumType === UserPremiumTypes.SUBSCRIPTION + ? await this.tryStackSubscriptionGiftOntoStripeSubscription(user, giftCode, code) + : null; Logger.debug( { userId, giftCode: code, - stackedOntoStripeSubscription, + stackedOntoStripeSubscription: stackedSubscription !== null, }, 'Gift redemption stacking decision completed', ); - if (stackedOntoStripeSubscription) { + if (stackedSubscription) { Logger.debug( { userId, @@ -166,13 +162,9 @@ export class StripeGiftService { }, 'Recording gift extension in gift bucket alongside Stripe trial stacking', ); - await this.premiumService.extendPremiumByGift( - userId, - premiumType, - giftCode.durationType, - giftCode.durationQuantity, - true, - ); + if (stackedSubscription.subscription) { + await this.premiumService.recordGiftTrialExtension(userId, stackedSubscription.subscription); + } } else if (premiumType === UserPremiumTypes.LIFETIME && giftCode.visionarySequenceNumber != null) { const GIFT_CODE_SENTINEL_USER_ID = createUserID(-1n); Logger.debug( @@ -368,7 +360,7 @@ export class StripeGiftService { user: User, giftCode: GiftCode, code: string, - ): Promise { + ): Promise<{subscription: Stripe.Subscription | null} | null> { const hasCurrentSubscriptionState = this.hasCurrentSubscriptionState(user); if (!hasCurrentSubscriptionState || !user.stripeSubscriptionId || !this.stripe) { Logger.debug( @@ -381,7 +373,7 @@ export class StripeGiftService { }, 'Skipping Stripe subscription stacking for gift redemption', ); - return false; + return null; } Logger.debug( { @@ -394,14 +386,14 @@ export class StripeGiftService { 'Attempting to stack gift duration onto active Stripe subscription', ); try { - await this.subscriptionService.extendSubscriptionWithGiftTrialDuration( + const subscription = await this.subscriptionService.extendSubscriptionWithGiftTrialDuration( user, giftCode.durationType, giftCode.durationQuantity, code, ); Logger.debug({userId: user.id, giftCode: code}, 'Stacked gift duration onto Stripe subscription'); - return true; + return {subscription}; } catch (error: unknown) { if (!this.shouldFallbackToPremiumFieldGrant(error)) { Logger.error( @@ -431,7 +423,7 @@ export class StripeGiftService { }, 'Falling back to premium-field gift grant after missing or inactive Stripe subscription', ); - return false; + return null; } } diff --git a/fluxer_api/src/api/stripe/services/StripePremiumService.ts b/fluxer_api/src/api/stripe/services/StripePremiumService.ts index 026338ca3..ab91ff206 100644 --- a/fluxer_api/src/api/stripe/services/StripePremiumService.ts +++ b/fluxer_api/src/api/stripe/services/StripePremiumService.ts @@ -13,12 +13,18 @@ import {Logger} from '@app/api/Logger'; import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {addGiftCodeDuration} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; +import { + getSubscriptionCurrentPeriodStart, + getSubscriptionPremiumPeriodEnd, +} from '@app/api/stripe/StripeSubscriptionPeriod'; +import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift'; import type {IUserRepository} from '@app/api/user/IUserRepository'; 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'; import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError'; +import type Stripe from 'stripe'; export class StripePremiumService { constructor( @@ -35,6 +41,7 @@ export class StripePremiumService { billingCycle: string | null = null, hasEverPurchased: boolean = false, premiumSinceAnchor: Date | null = null, + periodStart: Date | null = null, ): Promise { const user = await this.userRepository.findUnique(userId); if (!user) { @@ -42,14 +49,15 @@ export class StripePremiumService { } const now = new Date(); let visionarySequence: number | null = user.premiumLifetimeSequence; - if (premiumType === UserPremiumTypes.LIFETIME && !visionarySequence) { + if (premiumType === UserPremiumTypes.LIFETIME && visionarySequence == null) { visionarySequence = await this.allocateVisionarySequence(userId); } - const shiftMs = user.premiumUntil ? periodEnd.getTime() - user.premiumUntil.getTime() : 0; - const adjustedGiftEnd = - user.premiumGiftExtensionEndsAt && shiftMs > 0 - ? new Date(user.premiumGiftExtensionEndsAt.getTime() + shiftMs) - : user.premiumGiftExtensionEndsAt; + const adjustedGiftEnd = shiftGiftExtensionPastPremiumUntil( + {premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt}, + periodEnd, + now, + periodStart, + ); const updatedUser = await this.userRepository.patchUpsert( userId, { @@ -68,7 +76,7 @@ export class StripePremiumService { ); await this.dispatchUser(updatedUser); Logger.debug( - {userId, premiumType, periodEnd, shiftMs, adjustedGiftEnd, billingCycle}, + {userId, premiumType, periodEnd, adjustedGiftEnd, billingCycle}, 'Premium set from subscription period', ); } @@ -140,6 +148,39 @@ export class StripePremiumService { Logger.debug({userId, premiumType, durationType, durationQuantity, newGiftEnd}, 'Premium extended by gift'); } + async recordGiftTrialExtension(userId: UserID, subscription: Stripe.Subscription): Promise { + const user = await this.userRepository.findUnique(userId); + if (!user) { + throw new StripeError('User not found for premium grant'); + } + const now = new Date(); + const periodEnd = getSubscriptionPremiumPeriodEnd(subscription); + const premiumUntil = + periodEnd && (!user.premiumUntil || periodEnd > user.premiumUntil) ? periodEnd : user.premiumUntil; + const shiftedGiftEnd = premiumUntil + ? shiftGiftExtensionPastPremiumUntil( + {premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt}, + premiumUntil, + now, + getSubscriptionCurrentPeriodStart(subscription), + ) + : user.premiumGiftExtensionEndsAt; + const trialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null; + const giftEnd = trialEnd && (!shiftedGiftEnd || trialEnd > shiftedGiftEnd) ? trialEnd : shiftedGiftEnd; + const updatedUser = await this.userRepository.patchUpsert( + userId, + { + premium_until: premiumUntil, + premium_gift_extension_ends_at: giftEnd, + premium_grace_ends_at: null, + has_ever_purchased: true, + }, + user.toRow(), + ); + await this.dispatchUser(updatedUser); + Logger.debug({userId, premiumUntil, giftEnd}, 'Premium extended by gift stacked onto Stripe trial'); + } + async grantPremiumFromGiftWithDuration( userId: UserID, premiumType: 1 | 2, diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts index 7fc6ed6b1..02983622f 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts @@ -10,6 +10,7 @@ import {addGiftCodeDuration} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry'; +import {buildGiftTrialMetadata, getGiftTrialPaidUntil} from '@app/api/stripe/StripeGiftTrialMetadata'; import { getPrimarySubscriptionItem, getSubscriptionEntitlementPeriodEndUnix, @@ -233,12 +234,15 @@ export class StripeSubscriptionService { premium_grace_ends_at: null, }; if (user.premiumType !== UserPremiumTypes.LIFETIME) { - const hasActiveGift = - user.premiumGiftExtensionEndsAt != null && user.premiumGiftExtensionEndsAt.getTime() > Date.now(); + const now = new Date(); + const paidUntil = getGiftTrialPaidUntil(canceledSubscription, now.getTime()) ?? user.premiumUntil; + const giftEnd = this.pullGiftExtensionBackToNow(user.premiumGiftExtensionEndsAt, paidUntil, now); + const hasActiveGift = giftEnd != null && giftEnd.getTime() > now.getTime(); Object.assign(patch, { premium_type: hasActiveGift ? user.premiumType : UserPremiumTypes.NONE, premium_since: hasActiveGift ? user.premiumSince : null, - premium_until: new Date(), + premium_until: now, + premium_gift_extension_ends_at: giftEnd, }); } const updatedUser = await this.userRepository.patchUpsert(userId, patch, user.toRow()); @@ -255,6 +259,13 @@ export class StripeSubscriptionService { await this.storeEntitlementService?.reapplyAfterStripeChange(userId); } + private pullGiftExtensionBackToNow(giftEnd: Date | null, paidUntil: Date | null, now: Date): Date | null { + if (!giftEnd || !paidUntil || paidUntil.getTime() <= now.getTime() || giftEnd.getTime() < paidUntil.getTime()) { + return giftEnd; + } + return new Date(giftEnd.getTime() - (paidUntil.getTime() - now.getTime())); + } + async reactivateSubscription(userId: UserID): Promise { if (!this.stripe) { throw new StripePaymentNotAvailableError(); @@ -1215,7 +1226,7 @@ export class StripeSubscriptionService { durationType: GiftCodeDurationType, durationQuantity: number, idempotencyKey: string, - ): Promise { + ): Promise { if (!this.stripe || !user.stripeSubscriptionId) { Logger.debug( { @@ -1246,11 +1257,11 @@ export class StripeSubscriptionService { ); if (await this.cacheService.get(appliedKey)) { Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension already applied (idempotent hit)'); - return; + return null; } if (await this.cacheService.get(inflightKey)) { Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension in-flight; skipping duplicate'); - return; + return null; } await this.cacheService.set(inflightKey, true, seconds('1 minute')); Logger.debug({userId: user.id, idempotencyKey, inflightKey}, 'Gift trial inflight sentinel set'); @@ -1268,7 +1279,7 @@ export class StripeSubscriptionService { try { if (await this.cacheService.get(appliedKey)) { Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension already applied after lock acquisition'); - return; + return null; } const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId); Logger.debug( @@ -1320,6 +1331,7 @@ export class StripeSubscriptionService { { trial_end: newTrialEndUnix, proration_behavior: 'none', + metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, Date.now()), }, {idempotencyKey: stripeIdempotencyKey}, ); @@ -1357,6 +1369,7 @@ export class StripeSubscriptionService { }, 'Extended subscription with gift trial period', ); + return trialExtendedSubscription; } catch (error: unknown) { Logger.error( {error, userId: user.id, subscriptionId: user.stripeSubscriptionId, idempotencyKey}, diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts index 4ee03c438..04a54c84f 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts @@ -20,7 +20,9 @@ import { } from '@app/api/stripe/StripeSubscriptionAccessPolicy'; import { getInvoiceLatestLinePeriodEnd, + getInvoiceLatestLinePeriodStart, getPrimarySubscriptionItem, + getSubscriptionCurrentPeriodStart, getSubscriptionItemPeriodEnd, getSubscriptionPremiumPeriodEnd, getSubscriptionStartDate, @@ -28,6 +30,7 @@ import { import {extractId} from '@app/api/stripe/StripeUtils'; import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService'; import type {StripeSubscriptionReconciler} from '@app/api/stripe/services/StripeSubscriptionReconciler'; +import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository'; import {getPremiumPaymentRecoveryGraceMs} from '@app/api/user/UserHelpers'; @@ -151,6 +154,8 @@ export class StripeSubscriptionWebhookHandler { renewalContext.productInfo.billingCycle || null, true, premiumSinceAnchor, + (subscriptionSnapshot ? getSubscriptionCurrentPeriodStart(subscriptionSnapshot) : null) ?? + getInvoiceLatestLinePeriodStart(invoice), ); } catch (error) { const latestUser = await this.userRepository.findUnique(renewalContext.userId); @@ -223,6 +228,15 @@ export class StripeSubscriptionWebhookHandler { } if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) { patch.premium_until = premiumUntil; + const giftEnd = shiftGiftExtensionPastPremiumUntil( + {premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt}, + premiumUntil, + new Date(), + getSubscriptionCurrentPeriodStart(subscription), + ); + if (giftEnd !== user.premiumGiftExtensionEndsAt) { + patch.premium_gift_extension_ends_at = giftEnd; + } } if (user.premiumWillCancel !== willCancel) { patch.premium_will_cancel = willCancel; @@ -556,6 +570,7 @@ export class StripeSubscriptionWebhookHandler { const result = await this.userRepository.updateSubscriptionStatus(targetUser.id, { premiumWillCancel: willCancel, computedPremiumUntil, + periodStart: getSubscriptionCurrentPeriodStart(canonicalSubscription), }); if (result.finalVersion === null) { Logger.error( diff --git a/fluxer_api/src/api/stripe/tests/BillingConfigCache.test.ts b/fluxer_api/src/api/stripe/tests/BillingConfigCache.test.ts index 8ff4602d2..539c41292 100644 --- a/fluxer_api/src/api/stripe/tests/BillingConfigCache.test.ts +++ b/fluxer_api/src/api/stripe/tests/BillingConfigCache.test.ts @@ -38,6 +38,10 @@ const ENV_PRICES = { yearlyInr: 'price_yearly_inr', gift1MonthInr: 'price_gift_month_inr', gift1YearInr: 'price_gift_year_inr', + monthlyIsk: 'price_monthly_isk', + yearlyIsk: 'price_yearly_isk', + gift1MonthIsk: 'price_gift_month_isk', + gift1YearIsk: 'price_gift_year_isk', monthlyNok: 'price_monthly_nok', yearlyNok: 'price_yearly_nok', gift1MonthNok: 'price_gift_month_nok', @@ -58,7 +62,7 @@ const ENV_PRICES = { function expectedEnvCatalog(): Record> { const catalog: Record> = {}; - for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']) { + for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY']) { const suffix = currency.toLowerCase(); catalog[currency] = { monthly: `price_monthly_${suffix}`, diff --git a/fluxer_api/src/api/stripe/tests/OperatorBillingCatalog.test.ts b/fluxer_api/src/api/stripe/tests/OperatorBillingCatalog.test.ts index 4395dba75..f57ff7973 100644 --- a/fluxer_api/src/api/stripe/tests/OperatorBillingCatalog.test.ts +++ b/fluxer_api/src/api/stripe/tests/OperatorBillingCatalog.test.ts @@ -37,7 +37,7 @@ import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; import {HttpResponse, http} from 'msw'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest'; -const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'] as const; +const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY'] as const; function buildFullEnvPrices(): NonNullable { const prices: Record = {}; @@ -479,6 +479,18 @@ describe('operator billing catalog', () => { .execute(); }); + test('leaves adaptive pricing unset for an operator SEK checkout', async () => { + const token = await createPurchaser(); + await createBuilder<{url: string}>(harness, token) + .post('/stripe/checkout/subscription') + .body({price_id: 'price_opsekmonthly', country_code: 'SE'}) + .expect(HTTP_STATUS.OK) + .execute(); + const session = stripeHandlers.spies.createdCheckoutSessions[0]; + expect(session?.line_items?.[0]?.price).toBe('price_opsekmonthly'); + expect(session?.adaptive_pricing).toBeUndefined(); + }); + test('accepts an operator price that does not match the buyer country', async () => { const token = await createPurchaser(); await createBuilder<{url: string}>(harness, token) diff --git a/fluxer_api/src/api/stripe/tests/ProductRegistryLegacyPrices.test.ts b/fluxer_api/src/api/stripe/tests/ProductRegistryLegacyPrices.test.ts index 861165ec8..3bb8ca9c5 100644 --- a/fluxer_api/src/api/stripe/tests/ProductRegistryLegacyPrices.test.ts +++ b/fluxer_api/src/api/stripe/tests/ProductRegistryLegacyPrices.test.ts @@ -121,6 +121,7 @@ describe('ProductRegistry - legacy prices', () => { monthly_brl: ['legacy_brl'], monthly_dkk: ['legacy_dkk'], monthly_inr: ['legacy_inr'], + monthly_isk: ['legacy_isk'], monthly_nok: ['legacy_nok'], monthly_pln: ['legacy_pln'], monthly_sek: ['legacy_sek'], @@ -132,6 +133,7 @@ describe('ProductRegistry - legacy prices', () => { expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL'); expect(registry.getProduct('legacy_dkk')?.currency).toBe('DKK'); expect(registry.getProduct('legacy_inr')?.currency).toBe('INR'); + expect(registry.getProduct('legacy_isk')?.currency).toBe('ISK'); expect(registry.getProduct('legacy_nok')?.currency).toBe('NOK'); expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN'); expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK'); @@ -211,7 +213,7 @@ describe('ProductRegistry - legacy prices', () => { test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => { const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES); const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? [])); - const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']; + const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY']; const offered: Array = []; for (const currency of currencies) { diff --git a/fluxer_api/src/api/stripe/tests/StripeGiftSubscriptionShift.test.ts b/fluxer_api/src/api/stripe/tests/StripeGiftSubscriptionShift.test.ts new file mode 100644 index 000000000..f1bc6a738 --- /dev/null +++ b/fluxer_api/src/api/stripe/tests/StripeGiftSubscriptionShift.test.ts @@ -0,0 +1,753 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID, type UserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import type {UserRow} from '@app/api/database/types/UserTypes'; +import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; +import type {GuildService} from '@app/api/guild/services/GuildService'; +import {getCacheService, getPremiumStateReconciliationQueueService} from '@app/api/middleware/ServiceSingletons'; +import {findUser} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {getStripeClient} from '@app/api/stripe/StripeClient'; +import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; +import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService'; +import {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createStripeApiHandlers, type StripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {UserRepository} from '@app/api/user/repositories/UserRepository'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup'; +import {ms} from 'itty-time'; +import {HttpResponse, http} from 'msw'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest'; + +const {lookupGeoipMock} = vi.hoisted(() => ({ + lookupGeoipMock: vi.fn(), +})); + +vi.mock('@app/api/utils/IpUtils', async (importOriginal) => ({ + ...(await importOriginal()), + lookupGeoip: lookupGeoipMock, +})); + +const MOCK_PRICES = { + monthlyUsd: 'price_shift_monthly_usd', + yearlyUsd: 'price_shift_yearly_usd', + gift1MonthUsd: 'price_shift_gift_1_month_usd', + gift1YearUsd: 'price_shift_gift_1_year_usd', +}; + +const MOCK_PRICE_SEEDS = { + [MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const}, + [MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const}, +}; + +const CLOCK_TOLERANCE_MS = ms('10 seconds'); + +type WriteOrder = 'checkout first' | 'webhook first'; + +const WRITE_ORDERS: Array = ['checkout first', 'webhook first']; + +function geoipCountry(countryCode: string | null): GeoipResult { + return {countryCode, normalizedIp: '203.0.113.10', city: null, region: null, countryName: null}; +} + +function giftTrialMetadata(code: string, days: number, paidUntilUnix?: number): Record { + return { + [`gtrial_${code}`]: String(days * 24 * 60 * 60), + ...(paidUntilUnix ? {gtrial_paid_until: String(paidUntilUnix)} : {}), + }; +} + +function expectNear(actual: Date | null | undefined, expectedMs: number): void { + expect(actual).not.toBeNull(); + expect(Math.abs(actual!.getTime() - expectedMs)).toBeLessThanOrEqual(CLOCK_TOLERANCE_MS); +} + +describe('gift time around a Stripe subscription', () => { + let harness: ApiTestHarness; + let stripeHandlers: StripeApiHandlers; + let originalPrices: typeof Config.stripe.prices | undefined; + const users = new UserRepository(); + const premiumService = new StripePremiumService( + users, + new NoopGatewayService(), + {} as IGuildRepositoryAggregate, + {} as GuildService, + ); + + beforeAll(async () => { + originalPrices = Config.stripe.prices; + Config.stripe.prices = MOCK_PRICES; + harness = await createApiTestHarness(); + }); + afterAll(async () => { + await harness.shutdown(); + Config.stripe.prices = originalPrices; + }); + beforeEach(async () => { + await harness.resetData(); + Config.stripe.prices = MOCK_PRICES; + lookupGeoipMock.mockReset(); + lookupGeoipMock.mockResolvedValue(geoipCountry(null)); + useStripe(); + }); + afterEach(() => { + server.resetHandlers(); + }); + + function useStripe(config: Parameters[0] = {}): void { + stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, ...config}); + server.use(...stripeHandlers.handlers); + } + + async function createUser(patch: Partial): Promise<{account: TestAccount; userId: UserID}> { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const user = await findUser(account.userId); + await users.patchUpsert(userId, patch, user.toRow()); + return {account, userId}; + } + + async function checkoutWrite(userId: UserID, periodEnd: Date, periodStart: Date | null = null): Promise { + await premiumService.setPremiumFromSubscriptionPeriod( + userId, + UserPremiumTypes.SUBSCRIPTION, + periodEnd, + 'monthly', + true, + null, + periodStart, + ); + } + + async function webhookWrite(userId: UserID, periodEnd: Date, periodStart: Date | null = null): Promise { + await users.updateSubscriptionStatus(userId, { + premiumWillCancel: false, + computedPremiumUntil: periodEnd, + periodStart, + }); + } + + async function writeBoth( + order: WriteOrder, + userId: UserID, + periodEnd: Date, + periodStart: Date | null = null, + ): Promise { + if (order === 'checkout first') { + await checkoutWrite(userId, periodEnd, periodStart); + await webhookWrite(userId, periodEnd, periodStart); + return; + } + await webhookWrite(userId, periodEnd, periodStart); + await checkoutWrite(userId, periodEnd, periodStart); + } + + function newGiftCode(): string { + return `giftshift${randomUUID().replaceAll('-', '').slice(0, 20)}`; + } + + async function createRedeemedGift( + redeemerId: UserID, + redeemedAt: Date, + duration: {type: 'days' | 'months'; quantity: number}, + code: string = newGiftCode(), + ): Promise { + const gifter = await createTestAccount(harness); + await users.createGiftCode({ + code, + duration_months: null, + duration_type: duration.type, + duration_quantity: duration.quantity, + created_at: redeemedAt, + created_by_user_id: createUserID(BigInt(gifter.userId)), + redeemed_at: redeemedAt, + redeemed_by_user_id: redeemerId, + stripe_payment_intent_id: `pi_${code}`, + visionary_sequence_number: null, + checkout_session_id: null, + version: 1, + }); + return code; + } + + async function reverseGift(code: string): Promise { + const handler = new StripeGiftReversalHandler( + users, + new NoopGatewayService(), + getPremiumStateReconciliationQueueService(), + ); + const gift = await users.findGiftCode(code); + await handler.handleGiftPremiumReversal(gift!, {reason: 'gift_chargeback'}); + } + + describe('subscribing while gift time is left', () => { + test.each(WRITE_ORDERS)( + 'moves an active gift past the paid period when premium_until is empty (%s)', + async (order) => { + const giftEnd = new Date(Date.now() + ms('10 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: null, + premium_gift_extension_ends_at: giftEnd, + }); + const periodEnd = new Date(Date.now() + ms('30 days')); + + await writeBoth(order, userId, periodEnd); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime()); + expectNear(after.premiumGiftExtensionEndsAt, periodEnd.getTime() + ms('10 days')); + }, + ); + + test.each(WRITE_ORDERS)('anchors at now when premium_until is a stale past date (%s)', async (order) => { + const giftEnd = new Date(Date.now() + ms('10 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: new Date(Date.now() - ms('60 days')), + premium_gift_extension_ends_at: giftEnd, + }); + const periodEnd = new Date(Date.now() + ms('30 days')); + + await writeBoth(order, userId, periodEnd); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime()); + expectNear(after.premiumGiftExtensionEndsAt, periodEnd.getTime() + ms('10 days')); + }); + + test.each(WRITE_ORDERS)('leaves an expired gift alone when premium_until is stale (%s)', async (order) => { + const giftEnd = new Date(Date.now() - ms('5 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: new Date(Date.now() - ms('60 days')), + premium_gift_extension_ends_at: giftEnd, + }); + const periodEnd = new Date(Date.now() + ms('30 days')); + + await writeBoth(order, userId, periodEnd); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime()); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime()); + }); + + test('shifts the gift once when checkout.session.completed is replayed', async () => { + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')), + }); + const periodEnd = new Date(Date.now() + ms('30 days')); + + await checkoutWrite(userId, periodEnd); + const first = await findUser(account.userId); + await checkoutWrite(userId, periodEnd); + + const replayed = await findUser(account.userId); + expect(replayed.premiumGiftExtensionEndsAt?.getTime()).toBe(first.premiumGiftExtensionEndsAt?.getTime()); + }); + + test.each(WRITE_ORDERS)('moves a stacked gift along on renewal (%s)', async (order) => { + const premiumUntil = new Date(Date.now() + ms('1 hour')); + const giftEnd = new Date(premiumUntil.getTime() + ms('10 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: premiumUntil, + premium_gift_extension_ends_at: giftEnd, + }); + const renewedUntil = new Date(premiumUntil.getTime() + ms('30 days')); + + await writeBoth(order, userId, renewedUntil); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(renewedUntil.getTime()); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days')); + }); + + test.each(WRITE_ORDERS)('keeps every gift day when a renewal is paid late (%s)', async (order) => { + const premiumUntil = new Date(Date.now() - ms('6 days')); + const giftEnd = new Date(premiumUntil.getTime() + ms('60 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: premiumUntil, + premium_gift_extension_ends_at: giftEnd, + premium_grace_ends_at: new Date(premiumUntil.getTime() + ms('14 days')), + }); + const renewedUntil = new Date(premiumUntil.getTime() + ms('30 days')); + + await writeBoth(order, userId, renewedUntil, premiumUntil); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(renewedUntil.getTime()); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days')); + }); + + test.each(WRITE_ORDERS)( + 'moves the gift by the whole period when the first payment lands late (%s)', + async (order) => { + const periodStart = new Date(Date.now() - ms('2 days')); + const giftEnd = new Date(Date.now() + ms('10 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: null, + premium_gift_extension_ends_at: giftEnd, + }); + const periodEnd = new Date(periodStart.getTime() + ms('30 days')); + + await writeBoth(order, userId, periodEnd, periodStart); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime()); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days')); + }, + ); + + test('lets a user with gift time left start a subscription checkout', async () => { + const {account} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: new Date(Date.now() - ms('60 days')), + premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')), + }); + useStripe({subscriptionsListEmpty: true}); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/security-flags`) + .body({email_verified: true}) + .execute(); + + await createBuilder(harness, account.token) + .post('/stripe/checkout/subscription') + .body({price_id: MOCK_PRICES.monthlyUsd}) + .expect(HTTP_STATUS.OK) + .execute(); + + expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1); + }); + + test('still refuses a recurring checkout for a Visionary', async () => { + const {account} = await createUser({premium_type: UserPremiumTypes.LIFETIME, premium_lifetime_sequence: 7}); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/security-flags`) + .body({email_verified: true}) + .execute(); + + await createBuilder(harness, account.token) + .post('/stripe/checkout/subscription') + .body({price_id: MOCK_PRICES.monthlyUsd}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED) + .execute(); + }); + + test('still refuses a recurring checkout while a past_due subscription exists', async () => { + const {account} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_customer_id: 'cus_shift_past_due', + premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')), + }); + useStripe({subscriptions: {sub_shift_past_due: {customer: 'cus_shift_past_due', status: 'past_due'}}}); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/security-flags`) + .body({email_verified: true}) + .execute(); + + const error = await createBuilder<{data?: {reason?: string}}>(harness, account.token) + .post('/stripe/checkout/subscription') + .body({price_id: MOCK_PRICES.monthlyUsd}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED) + .execute(); + + expect(JSON.stringify(error)).toContain('existing_subscription'); + expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0); + }); + }); + + describe('refunds', () => { + test('a refund keeps only the gift part of a gift trial', async () => { + const subscriptionId = 'sub_shift_refund_trial'; + const paidUntil = Math.floor((Date.now() + ms('28 days')) / 1000); + const trialEnd = paidUntil + 30 * 24 * 60 * 60; + useStripe({ + subscriptions: { + [subscriptionId]: { + trial_end: trialEnd, + status: 'trialing', + metadata: giftTrialMetadata(newGiftCode(), 30, paidUntil), + }, + }, + }); + const {account} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_billing_cycle: 'monthly', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + const subscriptionService = new StripeSubscriptionService( + getStripeClient(), + users, + {getRecurringSubscriptionPriceId: () => null, getProduct: () => null}, + getCacheService(), + new NoopGatewayService(), + ); + + await subscriptionService.cancelSubscriptionImmediately( + createUserID(BigInt(account.userId)), + 'self_serve_refund', + ); + + const after = await findUser(account.userId); + expectNear(after.premiumUntil, Date.now()); + expectNear(after.premiumGiftExtensionEndsAt, Date.now() + ms('30 days')); + }); + + test('a refund right after subscribing keeps only the real gift remainder', async () => { + const subscriptionId = 'sub_shift_refund'; + const premiumUntil = new Date(Date.now() + ms('28 days')); + const {account} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_billing_cycle: 'monthly', + premium_until: premiumUntil, + premium_gift_extension_ends_at: new Date(premiumUntil.getTime() + ms('10 days')), + }); + const subscriptionService = new StripeSubscriptionService( + getStripeClient(), + users, + {getRecurringSubscriptionPriceId: () => null, getProduct: () => null}, + getCacheService(), + new NoopGatewayService(), + ); + + await subscriptionService.cancelSubscriptionImmediately( + createUserID(BigInt(account.userId)), + 'self_serve_refund', + ); + + const after = await findUser(account.userId); + expect(stripeHandlers.spies.cancelledSubscriptions).toContain(subscriptionId); + expect(after.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + expectNear(after.premiumUntil, Date.now()); + expectNear(after.premiumGiftExtensionEndsAt, Date.now() + ms('10 days')); + }); + }); + + describe('gift reversals', () => { + test('removes a gift that was moved past the paid period', async () => { + const premiumUntil = new Date(Date.now() + ms('30 days')); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: 'sub_shift_charge_now', + stripe_customer_id: 'cus_test_1', + premium_until: premiumUntil, + premium_gift_extension_ends_at: new Date(premiumUntil.getTime() + ms('20 days')), + }); + const code = await createRedeemedGift(userId, new Date(Date.now() - ms('10 days')), {type: 'days', quantity: 30}); + + await reverseGift(code); + + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(premiumUntil.getTime()); + expect(after.premiumGiftExtensionEndsAt).toBeNull(); + expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0); + }); + + test('shortens the Stripe trial by the reversed gift once', async () => { + const subscriptionId = 'sub_shift_gift_trial'; + const trialEnd = Math.floor((Date.now() + ms('50 days')) / 1000); + const code = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)}, + }, + }); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code); + + await reverseGift(code); + await reverseGift(code); + + const shortenedEnd = trialEnd - 30 * 24 * 60 * 60; + expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1); + expect(stripeHandlers.spies.updatedSubscriptions[0]?.id).toBe(subscriptionId); + expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(shortenedEnd); + expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.metadata).toEqual({[`gtrial_${code}`]: ''}); + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(shortenedEnd * 1000); + expect(after.premiumGiftExtensionEndsAt).toBeNull(); + const gift = await users.findGiftCode(code); + expect(gift?.premiumReversedSeconds).toBe(30 * 24 * 60 * 60); + }); + + test('ends the trial now when the reversed gift is already being used', async () => { + const subscriptionId = 'sub_shift_gift_trial_used'; + const trialEnd = Math.floor((Date.now() + ms('10 days')) / 1000); + const code = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)}, + }, + }); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('20 days')), {type: 'days', quantity: 30}, code); + + await reverseGift(code); + + expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1); + expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end).toBe('now'); + expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0); + const after = await findUser(account.userId); + expectNear(after.premiumUntil, Date.now()); + }); + + test('cancels a cancelling subscription instead of charging when the gift is already being used', async () => { + const subscriptionId = 'sub_shift_gift_trial_cancelling'; + const trialEnd = Math.floor((Date.now() + ms('10 days')) / 1000); + const code = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: { + trial_end: trialEnd, + status: 'trialing', + cancel_at_period_end: true, + metadata: giftTrialMetadata(code, 30), + }, + }, + }); + const {userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_will_cancel: true, + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('20 days')), {type: 'days', quantity: 30}, code); + + await reverseGift(code); + + expect(stripeHandlers.spies.cancelledSubscriptions).toEqual([subscriptionId]); + expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0); + }); + + test('releases the reversal claim when Stripe refuses the trial change', async () => { + const subscriptionId = 'sub_shift_gift_trial_fail'; + const trialEnd = Math.floor((Date.now() + ms('50 days')) / 1000); + const code = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)}, + }, + }); + server.use( + http.post('https://api.stripe.com/v1/subscriptions/:id', () => + HttpResponse.json( + {error: {type: 'invalid_request_error', message: 'Mock trial update failure'}}, + {status: 400}, + ), + ), + ); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code); + + await expect(reverseGift(code)).rejects.toThrow(); + + expect((await users.findGiftCode(code))?.premiumReversedSeconds).toBeNull(); + expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(trialEnd * 1000); + }); + + test('cuts a month gift back to exactly the paid period end', async () => { + const subscriptionId = 'sub_shift_gift_trial_month'; + const paidUntil = Math.floor((Date.now() + ms('15 days')) / 1000); + const trialEnd = paidUntil + 28 * 24 * 60 * 60; + const code = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: { + trial_end: trialEnd, + status: 'trialing', + metadata: giftTrialMetadata(code, 28, paidUntil), + }, + }, + }); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'months', quantity: 1}, code); + + await reverseGift(code); + + expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil); + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(paidUntil * 1000); + expect(after.premiumGiftExtensionEndsAt).toBeNull(); + }); + + test('never cuts the trial below the paid period end', async () => { + const subscriptionId = 'sub_shift_gift_trial_floor'; + const paidUntil = Math.floor((Date.now() + ms('15 days')) / 1000); + const trialEnd = paidUntil + 20 * 24 * 60 * 60; + const code = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: { + trial_end: trialEnd, + status: 'trialing', + metadata: giftTrialMetadata(code, 30, paidUntil), + }, + }, + }); + const {userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(trialEnd * 1000), + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code); + + await reverseGift(code); + + expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil); + }); + + test('leaves the trial alone when the reversed gift was not stacked onto it', async () => { + const subscriptionId = 'sub_shift_gift_trial_other'; + const paidUntil = Math.floor((Date.now() + ms('10 days')) / 1000); + const trialEnd = paidUntil + 30 * 24 * 60 * 60; + const stackedCode = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: { + trial_end: trialEnd, + status: 'trialing', + metadata: giftTrialMetadata(stackedCode, 30, paidUntil), + }, + }, + }); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(Date.now() + ms('55 days')), + }); + const plainCode = await createRedeemedGift(userId, new Date(Date.now() - ms('5 days')), { + type: 'days', + quantity: 30, + }); + await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, stackedCode); + + await reverseGift(plainCode); + + expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0); + expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0); + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(trialEnd * 1000); + expect(after.premiumGiftExtensionEndsAt!.getTime()).toBeLessThanOrEqual(trialEnd * 1000); + }); + + test("keeps the paid shift of the user's other gifts", async () => { + const subscriptionId = 'sub_shift_gift_trial_keep_shift'; + const now = Date.now(); + const paidUntil = Math.floor((now + ms('10 days')) / 1000); + const trialEnd = paidUntil + 30 * 24 * 60 * 60; + const stackedCode = newGiftCode(); + useStripe({ + subscriptions: { + [subscriptionId]: { + trial_end: trialEnd, + status: 'trialing', + metadata: giftTrialMetadata(stackedCode, 30, paidUntil), + }, + }, + }); + const firstRedeemedAt = new Date(now - ms('150 days')); + const firstGiftBaseEnd = firstRedeemedAt.getTime() + ms('365 days'); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_until: new Date(trialEnd * 1000), + premium_gift_extension_ends_at: new Date(firstGiftBaseEnd + ms('120 days') + ms('30 days')), + }); + await createRedeemedGift(userId, firstRedeemedAt, {type: 'days', quantity: 365}); + await createRedeemedGift(userId, new Date(now - ms('1 hour')), {type: 'days', quantity: 30}, stackedCode); + + await reverseGift(stackedCode); + + expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil); + const after = await findUser(account.userId); + expect(after.premiumUntil?.getTime()).toBe(paidUntil * 1000); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(firstGiftBaseEnd + ms('120 days')); + }); + }); + + describe('redeeming a gift onto a running subscription', () => { + test('records the stacked trial once whichever write lands first', async () => { + const subscriptionId = 'sub_shift_stack'; + const periodEnd = Math.floor((Date.now() + ms('20 days')) / 1000); + useStripe({ + subscriptions: { + [subscriptionId]: {current_period_start: periodEnd - 30 * 24 * 60 * 60, current_period_end: periodEnd}, + }, + }); + const {account, userId} = await createUser({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_billing_cycle: 'monthly', + premium_until: new Date(periodEnd * 1000), + }); + const gifter = await createTestAccount(harness); + await createBuilder(harness, gifter.token) + .post('/test/gifts/SHIFTSTACKGIFT') + .body({duration_type: 'days', duration_quantity: 30, created_by_user_id: gifter.userId}) + .execute(); + + await createBuilder(harness, account.token).post('/gifts/SHIFTSTACKGIFT/redeem').expect(204).execute(); + + const trialEnd = new Date((periodEnd + 30 * 24 * 60 * 60) * 1000); + expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.metadata).toEqual({ + gtrial_SHIFTSTACKGIFT: String(30 * 24 * 60 * 60), + gtrial_paid_until: String(periodEnd), + }); + const redeemed = await findUser(account.userId); + expect(redeemed.premiumUntil?.getTime()).toBe(trialEnd.getTime()); + expect(redeemed.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime()); + await webhookWrite(userId, trialEnd); + const afterWebhook = await findUser(account.userId); + expect(afterWebhook.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime()); + }); + }); +}); diff --git a/fluxer_api/src/api/stripe/tests/StripeNordicCurrencies.test.ts b/fluxer_api/src/api/stripe/tests/StripeNordicCurrencies.test.ts index b591b6fcc..1e11210c3 100644 --- a/fluxer_api/src/api/stripe/tests/StripeNordicCurrencies.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeNordicCurrencies.test.ts @@ -37,10 +37,20 @@ const MOCK_PRICES = { yearlyPln: 'price_nordic_yearly_pln', monthlySek: 'price_nordic_monthly_sek', yearlySek: 'price_nordic_yearly_sek', + monthlyIsk: 'price_nordic_monthly_isk', + yearlyIsk: 'price_nordic_yearly_isk', gift1MonthUsd: 'price_nordic_gift_1_month_usd', gift1YearUsd: 'price_nordic_gift_1_year_usd', gift1MonthEur: 'price_nordic_gift_1_month_eur', gift1YearEur: 'price_nordic_gift_1_year_eur', + gift1MonthDkk: 'price_nordic_gift_1_month_dkk', + gift1YearDkk: 'price_nordic_gift_1_year_dkk', + gift1MonthNok: 'price_nordic_gift_1_month_nok', + gift1YearNok: 'price_nordic_gift_1_year_nok', + gift1MonthSek: 'price_nordic_gift_1_month_sek', + gift1YearSek: 'price_nordic_gift_1_year_sek', + gift1MonthIsk: 'price_nordic_gift_1_month_isk', + gift1YearIsk: 'price_nordic_gift_1_year_isk', }; const MOCK_PRICE_SEEDS = { @@ -48,14 +58,24 @@ const MOCK_PRICE_SEEDS = { [MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const}, [MOCK_PRICES.monthlyEur]: {unit_amount: 499, currency: 'eur', interval: 'month' as const}, [MOCK_PRICES.yearlyEur]: {unit_amount: 4999, currency: 'eur', interval: 'year' as const}, - [MOCK_PRICES.monthlyDkk]: {unit_amount: 3700, currency: 'dkk', interval: 'month' as const}, - [MOCK_PRICES.yearlyDkk]: {unit_amount: 37000, currency: 'dkk', interval: 'year' as const}, - [MOCK_PRICES.monthlyNok]: {unit_amount: 5400, currency: 'nok', interval: 'month' as const}, - [MOCK_PRICES.yearlyNok]: {unit_amount: 54000, currency: 'nok', interval: 'year' as const}, + [MOCK_PRICES.monthlyDkk]: {unit_amount: 3500, currency: 'dkk', interval: 'month' as const}, + [MOCK_PRICES.yearlyDkk]: {unit_amount: 35000, currency: 'dkk', interval: 'year' as const}, + [MOCK_PRICES.monthlyNok]: {unit_amount: 4900, currency: 'nok', interval: 'month' as const}, + [MOCK_PRICES.yearlyNok]: {unit_amount: 49000, currency: 'nok', interval: 'year' as const}, [MOCK_PRICES.monthlyPln]: {unit_amount: 1900, currency: 'pln', interval: 'month' as const}, [MOCK_PRICES.yearlyPln]: {unit_amount: 19000, currency: 'pln', interval: 'year' as const}, - [MOCK_PRICES.monthlySek]: {unit_amount: 5400, currency: 'sek', interval: 'month' as const}, - [MOCK_PRICES.yearlySek]: {unit_amount: 54000, currency: 'sek', interval: 'year' as const}, + [MOCK_PRICES.monthlySek]: {unit_amount: 4900, currency: 'sek', interval: 'month' as const}, + [MOCK_PRICES.yearlySek]: {unit_amount: 49000, currency: 'sek', interval: 'year' as const}, + [MOCK_PRICES.monthlyIsk]: {unit_amount: 59000, currency: 'isk', interval: 'month' as const}, + [MOCK_PRICES.yearlyIsk]: {unit_amount: 590000, currency: 'isk', interval: 'year' as const}, + [MOCK_PRICES.gift1MonthDkk]: {unit_amount: 3500, currency: 'dkk'}, + [MOCK_PRICES.gift1YearDkk]: {unit_amount: 35000, currency: 'dkk'}, + [MOCK_PRICES.gift1MonthNok]: {unit_amount: 4900, currency: 'nok'}, + [MOCK_PRICES.gift1YearNok]: {unit_amount: 49000, currency: 'nok'}, + [MOCK_PRICES.gift1MonthSek]: {unit_amount: 4900, currency: 'sek'}, + [MOCK_PRICES.gift1YearSek]: {unit_amount: 49000, currency: 'sek'}, + [MOCK_PRICES.gift1MonthIsk]: {unit_amount: 59000, currency: 'isk'}, + [MOCK_PRICES.gift1YearIsk]: {unit_amount: 590000, currency: 'isk'}, }; const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur'; @@ -125,20 +145,35 @@ describe('Nordic localized currencies', () => { expect(priceIds.currency).toBe('SEK'); expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek); expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek); + expect(priceIds.monthly_amount_minor).toBe(4900); + expect(priceIds.yearly_amount_minor).toBe(49000); }); - test('Denmark resolves to the DKK subscription catalog', async () => { - const priceIds = await getPriceIds('DK'); + test.each(['DK', 'FO', 'GL'])('%s resolves to the DKK subscription catalog', async (country) => { + const priceIds = await getPriceIds(country); expect(priceIds.currency).toBe('DKK'); expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk); expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk); + expect(priceIds.monthly_amount_minor).toBe(3500); + expect(priceIds.yearly_amount_minor).toBe(35000); }); - test('Norway resolves to the NOK subscription catalog', async () => { - const priceIds = await getPriceIds('NO'); + test.each(['NO', 'SJ'])('%s resolves to the NOK subscription catalog', async (country) => { + const priceIds = await getPriceIds(country); expect(priceIds.currency).toBe('NOK'); expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok); expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok); + expect(priceIds.monthly_amount_minor).toBe(4900); + expect(priceIds.yearly_amount_minor).toBe(49000); + }); + + test('Iceland resolves to the ISK subscription catalog', async () => { + const priceIds = await getPriceIds('IS'); + expect(priceIds.currency).toBe('ISK'); + expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyIsk); + expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyIsk); + expect(priceIds.monthly_amount_minor).toBe(59000); + expect(priceIds.yearly_amount_minor).toBe(590000); }); test('Poland still resolves to the PLN subscription catalog', async () => { @@ -147,23 +182,45 @@ describe('Nordic localized currencies', () => { expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln); }); - test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%s still resolves to the EUR subscription catalog', async (country) => { + test.each(['DE', 'NL', 'FI', 'IE', 'AX'])('%s still resolves to the EUR subscription catalog', async (country) => { const priceIds = await getPriceIds(country); expect(priceIds.currency).toBe('EUR'); expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur); expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur); }); - test.each(['SE', 'DK', 'NO'])('%s keeps gifts on the base currency', async (country) => { + test.each([ + ['SE', 'SEK', MOCK_PRICES.gift1MonthSek, MOCK_PRICES.gift1YearSek, 4900], + ['DK', 'DKK', MOCK_PRICES.gift1MonthDkk, MOCK_PRICES.gift1YearDkk, 3500], + ['NO', 'NOK', MOCK_PRICES.gift1MonthNok, MOCK_PRICES.gift1YearNok, 4900], + ['IS', 'ISK', MOCK_PRICES.gift1MonthIsk, MOCK_PRICES.gift1YearIsk, 59000], + ])('%s sells gifts in %s at the subscription price', async (country, currency, giftMonth, giftYear, amount) => { const priceIds = await getPriceIds(country); - expect(priceIds.gift_currency).toBe('EUR'); - expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur); - expect(priceIds.gift_1_year).toBe(MOCK_PRICES.gift1YearEur); + expect(priceIds.gift_currency).toBe(currency); + expect(priceIds.gift_1_month).toBe(giftMonth); + expect(priceIds.gift_1_year).toBe(giftYear); + expect(priceIds.gift_1_month_amount_minor).toBe(amount); + expect(priceIds.gift_1_month_amount_minor).toBe(priceIds.monthly_amount_minor); + expect(priceIds.gift_1_year_amount_minor).toBe(priceIds.yearly_amount_minor); }); - test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => { + test('a Nordic gift falls back to EUR while the native gift prices are unconfigured', async () => { Config.stripe.prices = { ...MOCK_PRICES, + gift1MonthSek: undefined, + gift1YearSek: undefined, + }; + const priceIds = await getPriceIds('SE'); + expect(priceIds.currency).toBe('SEK'); + expect(priceIds.gift_currency).toBe('EUR'); + expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur); + }); + + test('a Nordic gift falls through to USD when no native or EUR gift price is configured', async () => { + Config.stripe.prices = { + ...MOCK_PRICES, + gift1MonthSek: undefined, + gift1YearSek: undefined, gift1MonthEur: undefined, gift1YearEur: undefined, }; @@ -182,7 +239,23 @@ describe('Nordic localized currencies', () => { const priceIds = await getPriceIds('SE'); expect(priceIds.currency).toBe('EUR'); expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur); + expect(priceIds.gift_currency).toBe('SEK'); + }); + + test('Iceland falls back to EUR while the ISK prices are still unconfigured', async () => { + Config.stripe.prices = { + ...MOCK_PRICES, + monthlyIsk: undefined, + yearlyIsk: undefined, + gift1MonthIsk: undefined, + gift1YearIsk: undefined, + }; + const priceIds = await getPriceIds('IS'); + expect(priceIds.currency).toBe('EUR'); + expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur); + expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur); expect(priceIds.gift_currency).toBe('EUR'); + expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur); }); }); @@ -232,18 +305,52 @@ describe('Nordic localized currencies', () => { expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1); }); - test('accepts the base gift price from inside Sweden', async () => { + test('accepts the SEK gift price from inside Sweden', async () => { lookupGeoipMock.mockResolvedValue(geoipCountry('SE')); const token = await createPurchaser(); const response = await createBuilder<{url: string}>(harness, token) .post('/stripe/checkout/gift') - .body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'}) + .body({price_id: MOCK_PRICES.gift1MonthSek, country_code: 'SE'}) .expect(HTTP_STATUS.OK) .execute(); expect(response.url).toContain('checkout.stripe.com'); expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1); }); + test('rejects the EUR gift price from inside Sweden once SEK gifts exist', async () => { + lookupGeoipMock.mockResolvedValue(geoipCountry('SE')); + const token = await createPurchaser(); + await createBuilder(harness, token) + .post('/stripe/checkout/gift') + .body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION) + .execute(); + expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0); + }); + + test('accepts the ISK subscription price for a purchase that geolocates to Iceland', async () => { + lookupGeoipMock.mockResolvedValue(geoipCountry('IS')); + const token = await createPurchaser(); + await createBuilder<{url: string}>(harness, token) + .post('/stripe/checkout/subscription') + .body({price_id: MOCK_PRICES.yearlyIsk, country_code: 'IS'}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1); + expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe(MOCK_PRICES.yearlyIsk); + }); + + test('marks Åland as EEA so the withdrawal waiver applies', async () => { + lookupGeoipMock.mockResolvedValue(geoipCountry('AX')); + const token = await createPurchaser(); + await createBuilder<{url: string}>(harness, token) + .post('/stripe/checkout/subscription') + .body({price_id: MOCK_PRICES.monthlyEur, country_code: 'AX'}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(stripeHandlers.spies.createdCheckoutSessions[0]?.metadata?.eu_withdrawal_waiver_required).toBe('true'); + }); + test('rejects the SEK subscription price on the gift endpoint', async () => { lookupGeoipMock.mockResolvedValue(geoipCountry('SE')); const token = await createPurchaser(); @@ -256,6 +363,43 @@ describe('Nordic localized currencies', () => { }); }); + describe('adaptive pricing', () => { + async function checkout(path: 'subscription' | 'gift', priceId: string, country: string) { + lookupGeoipMock.mockResolvedValue(geoipCountry(country)); + const token = await createPurchaser(); + await createBuilder<{url: string}>(harness, token) + .post(`/stripe/checkout/${path}`) + .body({price_id: priceId, country_code: country}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1); + return stripeHandlers.spies.createdCheckoutSessions[0]; + } + + test.each([ + ['subscription', MOCK_PRICES.monthlySek, 'SE'], + ['subscription', MOCK_PRICES.yearlySek, 'SE'], + ['gift', MOCK_PRICES.gift1MonthSek, 'SE'], + ['subscription', MOCK_PRICES.monthlyNok, 'NO'], + ['subscription', MOCK_PRICES.monthlyDkk, 'DK'], + ['subscription', MOCK_PRICES.monthlyIsk, 'IS'], + ['gift', MOCK_PRICES.gift1YearIsk, 'IS'], + ] as const)('turns adaptive pricing off for a %s checkout with %s', async (path, priceId, country) => { + const session = await checkout(path, priceId, country); + expect(session?.adaptive_pricing).toEqual({enabled: 'false'}); + }); + + test.each([ + ['subscription', MOCK_PRICES.monthlyEur, 'DE'], + ['gift', MOCK_PRICES.gift1MonthEur, 'FI'], + ['subscription', MOCK_PRICES.monthlyUsd, 'US'], + ['subscription', MOCK_PRICES.monthlyPln, 'PL'], + ] as const)('leaves adaptive pricing unset for a %s checkout with %s', async (path, priceId, country) => { + const session = await checkout(path, priceId, country); + expect(session?.adaptive_pricing).toBeUndefined(); + }); + }); + describe('existing subscribers', () => { test('a Swedish customer billed in EUR keeps the EUR price and is not repriced to SEK', async () => { lookupGeoipMock.mockResolvedValue(geoipCountry('SE')); diff --git a/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts b/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts index 5d62e08df..f7d3a50b5 100644 --- a/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts @@ -9,6 +9,7 @@ import {createGuild, createRole, getMember} from '@app/api/guild/tests/GuildTest import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {UserRepository} from '@app/api/user/repositories/UserRepository'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; @@ -159,5 +160,30 @@ describe('StripePremiumService', () => { expect(me.premium_type).toBe(UserPremiumTypes.LIFETIME); expect(me.premium_lifetime_sequence).toBe(5); }); + test('keeps Visionary number 0 when a lifetime grant comes from a subscription period', async () => { + const account = await createTestAccount(harness); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/premium`) + .body({ + premium_type: UserPremiumTypes.LIFETIME, + premium_lifetime_sequence: 0, + }) + .execute(); + const premiumService = new StripePremiumService( + new UserRepository(), + new NoopGatewayService(), + {} as IGuildRepositoryAggregate, + {} as GuildService, + ); + await premiumService.setPremiumFromSubscriptionPeriod( + createUserID(BigInt(account.userId)), + UserPremiumTypes.LIFETIME, + new Date(Date.now() + 86_400_000), + ); + const me = await createBuilder<{premium_lifetime_sequence: number | null}>(harness, account.token) + .get('/users/@me') + .execute(); + expect(me.premium_lifetime_sequence).toBe(0); + }); }); }); diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts index f7bebf033..b1832ed22 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts @@ -147,7 +147,7 @@ describe('Stripe Webhook Subscription Lifecycle', () => { expect(user.premium_will_cancel).toBe(true); expect(user.premium_until).not.toBeNull(); }); - test('preserves gifted extension when updating subscription', async () => { + test('moves the gifted extension along when the subscription period moves forward', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const subscriptionId = 'sub_test_gifted'; @@ -207,7 +207,9 @@ describe('Stripe Webhook Subscription Lifecycle', () => { expect(premiumUntil.getTime()).toBeGreaterThan(new Date(currentPeriodEnd * 1000).getTime()); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser?.premiumUntil?.toISOString()).toBe(new Date(currentPeriodEnd * 1000).toISOString()); - expect(updatedUser?.premiumGiftExtensionEndsAt?.toISOString()).toBe(giftExtensionEndsAt.toISOString()); + expect(updatedUser?.premiumGiftExtensionEndsAt?.getTime()).toBe( + giftExtensionEndsAt.getTime() + (currentPeriodEnd * 1000 - initialPremiumUntil.getTime()), + ); }); test('does not grant unpaid future time for past_due subscriptions and disables grace', async () => { const account = await createTestAccount(harness); diff --git a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts index d1448cba9..bd02ae1e9 100644 --- a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts +++ b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts @@ -6,6 +6,9 @@ import {HttpResponse, http, type RequestHandler} from 'msw'; const STRIPE_API_BASE = 'https://api.stripe.com'; interface CheckoutSessionParams { + adaptive_pricing?: { + enabled?: string; + }; billing_address_collection?: string; customer?: string; customer_email?: string; @@ -322,6 +325,7 @@ interface MockStripeSubscriptionState { latest_invoice: string | null; status: 'active' | 'canceled' | 'incomplete' | 'past_due' | 'trialing'; schedule_id: string | null; + metadata: Record; } interface MockStripeSubscriptionSchedule { @@ -355,7 +359,12 @@ interface MockStripeSubscriptionSchedule { const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const; +const ISK_PRICE_ID_PATTERN = /(?:^|_)isk(?:_|$)/; + function inferPriceIdCurrency(normalizedPriceId: string): string { + if (ISK_PRICE_ID_PATTERN.test(normalizedPriceId)) { + return 'isk'; + } return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd'; } @@ -610,6 +619,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip subscriptionStore.set(subscriptionId, { ...createDefaultSubscriptionState(), ...overrides, + metadata: {...overrides.metadata}, }); } } @@ -829,6 +839,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip latest_invoice: null, status: 'active', schedule_id: null, + metadata: {}, }; } function getOrCreateSubscriptionState(subscriptionId: string): MockStripeSubscriptionState { @@ -893,7 +904,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip ? getPaymentMethod(subState.default_payment_method) : null, livemode: false, - metadata: {}, + metadata: {...subState.metadata}, schedule: subState.schedule_id, start_date: Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60, }; @@ -1389,7 +1400,12 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip } spies.updatedSubscriptions.push({id: id as string, params: updateParams}); const subState = getOrCreateSubscriptionState(id as string); - if (updateParams.trial_end) { + if (updateParams.trial_end === 'now') { + const nowUnix = Math.floor(Date.now() / 1000); + subState.trial_end = null; + subState.current_period_start = nowUnix; + subState.current_period_end = nowUnix + (subState.interval === 'year' ? 365 : 30) * 24 * 60 * 60; + } else if (updateParams.trial_end) { subState.trial_end = Number(updateParams.trial_end); } if ('cancel_at' in updateParams) { @@ -1404,6 +1420,15 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip if (typeof updateParams.default_payment_method === 'string') { subState.default_payment_method = updateParams.default_payment_method; } + if (updateParams.metadata && typeof updateParams.metadata === 'object') { + for (const [key, value] of Object.entries(updateParams.metadata as Record)) { + if (value === '') { + delete subState.metadata[key]; + } else { + subState.metadata[key] = value; + } + } + } const updatedItem = Array.isArray(updateParams.items) ? updateParams.items[0] : null; if (updatedItem && typeof updatedItem === 'object') { if (typeof updatedItem.id === 'string') { @@ -1421,10 +1446,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip } } subscriptionStore.set(id as string, subState); - return HttpResponse.json({ - ...mapSubscriptionStateToStripeSubscription(id as string, subState), - metadata: updateParams.metadata || {}, - }); + return HttpResponse.json(mapSubscriptionStateToStripeSubscription(id as string, subState)); }), http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => { const {id} = params; diff --git a/fluxer_api/src/api/user/GiftExtensionShift.ts b/fluxer_api/src/api/user/GiftExtensionShift.ts new file mode 100644 index 000000000..8b1297f6a --- /dev/null +++ b/fluxer_api/src/api/user/GiftExtensionShift.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function shiftGiftExtensionEnd( + giftEnd: Date | null | undefined, + anchorMs: number, + nextPremiumUntil: Date, +): Date | null { + if (!giftEnd) { + return null; + } + const shiftMs = nextPremiumUntil.getTime() - anchorMs; + if (shiftMs <= 0 || giftEnd.getTime() <= anchorMs) { + return giftEnd; + } + return new Date(giftEnd.getTime() + shiftMs); +} + +export function shiftGiftExtensionPastPremiumUntil( + current: {premiumUntil: Date | null | undefined; giftEnd: Date | null | undefined}, + nextPremiumUntil: Date, + now: Date, + periodStart: Date | null, +): Date | null { + const paidFromMs = Math.min(now.getTime(), periodStart?.getTime() ?? now.getTime()); + const anchorMs = Math.max(paidFromMs, current.premiumUntil?.getTime() ?? 0); + return shiftGiftExtensionEnd(current.giftEnd, anchorMs, nextPremiumUntil); +} diff --git a/fluxer_api/src/api/user/repositories/IUserAccountRepository.ts b/fluxer_api/src/api/user/repositories/IUserAccountRepository.ts index e5957fe8e..2ae780ebf 100644 --- a/fluxer_api/src/api/user/repositories/IUserAccountRepository.ts +++ b/fluxer_api/src/api/user/repositories/IUserAccountRepository.ts @@ -71,6 +71,7 @@ export interface IUserAccountRepository { updates: { premiumWillCancel: boolean; computedPremiumUntil: Date | null; + periodStart: Date | null; }, ): Promise<{ finalVersion: number | null; diff --git a/fluxer_api/src/api/user/repositories/UserAccountRepository.ts b/fluxer_api/src/api/user/repositories/UserAccountRepository.ts index c64165526..30c124029 100644 --- a/fluxer_api/src/api/user/repositories/UserAccountRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserAccountRepository.ts @@ -188,6 +188,7 @@ export class UserAccountRepository implements IUserAccountRepository { updates: { premiumWillCancel: boolean; computedPremiumUntil: Date | null; + periodStart: Date | null; }, ): Promise<{ finalVersion: number | null; diff --git a/fluxer_api/src/api/user/repositories/UserRepository.ts b/fluxer_api/src/api/user/repositories/UserRepository.ts index 42258cf42..a31fe3d89 100644 --- a/fluxer_api/src/api/user/repositories/UserRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserRepository.ts @@ -225,6 +225,7 @@ export class UserRepository implements IUserRepositoryAggregate { updates: { premiumWillCancel: boolean; computedPremiumUntil: Date | null; + periodStart: Date | null; }, ): Promise<{ finalVersion: number | null; diff --git a/fluxer_api/src/api/user/repositories/account/UserAccountRepository.ts b/fluxer_api/src/api/user/repositories/account/UserAccountRepository.ts index d8f1914fd..0c93ca303 100644 --- a/fluxer_api/src/api/user/repositories/account/UserAccountRepository.ts +++ b/fluxer_api/src/api/user/repositories/account/UserAccountRepository.ts @@ -252,6 +252,7 @@ export class UserAccountRepository { updates: { premiumWillCancel: boolean; computedPremiumUntil: Date | null; + periodStart: Date | null; }, ): Promise<{ finalVersion: number | null; diff --git a/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts b/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts index 65be432c7..6cfc33401 100644 --- a/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts +++ b/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts @@ -10,6 +10,7 @@ import {EMPTY_USER_ROW, USER_COLUMNS} from '@app/api/database/types/UserTypes'; import {emitAccountChangedIfRelevant} from '@app/api/infrastructure/activity/AccountChangeEvents'; import {User} from '@app/api/models/User'; import {Users} from '@app/api/Tables'; +import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift'; import {isPendingDeletionBlocked} from '@app/api/user/services/PendingDeletionCoordinator'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants'; @@ -320,6 +321,7 @@ export class UserDataRepository { updates: { premiumWillCancel: boolean; computedPremiumUntil: Date | null; + periodStart: Date | null; }, ): Promise<{ finalVersion: number | null; @@ -329,12 +331,24 @@ export class UserDataRepository { async () => { return fetchOne(FETCH_USER_BY_ID_CQL, {user_id: userId}); }, - (_current) => { + (current) => { const computedPremiumUntil = updates.computedPremiumUntil; const patch: UserPatch = { premium_will_cancel: Db.set(updates.premiumWillCancel), premium_until: computedPremiumUntil ? Db.set(computedPremiumUntil) : Db.clear(), }; + const giftEnd = current?.premium_gift_extension_ends_at ?? null; + if (computedPremiumUntil && giftEnd) { + const shiftedGiftEnd = shiftGiftExtensionPastPremiumUntil( + {premiumUntil: current?.premium_until, giftEnd}, + computedPremiumUntil, + new Date(), + updates.periodStart, + ); + if (shiftedGiftEnd && shiftedGiftEnd.getTime() !== giftEnd.getTime()) { + patch.premium_gift_extension_ends_at = Db.set(shiftedGiftEnd); + } + } return { pk: {user_id: userId}, patch, diff --git a/fluxer_api/src/api/utils/CurrencyUtils.ts b/fluxer_api/src/api/utils/CurrencyUtils.ts index ab9a3c079..e0f497d44 100644 --- a/fluxer_api/src/api/utils/CurrencyUtils.ts +++ b/fluxer_api/src/api/utils/CurrencyUtils.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {Config} from '@app/api/Config'; import { type EffectiveBillingConfig, getEffectiveBillingConfig, @@ -32,13 +33,16 @@ function getEnvCurrencyPreferences(countryCode: string | null | undefined): Arra if (upperCode === 'BR') { return ['BRL', 'USD', 'EUR']; } - if (upperCode === 'DK') { + if (upperCode === 'DK' || upperCode === 'FO' || upperCode === 'GL') { return ['DKK', 'EUR', 'USD']; } if (upperCode === 'IN') { return ['INR', 'USD', 'EUR']; } - if (upperCode === 'NO') { + if (upperCode === 'IS') { + return ['ISK', 'EUR', 'USD']; + } + if (upperCode === 'NO' || upperCode === 'SJ') { return ['NOK', 'EUR', 'USD']; } if (upperCode === 'PL') { @@ -56,9 +60,22 @@ function getEnvCurrencyPreferences(countryCode: string | null | undefined): Arra return ['USD', 'EUR']; } -const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set(['DKK', 'NOK', 'SEK']); +const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set(['DKK', 'ISK', 'NOK', 'SEK']); -const ENV_CATALOG_CURRENCIES = new Set(['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']); +const ADAPTIVE_PRICING_DISABLED_CURRENCIES = new Set(['DKK', 'ISK', 'NOK', 'SEK']); + +const ENV_CATALOG_CURRENCIES = new Set([ + 'USD', + 'EUR', + 'BRL', + 'DKK', + 'INR', + 'ISK', + 'NOK', + 'PLN', + 'SEK', + 'TRY', +]); const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/; @@ -77,6 +94,10 @@ export function getGiftCurrencyPreferences( ); } +export function shouldDisableAdaptivePricing(currency: string): boolean { + return !Config.instance.selfHosted && ADAPTIVE_PRICING_DISABLED_CURRENCIES.has(currency.toUpperCase()); +} + export function isLocalizedCurrency( currency: Currency, config: EffectiveBillingConfig = getEffectiveBillingConfig(), diff --git a/fluxer_api/src/api/utils/tests/CurrencyUtils.test.ts b/fluxer_api/src/api/utils/tests/CurrencyUtils.test.ts index 547541e95..797c470ac 100644 --- a/fluxer_api/src/api/utils/tests/CurrencyUtils.test.ts +++ b/fluxer_api/src/api/utils/tests/CurrencyUtils.test.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils'; +import {Config} from '@app/api/Config'; +import { + getCurrency, + getCurrencyPreferences, + getGiftCurrencyPreferences, + shouldDisableAdaptivePricing, +} from '@app/api/utils/CurrencyUtils'; import {describe, expect, it} from 'vitest'; describe('getCurrency', () => { @@ -79,8 +85,8 @@ describe('getCurrency', () => { it('returns NOK for Norway (EEA but not EU)', () => { expect(getCurrency('NO')).toBe('NOK'); }); - it('returns EUR for Iceland (EEA but not EU)', () => { - expect(getCurrency('IS')).toBe('EUR'); + it('returns ISK for Iceland (EEA but not EU)', () => { + expect(getCurrency('IS')).toBe('ISK'); }); it('returns EUR for Liechtenstein (EEA but not EU)', () => { expect(getCurrency('LI')).toBe('EUR'); @@ -146,8 +152,8 @@ describe('getCurrency', () => { 'SK', 'SI', 'ES', - 'IS', 'LI', + 'AX', ]; for (const country of eeaCountries) { it(`returns EUR for ${country}`, () => { @@ -166,19 +172,62 @@ describe('getCurrency', () => { it('uses local currency for Norway', () => { expect(getCurrency('NO')).toBe('NOK'); }); + it('uses local currency for Iceland', () => { + expect(getCurrency('IS')).toBe('ISK'); + }); + }); + describe('maps Nordic territories to their home currency', () => { + it('returns DKK for the Faroe Islands and Greenland', () => { + expect(getCurrencyPreferences('FO')).toEqual(['DKK', 'EUR', 'USD']); + expect(getCurrencyPreferences('GL')).toEqual(['DKK', 'EUR', 'USD']); + }); + it('returns NOK for Svalbard and Jan Mayen', () => { + expect(getCurrencyPreferences('SJ')).toEqual(['NOK', 'EUR', 'USD']); + }); + it('returns ISK with EUR as the fallback for Iceland', () => { + expect(getCurrencyPreferences('IS')).toEqual(['ISK', 'EUR', 'USD']); + }); + it('returns EUR for Åland', () => { + expect(getCurrencyPreferences('AX')).toEqual(['EUR', 'USD']); + }); + }); +}); + +describe('shouldDisableAdaptivePricing', () => { + it('disables adaptive pricing for the native Nordic currencies', () => { + for (const currency of ['SEK', 'NOK', 'DKK', 'ISK', 'sek']) { + expect(shouldDisableAdaptivePricing(currency)).toBe(true); + } + }); + it('leaves adaptive pricing alone for every other currency', () => { + for (const currency of ['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY']) { + expect(shouldDisableAdaptivePricing(currency)).toBe(false); + } + }); + it('leaves adaptive pricing alone on a self-hosted instance', () => { + const originalSelfHosted = Config.instance.selfHosted; + Config.instance.selfHosted = true; + try { + expect(shouldDisableAdaptivePricing('SEK')).toBe(false); + } finally { + Config.instance.selfHosted = originalSelfHosted; + } }); }); describe('getGiftCurrencyPreferences', () => { - it('never offers a localized currency that is cheaper than the base price', () => { + it('never offers BRL, INR, PLN or TRY gifts', () => { for (const country of ['BR', 'IN', 'PL', 'TR']) { expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]); } }); - it('offers the localized currency where it is not cheaper than the base price', () => { + it('offers the Nordic localized currencies for gifts', () => { expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']); expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']); expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']); + expect(getGiftCurrencyPreferences('IS')).toEqual(['ISK', 'EUR', 'USD']); + expect(getGiftCurrencyPreferences('FO')).toEqual(['DKK', 'EUR', 'USD']); + expect(getGiftCurrencyPreferences('SJ')).toEqual(['NOK', 'EUR', 'USD']); }); it('uses EUR for other EEA countries', () => { expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']); diff --git a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts index 7541b4bed..a4014b2d8 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts @@ -11,9 +11,11 @@ import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeS import { getInvoiceLatestLinePeriodEnd, getPrimarySubscriptionItem, + getSubscriptionCurrentPeriodStart, getSubscriptionPremiumPeriodEnd, getSubscriptionStartDate, } from '@app/api/stripe/StripeSubscriptionPeriod'; +import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift'; import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; @@ -71,6 +73,15 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip } if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) { patch.premium_until = premiumUntil; + const giftEnd = shiftGiftExtensionPastPremiumUntil( + {premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt}, + premiumUntil, + new Date(), + getSubscriptionCurrentPeriodStart(subscription), + ); + if (giftEnd !== user.premiumGiftExtensionEndsAt) { + patch.premium_gift_extension_ends_at = giftEnd; + } } if (user.premiumWillCancel !== premiumWillCancel) { patch.premium_will_cancel = premiumWillCancel; diff --git a/fluxer_app/src/features/app/components/dialogs/components/PlutoniumContent.tsx b/fluxer_app/src/features/app/components/dialogs/components/PlutoniumContent.tsx index ca5acc544..9b7d57875 100644 --- a/fluxer_app/src/features/app/components/dialogs/components/PlutoniumContent.tsx +++ b/fluxer_app/src/features/app/components/dialogs/components/PlutoniumContent.tsx @@ -62,6 +62,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo const [isGiftMode, setIsGiftMode] = useState(defaultGiftMode); const giftSectionRef = useRef(null); const perksSectionRef = useRef(null); + const pricingSectionRef = useRef(null); const countryCode = GeoIP.countryCode; const guilds = Guilds.getGuilds(); const visionaryGuild = useMemo(() => { @@ -94,12 +95,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo handleCancelPendingSubscriptionChange, } = useSubscriptionActions(countryCode); const {loadingRejoinCommunity, handleCommunityButtonClick} = useCommunityActions(visionaryGuild); - const {loadingCheckout, handleSelectPlan} = useCheckoutActions( - priceIds, - countryCode, - subscriptionStatus.isGiftSubscription, - mobileLayoutState.enabled, - ); + const {loadingCheckout, handleSelectPlan} = useCheckoutActions(priceIds, countryCode, mobileLayoutState.enabled, { + hasGiftTime: subscriptionStatus.isGiftSubscription, + }); useEffect(() => { if (!currentUser?.id) return; void PremiumCommands.refreshPremiumState(countryCode ?? undefined); @@ -114,6 +112,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo const billingUnavailable = !shouldShowPremiumFeatures() || (!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions())); + const canSubscribe = purchasesAvailable && subscriptionStatus.canStartSubscription; const purchaseDisabledTooltip = !isClaimed ? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()}) : i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()}); @@ -127,6 +126,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo const scrollToPerks = useCallback(() => { perksSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'}); }, []); + const scrollToPricing = useCallback(() => { + pricingSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'}); + }, []); const navigateToRedeemGift = useCallback(() => { ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'}); }, []); @@ -227,6 +229,8 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo isVisionary={subscriptionStatus.isVisionary} perksDisabled={subscriptionStatus.perksDisabled} isGiftSubscription={subscriptionStatus.isGiftSubscription} + isGiftGrace={subscriptionStatus.isGiftGrace} + canSubscribe={canSubscribe} storeSubscription={subscriptionStatus.storeSubscription} premiumUntil={subscriptionStatus.actualPremiumUntil} billingCycle={subscriptionStatus.billingCycle} @@ -256,6 +260,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo loadingRejoinCommunity={loadingRejoinCommunity} scrollToPerks={scrollToPerks} navigateToRedeemGift={navigateToRedeemGift} + handleStartSubscription={scrollToPricing} handleOpenCustomerPortal={handleOpenCustomerPortal} handleReactivateSubscription={handleReactivateSubscription} handleCancelSubscription={handleCancelSubscriptionConfirmed} @@ -312,21 +317,23 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo ) - ) : !subscriptionStatus.shouldShowPremiumCard ? ( - + ) : canSubscribe ? ( +
+ +
) : ( giftPurchasesAvailable && ( - {!subscriptionStatus.isPremium && purchasesAvailable && ( + {canSubscribe && ( void; navigateToRedeemGift: () => void; + handleStartSubscription: () => void; handleOpenCustomerPortal: () => void; handleReactivateSubscription: () => void; handleCancelSubscription: () => void; @@ -144,6 +147,8 @@ export const SubscriptionCard: React.FC = observer( isVisionary, perksDisabled, isGiftSubscription, + isGiftGrace, + canSubscribe, storeSubscription, premiumUntil, billingCycle, @@ -173,6 +178,7 @@ export const SubscriptionCard: React.FC = observer( loadingRejoinCommunity, scrollToPerks, navigateToRedeemGift, + handleStartSubscription, handleOpenCustomerPortal, handleReactivateSubscription, handleCancelSubscription, @@ -497,6 +503,22 @@ export const SubscriptionCard: React.FC = observer( ); })() + ) : isGiftGrace ? ( + (() => { + const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined; + return ( + + Your gift time ended but{' '} + {' '} + stay active until{' '} + {graceDate}. Subscribe + before then to keep them. + + ); + })() ) : isInGracePeriod ? ( (() => { const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined; @@ -773,55 +795,102 @@ export const SubscriptionCard: React.FC = observer( > {i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)} - ) : isGiftSubscription || billingUnavailable ? ( - wrapIfDisabled( - , - 'redeem-gift', - purchaseDisabled, - ) - ) : ( + ) : isGiftSubscription || isGiftGrace || billingUnavailable ? ( <> - {hasEverPurchased && + {canSubscribe && + !billingUnavailable && wrapIfDisabled( , - 'manage-reactivate', - purchaseDisabled && shouldUseReactivateQuickAction, + 'start-subscription', + purchaseDisabled, )} + {wrapIfDisabled( + , + 'redeem-gift', + purchaseDisabled, + )} + + ) : ( + <> + {(isFullyExpired || isInGracePeriod) && canSubscribe + ? wrapIfDisabled( + , + 'resubscribe', + purchaseDisabled, + ) + : hasEverPurchased && + wrapIfDisabled( + , + 'manage-reactivate', + purchaseDisabled && shouldUseReactivateQuickAction, + )} {isVisionary && (