mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08:00
fix cancel subscription edge cases (#4691)
This commit is contained in:
@@ -93,7 +93,7 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (currentSubscription && currentSubscription.end < new Date()) {
|
if (currentSubscription && currentSubscription.end < new Date()) {
|
||||||
throw new Error('User already has a subscription');
|
throw new Error('You already have a subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices = await this.stripe.prices.list({
|
const prices = await this.stripe.prices.list({
|
||||||
@@ -137,11 +137,11 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user?.subscription) {
|
if (!user?.subscription) {
|
||||||
throw new Error('User has no subscription');
|
throw new Error('You do not have any subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription.canceledAt) {
|
if (user.subscription.canceledAt) {
|
||||||
throw new Error('User subscription has already been canceled ');
|
throw new Error('Your subscription has already been canceled ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// should release the schedule first
|
// should release the schedule first
|
||||||
@@ -174,17 +174,15 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user?.subscription) {
|
if (!user?.subscription) {
|
||||||
throw new Error('User has no subscription');
|
throw new Error('You do not have any subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.subscription.canceledAt) {
|
if (!user.subscription.canceledAt) {
|
||||||
throw new Error('User subscription is not canceled');
|
throw new Error('Your subscription has not been canceled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription.end < new Date()) {
|
if (user.subscription.end < new Date()) {
|
||||||
throw new Error(
|
throw new Error('Your subscription is expired, please checkout again.');
|
||||||
'User subscription has already expired, please checkout again.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await this.stripe.subscriptions.update(
|
const subscription = await this.stripe.subscriptions.update(
|
||||||
@@ -211,11 +209,15 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user?.subscription) {
|
if (!user?.subscription) {
|
||||||
throw new Error('User has no subscription');
|
throw new Error('You do not have any subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.subscription.canceledAt) {
|
||||||
|
throw new Error('Your subscription has already been canceled ');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription.recurring === recurring) {
|
if (user.subscription.recurring === recurring) {
|
||||||
throw new Error('User has already subscribed to this plan');
|
throw new Error('You have already subscribed to this plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices = await this.stripe.prices.list({
|
const prices = await this.stripe.prices.list({
|
||||||
@@ -344,29 +346,98 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('invoice.created')
|
@OnEvent('invoice.created')
|
||||||
async onInvoiceCreated(invoice: Stripe.Invoice) {
|
|
||||||
await this.saveInvoice(invoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('invoice.paid')
|
@OnEvent('invoice.paid')
|
||||||
async onInvoicePaid(invoice: Stripe.Invoice) {
|
|
||||||
await this.saveInvoice(invoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('invoice.finalization_failed')
|
@OnEvent('invoice.finalization_failed')
|
||||||
async onInvoiceFinalizeFailed(invoice: Stripe.Invoice) {
|
|
||||||
await this.saveInvoice(invoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('invoice.payment_failed')
|
@OnEvent('invoice.payment_failed')
|
||||||
async onInvoicePaymentFailed(invoice: Stripe.Invoice) {
|
async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
||||||
await this.saveInvoice(invoice);
|
if (!stripeInvoice.customer) {
|
||||||
|
throw new Error('Unexpected invoice with no customer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.retrieveUserFromCustomer(
|
||||||
|
typeof stripeInvoice.customer === 'string'
|
||||||
|
? stripeInvoice.customer
|
||||||
|
: stripeInvoice.customer.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoice = await this.db.userInvoice.findUnique({
|
||||||
|
where: {
|
||||||
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: Partial<UserInvoice> = {
|
||||||
|
currency: stripeInvoice.currency,
|
||||||
|
amount: stripeInvoice.total,
|
||||||
|
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
||||||
|
link: stripeInvoice.hosted_invoice_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// handle payment error
|
||||||
|
if (stripeInvoice.attempt_count > 1) {
|
||||||
|
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
||||||
|
stripeInvoice.payment_intent as string
|
||||||
|
);
|
||||||
|
|
||||||
|
if (paymentIntent.last_payment_error) {
|
||||||
|
if (paymentIntent.last_payment_error.type === 'card_error') {
|
||||||
|
data.lastPaymentError =
|
||||||
|
paymentIntent.last_payment_error.message ?? 'Failed to pay';
|
||||||
|
} else {
|
||||||
|
data.lastPaymentError = 'Internal Payment error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stripeInvoice.last_finalization_error) {
|
||||||
|
if (stripeInvoice.last_finalization_error.type === 'card_error') {
|
||||||
|
data.lastPaymentError =
|
||||||
|
stripeInvoice.last_finalization_error.message ??
|
||||||
|
'Failed to finalize invoice';
|
||||||
|
} else {
|
||||||
|
data.lastPaymentError = 'Internal Payment error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update invoice
|
||||||
|
if (invoice) {
|
||||||
|
await this.db.userInvoice.update({
|
||||||
|
where: {
|
||||||
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// create invoice
|
||||||
|
const price = stripeInvoice.lines.data[0].price;
|
||||||
|
|
||||||
|
if (!price || price.type !== 'recurring') {
|
||||||
|
throw new Error('Unexpected invoice with no recurring price');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.userInvoice.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: price.lookup_key ?? price.id,
|
||||||
|
reason: stripeInvoice.billing_reason ?? 'contact support',
|
||||||
|
...(data as any),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveSubscription(
|
private async saveSubscription(
|
||||||
user: User,
|
user: User,
|
||||||
subscription: Stripe.Subscription
|
subscription: Stripe.Subscription,
|
||||||
|
fromWebhook = true
|
||||||
): Promise<UserSubscription> {
|
): Promise<UserSubscription> {
|
||||||
|
// webhook events may not in sequential order
|
||||||
|
// always fetch the latest subscription and save
|
||||||
|
// see https://stripe.com/docs/webhooks#behaviors
|
||||||
|
if (fromWebhook) {
|
||||||
|
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
// get next bill date from upcoming invoice
|
// get next bill date from upcoming invoice
|
||||||
// see https://stripe.com/docs/api/invoices/upcoming
|
// see https://stripe.com/docs/api/invoices/upcoming
|
||||||
let nextBillAt: Date | null = null;
|
let nextBillAt: Date | null = null;
|
||||||
@@ -375,17 +446,7 @@ export class SubscriptionService {
|
|||||||
subscription.status === SubscriptionStatus.Trialing) &&
|
subscription.status === SubscriptionStatus.Trialing) &&
|
||||||
!subscription.canceled_at
|
!subscription.canceled_at
|
||||||
) {
|
) {
|
||||||
try {
|
nextBillAt = new Date(subscription.current_period_end * 1000);
|
||||||
const nextInvoice = await this.stripe.invoices.retrieveUpcoming({
|
|
||||||
customer: subscription.customer as string,
|
|
||||||
subscription: subscription.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
nextBillAt = new Date(nextInvoice.created * 1000);
|
|
||||||
} catch (e) {
|
|
||||||
// no upcoming invoice
|
|
||||||
// safe to ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const price = subscription.items.data[0].price;
|
const price = subscription.items.data[0].price;
|
||||||
@@ -522,79 +583,4 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
|
||||||
if (!stripeInvoice.customer) {
|
|
||||||
throw new Error('Unexpected invoice with no customer');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.retrieveUserFromCustomer(
|
|
||||||
stripeInvoice.customer as string
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoice = await this.db.userInvoice.findUnique({
|
|
||||||
where: {
|
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: Partial<UserInvoice> = {
|
|
||||||
currency: stripeInvoice.currency,
|
|
||||||
amount: stripeInvoice.total,
|
|
||||||
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
|
||||||
link: stripeInvoice.hosted_invoice_url,
|
|
||||||
};
|
|
||||||
|
|
||||||
// handle payment error
|
|
||||||
if (stripeInvoice.attempt_count > 1) {
|
|
||||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
|
||||||
stripeInvoice.payment_intent as string
|
|
||||||
);
|
|
||||||
|
|
||||||
if (paymentIntent.last_payment_error) {
|
|
||||||
if (paymentIntent.last_payment_error.type === 'card_error') {
|
|
||||||
data.lastPaymentError =
|
|
||||||
paymentIntent.last_payment_error.message ?? 'Failed to pay';
|
|
||||||
} else {
|
|
||||||
data.lastPaymentError = 'Internal Payment error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (stripeInvoice.last_finalization_error) {
|
|
||||||
if (stripeInvoice.last_finalization_error.type === 'card_error') {
|
|
||||||
data.lastPaymentError =
|
|
||||||
stripeInvoice.last_finalization_error.message ??
|
|
||||||
'Failed to finalize invoice';
|
|
||||||
} else {
|
|
||||||
data.lastPaymentError = 'Internal Payment error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update invoice
|
|
||||||
if (invoice) {
|
|
||||||
await this.db.userInvoice.update({
|
|
||||||
where: {
|
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// create invoice
|
|
||||||
const price = stripeInvoice.lines.data[0].price;
|
|
||||||
|
|
||||||
if (!price || price.type !== 'recurring') {
|
|
||||||
throw new Error('Unexpected invoice with no recurring price');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.userInvoice.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
|
||||||
plan: SubscriptionPlan.Pro,
|
|
||||||
recurring: price.lookup_key ?? price.id,
|
|
||||||
reason: stripeInvoice.billing_reason ?? 'contact support',
|
|
||||||
...(data as any),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user