initial commit
This commit is contained in:
149
fluxer_api/src/stripe/ProductRegistry.ts
Normal file
149
fluxer_api/src/stripe/ProductRegistry.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '~/Config';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
|
||||
export enum ProductType {
|
||||
MONTHLY_SUBSCRIPTION = 'monthly_subscription',
|
||||
YEARLY_SUBSCRIPTION = 'yearly_subscription',
|
||||
VISIONARY_LIFETIME = 'visionary_lifetime',
|
||||
GIFT_1_MONTH = 'gift_1_month',
|
||||
GIFT_1_YEAR = 'gift_1_year',
|
||||
GIFT_VISIONARY = 'gift_visionary',
|
||||
}
|
||||
|
||||
export interface ProductInfo {
|
||||
type: ProductType;
|
||||
premiumType: 1 | 2;
|
||||
durationMonths: number;
|
||||
isGift: boolean;
|
||||
billingCycle?: 'monthly' | 'yearly';
|
||||
}
|
||||
|
||||
export class ProductRegistry {
|
||||
private products = new Map<string, ProductInfo>();
|
||||
|
||||
constructor() {
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) return;
|
||||
|
||||
this.registerProduct(prices.monthlyUsd, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
|
||||
this.registerProduct(prices.monthlyEur, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
|
||||
this.registerProduct(prices.yearlyUsd, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
|
||||
this.registerProduct(prices.yearlyEur, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
|
||||
this.registerProduct(prices.visionaryUsd, {
|
||||
type: ProductType.VISIONARY_LIFETIME,
|
||||
premiumType: UserPremiumTypes.LIFETIME,
|
||||
durationMonths: 0,
|
||||
isGift: false,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.visionaryEur, {
|
||||
type: ProductType.VISIONARY_LIFETIME,
|
||||
premiumType: UserPremiumTypes.LIFETIME,
|
||||
durationMonths: 0,
|
||||
isGift: false,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.giftVisionaryUsd, {
|
||||
type: ProductType.VISIONARY_LIFETIME,
|
||||
premiumType: UserPremiumTypes.LIFETIME,
|
||||
durationMonths: 0,
|
||||
isGift: true,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.giftVisionaryEur, {
|
||||
type: ProductType.VISIONARY_LIFETIME,
|
||||
premiumType: UserPremiumTypes.LIFETIME,
|
||||
durationMonths: 0,
|
||||
isGift: true,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.gift1MonthUsd, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.gift1MonthEur, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.gift1YearUsd, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
});
|
||||
|
||||
this.registerProduct(prices.gift1YearEur, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
});
|
||||
}
|
||||
|
||||
private registerProduct(priceId: string | undefined, info: ProductInfo): void {
|
||||
if (priceId) {
|
||||
this.products.set(priceId, info);
|
||||
}
|
||||
}
|
||||
|
||||
getProduct(priceId: string): ProductInfo | null {
|
||||
return this.products.get(priceId) || null;
|
||||
}
|
||||
|
||||
isRecurringSubscription(info: ProductInfo): boolean {
|
||||
return !info.isGift && info.premiumType === UserPremiumTypes.SUBSCRIPTION;
|
||||
}
|
||||
}
|
||||
198
fluxer_api/src/stripe/StripeController.ts
Normal file
198
fluxer_api/src/stripe/StripeController.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {StripeWebhookSignatureMissingError} from '~/Errors';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, z} from '~/Schema';
|
||||
import {CreateCheckoutSessionRequest, mapGiftCodeToMetadataResponse, mapGiftCodeToResponse} from '~/stripe/StripeModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const StripeController = (app: HonoApp) => {
|
||||
app.post('/stripe/webhook', async (ctx) => {
|
||||
const signature = ctx.req.header('stripe-signature');
|
||||
if (!signature) {
|
||||
throw new StripeWebhookSignatureMissingError();
|
||||
}
|
||||
const body = await ctx.req.text();
|
||||
await ctx.get('stripeService').handleWebhook({body, signature});
|
||||
return ctx.json({received: true});
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/stripe/checkout/subscription',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', CreateCheckoutSessionRequest),
|
||||
async (ctx) => {
|
||||
const {price_id} = ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
const checkoutUrl = await ctx.get('stripeService').createCheckoutSession({
|
||||
userId,
|
||||
priceId: price_id,
|
||||
isGift: false,
|
||||
});
|
||||
return ctx.json({url: checkoutUrl});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/stripe/checkout/gift',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_GIFT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', CreateCheckoutSessionRequest),
|
||||
async (ctx) => {
|
||||
const {price_id} = ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
const checkoutUrl = await ctx.get('stripeService').createCheckoutSession({
|
||||
userId,
|
||||
priceId: price_id,
|
||||
isGift: true,
|
||||
});
|
||||
return ctx.json({url: checkoutUrl});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/gifts/:code',
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_GET),
|
||||
Validator('param', z.object({code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {code} = ctx.req.valid('param');
|
||||
const giftCode = await ctx.get('stripeService').getGiftCode(code);
|
||||
const response = await mapGiftCodeToResponse({
|
||||
giftCode,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
includeCreator: true,
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/gifts/:code/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_REDEEM),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {code} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
await ctx.get('stripeService').redeemGiftCode(userId, code);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/gifts',
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFTS_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const gifts = await ctx.get('stripeService').getUserGifts(userId);
|
||||
const responses = await Promise.all(
|
||||
gifts.map((gift) =>
|
||||
mapGiftCodeToMetadataResponse({
|
||||
giftCode: gift,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/premium/visionary/slots', RateLimitMiddleware(RateLimitConfigs.STRIPE_VISIONARY_SLOTS), async (ctx) => {
|
||||
const slots = await ctx.get('stripeService').getVisionarySlots();
|
||||
return ctx.json(slots);
|
||||
});
|
||||
|
||||
app.get('/premium/price-ids', RateLimitMiddleware(RateLimitConfigs.STRIPE_PRICE_IDS), async (ctx) => {
|
||||
const countryCode = ctx.req.query('country_code');
|
||||
const priceIds = await ctx.get('stripeService').getPriceIds(countryCode);
|
||||
return ctx.json(priceIds);
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/premium/customer-portal',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CUSTOMER_PORTAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const url = await ctx.get('stripeService').createCustomerPortalSession(userId);
|
||||
return ctx.json({url});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/premium/cancel-subscription',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CANCEL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
await ctx.get('stripeService').cancelSubscriptionAtPeriodEnd(userId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/premium/reactivate-subscription',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_REACTIVATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
await ctx.get('stripeService').reactivateSubscription(userId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/premium/visionary/rejoin',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_VISIONARY_REJOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
await ctx.get('stripeService').rejoinVisionariesGuild(userId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/premium/operator/rejoin',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_VISIONARY_REJOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
await ctx.get('stripeService').rejoinOperatorsGuild(userId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
117
fluxer_api/src/stripe/StripeModel.ts
Normal file
117
fluxer_api/src/stripe/StripeModel.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {GiftCode} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {createStringType, z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
|
||||
import {UserPartialResponse} from '~/user/UserModel';
|
||||
|
||||
export const CreateCheckoutSessionRequest = z.object({
|
||||
price_id: createStringType(),
|
||||
});
|
||||
|
||||
export type CreateCheckoutSessionRequest = z.infer<typeof CreateCheckoutSessionRequest>;
|
||||
|
||||
export const GiftCodeResponse = z.object({
|
||||
code: z.string(),
|
||||
duration_months: z.number().int(),
|
||||
redeemed: z.boolean(),
|
||||
created_by: z.lazy(() => UserPartialResponse).nullish(),
|
||||
});
|
||||
|
||||
export type GiftCodeResponse = z.infer<typeof GiftCodeResponse>;
|
||||
|
||||
export const GiftCodeMetadataResponse = z.object({
|
||||
code: z.string(),
|
||||
duration_months: z.number().int(),
|
||||
created_at: z.iso.datetime(),
|
||||
created_by: z.lazy(() => UserPartialResponse),
|
||||
redeemed_at: z.iso.datetime().nullish(),
|
||||
redeemed_by: z.lazy(() => UserPartialResponse).nullish(),
|
||||
});
|
||||
|
||||
export type GiftCodeMetadataResponse = z.infer<typeof GiftCodeMetadataResponse>;
|
||||
|
||||
interface MapGiftCodeToResponseParams {
|
||||
giftCode: GiftCode;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
includeCreator?: boolean;
|
||||
}
|
||||
|
||||
interface MapGiftCodeToMetadataResponseParams {
|
||||
giftCode: GiftCode;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
export const mapGiftCodeToResponse = async ({
|
||||
giftCode,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
includeCreator = false,
|
||||
}: MapGiftCodeToResponseParams): Promise<GiftCodeResponse> => {
|
||||
let createdBy = null;
|
||||
if (includeCreator) {
|
||||
createdBy = await getCachedUserPartialResponse({
|
||||
userId: giftCode.createdByUserId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
code: giftCode.code,
|
||||
duration_months: giftCode.durationMonths,
|
||||
redeemed: !!giftCode.redeemedAt,
|
||||
created_by: createdBy,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapGiftCodeToMetadataResponse = async ({
|
||||
giftCode,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: MapGiftCodeToMetadataResponseParams): Promise<GiftCodeMetadataResponse> => {
|
||||
const [createdBy, redeemedBy] = await Promise.all([
|
||||
getCachedUserPartialResponse({
|
||||
userId: giftCode.createdByUserId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
giftCode.redeemedByUserId
|
||||
? getCachedUserPartialResponse({
|
||||
userId: giftCode.redeemedByUserId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
return {
|
||||
code: giftCode.code,
|
||||
duration_months: giftCode.durationMonths,
|
||||
created_at: giftCode.createdAt.toISOString(),
|
||||
created_by: createdBy,
|
||||
redeemed_at: giftCode.redeemedAt?.toISOString() ?? null,
|
||||
redeemed_by: redeemedBy,
|
||||
};
|
||||
};
|
||||
160
fluxer_api/src/stripe/StripeService.ts
Normal file
160
fluxer_api/src/stripe/StripeService.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Stripe from 'stripe';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {GiftCode} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {Currency} from '~/utils/CurrencyUtils';
|
||||
import {ProductRegistry} from './ProductRegistry';
|
||||
import type {CreateCheckoutSessionParams} from './services/StripeCheckoutService';
|
||||
import {StripeCheckoutService} from './services/StripeCheckoutService';
|
||||
import {StripeGiftService} from './services/StripeGiftService';
|
||||
import {StripePremiumService} from './services/StripePremiumService';
|
||||
import {StripeSubscriptionService} from './services/StripeSubscriptionService';
|
||||
import type {HandleWebhookParams} from './services/StripeWebhookService';
|
||||
import {StripeWebhookService} from './services/StripeWebhookService';
|
||||
|
||||
export class StripeService {
|
||||
private stripe: Stripe | null = null;
|
||||
private productRegistry: ProductRegistry;
|
||||
private checkoutService: StripeCheckoutService;
|
||||
private subscriptionService: StripeSubscriptionService;
|
||||
private giftService: StripeGiftService;
|
||||
private premiumService: StripePremiumService;
|
||||
private webhookService: StripeWebhookService;
|
||||
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private authService: AuthService,
|
||||
private gatewayService: IGatewayService,
|
||||
private emailService: IEmailService,
|
||||
private guildRepository: IGuildRepository,
|
||||
private guildService: GuildService,
|
||||
private cacheService: ICacheService,
|
||||
) {
|
||||
this.productRegistry = new ProductRegistry();
|
||||
|
||||
if (Config.stripe.enabled && Config.stripe.secretKey) {
|
||||
this.stripe = new Stripe(Config.stripe.secretKey, {
|
||||
apiVersion: '2025-12-15.clover',
|
||||
});
|
||||
}
|
||||
|
||||
this.premiumService = new StripePremiumService(
|
||||
this.userRepository,
|
||||
this.gatewayService,
|
||||
this.guildRepository,
|
||||
this.guildService,
|
||||
);
|
||||
|
||||
this.checkoutService = new StripeCheckoutService(this.stripe, this.userRepository, this.productRegistry);
|
||||
|
||||
this.subscriptionService = new StripeSubscriptionService(
|
||||
this.stripe,
|
||||
this.userRepository,
|
||||
this.cacheService,
|
||||
this.gatewayService,
|
||||
);
|
||||
|
||||
this.giftService = new StripeGiftService(
|
||||
this.stripe,
|
||||
this.userRepository,
|
||||
this.cacheService,
|
||||
this.gatewayService,
|
||||
this.checkoutService,
|
||||
this.premiumService,
|
||||
this.subscriptionService,
|
||||
);
|
||||
|
||||
this.webhookService = new StripeWebhookService(
|
||||
this.stripe,
|
||||
this.userRepository,
|
||||
this.authService,
|
||||
this.emailService,
|
||||
this.gatewayService,
|
||||
this.productRegistry,
|
||||
this.giftService,
|
||||
this.premiumService,
|
||||
);
|
||||
}
|
||||
|
||||
async createCheckoutSession(params: CreateCheckoutSessionParams): Promise<string> {
|
||||
return this.checkoutService.createCheckoutSession(params);
|
||||
}
|
||||
|
||||
async createCustomerPortalSession(userId: UserID): Promise<string> {
|
||||
return this.checkoutService.createCustomerPortalSession(userId);
|
||||
}
|
||||
|
||||
getPriceIds(countryCode?: string): {
|
||||
monthly: string | null;
|
||||
yearly: string | null;
|
||||
visionary: string | null;
|
||||
giftVisionary: string | null;
|
||||
gift1Month: string | null;
|
||||
gift1Year: string | null;
|
||||
currency: Currency;
|
||||
} {
|
||||
return this.checkoutService.getPriceIds(countryCode);
|
||||
}
|
||||
|
||||
async cancelSubscriptionAtPeriodEnd(userId: UserID): Promise<void> {
|
||||
return this.subscriptionService.cancelSubscriptionAtPeriodEnd(userId);
|
||||
}
|
||||
|
||||
async reactivateSubscription(userId: UserID): Promise<void> {
|
||||
return this.subscriptionService.reactivateSubscription(userId);
|
||||
}
|
||||
|
||||
async getGiftCode(code: string): Promise<GiftCode> {
|
||||
return this.giftService.getGiftCode(code);
|
||||
}
|
||||
|
||||
async redeemGiftCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.giftService.redeemGiftCode(userId, code);
|
||||
}
|
||||
|
||||
async getUserGifts(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.giftService.getUserGifts(userId);
|
||||
}
|
||||
|
||||
async getVisionarySlots(): Promise<{total: number; remaining: number}> {
|
||||
return this.premiumService.getVisionarySlots();
|
||||
}
|
||||
|
||||
async rejoinVisionariesGuild(userId: UserID): Promise<void> {
|
||||
return this.premiumService.rejoinVisionariesGuild(userId);
|
||||
}
|
||||
|
||||
async rejoinOperatorsGuild(userId: UserID): Promise<void> {
|
||||
return this.premiumService.rejoinOperatorsGuild(userId);
|
||||
}
|
||||
|
||||
async handleWebhook(params: HandleWebhookParams): Promise<void> {
|
||||
return this.webhookService.handleWebhook(params);
|
||||
}
|
||||
}
|
||||
34
fluxer_api/src/stripe/StripeUtils.ts
Normal file
34
fluxer_api/src/stripe/StripeUtils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function extractId(value: string | {id: string} | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') return value || null;
|
||||
return value.id || null;
|
||||
}
|
||||
|
||||
export function addMonthsClamp(date: Date, months: number): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getDate();
|
||||
d.setMonth(d.getMonth() + months);
|
||||
if (d.getDate() < day) {
|
||||
d.setDate(0);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
47
fluxer_api/src/stripe/VisionarySlotInitializer.ts
Normal file
47
fluxer_api/src/stripe/VisionarySlotInitializer.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '~/Config';
|
||||
import {Logger} from '~/Logger';
|
||||
import {VisionarySlotRepository} from '~/user/repositories/VisionarySlotRepository';
|
||||
|
||||
const DEFAULT_SLOT_COUNT = 100;
|
||||
|
||||
export class VisionarySlotInitializer {
|
||||
async initialize(): Promise<void> {
|
||||
if (!Config.dev.testModeEnabled || !Config.stripe.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = new VisionarySlotRepository();
|
||||
const existingSlots = await repository.listVisionarySlots();
|
||||
|
||||
if (existingSlots.length === 0) {
|
||||
Logger.info(`[VisionarySlotInitializer] Creating ${DEFAULT_SLOT_COUNT} test visionary slots...`);
|
||||
await repository.expandVisionarySlots(DEFAULT_SLOT_COUNT);
|
||||
Logger.info(`[VisionarySlotInitializer] Successfully created ${DEFAULT_SLOT_COUNT} visionary slots`);
|
||||
} else {
|
||||
Logger.info(`[VisionarySlotInitializer] Found ${existingSlots.length} existing slots, skipping initialization`);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error}, '[VisionarySlotInitializer] Failed to create visionary slots');
|
||||
}
|
||||
}
|
||||
}
|
||||
251
fluxer_api/src/stripe/services/StripeCheckoutService.ts
Normal file
251
fluxer_api/src/stripe/services/StripeCheckoutService.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {UserFlags, UserPremiumTypes} from '~/Constants';
|
||||
import {NoVisionarySlotsAvailableError, PremiumPurchaseBlockedError, StripeError, UnknownUserError} from '~/Errors';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {type Currency, getCurrency} from '~/utils/CurrencyUtils';
|
||||
import type {ProductRegistry} from '../ProductRegistry';
|
||||
|
||||
const FIRST_REFUND_BLOCK_DAYS = 30;
|
||||
|
||||
export interface CreateCheckoutSessionParams {
|
||||
userId: UserID;
|
||||
priceId: string;
|
||||
isGift?: boolean;
|
||||
}
|
||||
|
||||
export class StripeCheckoutService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private userRepository: IUserRepository,
|
||||
private productRegistry: ProductRegistry,
|
||||
) {}
|
||||
|
||||
async createCheckoutSession({userId, priceId, isGift = false}: CreateCheckoutSessionParams): Promise<string> {
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Payment processing is not available');
|
||||
}
|
||||
|
||||
const productInfo = this.productRegistry.getProduct(priceId);
|
||||
if (!productInfo) {
|
||||
Logger.error({priceId, userId}, 'Invalid or unknown price ID');
|
||||
throw new StripeError('Invalid product selection');
|
||||
}
|
||||
|
||||
if (productInfo.isGift !== isGift) {
|
||||
Logger.error(
|
||||
{priceId, userId, expectedIsGift: productInfo.isGift, providedIsGift: isGift},
|
||||
'Gift parameter mismatch',
|
||||
);
|
||||
throw new StripeError('Invalid product configuration');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (
|
||||
user.premiumType === UserPremiumTypes.LIFETIME &&
|
||||
this.productRegistry.isRecurringSubscription(productInfo) &&
|
||||
!productInfo.isGift
|
||||
) {
|
||||
throw new PremiumPurchaseBlockedError();
|
||||
}
|
||||
|
||||
this.validateUserCanPurchase(user);
|
||||
|
||||
if (productInfo.premiumType === UserPremiumTypes.LIFETIME) {
|
||||
const allSlots = await this.userRepository.listVisionarySlots();
|
||||
const unreservedSlots = allSlots.filter((slot) => !slot.isReserved());
|
||||
|
||||
if (unreservedSlots.length === 0) {
|
||||
throw new NoVisionarySlotsAvailableError();
|
||||
}
|
||||
|
||||
if (unreservedSlots.length < 10) {
|
||||
Logger.warn({remainingSlots: unreservedSlots.length, userId, isGift}, 'Visionary slots running low');
|
||||
}
|
||||
}
|
||||
|
||||
const customerId = await this.ensureStripeCustomer(user);
|
||||
|
||||
const checkoutMode: Stripe.Checkout.SessionCreateParams.Mode = this.productRegistry.isRecurringSubscription(
|
||||
productInfo,
|
||||
)
|
||||
? 'subscription'
|
||||
: 'payment';
|
||||
|
||||
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: checkoutMode,
|
||||
success_url: `${Config.endpoints.webApp}/premium-callback?status=success`,
|
||||
cancel_url: `${Config.endpoints.webApp}/premium-callback?status=cancel`,
|
||||
...(checkoutMode === 'payment'
|
||||
? {
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.create(checkoutParams);
|
||||
|
||||
await this.userRepository.createPayment({
|
||||
checkout_session_id: session.id,
|
||||
user_id: userId,
|
||||
price_id: priceId,
|
||||
product_type: productInfo.type,
|
||||
status: 'pending',
|
||||
is_gift: isGift,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
Logger.debug({userId, sessionId: session.id, productType: productInfo.type}, 'Checkout session created');
|
||||
|
||||
return session.url!;
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error, userId}, 'Failed to create Stripe checkout session');
|
||||
const message = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||
throw new StripeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
async createCustomerPortalSession(userId: UserID): Promise<string> {
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Payment processing is not available');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
throw new StripeError('No purchase history found - customer portal not available');
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${Config.endpoints.webApp}/premium-callback?status=closed-billing-portal`,
|
||||
});
|
||||
|
||||
return session.url;
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error, userId, customerId: user.stripeCustomerId}, 'Failed to create customer portal session');
|
||||
const message = error instanceof Error ? error.message : 'Failed to create customer portal session';
|
||||
throw new StripeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
getPriceIds(countryCode?: string): {
|
||||
monthly: string | null;
|
||||
yearly: string | null;
|
||||
visionary: string | null;
|
||||
giftVisionary: string | null;
|
||||
gift1Month: string | null;
|
||||
gift1Year: string | null;
|
||||
currency: Currency;
|
||||
} {
|
||||
const currency = getCurrency(countryCode);
|
||||
const prices = Config.stripe.prices;
|
||||
|
||||
if (currency === 'EUR') {
|
||||
return {
|
||||
monthly: prices?.monthlyEur ?? null,
|
||||
yearly: prices?.yearlyEur ?? null,
|
||||
visionary: prices?.visionaryEur ?? null,
|
||||
giftVisionary: prices?.giftVisionaryEur ?? null,
|
||||
gift1Month: prices?.gift1MonthEur ?? null,
|
||||
gift1Year: prices?.gift1YearEur ?? null,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monthly: prices?.monthlyUsd ?? null,
|
||||
yearly: prices?.yearlyUsd ?? null,
|
||||
visionary: prices?.visionaryUsd ?? null,
|
||||
giftVisionary: prices?.giftVisionaryUsd ?? null,
|
||||
gift1Month: prices?.gift1MonthUsd ?? null,
|
||||
gift1Year: prices?.gift1YearUsd ?? null,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
validateUserCanPurchase(user: User): void {
|
||||
if (user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED) {
|
||||
throw new PremiumPurchaseBlockedError();
|
||||
}
|
||||
|
||||
if (user.firstRefundAt) {
|
||||
const daysSinceFirstRefund = Math.floor((Date.now() - user.firstRefundAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysSinceFirstRefund < FIRST_REFUND_BLOCK_DAYS) {
|
||||
throw new PremiumPurchaseBlockedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureStripeCustomer(user: User): Promise<string> {
|
||||
if (user.stripeCustomerId) {
|
||||
return user.stripeCustomerId;
|
||||
}
|
||||
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Payment processing is not available');
|
||||
}
|
||||
|
||||
const customer = await this.stripe.customers.create({
|
||||
email: user.email ?? undefined,
|
||||
metadata: {
|
||||
userId: user.id.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.userRepository.patchUpsert(user.id, {
|
||||
stripe_customer_id: customer.id,
|
||||
});
|
||||
|
||||
Logger.debug({userId: user.id, customerId: customer.id}, 'Stripe customer created');
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
}
|
||||
255
fluxer_api/src/stripe/services/StripeGiftService.ts
Normal file
255
fluxer_api/src/stripe/services/StripeGiftService.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
import {
|
||||
CannotRedeemPlutoniumWithVisionaryError,
|
||||
GiftCodeAlreadyRedeemedError,
|
||||
StripeError,
|
||||
UnknownGiftCodeError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {GiftCode, User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
import type {ProductInfo} from '../ProductRegistry';
|
||||
import {ProductType} from '../ProductRegistry';
|
||||
import type {StripeCheckoutService} from './StripeCheckoutService';
|
||||
import type {StripePremiumService} from './StripePremiumService';
|
||||
import type {StripeSubscriptionService} from './StripeSubscriptionService';
|
||||
|
||||
export class StripeGiftService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private userRepository: IUserRepository,
|
||||
private cacheService: ICacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
private checkoutService: StripeCheckoutService,
|
||||
private premiumService: StripePremiumService,
|
||||
private subscriptionService: StripeSubscriptionService,
|
||||
) {}
|
||||
|
||||
async getGiftCode(code: string): Promise<GiftCode> {
|
||||
const giftCode = await this.userRepository.findGiftCode(code);
|
||||
if (!giftCode) {
|
||||
throw new UnknownGiftCodeError();
|
||||
}
|
||||
return giftCode;
|
||||
}
|
||||
|
||||
async redeemGiftCode(userId: UserID, code: string): Promise<void> {
|
||||
const inflightKey = `gift_redeem_inflight:${code}`;
|
||||
const appliedKey = `gift_redeem_applied:${code}`;
|
||||
|
||||
if (await this.cacheService.get<boolean>(appliedKey)) {
|
||||
throw new GiftCodeAlreadyRedeemedError();
|
||||
}
|
||||
if (await this.cacheService.get<boolean>(inflightKey)) {
|
||||
throw new StripeError('Gift code redemption in progress. Please try again in a moment.');
|
||||
}
|
||||
await this.cacheService.set(inflightKey, 60);
|
||||
|
||||
try {
|
||||
const giftCode = await this.userRepository.findGiftCode(code);
|
||||
if (!giftCode) {
|
||||
throw new UnknownGiftCodeError();
|
||||
}
|
||||
|
||||
if (giftCode.redeemedByUserId) {
|
||||
await this.cacheService.set(appliedKey, 365 * 24 * 60 * 60);
|
||||
throw new GiftCodeAlreadyRedeemedError();
|
||||
}
|
||||
|
||||
if (await this.cacheService.get<boolean>(`redeemed_gift_codes:${code}`)) {
|
||||
await this.cacheService.set(appliedKey, 365 * 24 * 60 * 60);
|
||||
throw new GiftCodeAlreadyRedeemedError();
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
this.checkoutService.validateUserCanPurchase(user);
|
||||
|
||||
if (user.premiumType === UserPremiumTypes.LIFETIME) {
|
||||
throw new CannotRedeemPlutoniumWithVisionaryError();
|
||||
}
|
||||
|
||||
const premiumType = giftCode.durationMonths === 0 ? UserPremiumTypes.LIFETIME : UserPremiumTypes.SUBSCRIPTION;
|
||||
if (premiumType === UserPremiumTypes.LIFETIME && user.stripeSubscriptionId && this.stripe) {
|
||||
await this.cancelStripeSubscriptionImmediately(user);
|
||||
}
|
||||
|
||||
if (premiumType === UserPremiumTypes.SUBSCRIPTION && user.stripeSubscriptionId && this.stripe) {
|
||||
await this.subscriptionService.extendSubscriptionWithTrialPhase(user, giftCode.durationMonths, code);
|
||||
} else if (premiumType === UserPremiumTypes.LIFETIME && giftCode.visionarySequenceNumber != null) {
|
||||
const GIFT_CODE_SENTINEL_USER_ID = createUserID(-1n);
|
||||
await this.userRepository.unreserveVisionarySlot(giftCode.visionarySequenceNumber, GIFT_CODE_SENTINEL_USER_ID);
|
||||
await this.premiumService.grantPremiumFromGift(
|
||||
userId,
|
||||
premiumType,
|
||||
giftCode.durationMonths,
|
||||
giftCode.visionarySequenceNumber,
|
||||
);
|
||||
await this.userRepository.reserveVisionarySlot(giftCode.visionarySequenceNumber, userId);
|
||||
} else {
|
||||
await this.premiumService.grantPremium(userId, premiumType, giftCode.durationMonths, null, false);
|
||||
}
|
||||
|
||||
const redeemResult = await this.userRepository.redeemGiftCode(code, userId);
|
||||
if (!redeemResult.applied) {
|
||||
throw new GiftCodeAlreadyRedeemedError();
|
||||
}
|
||||
|
||||
await this.cacheService.set(`redeemed_gift_codes:${code}`, 300);
|
||||
await this.cacheService.set(appliedKey, 365 * 24 * 60 * 60);
|
||||
|
||||
Logger.debug({userId, giftCode: code, durationMonths: giftCode.durationMonths}, 'Gift code redeemed');
|
||||
} finally {
|
||||
await this.cacheService.delete(inflightKey);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserGifts(userId: UserID): Promise<Array<GiftCode>> {
|
||||
const gifts = await this.userRepository.findGiftCodesByCreator(userId);
|
||||
return gifts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async createGiftCode(
|
||||
checkoutSessionId: string,
|
||||
purchaser: User,
|
||||
productInfo: ProductInfo,
|
||||
paymentIntentId: string | null,
|
||||
): Promise<void> {
|
||||
const payment = await this.userRepository.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
if (!payment) {
|
||||
Logger.error({checkoutSessionId}, 'Payment not found for gift code creation');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.giftCode) {
|
||||
Logger.debug({checkoutSessionId, code: payment.giftCode}, 'Gift code already exists for checkout session');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = await this.generateUniqueGiftCode();
|
||||
let visionarySequenceNumber: number | null = null;
|
||||
|
||||
if (productInfo.type === ProductType.GIFT_VISIONARY) {
|
||||
const allSlots = await this.userRepository.listVisionarySlots();
|
||||
const unreservedSlot = allSlots.sort((a, b) => a.slotIndex - b.slotIndex).find((slot) => !slot.isReserved());
|
||||
|
||||
if (!unreservedSlot) {
|
||||
const maxSlotIndex = allSlots.length > 0 ? Math.max(...allSlots.map((s) => s.slotIndex)) : -1;
|
||||
const newSlotIndex = maxSlotIndex + 1;
|
||||
|
||||
await this.userRepository.expandVisionarySlots(1);
|
||||
visionarySequenceNumber = newSlotIndex;
|
||||
|
||||
Logger.warn(
|
||||
{purchaserId: purchaser.id, newSlotIndex, totalSlots: allSlots.length + 1},
|
||||
'Auto-expanded visionary slots for gift code',
|
||||
);
|
||||
} else {
|
||||
visionarySequenceNumber = unreservedSlot.slotIndex;
|
||||
}
|
||||
|
||||
const GIFT_CODE_SENTINEL_USER_ID = createUserID(-1n);
|
||||
await this.userRepository.reserveVisionarySlot(visionarySequenceNumber, GIFT_CODE_SENTINEL_USER_ID);
|
||||
|
||||
Logger.debug(
|
||||
{code, purchaserId: purchaser.id, sequenceNumber: visionarySequenceNumber},
|
||||
'Visionary sequence number allocated for gift',
|
||||
);
|
||||
}
|
||||
|
||||
await this.userRepository.createGiftCode({
|
||||
code,
|
||||
duration_months: productInfo.durationMonths,
|
||||
created_at: new Date(),
|
||||
created_by_user_id: purchaser.id,
|
||||
redeemed_at: null,
|
||||
redeemed_by_user_id: null,
|
||||
stripe_payment_intent_id: paymentIntentId,
|
||||
visionary_sequence_number: visionarySequenceNumber,
|
||||
checkout_session_id: checkoutSessionId,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.userRepository.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
|
||||
await this.userRepository.updatePayment({
|
||||
...payment.toRow(),
|
||||
gift_code: code,
|
||||
});
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(purchaser.id, {
|
||||
gift_inventory_server_seq: (purchaser.giftInventoryServerSeq ?? 0) + 1,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{code, purchaserId: purchaser.id, durationMonths: productInfo.durationMonths, productType: productInfo.type},
|
||||
'Gift code created',
|
||||
);
|
||||
}
|
||||
|
||||
private async generateUniqueGiftCode(): Promise<string> {
|
||||
let code: string;
|
||||
let exists = true;
|
||||
|
||||
while (exists) {
|
||||
code = RandomUtils.randomString(32);
|
||||
const existing = await this.userRepository.findGiftCode(code);
|
||||
exists = !!existing;
|
||||
}
|
||||
|
||||
return code!;
|
||||
}
|
||||
|
||||
private async cancelStripeSubscriptionImmediately(user: User): Promise<void> {
|
||||
if (!this.stripe || !user.stripeSubscriptionId) return;
|
||||
await this.stripe.subscriptions.cancel(user.stripeSubscriptionId, {invoice_now: false, prorate: false});
|
||||
const updatedUser = await this.userRepository.patchUpsert(user.id, {
|
||||
stripe_subscription_id: null,
|
||||
premium_billing_cycle: null,
|
||||
premium_will_cancel: false,
|
||||
});
|
||||
if (updatedUser) await this.dispatchUser(updatedUser);
|
||||
Logger.debug({userId: user.id}, 'Canceled active subscription due to lifetime grant');
|
||||
}
|
||||
|
||||
private async dispatchUser(user: User): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
246
fluxer_api/src/stripe/services/StripePremiumService.ts
Normal file
246
fluxer_api/src/stripe/services/StripePremiumService.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, type UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import {addMonthsClamp} from '../StripeUtils';
|
||||
|
||||
export class StripePremiumService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private guildRepository: IGuildRepository,
|
||||
private guildService: GuildService,
|
||||
) {}
|
||||
|
||||
async grantPremium(
|
||||
userId: UserID,
|
||||
premiumType: 1 | 2,
|
||||
durationMonths: number,
|
||||
billingCycle: string | null = null,
|
||||
hasEverPurchased: boolean = false,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let premiumUntil: Date | null = null;
|
||||
let visionarySequence: number | null = user.premiumLifetimeSequence;
|
||||
|
||||
if (durationMonths > 0) {
|
||||
const currentPremiumUntil = user.premiumUntil && user.premiumUntil > now ? user.premiumUntil : now;
|
||||
premiumUntil = addMonthsClamp(currentPremiumUntil, durationMonths);
|
||||
}
|
||||
|
||||
if (premiumType === UserPremiumTypes.LIFETIME && !visionarySequence) {
|
||||
const allSlots = await this.userRepository.listVisionarySlots();
|
||||
|
||||
const myReservedSlot = allSlots
|
||||
.slice()
|
||||
.sort((a, b) => a.slotIndex - b.slotIndex)
|
||||
.find((slot) => slot.userId === userId);
|
||||
|
||||
if (myReservedSlot) {
|
||||
visionarySequence = myReservedSlot.slotIndex;
|
||||
} else {
|
||||
const unreservedSlot = allSlots
|
||||
.slice()
|
||||
.sort((a, b) => a.slotIndex - b.slotIndex)
|
||||
.find((slot) => !slot.isReserved());
|
||||
|
||||
if (!unreservedSlot) {
|
||||
const maxSlotIndex = allSlots.length > 0 ? Math.max(...allSlots.map((s) => s.slotIndex)) : -1;
|
||||
const newSlotIndex = maxSlotIndex + 1;
|
||||
|
||||
await this.userRepository.expandVisionarySlots(1);
|
||||
visionarySequence = newSlotIndex;
|
||||
await this.userRepository.reserveVisionarySlot(newSlotIndex, userId);
|
||||
|
||||
Logger.warn(
|
||||
{userId, newSlotIndex, totalSlots: allSlots.length + 1},
|
||||
'Auto-expanded visionary slots due to payment completion',
|
||||
);
|
||||
} else {
|
||||
visionarySequence = unreservedSlot.slotIndex;
|
||||
await this.userRepository.reserveVisionarySlot(unreservedSlot.slotIndex, userId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.addToVisionariesGuild(userId);
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(userId, {
|
||||
premium_type: premiumType,
|
||||
premium_since: user.premiumSince || now,
|
||||
premium_until: premiumUntil,
|
||||
premium_lifetime_sequence: visionarySequence,
|
||||
has_ever_purchased: hasEverPurchased,
|
||||
premium_will_cancel: false,
|
||||
premium_billing_cycle: billingCycle,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug({userId, premiumType, durationMonths, visionarySequence, billingCycle}, 'Premium granted to user');
|
||||
}
|
||||
|
||||
async grantPremiumFromGift(
|
||||
userId: UserID,
|
||||
premiumType: 1 | 2,
|
||||
durationMonths: number,
|
||||
visionarySequenceNumber: number,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let premiumUntil: Date | null = null;
|
||||
|
||||
if (durationMonths > 0) {
|
||||
const currentPremiumUntil = user.premiumUntil && user.premiumUntil > now ? user.premiumUntil : now;
|
||||
premiumUntil = addMonthsClamp(currentPremiumUntil, durationMonths);
|
||||
}
|
||||
|
||||
if (premiumType === UserPremiumTypes.LIFETIME) {
|
||||
await this.addToVisionariesGuild(userId);
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(userId, {
|
||||
premium_type: premiumType,
|
||||
premium_since: user.premiumSince || now,
|
||||
premium_until: premiumUntil,
|
||||
premium_lifetime_sequence:
|
||||
premiumType === UserPremiumTypes.LIFETIME ? visionarySequenceNumber : user.premiumLifetimeSequence,
|
||||
premium_will_cancel: false,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{userId, premiumType, durationMonths, lifetimeSequence: visionarySequenceNumber},
|
||||
'Premium granted to user from gift',
|
||||
);
|
||||
}
|
||||
|
||||
async revokePremium(userId: UserID): Promise<void> {
|
||||
const updatedUser = await this.userRepository.patchUpsert(userId, {
|
||||
premium_type: UserPremiumTypes.NONE,
|
||||
premium_until: null,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
async getVisionarySlots(): Promise<{total: number; remaining: number}> {
|
||||
const allSlots = await this.userRepository.listVisionarySlots();
|
||||
const usedSlots = allSlots.filter((s) => s.isReserved());
|
||||
return {total: allSlots.length, remaining: allSlots.length - usedSlots.length};
|
||||
}
|
||||
|
||||
async rejoinVisionariesGuild(userId: UserID): Promise<void> {
|
||||
await this.addToVisionariesGuild(userId);
|
||||
}
|
||||
|
||||
async rejoinOperatorsGuild(userId: UserID): Promise<void> {
|
||||
await this.addToOperatorsGuild(userId);
|
||||
}
|
||||
|
||||
private async addToVisionariesGuild(userId: UserID): Promise<void> {
|
||||
if (!Config.instance.visionariesGuildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const visionariesGuildId = createGuildID(BigInt(Config.instance.visionariesGuildId));
|
||||
const existingMember = await this.guildRepository.getMember(visionariesGuildId, userId);
|
||||
|
||||
if (!existingMember) {
|
||||
await this.guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId: visionariesGuildId,
|
||||
sendJoinMessage: true,
|
||||
skipBanCheck: true,
|
||||
requestCache: {
|
||||
userPartials: new Map(),
|
||||
clear: () => {},
|
||||
},
|
||||
});
|
||||
Logger.debug({userId, guildId: visionariesGuildId}, 'Added visionary user to visionaries guild');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, userId, guildId: Config.instance.visionariesGuildId},
|
||||
'Failed to add user to visionaries guild',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async addToOperatorsGuild(userId: UserID): Promise<void> {
|
||||
if (!Config.instance.operatorsGuildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const operatorsGuildId = createGuildID(BigInt(Config.instance.operatorsGuildId));
|
||||
const existingMember = await this.guildRepository.getMember(operatorsGuildId, userId);
|
||||
|
||||
if (!existingMember) {
|
||||
await this.guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId: operatorsGuildId,
|
||||
sendJoinMessage: true,
|
||||
skipBanCheck: true,
|
||||
requestCache: {
|
||||
userPartials: new Map(),
|
||||
clear: () => {},
|
||||
},
|
||||
});
|
||||
Logger.debug({userId, guildId: operatorsGuildId}, 'Added operator user to operators guild');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId: Config.instance.operatorsGuildId}, 'Failed to add user to operators guild');
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchUser(user: User): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
252
fluxer_api/src/stripe/services/StripeSubscriptionService.ts
Normal file
252
fluxer_api/src/stripe/services/StripeSubscriptionService.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {NoActiveSubscriptionError, StripeError, UnknownUserError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import {addMonthsClamp} from '../StripeUtils';
|
||||
|
||||
export class StripeSubscriptionService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private userRepository: IUserRepository,
|
||||
private cacheService: ICacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
async cancelSubscriptionAtPeriodEnd(userId: UserID): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Payment processing is not available');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeError('No active subscription found');
|
||||
}
|
||||
|
||||
if (user.premiumWillCancel) {
|
||||
throw new StripeError('Subscription is already set to cancel at period end');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stripe.subscriptions.update(user.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(userId, {
|
||||
premium_will_cancel: true,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, 'Subscription set to cancel at period end');
|
||||
} catch (error: unknown) {
|
||||
Logger.error(
|
||||
{error, userId, subscriptionId: user.stripeSubscriptionId},
|
||||
'Failed to cancel subscription at period end',
|
||||
);
|
||||
const message = error instanceof Error ? error.message : 'Failed to cancel subscription';
|
||||
throw new StripeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
async reactivateSubscription(userId: UserID): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Payment processing is not available');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeError('No subscription found');
|
||||
}
|
||||
|
||||
if (!user.premiumWillCancel) {
|
||||
throw new StripeError('Subscription is not set to cancel');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stripe.subscriptions.update(user.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(userId, {
|
||||
premium_will_cancel: false,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, 'Subscription reactivated');
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error, userId, subscriptionId: user.stripeSubscriptionId}, 'Failed to reactivate subscription');
|
||||
const message = error instanceof Error ? error.message : 'Failed to reactivate subscription';
|
||||
throw new StripeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
async extendSubscriptionWithTrialPhase(user: User, durationMonths: number, idempotencyKey: string): Promise<void> {
|
||||
if (!this.stripe || !user.stripeSubscriptionId) {
|
||||
throw new NoActiveSubscriptionError();
|
||||
}
|
||||
|
||||
const appliedKey = `gift_schedule_applied:${user.id}:${idempotencyKey}`;
|
||||
const inflightKey = `gift_schedule_inflight:${user.id}:${idempotencyKey}`;
|
||||
|
||||
if (await this.cacheService.get<boolean>(appliedKey)) {
|
||||
Logger.debug({userId: user.id, idempotencyKey}, 'Schedule extension already applied (idempotent hit)');
|
||||
return;
|
||||
}
|
||||
if (await this.cacheService.get<boolean>(inflightKey)) {
|
||||
Logger.debug({userId: user.id, idempotencyKey}, 'Schedule extension in-flight; skipping duplicate worker');
|
||||
return;
|
||||
}
|
||||
await this.cacheService.set(inflightKey, 60);
|
||||
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId);
|
||||
const baseEndUnix = subscription.items.data[0]?.current_period_end;
|
||||
if (!baseEndUnix) {
|
||||
throw new StripeError('Subscription current_period_end is missing');
|
||||
}
|
||||
|
||||
const baseEnd = new Date(baseEndUnix * 1000);
|
||||
const extensionEnd = addMonthsClamp(baseEnd, durationMonths);
|
||||
|
||||
let scheduleId = (subscription as Stripe.Subscription & {schedule?: string | null}).schedule ?? null;
|
||||
if (!scheduleId) {
|
||||
const created = await this.stripe.subscriptionSchedules.create({
|
||||
from_subscription: subscription.id,
|
||||
end_behavior: 'release',
|
||||
});
|
||||
scheduleId = created.id;
|
||||
Logger.debug({userId: user.id, scheduleId}, 'Created subscription schedule from subscription');
|
||||
}
|
||||
|
||||
const schedule = await this.stripe.subscriptionSchedules.retrieve(scheduleId!, {
|
||||
expand: ['phases.items'],
|
||||
});
|
||||
|
||||
if ((schedule.phases ?? []).some((p) => p.metadata?.gift_idempotency_key === idempotencyKey)) {
|
||||
await this.cacheService.set(appliedKey, 365 * 24 * 60 * 60);
|
||||
Logger.debug({userId: user.id, scheduleId, idempotencyKey}, 'Gift phase already present on schedule');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPhases = schedule.phases ?? [];
|
||||
const lastPhase = existingPhases[existingPhases.length - 1];
|
||||
|
||||
const chainStartUnix: number = (() => {
|
||||
const lastEnd = lastPhase?.end_date;
|
||||
if (typeof lastEnd === 'number' && lastEnd > baseEndUnix) return lastEnd;
|
||||
return baseEndUnix;
|
||||
})();
|
||||
|
||||
const chainStart = new Date(chainStartUnix * 1000);
|
||||
const finalExtensionEnd =
|
||||
chainStartUnix === baseEndUnix ? extensionEnd : addMonthsClamp(chainStart, durationMonths);
|
||||
const finalExtensionEndUnix = Math.floor(finalExtensionEnd.getTime() / 1000);
|
||||
|
||||
// NOTE: schedule phases require items[].price and quantity.
|
||||
const scheduleItems: Array<Stripe.SubscriptionScheduleCreateParams.Phase.Item> = subscription.items.data.map(
|
||||
(it) => ({
|
||||
price: (it.price as Stripe.Price).id,
|
||||
quantity: it.quantity ?? 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const newPhases: Array<Stripe.SubscriptionScheduleUpdateParams.Phase> = existingPhases.map((p) => ({
|
||||
items: (p.items ?? []).map((i) => ({
|
||||
price: typeof i.price === 'string' ? i.price : (i.price as Stripe.Price).id,
|
||||
quantity: i.quantity ?? 1,
|
||||
})),
|
||||
start_date: p.start_date!,
|
||||
end_date: p.end_date!,
|
||||
proration_behavior: (p.proration_behavior ??
|
||||
'none') as Stripe.SubscriptionScheduleUpdateParams.Phase.ProrationBehavior,
|
||||
metadata: (p.metadata ?? undefined) as Stripe.MetadataParam | undefined,
|
||||
}));
|
||||
|
||||
newPhases.push({
|
||||
start_date: chainStartUnix,
|
||||
end_date: finalExtensionEndUnix,
|
||||
items: scheduleItems,
|
||||
trial_end: finalExtensionEndUnix,
|
||||
proration_behavior: 'none',
|
||||
metadata: {
|
||||
gift_idempotency_key: idempotencyKey,
|
||||
gift_duration_months: String(durationMonths),
|
||||
},
|
||||
});
|
||||
|
||||
await this.stripe.subscriptionSchedules.update(scheduleId!, {
|
||||
end_behavior: schedule.end_behavior ?? 'release',
|
||||
proration_behavior: 'none',
|
||||
phases: newPhases,
|
||||
});
|
||||
|
||||
await this.cacheService.set(appliedKey, 365 * 24 * 60 * 60);
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
userId: user.id,
|
||||
scheduleId,
|
||||
subscriptionId: subscription.id,
|
||||
chainStart: new Date(chainStartUnix * 1000),
|
||||
extensionEnd: finalExtensionEnd,
|
||||
idempotencyKey,
|
||||
},
|
||||
'Appended full-trial phase to subscription schedule (Stripe is source of truth)',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
Logger.error(
|
||||
{error, userId: user.id, subscriptionId: user.stripeSubscriptionId, idempotencyKey},
|
||||
'Failed to append trial phase to subscription schedule',
|
||||
);
|
||||
const message = error instanceof Error ? error.message : 'Failed to extend subscription with gift';
|
||||
throw new StripeError(message);
|
||||
} finally {
|
||||
await this.cacheService.delete(inflightKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchUser(user: User): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
542
fluxer_api/src/stripe/services/StripeWebhookService.ts
Normal file
542
fluxer_api/src/stripe/services/StripeWebhookService.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {DeletionReasons, UserFlags, UserPremiumTypes} from '~/Constants';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import {StripeError, StripeWebhookSignatureInvalidError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {GiftCode, User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import type {ProductRegistry} from '../ProductRegistry';
|
||||
import {extractId} from '../StripeUtils';
|
||||
import type {StripeGiftService} from './StripeGiftService';
|
||||
import type {StripePremiumService} from './StripePremiumService';
|
||||
|
||||
export interface HandleWebhookParams {
|
||||
body: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export class StripeWebhookService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private userRepository: IUserRepository,
|
||||
private authService: AuthService,
|
||||
private emailService: IEmailService,
|
||||
private gatewayService: IGatewayService,
|
||||
private productRegistry: ProductRegistry,
|
||||
private giftService: StripeGiftService,
|
||||
private premiumService: StripePremiumService,
|
||||
) {}
|
||||
|
||||
async handleWebhook({body, signature}: HandleWebhookParams): Promise<void> {
|
||||
if (!this.stripe || !Config.stripe.webhookSecret) {
|
||||
throw new StripeError('Webhook processing is not available');
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(body, signature, Config.stripe.webhookSecret);
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error}, 'Invalid webhook signature');
|
||||
throw new StripeWebhookSignatureInvalidError();
|
||||
}
|
||||
|
||||
Logger.debug({eventType: event.type, eventId: event.id}, 'Processing Stripe webhook');
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await this.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
|
||||
case 'charge.dispute.created':
|
||||
await this.handleChargebackCreated(event.data.object as Stripe.Dispute);
|
||||
break;
|
||||
|
||||
case 'charge.dispute.closed':
|
||||
await this.handleChargebackClosed(event.data.object as Stripe.Dispute);
|
||||
break;
|
||||
|
||||
case 'charge.refunded':
|
||||
await this.handleRefund(event.data.object as Stripe.Charge);
|
||||
break;
|
||||
|
||||
default:
|
||||
Logger.debug({eventType: event.type}, 'Unhandled webhook event type');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error, eventType: event.type, eventId: event.id}, 'Failed to process webhook event');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const payment = await this.userRepository.getPaymentByCheckoutSession(session.id);
|
||||
if (!payment) {
|
||||
Logger.error({sessionId: session.id}, 'No payment record found for checkout session');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.status !== 'pending') {
|
||||
Logger.debug({sessionId: session.id, status: payment.status}, 'Payment already processed');
|
||||
return;
|
||||
}
|
||||
|
||||
const productInfo = this.productRegistry.getProduct(payment.priceId!);
|
||||
if (!productInfo) {
|
||||
Logger.error({sessionId: session.id, priceId: payment.priceId}, 'Unknown price ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(payment.userId);
|
||||
if (!user) {
|
||||
Logger.error({userId: payment.userId, sessionId: session.id}, 'User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.userRepository.updatePayment({
|
||||
...payment.toRow(),
|
||||
stripe_customer_id: extractId(session.customer),
|
||||
payment_intent_id: extractId(session.payment_intent),
|
||||
subscription_id: extractId(session.subscription),
|
||||
invoice_id: typeof session.invoice === 'string' ? session.invoice : null,
|
||||
amount_cents: session.amount_total || 0,
|
||||
currency: session.currency || 'usd',
|
||||
status: 'completed',
|
||||
completed_at: new Date(),
|
||||
});
|
||||
|
||||
const customerId = extractId(session.customer);
|
||||
if (customerId && !user.stripeCustomerId) {
|
||||
await this.userRepository.patchUpsert(payment.userId, {
|
||||
stripe_customer_id: customerId,
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionId = extractId(session.subscription);
|
||||
if (subscriptionId && this.productRegistry.isRecurringSubscription(productInfo)) {
|
||||
await this.userRepository.patchUpsert(payment.userId, {
|
||||
stripe_subscription_id: subscriptionId,
|
||||
premium_billing_cycle: productInfo.billingCycle || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (payment.isGift) {
|
||||
const paymentIntentId = extractId(session.payment_intent);
|
||||
if (payment.giftCode) {
|
||||
Logger.debug({sessionId: session.id, giftCode: payment.giftCode}, 'Gift already created for payment');
|
||||
} else {
|
||||
await this.giftService.createGiftCode(session.id, user, productInfo, paymentIntentId);
|
||||
}
|
||||
await this.userRepository.patchUpsert(payment.userId, {
|
||||
has_ever_purchased: true,
|
||||
});
|
||||
await this.dispatchUser(user);
|
||||
} else {
|
||||
if (productInfo.premiumType === UserPremiumTypes.LIFETIME && user.stripeSubscriptionId && this.stripe) {
|
||||
await this.cancelStripeSubscriptionImmediately(user);
|
||||
}
|
||||
await this.premiumService.grantPremium(
|
||||
payment.userId,
|
||||
productInfo.premiumType,
|
||||
productInfo.durationMonths,
|
||||
productInfo.billingCycle || null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
userId: payment.userId,
|
||||
sessionId: session.id,
|
||||
productType: productInfo.type,
|
||||
isGift: payment.isGift,
|
||||
},
|
||||
'Checkout session completed and processed',
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInvoicePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
|
||||
if (invoice.billing_reason === 'subscription_create') {
|
||||
Logger.debug({invoiceId: invoice.id}, 'Skipping first invoice - handled by checkout.session.completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
if (!subscriptionId) {
|
||||
Logger.warn({invoiceId: invoice.id}, 'No subscription ID found in invoice');
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionInfo = await this.userRepository.getSubscriptionInfo(subscriptionId);
|
||||
if (!subscriptionInfo) {
|
||||
Logger.warn({invoiceId: invoice.id, subscriptionId}, 'No subscription info found');
|
||||
return;
|
||||
}
|
||||
|
||||
const productInfo = this.productRegistry.getProduct(subscriptionInfo.price_id);
|
||||
if (!productInfo) {
|
||||
Logger.error({invoiceId: invoice.id, priceId: subscriptionInfo.price_id}, 'Unknown product for renewal');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.premiumService.grantPremium(
|
||||
subscriptionInfo.user_id,
|
||||
productInfo.premiumType,
|
||||
productInfo.durationMonths,
|
||||
productInfo.billingCycle || null,
|
||||
true,
|
||||
);
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
userId: subscriptionInfo.user_id,
|
||||
invoiceId: invoice.id,
|
||||
subscriptionId,
|
||||
durationMonths: productInfo.durationMonths,
|
||||
},
|
||||
'Subscription renewed from invoice payment',
|
||||
);
|
||||
}
|
||||
|
||||
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
||||
const subscriptionInfo = await this.userRepository.getSubscriptionInfo(subscription.id);
|
||||
if (!subscriptionInfo) {
|
||||
Logger.warn({subscriptionId: subscription.id}, 'No subscription info found');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPeriodEnd = subscription.items.data[0]?.current_period_end;
|
||||
const computedBase = currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null;
|
||||
|
||||
const user = await this.userRepository.findUnique(subscriptionInfo.user_id);
|
||||
if (!user) {
|
||||
Logger.warn({subscriptionId: subscription.id}, 'User not found for subscription update');
|
||||
return;
|
||||
}
|
||||
|
||||
let nextPremiumUntil: Date | null = user.premiumUntil ?? null;
|
||||
|
||||
if (subscription.cancel_at != null) {
|
||||
nextPremiumUntil = new Date(subscription.cancel_at * 1000);
|
||||
} else if (computedBase) {
|
||||
if (!nextPremiumUntil || computedBase > nextPremiumUntil) {
|
||||
nextPremiumUntil = computedBase;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(subscriptionInfo.user_id, {
|
||||
premium_will_cancel: subscription.cancel_at != null,
|
||||
premium_until: nextPremiumUntil,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
userId: subscriptionInfo.user_id,
|
||||
subscriptionId: subscription.id,
|
||||
willCancel: subscription.cancel_at != null,
|
||||
premiumUntil: nextPremiumUntil,
|
||||
status: subscription.status,
|
||||
},
|
||||
'Subscription updated (preserved gifted extension)',
|
||||
);
|
||||
}
|
||||
|
||||
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||
const info = await this.userRepository.getSubscriptionInfo(subscription.id);
|
||||
if (!info) return;
|
||||
|
||||
const user = await this.userRepository.findUnique(info.user_id);
|
||||
if (!user) return;
|
||||
|
||||
const updates: Partial<UserRow> = {
|
||||
premium_will_cancel: false,
|
||||
stripe_subscription_id: null,
|
||||
premium_billing_cycle: null,
|
||||
};
|
||||
|
||||
if (user.premiumType !== UserPremiumTypes.LIFETIME) {
|
||||
Object.assign(updates, {premium_type: UserPremiumTypes.NONE, premium_until: null});
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(info.user_id, updates);
|
||||
if (updatedUser) await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
private async handleChargebackCreated(dispute: Stripe.Dispute): Promise<void> {
|
||||
const paymentIntentId = extractId(dispute.payment_intent);
|
||||
if (!paymentIntentId) {
|
||||
Logger.warn({dispute}, 'Chargeback missing payment intent');
|
||||
return;
|
||||
}
|
||||
|
||||
const giftCode = await this.userRepository.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
if (giftCode) {
|
||||
await this.handleGiftChargeback(giftCode);
|
||||
return;
|
||||
}
|
||||
|
||||
const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
if (!payment) {
|
||||
Logger.warn({paymentIntentId}, 'No payment found for chargeback');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.scheduleAccountDeletionForFraud(payment.userId);
|
||||
}
|
||||
|
||||
private async handleGiftChargeback(giftCode: GiftCode): Promise<void> {
|
||||
if (giftCode.redeemedByUserId) {
|
||||
await this.premiumService.revokePremium(giftCode.redeemedByUserId);
|
||||
|
||||
const redeemer = await this.userRepository.findUnique(giftCode.redeemedByUserId);
|
||||
if (redeemer?.email) {
|
||||
await this.emailService.sendGiftChargebackNotification(redeemer.email, redeemer.username, redeemer.locale);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
|
||||
'Premium revoked due to gift chargeback',
|
||||
);
|
||||
}
|
||||
|
||||
await this.scheduleAccountDeletionForFraud(giftCode.createdByUserId);
|
||||
}
|
||||
|
||||
private async handleChargebackClosed(dispute: Stripe.Dispute): Promise<void> {
|
||||
if (dispute.status !== 'won') {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntentId = extractId(dispute.payment_intent);
|
||||
if (!paymentIntentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
if (!payment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(payment.userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.flags & UserFlags.DELETED && user.deletionReasonCode === DeletionReasons.FRIENDLY_FRAUD) {
|
||||
if (user.pendingDeletionAt) {
|
||||
await this.userRepository.removePendingDeletion(payment.userId, user.pendingDeletionAt);
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(payment.userId, {
|
||||
flags: user.flags & ~UserFlags.DELETED,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
first_refund_at: user.firstRefundAt || new Date(),
|
||||
});
|
||||
|
||||
if (updatedUser?.email) {
|
||||
await this.emailService.sendUnbanNotification(
|
||||
updatedUser.email,
|
||||
updatedUser.username,
|
||||
'chargeback withdrawal',
|
||||
updatedUser.locale,
|
||||
);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{userId: payment.userId},
|
||||
'User unsuspended after chargeback withdrawal - 30 day purchase block applied',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRefund(charge: Stripe.Charge): Promise<void> {
|
||||
const paymentIntentId = extractId(charge.payment_intent);
|
||||
if (!paymentIntentId) {
|
||||
Logger.warn({chargeId: charge.id}, 'Refund missing payment intent');
|
||||
return;
|
||||
}
|
||||
|
||||
const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
if (!payment) {
|
||||
Logger.warn({paymentIntentId, chargeId: charge.id}, 'No payment found for refund');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(payment.userId);
|
||||
if (!user) {
|
||||
Logger.error({userId: payment.userId, chargeId: charge.id}, 'User not found for refund');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.userRepository.updatePayment({
|
||||
...payment.toRow(),
|
||||
status: 'refunded',
|
||||
});
|
||||
|
||||
await this.premiumService.revokePremium(payment.userId);
|
||||
|
||||
if (!user.firstRefundAt) {
|
||||
await this.userRepository.patchUpsert(payment.userId, {
|
||||
first_refund_at: new Date(),
|
||||
});
|
||||
Logger.debug(
|
||||
{userId: payment.userId, chargeId: charge.id, paymentIntentId},
|
||||
'First refund recorded - 30 day purchase block applied',
|
||||
);
|
||||
} else {
|
||||
const updatedUser = await this.userRepository.patchUpsert(payment.userId, {
|
||||
flags: user.flags | UserFlags.PREMIUM_PURCHASE_DISABLED,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.dispatchUser(updatedUser);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
{userId: payment.userId, chargeId: charge.id, paymentIntentId},
|
||||
'Second refund recorded - permanent purchase block applied',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
type InvoiceWithSubscription = Stripe.Invoice & {
|
||||
subscription?: string | Stripe.Subscription;
|
||||
};
|
||||
const invoiceWithSubscription = invoice as InvoiceWithSubscription;
|
||||
const directSubscription = invoiceWithSubscription.subscription;
|
||||
if (directSubscription) {
|
||||
return extractId(directSubscription);
|
||||
}
|
||||
type InvoiceWithParent = Stripe.Invoice & {
|
||||
parent?: {
|
||||
subscription_details?: {
|
||||
subscription?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
type InvoiceLineWithParent = Stripe.InvoiceLineItem & {
|
||||
parent?: {
|
||||
subscription_item_details?: {
|
||||
subscription?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const invoiceWithParent = invoice as InvoiceWithParent;
|
||||
const parentSubscription = invoiceWithParent.parent?.subscription_details?.subscription;
|
||||
if (parentSubscription) {
|
||||
return extractId(parentSubscription);
|
||||
}
|
||||
|
||||
if (invoice.lines?.data?.length) {
|
||||
for (const line of invoice.lines.data) {
|
||||
const lineWithParent = line as InvoiceLineWithParent;
|
||||
const subscriptionId = lineWithParent.parent?.subscription_item_details?.subscription;
|
||||
if (subscriptionId) {
|
||||
return extractId(subscriptionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async scheduleAccountDeletionForFraud(userId: UserID): Promise<void> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingDeletionAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: DeletionReasons.FRIENDLY_FRAUD,
|
||||
deletion_public_reason: 'Payment dispute',
|
||||
deletion_audit_log_reason: 'Chargeback filed',
|
||||
});
|
||||
|
||||
await this.userRepository.addPendingDeletion(userId, pendingDeletionAt, DeletionReasons.FRIENDLY_FRAUD);
|
||||
|
||||
await this.authService.terminateAllUserSessions(userId);
|
||||
|
||||
if (updatedUser?.email) {
|
||||
await this.emailService.sendScheduledDeletionNotification(
|
||||
updatedUser.email,
|
||||
updatedUser.username,
|
||||
pendingDeletionAt,
|
||||
'Payment dispute - chargeback filed',
|
||||
updatedUser.locale,
|
||||
);
|
||||
}
|
||||
|
||||
Logger.debug({userId, pendingDeletionAt}, 'Account scheduled for deletion due to chargeback');
|
||||
}
|
||||
|
||||
private async cancelStripeSubscriptionImmediately(user: User): Promise<void> {
|
||||
if (!this.stripe || !user.stripeSubscriptionId) return;
|
||||
await this.stripe.subscriptions.cancel(user.stripeSubscriptionId, {invoice_now: false, prorate: false});
|
||||
const updatedUser = await this.userRepository.patchUpsert(user.id, {
|
||||
stripe_subscription_id: null,
|
||||
premium_billing_cycle: null,
|
||||
premium_will_cancel: false,
|
||||
});
|
||||
if (updatedUser) await this.dispatchUser(updatedUser);
|
||||
Logger.debug({userId: user.id}, 'Canceled active subscription due to lifetime grant');
|
||||
}
|
||||
|
||||
private async dispatchUser(user: User): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user