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 { 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,

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 { 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;
}
} }
} }

View File

@@ -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 {}

View File

@@ -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({