feat: add idempotent request support for payment apis (#4753)

This commit is contained in:
DarkSky
2023-10-30 00:54:09 -05:00
committed by GitHub
parent 3798293d3e
commit de9e7f97a4
12 changed files with 244 additions and 138 deletions

View File

@@ -189,12 +189,14 @@ export class SubscriptionResolver {
async checkout(
@CurrentUser() user: User,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring
recurring: SubscriptionRecurring,
@Args('idempotencyKey') idempotencyKey: string
) {
const session = await this.service.createCheckoutSession({
user,
recurring,
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
idempotencyKey,
});
if (!session.url) {
@@ -217,22 +219,33 @@ export class SubscriptionResolver {
}
@Mutation(() => UserSubscriptionType)
async cancelSubscription(@CurrentUser() user: User) {
return this.service.cancelSubscription(user.id);
async cancelSubscription(
@CurrentUser() user: User,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.cancelSubscription(idempotencyKey, user.id);
}
@Mutation(() => UserSubscriptionType)
async resumeSubscription(@CurrentUser() user: User) {
return this.service.resumeCanceledSubscription(user.id);
async resumeSubscription(
@CurrentUser() user: User,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
}
@Mutation(() => UserSubscriptionType)
async updateSubscriptionRecurring(
@CurrentUser() user: User,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring
recurring: SubscriptionRecurring,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.updateSubscriptionRecurring(user.id, recurring);
return this.service.updateSubscriptionRecurring(
idempotencyKey,
user.id,
recurring
);
}
}

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
@Injectable()
export class ScheduleManager {
private _schedule: Stripe.SubscriptionSchedule | null = null;
private readonly logger = new Logger(ScheduleManager.name);
constructor(private readonly stripe: Stripe) {}
@@ -50,7 +51,10 @@ export class ScheduleManager {
if (typeof schedule === 'string') {
const s = await this.stripe.subscriptionSchedules
.retrieve(schedule)
.catch(() => undefined);
.catch(e => {
this.logger.error('Failed to retrieve subscription schedule', e);
return undefined;
});
return ScheduleManager.create(this.stripe, s);
} else {
@@ -58,7 +62,10 @@ export class ScheduleManager {
}
}
async fromSubscription(subscription: string | Stripe.Subscription) {
async fromSubscription(
idempotencyKey: string,
subscription: string | Stripe.Subscription
) {
if (typeof subscription === 'string') {
subscription = await this.stripe.subscriptions.retrieve(subscription, {
expand: ['schedule'],
@@ -68,9 +75,10 @@ export class ScheduleManager {
if (subscription.schedule) {
return await this.fromSchedule(subscription.schedule);
} else {
const schedule = await this.stripe.subscriptionSchedules.create({
from_subscription: subscription.id,
});
const schedule = await this.stripe.subscriptionSchedules.create(
{ from_subscription: subscription.id },
{ idempotencyKey }
);
return await this.fromSchedule(schedule);
}
@@ -80,7 +88,7 @@ export class ScheduleManager {
* 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() {
async cancel(idempotencyKey: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -111,13 +119,17 @@ export class ScheduleManager {
};
}
await this.stripe.subscriptionSchedules.update(this._schedule.id, {
phases: [phases],
end_behavior: 'cancel',
});
await this.stripe.subscriptionSchedules.update(
this._schedule.id,
{
phases: [phases],
end_behavior: 'cancel',
},
{ idempotencyKey }
);
}
async resume() {
async resume(idempotencyKey: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -156,21 +168,27 @@ export class ScheduleManager {
});
}
await this.stripe.subscriptionSchedules.update(this._schedule.id, {
phases: phases,
end_behavior: 'release',
});
await this.stripe.subscriptionSchedules.update(
this._schedule.id,
{
phases: phases,
end_behavior: 'release',
},
{ idempotencyKey }
);
}
async release() {
async release(idempotencyKey: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
await this.stripe.subscriptionSchedules.release(this._schedule.id);
await this.stripe.subscriptionSchedules.release(this._schedule.id, {
idempotencyKey,
});
}
async update(price: string, coupon?: string) {
async update(idempotencyKey: string, price: string, coupon?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -184,31 +202,37 @@ export class ScheduleManager {
this.currentPhase.items[0].price === price &&
(!coupon || this.currentPhase.coupon === coupon)
) {
await this.stripe.subscriptionSchedules.release(this._schedule.id);
await this.stripe.subscriptionSchedules.release(this._schedule.id, {
idempotencyKey,
});
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,
},
],
});
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,
},
],
},
{ idempotencyKey }
);
}
}
}

View File

@@ -103,12 +103,14 @@ export class SubscriptionService {
user,
recurring,
redirectUrl,
idempotencyKey,
plan = SubscriptionPlan.Pro,
}: {
user: User;
plan?: SubscriptionPlan;
recurring: SubscriptionRecurring;
redirectUrl: string;
idempotencyKey: string;
}) {
const currentSubscription = await this.db.userSubscription.findUnique({
where: {
@@ -121,37 +123,43 @@ export class SubscriptionService {
}
const price = await this.getPrice(plan, recurring);
const customer = await this.getOrCreateCustomer(user);
const customer = await this.getOrCreateCustomer(idempotencyKey, user);
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
return await this.stripe.checkout.sessions.create({
line_items: [
{
price,
quantity: 1,
return await this.stripe.checkout.sessions.create(
{
line_items: [
{
price,
quantity: 1,
},
],
tax_id_collection: {
enabled: true,
},
...(coupon
? {
discounts: [{ coupon }],
}
: {
allow_promotion_codes: true,
}),
mode: 'subscription',
success_url: redirectUrl,
customer: customer.stripeCustomerId,
customer_update: {
address: 'auto',
name: 'auto',
},
],
tax_id_collection: {
enabled: true,
},
...(coupon
? {
discounts: [{ coupon }],
}
: {
allow_promotion_codes: true,
}),
mode: 'subscription',
success_url: redirectUrl,
customer: customer.stripeCustomerId,
customer_update: {
address: 'auto',
name: 'auto',
},
});
{ idempotencyKey }
);
}
async cancelSubscription(userId: string): Promise<UserSubscription> {
async cancelSubscription(
idempotencyKey: string,
userId: string
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
where: {
id: userId,
@@ -174,7 +182,7 @@ export class SubscriptionService {
const manager = await this.scheduleManager.fromSchedule(
user.subscription.stripeScheduleId
);
await manager.cancel();
await manager.cancel(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
@@ -187,15 +195,17 @@ export class SubscriptionService {
// see https://stripe.com/docs/billing/subscriptions/cancel
const subscription = await this.stripe.subscriptions.update(
user.subscription.stripeSubscriptionId,
{
cancel_at_period_end: true,
}
{ cancel_at_period_end: true },
{ idempotencyKey }
);
return await this.saveSubscription(user, subscription);
}
}
async resumeCanceledSubscription(userId: string): Promise<UserSubscription> {
async resumeCanceledSubscription(
idempotencyKey: string,
userId: string
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
where: {
id: userId,
@@ -221,7 +231,7 @@ export class SubscriptionService {
const manager = await this.scheduleManager.fromSchedule(
user.subscription.stripeScheduleId
);
await manager.resume();
await manager.resume(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
@@ -232,9 +242,8 @@ export class SubscriptionService {
} else {
const subscription = await this.stripe.subscriptions.update(
user.subscription.stripeSubscriptionId,
{
cancel_at_period_end: false,
}
{ cancel_at_period_end: false },
{ idempotencyKey }
);
return await this.saveSubscription(user, subscription);
@@ -242,6 +251,7 @@ export class SubscriptionService {
}
async updateSubscriptionRecurring(
idempotencyKey: string,
userId: string,
recurring: SubscriptionRecurring
): Promise<UserSubscription> {
@@ -272,10 +282,12 @@ export class SubscriptionService {
);
const manager = await this.scheduleManager.fromSubscription(
idempotencyKey,
user.subscription.stripeSubscriptionId
);
await manager.update(
idempotencyKey,
price,
// if user is early access user, use early access coupon
manager.currentPhase?.coupon === CouponType.EarlyAccess ||
@@ -355,10 +367,16 @@ export class SubscriptionService {
// deal with early access user
if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) {
const idempotencyKey = stripeInvoice.id + '_earlyaccess';
const manager = await this.scheduleManager.fromSubscription(
idempotencyKey,
line.subscription as string
);
await manager.update(line.price.id, CouponType.EarlyAccessRenew);
await manager.update(
idempotencyKey,
line.price.id,
CouponType.EarlyAccessRenew
);
}
}
@@ -530,7 +548,10 @@ export class SubscriptionService {
}
}
private async getOrCreateCustomer(user: User): Promise<UserStripeCustomer> {
private async getOrCreateCustomer(
idempotencyKey: string,
user: User
): Promise<UserStripeCustomer> {
const customer = await this.db.userStripeCustomer.findUnique({
where: {
userId: user.id,
@@ -550,9 +571,10 @@ export class SubscriptionService {
if (stripeCustomersList.data.length) {
stripeCustomer = stripeCustomersList.data[0];
} else {
stripeCustomer = await this.stripe.customers.create({
email: user.email,
});
stripeCustomer = await this.stripe.customers.create(
{ email: user.email },
{ idempotencyKey }
);
}
return await this.db.userStripeCustomer.create({