mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
feat(server): auto attach early access coupon (#4728)
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { UsersModule } from '../users';
|
||||||
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
|
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
|
||||||
|
import { ScheduleManager } from './schedule';
|
||||||
import { SubscriptionService } from './service';
|
import { SubscriptionService } from './service';
|
||||||
import { StripeProvider } from './stripe';
|
import { StripeProvider } from './stripe';
|
||||||
import { StripeWebhook } from './webhook';
|
import { StripeWebhook } from './webhook';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [UsersModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
ScheduleManager,
|
||||||
StripeProvider,
|
StripeProvider,
|
||||||
SubscriptionService,
|
SubscriptionService,
|
||||||
SubscriptionResolver,
|
SubscriptionResolver,
|
||||||
|
|||||||
214
packages/backend/server/src/modules/payment/schedule.ts
Normal file
214
packages/backend/server/src/modules/payment/schedule.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ScheduleManager {
|
||||||
|
private _schedule: Stripe.SubscriptionSchedule | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly stripe: Stripe) {}
|
||||||
|
|
||||||
|
static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) {
|
||||||
|
const manager = new ScheduleManager(stripe);
|
||||||
|
if (schedule) {
|
||||||
|
manager._schedule = schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
get schedule() {
|
||||||
|
return this._schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentPhase() {
|
||||||
|
if (!this._schedule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._schedule.phases.find(
|
||||||
|
phase =>
|
||||||
|
phase.start_date * 1000 < Date.now() &&
|
||||||
|
phase.end_date * 1000 > Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get nextPhase() {
|
||||||
|
if (!this._schedule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._schedule.phases.find(
|
||||||
|
phase => phase.start_date * 1000 > Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive() {
|
||||||
|
return this._schedule?.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
async fromSchedule(schedule: string | Stripe.SubscriptionSchedule) {
|
||||||
|
if (typeof schedule === 'string') {
|
||||||
|
const s = await this.stripe.subscriptionSchedules
|
||||||
|
.retrieve(schedule)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return ScheduleManager.create(this.stripe, s);
|
||||||
|
} else {
|
||||||
|
return ScheduleManager.create(this.stripe, schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fromSubscription(subscription: string | Stripe.Subscription) {
|
||||||
|
if (typeof subscription === 'string') {
|
||||||
|
subscription = await this.stripe.subscriptions.retrieve(subscription, {
|
||||||
|
expand: ['schedule'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.schedule) {
|
||||||
|
return await this.fromSchedule(subscription.schedule);
|
||||||
|
} else {
|
||||||
|
const schedule = await this.stripe.subscriptionSchedules.create({
|
||||||
|
from_subscription: subscription.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.fromSchedule(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a subscription by marking schedule's end behavior to `cancel`.
|
||||||
|
* At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
|
||||||
|
*/
|
||||||
|
async cancel() {
|
||||||
|
if (!this._schedule) {
|
||||||
|
throw new Error('No schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isActive || !this.currentPhase) {
|
||||||
|
throw new Error('Unexpected subscription schedule status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
price: this.currentPhase.items[0].price as string,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
|
||||||
|
start_date: this.currentPhase.start_date,
|
||||||
|
end_date: this.currentPhase.end_date,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.nextPhase) {
|
||||||
|
// cancel a subscription with a schedule exiting will delete the upcoming phase,
|
||||||
|
// it's hard to recover the subscription to the original state if user wan't to resume before due.
|
||||||
|
// so we manually save the next phase's key information to metadata for later easy resuming.
|
||||||
|
phases.metadata = {
|
||||||
|
next_coupon: (this.nextPhase.coupon as string | null) || null, // avoid empty string
|
||||||
|
next_price: this.nextPhase.items[0].price as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.stripe.subscriptionSchedules.update(this._schedule.id, {
|
||||||
|
phases: [phases],
|
||||||
|
end_behavior: 'cancel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume() {
|
||||||
|
if (!this._schedule) {
|
||||||
|
throw new Error('No schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isActive || !this.currentPhase) {
|
||||||
|
throw new Error('Unexpected subscription schedule status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
price: this.currentPhase.items[0].price as string,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
|
||||||
|
start_date: this.currentPhase.start_date,
|
||||||
|
end_date: this.currentPhase.end_date,
|
||||||
|
metadata: {
|
||||||
|
next_coupon: null,
|
||||||
|
next_price: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.currentPhase.metadata && this.currentPhase.metadata.next_price) {
|
||||||
|
phases.push({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
price: this.currentPhase.metadata.next_price,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coupon: this.currentPhase.metadata.next_coupon || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.stripe.subscriptionSchedules.update(this._schedule.id, {
|
||||||
|
phases: phases,
|
||||||
|
end_behavior: 'release',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() {
|
||||||
|
if (!this._schedule) {
|
||||||
|
throw new Error('No schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.stripe.subscriptionSchedules.release(this._schedule.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(price: string, coupon?: string) {
|
||||||
|
if (!this._schedule) {
|
||||||
|
throw new Error('No schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isActive || !this.currentPhase) {
|
||||||
|
throw new Error('Unexpected subscription schedule status');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if current phase's plan matches target, and no coupon change, just release the schedule
|
||||||
|
if (
|
||||||
|
this.currentPhase.items[0].price === price &&
|
||||||
|
(!coupon || this.currentPhase.coupon === coupon)
|
||||||
|
) {
|
||||||
|
await this.stripe.subscriptionSchedules.release(this._schedule.id);
|
||||||
|
this._schedule = null;
|
||||||
|
} else {
|
||||||
|
await this.stripe.subscriptionSchedules.update(this._schedule.id, {
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
price: this.currentPhase.items[0].price as string,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
start_date: this.currentPhase.start_date,
|
||||||
|
end_date: this.currentPhase.end_date,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
price: price,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coupon,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import Stripe from 'stripe';
|
|||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
|
import { UsersService } from '../users';
|
||||||
|
import { ScheduleManager } from './schedule';
|
||||||
|
|
||||||
const OnEvent = (
|
const OnEvent = (
|
||||||
event: Stripe.Event.Type,
|
event: Stripe.Event.Type,
|
||||||
@@ -65,7 +67,7 @@ export enum InvoiceStatus {
|
|||||||
Uncollectible = 'uncollectible',
|
Uncollectible = 'uncollectible',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Coupon {
|
export enum CouponType {
|
||||||
EarlyAccess = 'earlyaccess',
|
EarlyAccess = 'earlyaccess',
|
||||||
EarlyAccessRenew = 'earlyaccessrenew',
|
EarlyAccessRenew = 'earlyaccessrenew',
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,9 @@ export class SubscriptionService {
|
|||||||
constructor(
|
constructor(
|
||||||
config: Config,
|
config: Config,
|
||||||
private readonly stripe: Stripe,
|
private readonly stripe: Stripe,
|
||||||
private readonly db: PrismaService
|
private readonly db: PrismaService,
|
||||||
|
private readonly user: UsersService,
|
||||||
|
private readonly scheduleManager: ScheduleManager
|
||||||
) {
|
) {
|
||||||
this.paymentConfig = config.payment;
|
this.paymentConfig = config.payment;
|
||||||
|
|
||||||
@@ -117,8 +121,9 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const price = await this.getPrice(plan, recurring);
|
const price = await this.getPrice(plan, recurring);
|
||||||
|
|
||||||
const customer = await this.getOrCreateCustomer(user);
|
const customer = await this.getOrCreateCustomer(user);
|
||||||
|
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
|
||||||
|
|
||||||
return await this.stripe.checkout.sessions.create({
|
return await this.stripe.checkout.sessions.create({
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
@@ -126,10 +131,16 @@ export class SubscriptionService {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
allow_promotion_codes: true,
|
|
||||||
tax_id_collection: {
|
tax_id_collection: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
...(coupon
|
||||||
|
? {
|
||||||
|
discounts: [{ coupon }],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
}),
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
success_url: redirectUrl,
|
success_url: redirectUrl,
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
@@ -160,12 +171,16 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
// should release the schedule first
|
// should release the schedule first
|
||||||
if (user.subscription.stripeScheduleId) {
|
if (user.subscription.stripeScheduleId) {
|
||||||
await this.cancelSubscriptionSchedule(user.subscription.stripeScheduleId);
|
const manager = await this.scheduleManager.fromSchedule(
|
||||||
|
user.subscription.stripeScheduleId
|
||||||
|
);
|
||||||
|
await manager.cancel();
|
||||||
return this.saveSubscription(
|
return this.saveSubscription(
|
||||||
user,
|
user,
|
||||||
await this.stripe.subscriptions.retrieve(
|
await this.stripe.subscriptions.retrieve(
|
||||||
user.subscription.stripeSubscriptionId
|
user.subscription.stripeSubscriptionId
|
||||||
)
|
),
|
||||||
|
false
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// let customer contact support if they want to cancel immediately
|
// let customer contact support if they want to cancel immediately
|
||||||
@@ -203,12 +218,16 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription.stripeScheduleId) {
|
if (user.subscription.stripeScheduleId) {
|
||||||
await this.resumeSubscriptionSchedule(user.subscription.stripeScheduleId);
|
const manager = await this.scheduleManager.fromSchedule(
|
||||||
|
user.subscription.stripeScheduleId
|
||||||
|
);
|
||||||
|
await manager.resume();
|
||||||
return this.saveSubscription(
|
return this.saveSubscription(
|
||||||
user,
|
user,
|
||||||
await this.stripe.subscriptions.retrieve(
|
await this.stripe.subscriptions.retrieve(
|
||||||
user.subscription.stripeSubscriptionId
|
user.subscription.stripeSubscriptionId
|
||||||
)
|
),
|
||||||
|
false
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const subscription = await this.stripe.subscriptions.update(
|
const subscription = await this.stripe.subscriptions.update(
|
||||||
@@ -252,27 +271,26 @@ export class SubscriptionService {
|
|||||||
recurring
|
recurring
|
||||||
);
|
);
|
||||||
|
|
||||||
let scheduleId: string | null;
|
const manager = await this.scheduleManager.fromSubscription(
|
||||||
// a schedule existing
|
user.subscription.stripeSubscriptionId
|
||||||
if (user.subscription.stripeScheduleId) {
|
);
|
||||||
scheduleId = await this.scheduleNewPrice(
|
|
||||||
user.subscription.stripeScheduleId,
|
await manager.update(
|
||||||
price
|
price,
|
||||||
);
|
// if user is early access user, use early access coupon
|
||||||
} else {
|
manager.currentPhase?.coupon === CouponType.EarlyAccess ||
|
||||||
const schedule = await this.stripe.subscriptionSchedules.create({
|
manager.currentPhase?.coupon === CouponType.EarlyAccessRenew ||
|
||||||
from_subscription: user.subscription.stripeSubscriptionId,
|
manager.nextPhase?.coupon === CouponType.EarlyAccessRenew
|
||||||
});
|
? CouponType.EarlyAccessRenew
|
||||||
await this.scheduleNewPrice(schedule.id, price);
|
: undefined
|
||||||
scheduleId = schedule.id;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return await this.db.userSubscription.update({
|
return await this.db.userSubscription.update({
|
||||||
where: {
|
where: {
|
||||||
id: user.subscription.id,
|
id: user.subscription.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
stripeScheduleId: scheduleId,
|
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
|
||||||
recurring,
|
recurring,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -325,8 +343,26 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('invoice.created')
|
|
||||||
@OnEvent('invoice.paid')
|
@OnEvent('invoice.paid')
|
||||||
|
async onInvoicePaid(stripeInvoice: Stripe.Invoice) {
|
||||||
|
await this.saveInvoice(stripeInvoice);
|
||||||
|
|
||||||
|
const line = stripeInvoice.lines.data[0];
|
||||||
|
|
||||||
|
if (!line.price || line.price.type !== 'recurring') {
|
||||||
|
throw new Error('Unknown invoice with no recurring price');
|
||||||
|
}
|
||||||
|
|
||||||
|
// deal with early access user
|
||||||
|
if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) {
|
||||||
|
const manager = await this.scheduleManager.fromSubscription(
|
||||||
|
line.subscription as string
|
||||||
|
);
|
||||||
|
await manager.update(line.price.id, CouponType.EarlyAccessRenew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('invoice.created')
|
||||||
@OnEvent('invoice.finalization_failed')
|
@OnEvent('invoice.finalization_failed')
|
||||||
@OnEvent('invoice.payment_failed')
|
@OnEvent('invoice.payment_failed')
|
||||||
async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
||||||
@@ -591,165 +627,21 @@ export class SubscriptionService {
|
|||||||
return prices.data[0].id;
|
return prices.data[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async getAvailableCoupon(
|
||||||
* If a subscription is managed by a schedule, it has a different way to cancel.
|
user: User,
|
||||||
*/
|
couponType: CouponType
|
||||||
private async cancelSubscriptionSchedule(scheduleId: string) {
|
|
||||||
const schedule =
|
|
||||||
await this.stripe.subscriptionSchedules.retrieve(scheduleId);
|
|
||||||
|
|
||||||
const currentPhase = schedule.phases.find(
|
|
||||||
phase =>
|
|
||||||
phase.start_date * 1000 < Date.now() &&
|
|
||||||
phase.end_date * 1000 > Date.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
schedule.status !== 'active' ||
|
|
||||||
schedule.phases.length > 2 ||
|
|
||||||
!currentPhase
|
|
||||||
) {
|
|
||||||
throw new Error('Unexpected subscription schedule status');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedule.status !== 'active') {
|
|
||||||
throw new Error('unexpected subscription schedule status');
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPhase = schedule.phases.find(
|
|
||||||
phase => phase.start_date * 1000 > Date.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentPhase) {
|
|
||||||
throw new Error('Unexpected subscription schedule status');
|
|
||||||
}
|
|
||||||
|
|
||||||
const update: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: currentPhase.items[0].price as string,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
coupon: (currentPhase.coupon as string | null) ?? undefined,
|
|
||||||
start_date: currentPhase.start_date,
|
|
||||||
end_date: currentPhase.end_date,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (nextPhase) {
|
|
||||||
// cancel a subscription with a schedule exiting will delete the upcoming phase,
|
|
||||||
// it's hard to recover the subscription to the original state if user wan't to resume before due.
|
|
||||||
// so we manually save the next phase's key information to metadata for later easy resuming.
|
|
||||||
update.metadata = {
|
|
||||||
next_coupon: (nextPhase.coupon as string | null) || null, // avoid empty string
|
|
||||||
next_price: nextPhase.items[0].price as string,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.stripe.subscriptionSchedules.update(schedule.id, {
|
|
||||||
phases: [update],
|
|
||||||
end_behavior: 'cancel',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resumeSubscriptionSchedule(scheduleId: string) {
|
|
||||||
const schedule =
|
|
||||||
await this.stripe.subscriptionSchedules.retrieve(scheduleId);
|
|
||||||
|
|
||||||
const currentPhase = schedule.phases.find(
|
|
||||||
phase =>
|
|
||||||
phase.start_date * 1000 < Date.now() &&
|
|
||||||
phase.end_date * 1000 > Date.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (schedule.status !== 'active' || !currentPhase) {
|
|
||||||
throw new Error('Unexpected subscription schedule status');
|
|
||||||
}
|
|
||||||
|
|
||||||
const update: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
|
|
||||||
{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: currentPhase.items[0].price as string,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
coupon: (currentPhase.coupon as string | null) ?? undefined,
|
|
||||||
start_date: currentPhase.start_date,
|
|
||||||
end_date: currentPhase.end_date,
|
|
||||||
metadata: {
|
|
||||||
next_coupon: null,
|
|
||||||
next_price: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (currentPhase.metadata && currentPhase.metadata.next_price) {
|
|
||||||
update.push({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: currentPhase.metadata.next_price,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
coupon: currentPhase.metadata.next_coupon || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.stripe.subscriptionSchedules.update(schedule.id, {
|
|
||||||
phases: update,
|
|
||||||
end_behavior: 'release',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* we only schedule a new price when user change the recurring plan and there is now upcoming phases.
|
|
||||||
*/
|
|
||||||
private async scheduleNewPrice(
|
|
||||||
scheduleId: string,
|
|
||||||
priceId: string
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const schedule =
|
const earlyAccess = await this.user.isEarlyAccessUser(user.email);
|
||||||
await this.stripe.subscriptionSchedules.retrieve(scheduleId);
|
if (earlyAccess) {
|
||||||
|
try {
|
||||||
const currentPhase = schedule.phases.find(
|
const coupon = await this.stripe.coupons.retrieve(couponType);
|
||||||
phase =>
|
return coupon.valid ? coupon.id : null;
|
||||||
phase.start_date * 1000 < Date.now() &&
|
} catch (e) {
|
||||||
phase.end_date * 1000 > Date.now()
|
this.logger.error('Failed to get early access coupon', e);
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
if (schedule.status !== 'active' || !currentPhase) {
|
|
||||||
throw new Error('Unexpected subscription schedule status');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if current phase's plan matches target, just release the schedule
|
return null;
|
||||||
if (currentPhase.items[0].price === priceId) {
|
|
||||||
await this.stripe.subscriptionSchedules.release(scheduleId);
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
await this.stripe.subscriptionSchedules.update(schedule.id, {
|
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: currentPhase.items[0].price as string,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
start_date: schedule.phases[0].start_date,
|
|
||||||
end_date: schedule.phases[0].end_date,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: priceId,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return scheduleId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { UsersService } from './users';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [StorageModule],
|
imports: [StorageModule],
|
||||||
providers: [UserResolver, UsersService],
|
providers: [UserResolver, UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,21 @@ export class UsersService {
|
|||||||
|
|
||||||
async canEarlyAccess(email: string) {
|
async canEarlyAccess(email: string) {
|
||||||
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
|
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
|
||||||
return this.prisma.newFeaturesWaitingList
|
return this.isEarlyAccessUser(email);
|
||||||
.findUnique({
|
|
||||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
|
||||||
})
|
|
||||||
.catch(() => false);
|
|
||||||
} else {
|
} else {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isEarlyAccessUser(email: string) {
|
||||||
|
return this.prisma.newFeaturesWaitingList
|
||||||
|
.count({
|
||||||
|
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||||
|
})
|
||||||
|
.then(count => count > 0)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
async getStorageQuotaById(id: string) {
|
async getStorageQuotaById(id: string) {
|
||||||
const features = await this.prisma.user
|
const features = await this.prisma.user
|
||||||
.findUnique({
|
.findUnique({
|
||||||
|
|||||||
Reference in New Issue
Block a user