Merge pull request #4699 from toeverything/61/subscription-edge-case
fix(server): subscription edge case
This commit is contained in:
@@ -16,12 +16,14 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
import { Auth, CurrentUser, Public } from '../auth';
|
import { Auth, CurrentUser, Public } from '../auth';
|
||||||
import { UserType } from '../users';
|
import { UserType } from '../users';
|
||||||
import {
|
import {
|
||||||
|
decodeLookupKey,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
@@ -140,26 +142,45 @@ export class SubscriptionResolver {
|
|||||||
async prices(): Promise<SubscriptionPrice[]> {
|
async prices(): Promise<SubscriptionPrice[]> {
|
||||||
const prices = await this.service.listPrices();
|
const prices = await this.service.listPrices();
|
||||||
|
|
||||||
const yearly = prices.data.find(
|
const group = groupBy(
|
||||||
price => price.lookup_key === SubscriptionRecurring.Yearly
|
prices.data.filter(price => !!price.lookup_key),
|
||||||
);
|
price => {
|
||||||
const monthly = prices.data.find(
|
// @ts-expect-error empty lookup key is filtered out
|
||||||
price => price.lookup_key === SubscriptionRecurring.Monthly
|
const [plan] = decodeLookupKey(price.lookup_key);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!yearly || !monthly) {
|
return Object.entries(group).map(([plan, prices]) => {
|
||||||
throw new BadGatewayException('The prices are not configured correctly');
|
const yearly = prices.find(
|
||||||
}
|
price =>
|
||||||
|
decodeLookupKey(
|
||||||
|
// @ts-expect-error empty lookup key is filtered out
|
||||||
|
price.lookup_key
|
||||||
|
)[1] === SubscriptionRecurring.Yearly
|
||||||
|
);
|
||||||
|
const monthly = prices.find(
|
||||||
|
price =>
|
||||||
|
decodeLookupKey(
|
||||||
|
// @ts-expect-error empty lookup key is filtered out
|
||||||
|
price.lookup_key
|
||||||
|
)[1] === SubscriptionRecurring.Monthly
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
if (!yearly || !monthly) {
|
||||||
{
|
throw new BadGatewayException(
|
||||||
|
'The prices are not configured correctly'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
type: 'fixed',
|
type: 'fixed',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: plan as SubscriptionPlan,
|
||||||
currency: monthly.currency,
|
currency: monthly.currency,
|
||||||
amount: monthly.unit_amount ?? 0,
|
amount: monthly.unit_amount ?? 0,
|
||||||
yearlyAmount: yearly.unit_amount ?? 0,
|
yearlyAmount: yearly.unit_amount ?? 0,
|
||||||
},
|
};
|
||||||
];
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => String, {
|
@Mutation(() => String, {
|
||||||
@@ -200,7 +221,7 @@ export class SubscriptionResolver {
|
|||||||
|
|
||||||
@Mutation(() => UserSubscriptionType)
|
@Mutation(() => UserSubscriptionType)
|
||||||
async resumeSubscription(@CurrentUser() user: User) {
|
async resumeSubscription(@CurrentUser() user: User) {
|
||||||
return this.service.resumeCanceledSubscriptin(user.id);
|
return this.service.resumeCanceledSubscription(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => UserSubscriptionType)
|
@Mutation(() => UserSubscriptionType)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const OnEvent = (
|
|||||||
opts?: Parameters<typeof RawOnEvent>[1]
|
opts?: Parameters<typeof RawOnEvent>[1]
|
||||||
) => RawOnEvent(event, opts);
|
) => RawOnEvent(event, opts);
|
||||||
|
|
||||||
// also used as lookup key for stripe prices
|
// Plan x Recurring make a stripe price lookup key
|
||||||
export enum SubscriptionRecurring {
|
export enum SubscriptionRecurring {
|
||||||
Monthly = 'monthly',
|
Monthly = 'monthly',
|
||||||
Yearly = 'yearly',
|
Yearly = 'yearly',
|
||||||
@@ -30,6 +30,21 @@ export enum SubscriptionPlan {
|
|||||||
Enterprise = 'enterprise',
|
Enterprise = 'enterprise',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeLookupKey(
|
||||||
|
plan: SubscriptionPlan,
|
||||||
|
recurring: SubscriptionRecurring
|
||||||
|
): string {
|
||||||
|
return plan + '_' + recurring;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLookupKey(
|
||||||
|
key: string
|
||||||
|
): [SubscriptionPlan, SubscriptionRecurring] {
|
||||||
|
const [plan, recurring] = key.split('_');
|
||||||
|
|
||||||
|
return [plan as SubscriptionPlan, recurring as SubscriptionRecurring];
|
||||||
|
}
|
||||||
|
|
||||||
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||||
export enum SubscriptionStatus {
|
export enum SubscriptionStatus {
|
||||||
Active = 'active',
|
Active = 'active',
|
||||||
@@ -50,6 +65,11 @@ export enum InvoiceStatus {
|
|||||||
Uncollectible = 'uncollectible',
|
Uncollectible = 'uncollectible',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum Coupon {
|
||||||
|
EarlyAccess = 'earlyaccess',
|
||||||
|
EarlyAccessRenew = 'earlyaccessrenew',
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionService {
|
export class SubscriptionService {
|
||||||
private readonly paymentConfig: Config['payment'];
|
private readonly paymentConfig: Config['payment'];
|
||||||
@@ -72,17 +92,17 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listPrices() {
|
async listPrices() {
|
||||||
return this.stripe.prices.list({
|
return this.stripe.prices.list();
|
||||||
lookup_keys: Object.values(SubscriptionRecurring),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCheckoutSession({
|
async createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
recurring,
|
recurring,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
|
plan = SubscriptionPlan.Pro,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
|
plan?: SubscriptionPlan;
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -96,19 +116,13 @@ export class SubscriptionService {
|
|||||||
throw new Error('You already have a subscription');
|
throw new Error('You already have a subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices = await this.stripe.prices.list({
|
const price = await this.getPrice(plan, recurring);
|
||||||
lookup_keys: [recurring],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!prices.data.length) {
|
|
||||||
throw new Error(`Unknown subscription recurring: ${recurring}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await this.getOrCreateCustomer(user);
|
const customer = await this.getOrCreateCustomer(user);
|
||||||
return await this.stripe.checkout.sessions.create({
|
return await this.stripe.checkout.sessions.create({
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: prices.data[0].id,
|
price,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -141,29 +155,32 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription.canceledAt) {
|
if (user.subscription.canceledAt) {
|
||||||
throw new Error('Your subscription has already been canceled ');
|
throw new Error('Your subscription has already been canceled');
|
||||||
}
|
}
|
||||||
|
|
||||||
// should release the schedule first
|
// should release the schedule first
|
||||||
if (user.subscription.stripeScheduleId) {
|
if (user.subscription.stripeScheduleId) {
|
||||||
await this.stripe.subscriptionSchedules.release(
|
await this.cancelSubscriptionSchedule(user.subscription.stripeScheduleId);
|
||||||
user.subscription.stripeScheduleId
|
return this.saveSubscription(
|
||||||
|
user,
|
||||||
|
await this.stripe.subscriptions.retrieve(
|
||||||
|
user.subscription.stripeSubscriptionId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// let customer contact support if they want to cancel immediately
|
||||||
|
// see https://stripe.com/docs/billing/subscriptions/cancel
|
||||||
|
const subscription = await this.stripe.subscriptions.update(
|
||||||
|
user.subscription.stripeSubscriptionId,
|
||||||
|
{
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return await this.saveSubscription(user, subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
// let customer contact support if they want to cancel immediately
|
|
||||||
// see https://stripe.com/docs/billing/subscriptions/cancel
|
|
||||||
const subscription = await this.stripe.subscriptions.update(
|
|
||||||
user.subscription.stripeSubscriptionId,
|
|
||||||
{
|
|
||||||
cancel_at_period_end: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return await this.saveSubscription(user, subscription);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeCanceledSubscriptin(userId: string): Promise<UserSubscription> {
|
async resumeCanceledSubscription(userId: string): Promise<UserSubscription> {
|
||||||
const user = await this.db.user.findUnique({
|
const user = await this.db.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -185,19 +202,29 @@ export class SubscriptionService {
|
|||||||
throw new Error('Your subscription is expired, please checkout again.');
|
throw new Error('Your subscription is expired, please checkout again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await this.stripe.subscriptions.update(
|
if (user.subscription.stripeScheduleId) {
|
||||||
user.subscription.stripeSubscriptionId,
|
await this.resumeSubscriptionSchedule(user.subscription.stripeScheduleId);
|
||||||
{
|
return this.saveSubscription(
|
||||||
cancel_at_period_end: false,
|
user,
|
||||||
}
|
await this.stripe.subscriptions.retrieve(
|
||||||
);
|
user.subscription.stripeSubscriptionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const subscription = await this.stripe.subscriptions.update(
|
||||||
|
user.subscription.stripeSubscriptionId,
|
||||||
|
{
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return await this.saveSubscription(user, subscription);
|
return await this.saveSubscription(user, subscription);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSubscriptionRecurring(
|
async updateSubscriptionRecurring(
|
||||||
userId: string,
|
userId: string,
|
||||||
recurring: string
|
recurring: SubscriptionRecurring
|
||||||
): Promise<UserSubscription> {
|
): Promise<UserSubscription> {
|
||||||
const user = await this.db.user.findUnique({
|
const user = await this.db.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -220,82 +247,35 @@ export class SubscriptionService {
|
|||||||
throw new Error('You have already subscribed to this plan');
|
throw new Error('You have already subscribed to this plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices = await this.stripe.prices.list({
|
const price = await this.getPrice(
|
||||||
lookup_keys: [recurring],
|
user.subscription.plan as SubscriptionPlan,
|
||||||
});
|
recurring
|
||||||
|
);
|
||||||
if (!prices.data.length) {
|
|
||||||
throw new Error(`Unknown subscription recurring: ${recurring}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPrice = prices.data[0];
|
|
||||||
|
|
||||||
|
let scheduleId: string | null;
|
||||||
// a schedule existing
|
// a schedule existing
|
||||||
if (user.subscription.stripeScheduleId) {
|
if (user.subscription.stripeScheduleId) {
|
||||||
const schedule = await this.stripe.subscriptionSchedules.retrieve(
|
scheduleId = await this.scheduleNewPrice(
|
||||||
user.subscription.stripeScheduleId
|
user.subscription.stripeScheduleId,
|
||||||
|
price
|
||||||
);
|
);
|
||||||
|
|
||||||
// a scheduled subscription's old price equals the change
|
|
||||||
if (
|
|
||||||
schedule.phases[0] &&
|
|
||||||
(schedule.phases[0].items[0].price as string) === newPrice.id
|
|
||||||
) {
|
|
||||||
await this.stripe.subscriptionSchedules.release(
|
|
||||||
user.subscription.stripeScheduleId
|
|
||||||
);
|
|
||||||
|
|
||||||
return await this.db.userSubscription.update({
|
|
||||||
where: {
|
|
||||||
id: user.subscription.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
recurring,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Unexpected subscription scheduled, please contact the supporters'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const schedule = await this.stripe.subscriptionSchedules.create({
|
const schedule = await this.stripe.subscriptionSchedules.create({
|
||||||
from_subscription: user.subscription.stripeSubscriptionId,
|
from_subscription: user.subscription.stripeSubscriptionId,
|
||||||
});
|
});
|
||||||
|
await this.scheduleNewPrice(schedule.id, price);
|
||||||
await this.stripe.subscriptionSchedules.update(schedule.id, {
|
scheduleId = schedule.id;
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: schedule.phases[0].items[0].price as string,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
start_date: schedule.phases[0].start_date,
|
|
||||||
end_date: schedule.phases[0].end_date,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: newPrice.id,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.db.userSubscription.update({
|
|
||||||
where: {
|
|
||||||
id: user.subscription.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
recurring,
|
|
||||||
stripeScheduleId: schedule.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await this.db.userSubscription.update({
|
||||||
|
where: {
|
||||||
|
id: user.subscription.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
stripeScheduleId: scheduleId,
|
||||||
|
recurring,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCustomerPortal(id: string) {
|
async createCustomerPortal(id: string) {
|
||||||
@@ -450,6 +430,11 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const price = subscription.items.data[0].price;
|
const price = subscription.items.data[0].price;
|
||||||
|
if (!price.lookup_key) {
|
||||||
|
throw new Error('Unexpected subscription with no key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||||
|
|
||||||
const commonData = {
|
const commonData = {
|
||||||
start: new Date(subscription.current_period_start * 1000),
|
start: new Date(subscription.current_period_start * 1000),
|
||||||
@@ -465,9 +450,8 @@ export class SubscriptionService {
|
|||||||
? new Date(subscription.canceled_at * 1000)
|
? new Date(subscription.canceled_at * 1000)
|
||||||
: null,
|
: null,
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
recurring: price.lookup_key ?? price.id,
|
plan,
|
||||||
// TODO: dynamic plans
|
recurring,
|
||||||
plan: SubscriptionPlan.Pro,
|
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
stripeScheduleId: subscription.schedule as string | null,
|
stripeScheduleId: subscription.schedule as string | null,
|
||||||
};
|
};
|
||||||
@@ -583,4 +567,183 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getPrice(
|
||||||
|
plan: SubscriptionPlan,
|
||||||
|
recurring: SubscriptionRecurring
|
||||||
|
): Promise<string> {
|
||||||
|
const prices = await this.stripe.prices.list({
|
||||||
|
lookup_keys: [encodeLookupKey(plan, recurring)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!prices.data.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown subscription plan ${plan} with recurring ${recurring}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
): 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,14 @@ export class StripeWebhook {
|
|||||||
`[${event.id}] Stripe Webhook {${event.type}} received.`
|
`[${event.id}] Stripe Webhook {${event.type}} received.`
|
||||||
);
|
);
|
||||||
|
|
||||||
// handle duplicated events?
|
// Stripe requires responseing webhook immediately and handle event asynchronously.
|
||||||
// see https://stripe.com/docs/webhooks#handle-duplicate-events
|
setImmediate(() => {
|
||||||
await this.event.emitAsync(event.type, event.data.object);
|
// handle duplicated events?
|
||||||
|
// see https://stripe.com/docs/webhooks#handle-duplicate-events
|
||||||
|
this.event.emitAsync(event.type, event.data.object).catch(e => {
|
||||||
|
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Stripe Webhook error', err);
|
this.logger.error('Stripe Webhook error', err);
|
||||||
throw new NotAcceptableException();
|
throw new NotAcceptableException();
|
||||||
|
|||||||
Reference in New Issue
Block a user