feat(server): auto attach early access coupon (#4728)

This commit is contained in:
liuyi
2023-10-27 10:28:22 +08:00
committed by GitHub
parent edb6e0fd69
commit 50563dcb6e
5 changed files with 302 additions and 186 deletions

View File

@@ -1,12 +1,16 @@
import { Module } from '@nestjs/common';
import { UsersModule } from '../users';
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
import { ScheduleManager } from './schedule';
import { SubscriptionService } from './service';
import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook';
@Module({
imports: [UsersModule],
providers: [
ScheduleManager,
StripeProvider,
SubscriptionService,
SubscriptionResolver,

View 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,
},
],
});
}
}
}

View File

@@ -11,6 +11,8 @@ import Stripe from 'stripe';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { UsersService } from '../users';
import { ScheduleManager } from './schedule';
const OnEvent = (
event: Stripe.Event.Type,
@@ -65,7 +67,7 @@ export enum InvoiceStatus {
Uncollectible = 'uncollectible',
}
export enum Coupon {
export enum CouponType {
EarlyAccess = 'earlyaccess',
EarlyAccessRenew = 'earlyaccessrenew',
}
@@ -78,7 +80,9 @@ export class SubscriptionService {
constructor(
config: Config,
private readonly stripe: Stripe,
private readonly db: PrismaService
private readonly db: PrismaService,
private readonly user: UsersService,
private readonly scheduleManager: ScheduleManager
) {
this.paymentConfig = config.payment;
@@ -117,8 +121,9 @@ export class SubscriptionService {
}
const price = await this.getPrice(plan, recurring);
const customer = await this.getOrCreateCustomer(user);
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
return await this.stripe.checkout.sessions.create({
line_items: [
{
@@ -126,10 +131,16 @@ export class SubscriptionService {
quantity: 1,
},
],
allow_promotion_codes: true,
tax_id_collection: {
enabled: true,
},
...(coupon
? {
discounts: [{ coupon }],
}
: {
allow_promotion_codes: true,
}),
mode: 'subscription',
success_url: redirectUrl,
customer: customer.stripeCustomerId,
@@ -160,12 +171,16 @@ export class SubscriptionService {
// should release the schedule first
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(
user,
await this.stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
)
),
false
);
} else {
// let customer contact support if they want to cancel immediately
@@ -203,12 +218,16 @@ export class SubscriptionService {
}
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(
user,
await this.stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
)
),
false
);
} else {
const subscription = await this.stripe.subscriptions.update(
@@ -252,27 +271,26 @@ export class SubscriptionService {
recurring
);
let scheduleId: string | null;
// a schedule existing
if (user.subscription.stripeScheduleId) {
scheduleId = await this.scheduleNewPrice(
user.subscription.stripeScheduleId,
price
);
} else {
const schedule = await this.stripe.subscriptionSchedules.create({
from_subscription: user.subscription.stripeSubscriptionId,
});
await this.scheduleNewPrice(schedule.id, price);
scheduleId = schedule.id;
}
const manager = await this.scheduleManager.fromSubscription(
user.subscription.stripeSubscriptionId
);
await manager.update(
price,
// if user is early access user, use early access coupon
manager.currentPhase?.coupon === CouponType.EarlyAccess ||
manager.currentPhase?.coupon === CouponType.EarlyAccessRenew ||
manager.nextPhase?.coupon === CouponType.EarlyAccessRenew
? CouponType.EarlyAccessRenew
: undefined
);
return await this.db.userSubscription.update({
where: {
id: user.subscription.id,
},
data: {
stripeScheduleId: scheduleId,
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
recurring,
},
});
@@ -325,8 +343,26 @@ export class SubscriptionService {
});
}
@OnEvent('invoice.created')
@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.payment_failed')
async saveInvoice(stripeInvoice: Stripe.Invoice) {
@@ -591,165 +627,21 @@ export class SubscriptionService {
return prices.data[0].id;
}
/**
* If a subscription is managed by a schedule, it has a different way to cancel.
*/
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
private async getAvailableCoupon(
user: User,
couponType: CouponType
): Promise<string | null> {
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 earlyAccess = await this.user.isEarlyAccessUser(user.email);
if (earlyAccess) {
try {
const coupon = await this.stripe.coupons.retrieve(couponType);
return coupon.valid ? coupon.id : null;
} catch (e) {
this.logger.error('Failed to get early access coupon', e);
return null;
}
}
// if current phase's plan matches target, just release the schedule
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;
}
return null;
}
}

View File

@@ -7,6 +7,7 @@ import { UsersService } from './users';
@Module({
imports: [StorageModule],
providers: [UserResolver, UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -15,16 +15,21 @@ export class UsersService {
async canEarlyAccess(email: string) {
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
return this.prisma.newFeaturesWaitingList
.findUnique({
where: { email, type: NewFeaturesKind.EarlyAccess },
})
.catch(() => false);
return this.isEarlyAccessUser(email);
} else {
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) {
const features = await this.prisma.user
.findUnique({