mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat(server): support onetime payment subscription (#8369)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user