mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 15:26:59 +08:00
feat(server): support onetime payment subscription (#8369)
This commit is contained in:
@@ -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
|
id Int @id @default(autoincrement()) @db.Integer
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
plan String @db.VarChar(20)
|
plan String @db.VarChar(20)
|
||||||
// yearly/monthly
|
// yearly/monthly/lifetime
|
||||||
recurring String @db.VarChar(20)
|
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")
|
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||||
// subscription.status, active/past_due/canceled/unpaid...
|
// subscription.status, active/past_due/canceled/unpaid...
|
||||||
status String @db.VarChar(20)
|
status String @db.VarChar(20)
|
||||||
|
|||||||
@@ -443,9 +443,9 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
args: { plan: 'string', recurring: 'string' },
|
args: { plan: 'string', recurring: 'string' },
|
||||||
message: 'You are trying to access a unknown subscription plan.',
|
message: 'You are trying to access a unknown subscription plan.',
|
||||||
},
|
},
|
||||||
cant_update_lifetime_subscription: {
|
cant_update_onetime_payment_subscription: {
|
||||||
type: 'action_forbidden',
|
type: 'action_forbidden',
|
||||||
message: 'You cannot update a lifetime subscription.',
|
message: 'You cannot update an onetime payment subscription.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Copilot errors
|
// Copilot errors
|
||||||
|
|||||||
@@ -390,9 +390,9 @@ export class SubscriptionPlanNotFound extends UserFriendlyError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CantUpdateLifetimeSubscription extends UserFriendlyError {
|
export class CantUpdateOnetimePaymentSubscription extends UserFriendlyError {
|
||||||
constructor(message?: string) {
|
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,
|
SAME_SUBSCRIPTION_RECURRING,
|
||||||
CUSTOMER_PORTAL_CREATE_FAILED,
|
CUSTOMER_PORTAL_CREATE_FAILED,
|
||||||
SUBSCRIPTION_PLAN_NOT_FOUND,
|
SUBSCRIPTION_PLAN_NOT_FOUND,
|
||||||
CANT_UPDATE_LIFETIME_SUBSCRIPTION,
|
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
|
||||||
COPILOT_SESSION_NOT_FOUND,
|
COPILOT_SESSION_NOT_FOUND,
|
||||||
COPILOT_SESSION_DELETED,
|
COPILOT_SESSION_DELETED,
|
||||||
NO_COPILOT_PROVIDER_AVAILABLE,
|
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import {
|
|||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
|
SubscriptionVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
||||||
registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
|
registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
|
||||||
|
registerEnumType(SubscriptionVariant, { name: 'SubscriptionVariant' });
|
||||||
registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
|
registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
|
||||||
registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
|
registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
|
||||||
|
|
||||||
@@ -72,6 +74,9 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
|
|||||||
@Field(() => SubscriptionRecurring)
|
@Field(() => SubscriptionRecurring)
|
||||||
recurring!: SubscriptionRecurring;
|
recurring!: SubscriptionRecurring;
|
||||||
|
|
||||||
|
@Field(() => SubscriptionVariant, { nullable: true })
|
||||||
|
variant?: SubscriptionVariant | null;
|
||||||
|
|
||||||
@Field(() => SubscriptionStatus)
|
@Field(() => SubscriptionStatus)
|
||||||
status!: SubscriptionStatus;
|
status!: SubscriptionStatus;
|
||||||
|
|
||||||
@@ -150,6 +155,11 @@ class CreateCheckoutSessionInput {
|
|||||||
})
|
})
|
||||||
plan!: SubscriptionPlan;
|
plan!: SubscriptionPlan;
|
||||||
|
|
||||||
|
@Field(() => SubscriptionVariant, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
variant?: SubscriptionVariant;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
coupon!: string | null;
|
coupon!: string | null;
|
||||||
|
|
||||||
@@ -236,6 +246,7 @@ export class SubscriptionResolver {
|
|||||||
user,
|
user,
|
||||||
plan: input.plan,
|
plan: input.plan,
|
||||||
recurring: input.recurring,
|
recurring: input.recurring,
|
||||||
|
variant: input.variant,
|
||||||
promotionCode: input.coupon,
|
promotionCode: input.coupon,
|
||||||
redirectUrl: this.url.link(input.successCallbackLink),
|
redirectUrl: this.url.link(input.successCallbackLink),
|
||||||
idempotencyKey: input.idempotencyKey,
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import { CurrentUser } from '../../core/auth';
|
|||||||
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
||||||
import {
|
import {
|
||||||
ActionForbidden,
|
ActionForbidden,
|
||||||
CantUpdateLifetimeSubscription,
|
CantUpdateOnetimePaymentSubscription,
|
||||||
Config,
|
Config,
|
||||||
CustomerPortalCreateFailed,
|
CustomerPortalCreateFailed,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
InternalServerError,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
SameSubscriptionRecurring,
|
SameSubscriptionRecurring,
|
||||||
SubscriptionAlreadyExists,
|
SubscriptionAlreadyExists,
|
||||||
@@ -32,9 +33,9 @@ import { ScheduleManager } from './schedule';
|
|||||||
import {
|
import {
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionPriceVariant,
|
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
|
SubscriptionVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const OnStripeEvent = (
|
const OnStripeEvent = (
|
||||||
@@ -46,20 +47,20 @@ const OnStripeEvent = (
|
|||||||
export function encodeLookupKey(
|
export function encodeLookupKey(
|
||||||
plan: SubscriptionPlan,
|
plan: SubscriptionPlan,
|
||||||
recurring: SubscriptionRecurring,
|
recurring: SubscriptionRecurring,
|
||||||
variant?: SubscriptionPriceVariant
|
variant?: SubscriptionVariant
|
||||||
): string {
|
): string {
|
||||||
return `${plan}_${recurring}` + (variant ? `_${variant}` : '');
|
return `${plan}_${recurring}` + (variant ? `_${variant}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeLookupKey(
|
export function decodeLookupKey(
|
||||||
key: string
|
key: string
|
||||||
): [SubscriptionPlan, SubscriptionRecurring, SubscriptionPriceVariant?] {
|
): [SubscriptionPlan, SubscriptionRecurring, SubscriptionVariant?] {
|
||||||
const [plan, recurring, variant] = key.split('_');
|
const [plan, recurring, variant] = key.split('_');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
plan as SubscriptionPlan,
|
plan as SubscriptionPlan,
|
||||||
recurring as SubscriptionRecurring,
|
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);
|
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
|
// no variant price should be used for monthly or lifetime subscription
|
||||||
if (
|
if (
|
||||||
recurring === SubscriptionRecurring.Monthly ||
|
recurring === SubscriptionRecurring.Monthly ||
|
||||||
@@ -167,6 +174,7 @@ export class SubscriptionService {
|
|||||||
user,
|
user,
|
||||||
recurring,
|
recurring,
|
||||||
plan,
|
plan,
|
||||||
|
variant,
|
||||||
promotionCode,
|
promotionCode,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -174,6 +182,7 @@ export class SubscriptionService {
|
|||||||
user: CurrentUser;
|
user: CurrentUser;
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
|
variant?: SubscriptionVariant;
|
||||||
promotionCode?: string | null;
|
promotionCode?: string | null;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
@@ -186,6 +195,11 @@ export class SubscriptionService {
|
|||||||
throw new ActionForbidden();
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// variant is not allowed for lifetime subscription
|
||||||
|
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||||
|
variant = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const currentSubscription = await this.db.userSubscription.findFirst({
|
const currentSubscription = await this.db.userSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -196,9 +210,18 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
currentSubscription &&
|
currentSubscription &&
|
||||||
// do not allow to re-subscribe unless the new recurring is `Lifetime`
|
// do not allow to re-subscribe unless
|
||||||
(currentSubscription.recurring === recurring ||
|
!(
|
||||||
recurring !== SubscriptionRecurring.Lifetime)
|
/* 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 });
|
throw new SubscriptionAlreadyExists({ plan });
|
||||||
}
|
}
|
||||||
@@ -211,7 +234,8 @@ export class SubscriptionService {
|
|||||||
const { price, coupon } = await this.getAvailablePrice(
|
const { price, coupon } = await this.getAvailablePrice(
|
||||||
customer,
|
customer,
|
||||||
plan,
|
plan,
|
||||||
recurring
|
recurring,
|
||||||
|
variant
|
||||||
);
|
);
|
||||||
|
|
||||||
let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = [];
|
let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = [];
|
||||||
@@ -241,8 +265,9 @@ export class SubscriptionService {
|
|||||||
},
|
},
|
||||||
// discount
|
// discount
|
||||||
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
|
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
|
||||||
// mode: 'subscription' or 'payment' for lifetime
|
// mode: 'subscription' or 'payment' for lifetime and onetime payment
|
||||||
...(recurring === SubscriptionRecurring.Lifetime
|
...(recurring === SubscriptionRecurring.Lifetime ||
|
||||||
|
variant === SubscriptionVariant.Onetime
|
||||||
? {
|
? {
|
||||||
mode: 'payment',
|
mode: 'payment',
|
||||||
invoice_creation: {
|
invoice_creation: {
|
||||||
@@ -291,8 +316,8 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!subscriptionInDB.stripeSubscriptionId) {
|
if (!subscriptionInDB.stripeSubscriptionId) {
|
||||||
throw new CantUpdateLifetimeSubscription(
|
throw new CantUpdateOnetimePaymentSubscription(
|
||||||
'Lifetime subscription cannot be canceled.'
|
'Onetime payment subscription cannot be canceled.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,8 +373,8 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
|
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
|
||||||
throw new CantUpdateLifetimeSubscription(
|
throw new CantUpdateOnetimePaymentSubscription(
|
||||||
'Lifetime subscription cannot be resumed.'
|
'Onetime payment subscription cannot be resumed.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +432,7 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!subscriptionInDB.stripeSubscriptionId) {
|
if (!subscriptionInDB.stripeSubscriptionId) {
|
||||||
throw new CantUpdateLifetimeSubscription(
|
throw new CantUpdateOnetimePaymentSubscription();
|
||||||
'Can not update lifetime subscription.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.canceledAt) {
|
if (subscriptionInDB.canceledAt) {
|
||||||
@@ -525,7 +548,7 @@ export class SubscriptionService {
|
|||||||
throw new Error('Unexpected subscription with no key');
|
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({
|
const invoice = await this.db.userInvoice.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -537,7 +560,7 @@ export class SubscriptionService {
|
|||||||
stripeInvoiceId: stripeInvoice.id,
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
plan,
|
plan,
|
||||||
recurring,
|
recurring,
|
||||||
reason: stripeInvoice.billing_reason ?? 'contact support',
|
reason: stripeInvoice.billing_reason ?? 'subscription_update',
|
||||||
...(data as any),
|
...(data as any),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -545,10 +568,13 @@ export class SubscriptionService {
|
|||||||
// handle one time payment, no subscription created by stripe
|
// handle one time payment, no subscription created by stripe
|
||||||
if (
|
if (
|
||||||
event === 'invoice.payment_succeeded' &&
|
event === 'invoice.payment_succeeded' &&
|
||||||
recurring === SubscriptionRecurring.Lifetime &&
|
|
||||||
stripeInvoice.status === 'paid'
|
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.created')
|
||||||
@OnStripeEvent('customer.subscription.updated')
|
@OnStripeEvent('customer.subscription.updated')
|
||||||
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
||||||
@@ -656,7 +748,8 @@ export class SubscriptionService {
|
|||||||
throw new Error('Unexpected subscription with no key');
|
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);
|
const planActivated = SubscriptionActivated.includes(subscription.status);
|
||||||
|
|
||||||
// update features first, features modify are idempotent
|
// update features first, features modify are idempotent
|
||||||
@@ -689,6 +782,8 @@ export class SubscriptionService {
|
|||||||
: null,
|
: null,
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
plan,
|
plan,
|
||||||
|
recurring,
|
||||||
|
variant,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
stripeScheduleId: subscription.schedule as string | null,
|
stripeScheduleId: subscription.schedule as string | null,
|
||||||
};
|
};
|
||||||
@@ -700,7 +795,6 @@ export class SubscriptionService {
|
|||||||
update: commonData,
|
update: commonData,
|
||||||
create: {
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
recurring,
|
|
||||||
...commonData,
|
...commonData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -813,7 +907,7 @@ export class SubscriptionService {
|
|||||||
private async getPrice(
|
private async getPrice(
|
||||||
plan: SubscriptionPlan,
|
plan: SubscriptionPlan,
|
||||||
recurring: SubscriptionRecurring,
|
recurring: SubscriptionRecurring,
|
||||||
variant?: SubscriptionPriceVariant
|
variant?: SubscriptionVariant
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (recurring === SubscriptionRecurring.Lifetime) {
|
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||||
const lifetimePriceEnabled = await this.config.runtime.fetch(
|
const lifetimePriceEnabled = await this.config.runtime.fetch(
|
||||||
@@ -845,8 +939,14 @@ export class SubscriptionService {
|
|||||||
private async getAvailablePrice(
|
private async getAvailablePrice(
|
||||||
customer: UserStripeCustomer,
|
customer: UserStripeCustomer,
|
||||||
plan: SubscriptionPlan,
|
plan: SubscriptionPlan,
|
||||||
recurring: SubscriptionRecurring
|
recurring: SubscriptionRecurring,
|
||||||
|
variant?: SubscriptionVariant
|
||||||
): Promise<{ price: string; coupon?: string }> {
|
): 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 isEaUser = await this.feature.isEarlyAccessUser(customer.userId);
|
||||||
const oldSubscriptions = await this.stripe.subscriptions.list({
|
const oldSubscriptions = await this.stripe.subscriptions.list({
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
@@ -867,7 +967,7 @@ export class SubscriptionService {
|
|||||||
const price = await this.getPrice(
|
const price = await this.getPrice(
|
||||||
plan,
|
plan,
|
||||||
recurring,
|
recurring,
|
||||||
canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
|
canHaveEADiscount ? SubscriptionVariant.EA : undefined
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
price,
|
price,
|
||||||
@@ -886,7 +986,7 @@ export class SubscriptionService {
|
|||||||
const price = await this.getPrice(
|
const price = await this.getPrice(
|
||||||
plan,
|
plan,
|
||||||
recurring,
|
recurring,
|
||||||
canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
|
canHaveEADiscount ? SubscriptionVariant.EA : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ export enum SubscriptionPlan {
|
|||||||
SelfHosted = 'selfhosted',
|
SelfHosted = 'selfhosted',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SubscriptionPriceVariant {
|
export enum SubscriptionVariant {
|
||||||
EA = 'earlyaccess',
|
EA = 'earlyaccess',
|
||||||
|
Onetime = 'onetime',
|
||||||
}
|
}
|
||||||
|
|
||||||
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ input CreateCheckoutSessionInput {
|
|||||||
plan: SubscriptionPlan = Pro
|
plan: SubscriptionPlan = Pro
|
||||||
recurring: SubscriptionRecurring = Yearly
|
recurring: SubscriptionRecurring = Yearly
|
||||||
successCallbackLink: String!
|
successCallbackLink: String!
|
||||||
|
variant: SubscriptionVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateCopilotPromptInput {
|
input CreateCopilotPromptInput {
|
||||||
@@ -217,7 +218,7 @@ 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_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
|
||||||
@@ -763,6 +764,11 @@ enum SubscriptionStatus {
|
|||||||
Unpaid
|
Unpaid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SubscriptionVariant {
|
||||||
|
EA
|
||||||
|
Onetime
|
||||||
|
}
|
||||||
|
|
||||||
type UnknownOauthProviderDataType {
|
type UnknownOauthProviderDataType {
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
@@ -835,6 +841,7 @@ type UserSubscription {
|
|||||||
trialEnd: DateTime
|
trialEnd: DateTime
|
||||||
trialStart: DateTime
|
trialStart: DateTime
|
||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
|
variant: SubscriptionVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserType {
|
type UserType {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
} from '../../src/plugins/payment/service';
|
} from '../../src/plugins/payment/service';
|
||||||
import {
|
import {
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionPriceVariant,
|
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
|
SubscriptionVariant,
|
||||||
} from '../../src/plugins/payment/types';
|
} from '../../src/plugins/payment/types';
|
||||||
import { createTestingApp } from '../utils';
|
import { createTestingApp } from '../utils';
|
||||||
|
|
||||||
@@ -85,9 +85,13 @@ test.afterEach.always(async t => {
|
|||||||
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
|
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
|
||||||
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
|
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
|
||||||
const PRO_LIFETIME = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`;
|
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 = `${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 = {
|
const PRICES = {
|
||||||
[PRO_MONTHLY]: {
|
[PRO_MONTHLY]: {
|
||||||
@@ -135,6 +139,21 @@ const PRICES = {
|
|||||||
currency: 'usd',
|
currency: 'usd',
|
||||||
lookup_key: AI_YEARLY_EA,
|
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 = {
|
const sub: Stripe.Subscription = {
|
||||||
@@ -951,8 +970,8 @@ test('should operate with latest subscription status', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ============== Lifetime Subscription ===============
|
// ============== Lifetime Subscription ===============
|
||||||
const invoice: Stripe.Invoice = {
|
const lifetimeInvoice: Stripe.Invoice = {
|
||||||
id: 'in_xxx',
|
id: 'in_1',
|
||||||
object: 'invoice',
|
object: 'invoice',
|
||||||
amount_paid: 49900,
|
amount_paid: 49900,
|
||||||
total: 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 => {
|
test('should not be able to checkout for lifetime recurring if not enabled', async t => {
|
||||||
const { service, stripe, u1 } = t.context;
|
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);
|
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 => {
|
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
|
// 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 { service, stripe, db, u1, event } = t.context;
|
||||||
|
|
||||||
const emitStub = Sinon.stub(event, 'emit');
|
const emitStub = Sinon.stub(event, 'emit');
|
||||||
Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any);
|
Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any);
|
||||||
await service.saveInvoice(invoice, 'invoice.payment_succeeded');
|
await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded');
|
||||||
|
|
||||||
const subInDB = await db.userSubscription.findFirst({
|
const subInDB = await db.userSubscription.findFirst({
|
||||||
where: { userId: u1.id },
|
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');
|
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);
|
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({
|
const subInDB = await db.userSubscription.findFirst({
|
||||||
where: { userId: u1.id },
|
where: { userId: u1.id },
|
||||||
@@ -1086,7 +1190,7 @@ test('should not be able to update lifetime recurring', async t => {
|
|||||||
|
|
||||||
await t.throwsAsync(
|
await t.throwsAsync(
|
||||||
() => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro),
|
() => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro),
|
||||||
{ message: 'Lifetime subscription cannot be canceled.' }
|
{ message: 'Onetime payment subscription cannot be canceled.' }
|
||||||
);
|
);
|
||||||
|
|
||||||
await t.throwsAsync(
|
await t.throwsAsync(
|
||||||
@@ -1097,11 +1201,211 @@ test('should not be able to update lifetime recurring', async t => {
|
|||||||
SubscriptionPlan.Pro,
|
SubscriptionPlan.Pro,
|
||||||
SubscriptionRecurring.Monthly
|
SubscriptionRecurring.Monthly
|
||||||
),
|
),
|
||||||
{ message: 'Can not update lifetime subscription.' }
|
{ message: 'You cannot update an onetime payment subscription.' }
|
||||||
);
|
);
|
||||||
|
|
||||||
await t.throwsAsync(
|
await t.throwsAsync(
|
||||||
() => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro),
|
() => 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,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
plan: SubscriptionPlan.AI,
|
plan: SubscriptionPlan.AI,
|
||||||
|
variant: null,
|
||||||
coupon: null,
|
coupon: null,
|
||||||
successCallbackLink: generateSubscriptionCallbackLink(
|
successCallbackLink: generateSubscriptionCallbackLink(
|
||||||
authService.session.account$.value,
|
authService.session.account$.value,
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export const Upgrade = ({
|
|||||||
recurring,
|
recurring,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
||||||
|
variant: null,
|
||||||
coupon: null,
|
coupon: null,
|
||||||
successCallbackLink: generateSubscriptionCallbackLink(
|
successCallbackLink: generateSubscriptionCallbackLink(
|
||||||
authService.session.account$.value,
|
authService.session.account$.value,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export const Component = () => {
|
|||||||
plan: targetPlan,
|
plan: targetPlan,
|
||||||
coupon: null,
|
coupon: null,
|
||||||
recurring: targetRecurring,
|
recurring: targetRecurring,
|
||||||
|
variant: null,
|
||||||
successCallbackLink: generateSubscriptionCallbackLink(
|
successCallbackLink: generateSubscriptionCallbackLink(
|
||||||
account,
|
account,
|
||||||
targetPlan,
|
targetPlan,
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export interface CreateCheckoutSessionInput {
|
|||||||
plan: InputMaybe<SubscriptionPlan>;
|
plan: InputMaybe<SubscriptionPlan>;
|
||||||
recurring: InputMaybe<SubscriptionRecurring>;
|
recurring: InputMaybe<SubscriptionRecurring>;
|
||||||
successCallbackLink: Scalars['String']['input'];
|
successCallbackLink: Scalars['String']['input'];
|
||||||
|
variant: InputMaybe<SubscriptionVariant>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCopilotPromptInput {
|
export interface CreateCopilotPromptInput {
|
||||||
@@ -291,7 +292,7 @@ export enum ErrorNames {
|
|||||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
|
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
|
||||||
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
|
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
|
||||||
CANT_CHANGE_SPACE_OWNER = 'CANT_CHANGE_SPACE_OWNER',
|
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',
|
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
|
||||||
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
|
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
|
||||||
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
|
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
|
||||||
@@ -1063,6 +1064,11 @@ export enum SubscriptionStatus {
|
|||||||
Unpaid = 'Unpaid',
|
Unpaid = 'Unpaid',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SubscriptionVariant {
|
||||||
|
EA = 'EA',
|
||||||
|
Onetime = 'Onetime',
|
||||||
|
}
|
||||||
|
|
||||||
export interface UnknownOauthProviderDataType {
|
export interface UnknownOauthProviderDataType {
|
||||||
__typename?: 'UnknownOauthProviderDataType';
|
__typename?: 'UnknownOauthProviderDataType';
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
|
|||||||
Reference in New Issue
Block a user