mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 08:53:27 +03:00
feat(server): support onetime payment subscription (#8369)
This commit is contained in:
parent
06e059db88
commit
69fb5c06f4
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ADD COLUMN "variant" VARCHAR(20);
|
@ -332,9 +332,11 @@ model UserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly
|
||||
// yearly/monthly/lifetime
|
||||
recurring String @db.VarChar(20)
|
||||
// subscription.id, null for linefetime payment
|
||||
// onetime subscription or anything else
|
||||
variant String? @db.VarChar(20)
|
||||
// subscription.id, null for linefetime payment or one time payment subscription
|
||||
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
|
@ -443,9 +443,9 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
args: { plan: 'string', recurring: 'string' },
|
||||
message: 'You are trying to access a unknown subscription plan.',
|
||||
},
|
||||
cant_update_lifetime_subscription: {
|
||||
cant_update_onetime_payment_subscription: {
|
||||
type: 'action_forbidden',
|
||||
message: 'You cannot update a lifetime subscription.',
|
||||
message: 'You cannot update an onetime payment subscription.',
|
||||
},
|
||||
|
||||
// Copilot errors
|
||||
|
@ -390,9 +390,9 @@ export class SubscriptionPlanNotFound extends UserFriendlyError {
|
||||
}
|
||||
}
|
||||
|
||||
export class CantUpdateLifetimeSubscription extends UserFriendlyError {
|
||||
export class CantUpdateOnetimePaymentSubscription extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cant_update_lifetime_subscription', message);
|
||||
super('action_forbidden', 'cant_update_onetime_payment_subscription', message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -591,7 +591,7 @@ export enum ErrorNames {
|
||||
SAME_SUBSCRIPTION_RECURRING,
|
||||
CUSTOMER_PORTAL_CREATE_FAILED,
|
||||
SUBSCRIPTION_PLAN_NOT_FOUND,
|
||||
CANT_UPDATE_LIFETIME_SUBSCRIPTION,
|
||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
|
||||
COPILOT_SESSION_NOT_FOUND,
|
||||
COPILOT_SESSION_DELETED,
|
||||
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||
|
@ -30,10 +30,12 @@ import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from './types';
|
||||
|
||||
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
||||
registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
|
||||
registerEnumType(SubscriptionVariant, { name: 'SubscriptionVariant' });
|
||||
registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
|
||||
registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
|
||||
|
||||
@ -72,6 +74,9 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field(() => SubscriptionVariant, { nullable: true })
|
||||
variant?: SubscriptionVariant | null;
|
||||
|
||||
@Field(() => SubscriptionStatus)
|
||||
status!: SubscriptionStatus;
|
||||
|
||||
@ -150,6 +155,11 @@ class CreateCheckoutSessionInput {
|
||||
})
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionVariant, {
|
||||
nullable: true,
|
||||
})
|
||||
variant?: SubscriptionVariant;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
coupon!: string | null;
|
||||
|
||||
@ -236,6 +246,7 @@ export class SubscriptionResolver {
|
||||
user,
|
||||
plan: input.plan,
|
||||
recurring: input.recurring,
|
||||
variant: input.variant,
|
||||
promotionCode: input.coupon,
|
||||
redirectUrl: this.url.link(input.successCallbackLink),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
|
@ -15,10 +15,11 @@ import { CurrentUser } from '../../core/auth';
|
||||
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
||||
import {
|
||||
ActionForbidden,
|
||||
CantUpdateLifetimeSubscription,
|
||||
CantUpdateOnetimePaymentSubscription,
|
||||
Config,
|
||||
CustomerPortalCreateFailed,
|
||||
EventEmitter,
|
||||
InternalServerError,
|
||||
OnEvent,
|
||||
SameSubscriptionRecurring,
|
||||
SubscriptionAlreadyExists,
|
||||
@ -32,9 +33,9 @@ import { ScheduleManager } from './schedule';
|
||||
import {
|
||||
InvoiceStatus,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPriceVariant,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from './types';
|
||||
|
||||
const OnStripeEvent = (
|
||||
@ -46,20 +47,20 @@ const OnStripeEvent = (
|
||||
export function encodeLookupKey(
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring,
|
||||
variant?: SubscriptionPriceVariant
|
||||
variant?: SubscriptionVariant
|
||||
): string {
|
||||
return `${plan}_${recurring}` + (variant ? `_${variant}` : '');
|
||||
}
|
||||
|
||||
export function decodeLookupKey(
|
||||
key: string
|
||||
): [SubscriptionPlan, SubscriptionRecurring, SubscriptionPriceVariant?] {
|
||||
): [SubscriptionPlan, SubscriptionRecurring, SubscriptionVariant?] {
|
||||
const [plan, recurring, variant] = key.split('_');
|
||||
|
||||
return [
|
||||
plan as SubscriptionPlan,
|
||||
recurring as SubscriptionRecurring,
|
||||
variant as SubscriptionPriceVariant | undefined,
|
||||
variant as SubscriptionVariant | undefined,
|
||||
];
|
||||
}
|
||||
|
||||
@ -137,6 +138,12 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key);
|
||||
|
||||
// never return onetime payment price
|
||||
if (variant === SubscriptionVariant.Onetime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// no variant price should be used for monthly or lifetime subscription
|
||||
if (
|
||||
recurring === SubscriptionRecurring.Monthly ||
|
||||
@ -167,6 +174,7 @@ export class SubscriptionService {
|
||||
user,
|
||||
recurring,
|
||||
plan,
|
||||
variant,
|
||||
promotionCode,
|
||||
redirectUrl,
|
||||
idempotencyKey,
|
||||
@ -174,6 +182,7 @@ export class SubscriptionService {
|
||||
user: CurrentUser;
|
||||
recurring: SubscriptionRecurring;
|
||||
plan: SubscriptionPlan;
|
||||
variant?: SubscriptionVariant;
|
||||
promotionCode?: string | null;
|
||||
redirectUrl: string;
|
||||
idempotencyKey: string;
|
||||
@ -186,6 +195,11 @@ export class SubscriptionService {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
// variant is not allowed for lifetime subscription
|
||||
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||
variant = undefined;
|
||||
}
|
||||
|
||||
const currentSubscription = await this.db.userSubscription.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@ -196,9 +210,18 @@ export class SubscriptionService {
|
||||
|
||||
if (
|
||||
currentSubscription &&
|
||||
// do not allow to re-subscribe unless the new recurring is `Lifetime`
|
||||
(currentSubscription.recurring === recurring ||
|
||||
recurring !== SubscriptionRecurring.Lifetime)
|
||||
// do not allow to re-subscribe unless
|
||||
!(
|
||||
/* current subscription is a onetime subscription and so as the one that's checking out */
|
||||
(
|
||||
(currentSubscription.variant === SubscriptionVariant.Onetime &&
|
||||
variant === SubscriptionVariant.Onetime) ||
|
||||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
|
||||
(currentSubscription.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
currentSubscription.variant !== SubscriptionVariant.Onetime &&
|
||||
recurring === SubscriptionRecurring.Lifetime)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan });
|
||||
}
|
||||
@ -211,7 +234,8 @@ export class SubscriptionService {
|
||||
const { price, coupon } = await this.getAvailablePrice(
|
||||
customer,
|
||||
plan,
|
||||
recurring
|
||||
recurring,
|
||||
variant
|
||||
);
|
||||
|
||||
let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = [];
|
||||
@ -241,8 +265,9 @@ export class SubscriptionService {
|
||||
},
|
||||
// discount
|
||||
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
|
||||
// mode: 'subscription' or 'payment' for lifetime
|
||||
...(recurring === SubscriptionRecurring.Lifetime
|
||||
// mode: 'subscription' or 'payment' for lifetime and onetime payment
|
||||
...(recurring === SubscriptionRecurring.Lifetime ||
|
||||
variant === SubscriptionVariant.Onetime
|
||||
? {
|
||||
mode: 'payment',
|
||||
invoice_creation: {
|
||||
@ -291,8 +316,8 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
if (!subscriptionInDB.stripeSubscriptionId) {
|
||||
throw new CantUpdateLifetimeSubscription(
|
||||
'Lifetime subscription cannot be canceled.'
|
||||
throw new CantUpdateOnetimePaymentSubscription(
|
||||
'Onetime payment subscription cannot be canceled.'
|
||||
);
|
||||
}
|
||||
|
||||
@ -348,8 +373,8 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
|
||||
throw new CantUpdateLifetimeSubscription(
|
||||
'Lifetime subscription cannot be resumed.'
|
||||
throw new CantUpdateOnetimePaymentSubscription(
|
||||
'Onetime payment subscription cannot be resumed.'
|
||||
);
|
||||
}
|
||||
|
||||
@ -407,9 +432,7 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
if (!subscriptionInDB.stripeSubscriptionId) {
|
||||
throw new CantUpdateLifetimeSubscription(
|
||||
'Can not update lifetime subscription.'
|
||||
);
|
||||
throw new CantUpdateOnetimePaymentSubscription();
|
||||
}
|
||||
|
||||
if (subscriptionInDB.canceledAt) {
|
||||
@ -525,7 +548,7 @@ export class SubscriptionService {
|
||||
throw new Error('Unexpected subscription with no key');
|
||||
}
|
||||
|
||||
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key);
|
||||
|
||||
const invoice = await this.db.userInvoice.upsert({
|
||||
where: {
|
||||
@ -537,7 +560,7 @@ export class SubscriptionService {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
plan,
|
||||
recurring,
|
||||
reason: stripeInvoice.billing_reason ?? 'contact support',
|
||||
reason: stripeInvoice.billing_reason ?? 'subscription_update',
|
||||
...(data as any),
|
||||
},
|
||||
});
|
||||
@ -545,10 +568,13 @@ export class SubscriptionService {
|
||||
// handle one time payment, no subscription created by stripe
|
||||
if (
|
||||
event === 'invoice.payment_succeeded' &&
|
||||
recurring === SubscriptionRecurring.Lifetime &&
|
||||
stripeInvoice.status === 'paid'
|
||||
) {
|
||||
await this.saveLifetimeSubscription(user, invoice);
|
||||
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||
await this.saveLifetimeSubscription(user, invoice);
|
||||
} else if (variant === SubscriptionVariant.Onetime) {
|
||||
await this.saveOnetimePaymentSubscription(user, invoice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -607,6 +633,72 @@ export class SubscriptionService {
|
||||
});
|
||||
}
|
||||
|
||||
async saveOnetimePaymentSubscription(user: User, invoice: UserInvoice) {
|
||||
const savedSubscription = await this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId_plan: {
|
||||
userId: user.id,
|
||||
plan: invoice.plan,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// TODO(@forehalo): time helper
|
||||
const subscriptionTime =
|
||||
(invoice.recurring === SubscriptionRecurring.Monthly ? 30 : 365) *
|
||||
24 *
|
||||
60 *
|
||||
60 *
|
||||
1000;
|
||||
|
||||
// extends the subscription time if exists
|
||||
if (savedSubscription) {
|
||||
if (!savedSubscription.end) {
|
||||
throw new InternalServerError(
|
||||
'Unexpected onetime subscription with no end date'
|
||||
);
|
||||
}
|
||||
|
||||
const period =
|
||||
// expired, reset the period
|
||||
savedSubscription.end <= new Date()
|
||||
? {
|
||||
start: new Date(),
|
||||
end: new Date(Date.now() + subscriptionTime),
|
||||
}
|
||||
: {
|
||||
end: new Date(savedSubscription.end.getTime() + subscriptionTime),
|
||||
};
|
||||
|
||||
await this.db.userSubscription.update({
|
||||
where: {
|
||||
id: savedSubscription.id,
|
||||
},
|
||||
data: period,
|
||||
});
|
||||
} else {
|
||||
await this.db.userSubscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeSubscriptionId: null,
|
||||
plan: invoice.plan,
|
||||
recurring: invoice.recurring,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
start: new Date(),
|
||||
end: new Date(Date.now() + subscriptionTime),
|
||||
status: SubscriptionStatus.Active,
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.event.emit('user.subscription.activated', {
|
||||
userId: user.id,
|
||||
plan: invoice.plan as SubscriptionPlan,
|
||||
recurring: invoice.recurring as SubscriptionRecurring,
|
||||
});
|
||||
}
|
||||
|
||||
@OnStripeEvent('customer.subscription.created')
|
||||
@OnStripeEvent('customer.subscription.updated')
|
||||
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
||||
@ -656,7 +748,8 @@ export class SubscriptionService {
|
||||
throw new Error('Unexpected subscription with no key');
|
||||
}
|
||||
|
||||
const [plan, recurring] = this.decodePlanFromSubscription(subscription);
|
||||
const [plan, recurring, variant] =
|
||||
this.decodePlanFromSubscription(subscription);
|
||||
const planActivated = SubscriptionActivated.includes(subscription.status);
|
||||
|
||||
// update features first, features modify are idempotent
|
||||
@ -689,6 +782,8 @@ export class SubscriptionService {
|
||||
: null,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
plan,
|
||||
recurring,
|
||||
variant,
|
||||
status: subscription.status,
|
||||
stripeScheduleId: subscription.schedule as string | null,
|
||||
};
|
||||
@ -700,7 +795,6 @@ export class SubscriptionService {
|
||||
update: commonData,
|
||||
create: {
|
||||
userId: user.id,
|
||||
recurring,
|
||||
...commonData,
|
||||
},
|
||||
});
|
||||
@ -813,7 +907,7 @@ export class SubscriptionService {
|
||||
private async getPrice(
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring,
|
||||
variant?: SubscriptionPriceVariant
|
||||
variant?: SubscriptionVariant
|
||||
): Promise<string> {
|
||||
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||
const lifetimePriceEnabled = await this.config.runtime.fetch(
|
||||
@ -845,8 +939,14 @@ export class SubscriptionService {
|
||||
private async getAvailablePrice(
|
||||
customer: UserStripeCustomer,
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
recurring: SubscriptionRecurring,
|
||||
variant?: SubscriptionVariant
|
||||
): Promise<{ price: string; coupon?: string }> {
|
||||
if (variant) {
|
||||
const price = await this.getPrice(plan, recurring, variant);
|
||||
return { price };
|
||||
}
|
||||
|
||||
const isEaUser = await this.feature.isEarlyAccessUser(customer.userId);
|
||||
const oldSubscriptions = await this.stripe.subscriptions.list({
|
||||
customer: customer.stripeCustomerId,
|
||||
@ -867,7 +967,7 @@ export class SubscriptionService {
|
||||
const price = await this.getPrice(
|
||||
plan,
|
||||
recurring,
|
||||
canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
|
||||
canHaveEADiscount ? SubscriptionVariant.EA : undefined
|
||||
);
|
||||
return {
|
||||
price,
|
||||
@ -886,7 +986,7 @@ export class SubscriptionService {
|
||||
const price = await this.getPrice(
|
||||
plan,
|
||||
recurring,
|
||||
canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
|
||||
canHaveEADiscount ? SubscriptionVariant.EA : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -17,8 +17,9 @@ export enum SubscriptionPlan {
|
||||
SelfHosted = 'selfhosted',
|
||||
}
|
||||
|
||||
export enum SubscriptionPriceVariant {
|
||||
export enum SubscriptionVariant {
|
||||
EA = 'earlyaccess',
|
||||
Onetime = 'onetime',
|
||||
}
|
||||
|
||||
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||
|
@ -143,6 +143,7 @@ input CreateCheckoutSessionInput {
|
||||
plan: SubscriptionPlan = Pro
|
||||
recurring: SubscriptionRecurring = Yearly
|
||||
successCallbackLink: String!
|
||||
variant: SubscriptionVariant
|
||||
}
|
||||
|
||||
input CreateCopilotPromptInput {
|
||||
@ -217,7 +218,7 @@ enum ErrorNames {
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
|
||||
CANNOT_DELETE_OWN_ACCOUNT
|
||||
CANT_CHANGE_SPACE_OWNER
|
||||
CANT_UPDATE_LIFETIME_SUBSCRIPTION
|
||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION
|
||||
CAPTCHA_VERIFICATION_FAILED
|
||||
COPILOT_ACTION_TAKEN
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE
|
||||
@ -763,6 +764,11 @@ enum SubscriptionStatus {
|
||||
Unpaid
|
||||
}
|
||||
|
||||
enum SubscriptionVariant {
|
||||
EA
|
||||
Onetime
|
||||
}
|
||||
|
||||
type UnknownOauthProviderDataType {
|
||||
name: String!
|
||||
}
|
||||
@ -835,6 +841,7 @@ type UserSubscription {
|
||||
trialEnd: DateTime
|
||||
trialStart: DateTime
|
||||
updatedAt: DateTime!
|
||||
variant: SubscriptionVariant
|
||||
}
|
||||
|
||||
type UserType {
|
||||
|
@ -22,9 +22,9 @@ import {
|
||||
} from '../../src/plugins/payment/service';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionPriceVariant,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from '../../src/plugins/payment/types';
|
||||
import { createTestingApp } from '../utils';
|
||||
|
||||
@ -85,9 +85,13 @@ test.afterEach.always(async t => {
|
||||
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
|
||||
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
|
||||
const PRO_LIFETIME = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`;
|
||||
const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`;
|
||||
const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`;
|
||||
const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`;
|
||||
const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`;
|
||||
const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`;
|
||||
// prices for code redeeming
|
||||
const PRO_MONTHLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`;
|
||||
const PRO_YEARLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`;
|
||||
const AI_YEARLY_CODE = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`;
|
||||
|
||||
const PRICES = {
|
||||
[PRO_MONTHLY]: {
|
||||
@ -135,6 +139,21 @@ const PRICES = {
|
||||
currency: 'usd',
|
||||
lookup_key: AI_YEARLY_EA,
|
||||
},
|
||||
[PRO_MONTHLY_CODE]: {
|
||||
unit_amount: 799,
|
||||
currency: 'usd',
|
||||
lookup_key: PRO_MONTHLY_CODE,
|
||||
},
|
||||
[PRO_YEARLY_CODE]: {
|
||||
unit_amount: 8100,
|
||||
currency: 'usd',
|
||||
lookup_key: PRO_YEARLY_CODE,
|
||||
},
|
||||
[AI_YEARLY_CODE]: {
|
||||
unit_amount: 10680,
|
||||
currency: 'usd',
|
||||
lookup_key: AI_YEARLY_CODE,
|
||||
},
|
||||
};
|
||||
|
||||
const sub: Stripe.Subscription = {
|
||||
@ -951,8 +970,8 @@ test('should operate with latest subscription status', async t => {
|
||||
});
|
||||
|
||||
// ============== Lifetime Subscription ===============
|
||||
const invoice: Stripe.Invoice = {
|
||||
id: 'in_xxx',
|
||||
const lifetimeInvoice: Stripe.Invoice = {
|
||||
id: 'in_1',
|
||||
object: 'invoice',
|
||||
amount_paid: 49900,
|
||||
total: 49900,
|
||||
@ -969,6 +988,42 @@ const invoice: Stripe.Invoice = {
|
||||
},
|
||||
};
|
||||
|
||||
const onetimeMonthlyInvoice: Stripe.Invoice = {
|
||||
id: 'in_2',
|
||||
object: 'invoice',
|
||||
amount_paid: 799,
|
||||
total: 799,
|
||||
customer: 'cus_1',
|
||||
currency: 'usd',
|
||||
status: 'paid',
|
||||
lines: {
|
||||
data: [
|
||||
{
|
||||
// @ts-expect-error stub
|
||||
price: PRICES[PRO_MONTHLY_CODE],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const onetimeYearlyInvoice: Stripe.Invoice = {
|
||||
id: 'in_3',
|
||||
object: 'invoice',
|
||||
amount_paid: 8100,
|
||||
total: 8100,
|
||||
customer: 'cus_1',
|
||||
currency: 'usd',
|
||||
status: 'paid',
|
||||
lines: {
|
||||
data: [
|
||||
{
|
||||
// @ts-expect-error stub
|
||||
price: PRICES[PRO_YEARLY_CODE],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test('should not be able to checkout for lifetime recurring if not enabled', async t => {
|
||||
const { service, stripe, u1 } = t.context;
|
||||
|
||||
@ -1008,13 +1063,62 @@ test('should be able to checkout for lifetime recurring', async t => {
|
||||
t.true(sessionStub.calledOnce);
|
||||
});
|
||||
|
||||
test('should not be able to checkout for lifetime recurring if already subscribed', async t => {
|
||||
const { service, u1, db } = t.context;
|
||||
|
||||
await db.userSubscription.create({
|
||||
data: {
|
||||
userId: u1.id,
|
||||
stripeSubscriptionId: null,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.createCheckoutSession({
|
||||
user: u1,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
redirectUrl: '',
|
||||
idempotencyKey: '',
|
||||
}),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
|
||||
await db.userSubscription.updateMany({
|
||||
where: { userId: u1.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
end: new Date(Date.now() + 100000),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.createCheckoutSession({
|
||||
user: u1,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
redirectUrl: '',
|
||||
idempotencyKey: '',
|
||||
}),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to subscribe to lifetime recurring', async t => {
|
||||
// lifetime payment isn't a subscription, so we need to trigger the creation by invoice payment event
|
||||
const { service, stripe, db, u1, event } = t.context;
|
||||
|
||||
const emitStub = Sinon.stub(event, 'emit');
|
||||
Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any);
|
||||
await service.saveInvoice(invoice, 'invoice.payment_succeeded');
|
||||
Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any);
|
||||
await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded');
|
||||
|
||||
const subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
@ -1049,9 +1153,9 @@ test('should be able to subscribe to lifetime recurring with old subscription',
|
||||
});
|
||||
|
||||
const emitStub = Sinon.stub(event, 'emit');
|
||||
Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any);
|
||||
Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any);
|
||||
Sinon.stub(stripe.subscriptions, 'cancel').resolves(sub as any);
|
||||
await service.saveInvoice(invoice, 'invoice.payment_succeeded');
|
||||
await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded');
|
||||
|
||||
const subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
@ -1086,7 +1190,7 @@ test('should not be able to update lifetime recurring', async t => {
|
||||
|
||||
await t.throwsAsync(
|
||||
() => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro),
|
||||
{ message: 'Lifetime subscription cannot be canceled.' }
|
||||
{ message: 'Onetime payment subscription cannot be canceled.' }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
@ -1097,11 +1201,211 @@ test('should not be able to update lifetime recurring', async t => {
|
||||
SubscriptionPlan.Pro,
|
||||
SubscriptionRecurring.Monthly
|
||||
),
|
||||
{ message: 'Can not update lifetime subscription.' }
|
||||
{ message: 'You cannot update an onetime payment subscription.' }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
() => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro),
|
||||
{ message: 'Lifetime subscription cannot be resumed.' }
|
||||
{ message: 'Onetime payment subscription cannot be resumed.' }
|
||||
);
|
||||
});
|
||||
|
||||
// ============== Onetime Subscription ===============
|
||||
test('should be able to checkout for onetime payment', async t => {
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
const checkoutStub = Sinon.stub(stripe.checkout.sessions, 'create');
|
||||
// @ts-expect-error private member
|
||||
Sinon.stub(service, 'getAvailablePrice').resolves({
|
||||
// @ts-expect-error type inference error
|
||||
price: PRO_MONTHLY_CODE,
|
||||
coupon: undefined,
|
||||
});
|
||||
|
||||
await service.createCheckoutSession({
|
||||
user: u1,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
redirectUrl: '',
|
||||
idempotencyKey: '',
|
||||
});
|
||||
|
||||
t.true(checkoutStub.calledOnce);
|
||||
const arg = checkoutStub.firstCall
|
||||
.args[0] as Stripe.Checkout.SessionCreateParams;
|
||||
t.is(arg.mode, 'payment');
|
||||
t.is(arg.line_items?.[0].price, PRO_MONTHLY_CODE);
|
||||
});
|
||||
|
||||
test('should be able to checkout onetime payment if previous subscription is onetime', async t => {
|
||||
const { service, u1, stripe, db } = t.context;
|
||||
|
||||
await db.userSubscription.create({
|
||||
data: {
|
||||
userId: u1.id,
|
||||
stripeSubscriptionId: 'sub_1',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const checkoutStub = Sinon.stub(stripe.checkout.sessions, 'create');
|
||||
// @ts-expect-error private member
|
||||
Sinon.stub(service, 'getAvailablePrice').resolves({
|
||||
// @ts-expect-error type inference error
|
||||
price: PRO_MONTHLY_CODE,
|
||||
coupon: undefined,
|
||||
});
|
||||
|
||||
await service.createCheckoutSession({
|
||||
user: u1,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
redirectUrl: '',
|
||||
idempotencyKey: '',
|
||||
});
|
||||
|
||||
t.true(checkoutStub.calledOnce);
|
||||
const arg = checkoutStub.firstCall
|
||||
.args[0] as Stripe.Checkout.SessionCreateParams;
|
||||
t.is(arg.mode, 'payment');
|
||||
t.is(arg.line_items?.[0].price, PRO_MONTHLY_CODE);
|
||||
});
|
||||
|
||||
test('should not be able to checkout out onetime payment if previous subscription is not onetime', async t => {
|
||||
const { service, u1, db } = t.context;
|
||||
|
||||
await db.userSubscription.create({
|
||||
data: {
|
||||
userId: u1.id,
|
||||
stripeSubscriptionId: 'sub_1',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.createCheckoutSession({
|
||||
user: u1,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
redirectUrl: '',
|
||||
idempotencyKey: '',
|
||||
}),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
|
||||
await db.userSubscription.updateMany({
|
||||
where: { userId: u1.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.createCheckoutSession({
|
||||
user: u1,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
redirectUrl: '',
|
||||
idempotencyKey: '',
|
||||
}),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to subscribe onetime payment subscription', async t => {
|
||||
const { service, stripe, db, u1, event } = t.context;
|
||||
|
||||
const emitStub = Sinon.stub(event, 'emit');
|
||||
Sinon.stub(stripe.invoices, 'retrieve').resolves(
|
||||
onetimeMonthlyInvoice as any
|
||||
);
|
||||
await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded');
|
||||
|
||||
const subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
|
||||
t.true(
|
||||
emitStub.calledOnceWith('user.subscription.activated', {
|
||||
userId: u1.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
})
|
||||
);
|
||||
t.is(subInDB?.plan, SubscriptionPlan.Pro);
|
||||
t.is(subInDB?.recurring, SubscriptionRecurring.Monthly);
|
||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||
t.is(subInDB?.stripeSubscriptionId, null);
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to recalculate onetime payment subscription period', async t => {
|
||||
const { service, stripe, db, u1 } = t.context;
|
||||
|
||||
const stub = Sinon.stub(stripe.invoices, 'retrieve').resolves(
|
||||
onetimeMonthlyInvoice as any
|
||||
);
|
||||
await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded');
|
||||
|
||||
let subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
|
||||
t.truthy(subInDB);
|
||||
|
||||
let end = subInDB!.end!;
|
||||
await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded');
|
||||
subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
|
||||
// add 30 days
|
||||
t.is(subInDB!.end!.getTime(), end.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
end = subInDB!.end!;
|
||||
stub.resolves(onetimeYearlyInvoice as any);
|
||||
await service.saveInvoice(onetimeYearlyInvoice, 'invoice.payment_succeeded');
|
||||
subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
|
||||
// add 365 days
|
||||
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// make subscription expired
|
||||
await db.userSubscription.update({
|
||||
where: { id: subInDB!.id },
|
||||
data: {
|
||||
end: new Date(Date.now() - 1000),
|
||||
},
|
||||
});
|
||||
await service.saveInvoice(onetimeYearlyInvoice, 'invoice.payment_succeeded');
|
||||
subInDB = await db.userSubscription.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
|
||||
// add 365 days from now
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
});
|
||||
|
@ -59,6 +59,7 @@ export const AISubscribe = ({
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
idempotencyKey,
|
||||
plan: SubscriptionPlan.AI,
|
||||
variant: null,
|
||||
coupon: null,
|
||||
successCallbackLink: generateSubscriptionCallbackLink(
|
||||
authService.session.account$.value,
|
||||
|
@ -282,6 +282,7 @@ export const Upgrade = ({
|
||||
recurring,
|
||||
idempotencyKey,
|
||||
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
||||
variant: null,
|
||||
coupon: null,
|
||||
successCallbackLink: generateSubscriptionCallbackLink(
|
||||
authService.session.account$.value,
|
||||
|
@ -100,6 +100,7 @@ export const Component = () => {
|
||||
plan: targetPlan,
|
||||
coupon: null,
|
||||
recurring: targetRecurring,
|
||||
variant: null,
|
||||
successCallbackLink: generateSubscriptionCallbackLink(
|
||||
account,
|
||||
targetPlan,
|
||||
|
@ -192,6 +192,7 @@ export interface CreateCheckoutSessionInput {
|
||||
plan: InputMaybe<SubscriptionPlan>;
|
||||
recurring: InputMaybe<SubscriptionRecurring>;
|
||||
successCallbackLink: Scalars['String']['input'];
|
||||
variant: InputMaybe<SubscriptionVariant>;
|
||||
}
|
||||
|
||||
export interface CreateCopilotPromptInput {
|
||||
@ -291,7 +292,7 @@ export enum ErrorNames {
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
|
||||
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
|
||||
CANT_CHANGE_SPACE_OWNER = 'CANT_CHANGE_SPACE_OWNER',
|
||||
CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION',
|
||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION = 'CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION',
|
||||
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
|
||||
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
|
||||
@ -1063,6 +1064,11 @@ export enum SubscriptionStatus {
|
||||
Unpaid = 'Unpaid',
|
||||
}
|
||||
|
||||
export enum SubscriptionVariant {
|
||||
EA = 'EA',
|
||||
Onetime = 'Onetime',
|
||||
}
|
||||
|
||||
export interface UnknownOauthProviderDataType {
|
||||
__typename?: 'UnknownOauthProviderDataType';
|
||||
name: Scalars['String']['output'];
|
||||
|
Loading…
Reference in New Issue
Block a user