feat(server): support onetime payment subscription (#8369)

This commit is contained in:
forehalo 2024-10-10 10:12:40 +00:00
parent 06e059db88
commit 69fb5c06f4
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
13 changed files with 486 additions and 50 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user_subscriptions" ADD COLUMN "variant" VARCHAR(20);

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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'
) {
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 {

View File

@ -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

View File

@ -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 {

View File

@ -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()
);
});

View File

@ -59,6 +59,7 @@ export const AISubscribe = ({
recurring: SubscriptionRecurring.Yearly,
idempotencyKey,
plan: SubscriptionPlan.AI,
variant: null,
coupon: null,
successCallbackLink: generateSubscriptionCallbackLink(
authService.session.account$.value,

View File

@ -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,

View File

@ -100,6 +100,7 @@ export const Component = () => {
plan: targetPlan,
coupon: null,
recurring: targetRecurring,
variant: null,
successCallbackLink: generateSubscriptionCallbackLink(
account,
targetPlan,

View File

@ -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'];