mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-26 15:22:12 +03:00
feat(server): support lifetime subscription (#7405)
closes CLOUD-48 - [x] lifetime subscription quota - [ ] tests
This commit is contained in:
parent
7235779b02
commit
de91027852
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user_subscriptions" ALTER COLUMN "stripe_subscription_id" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "end" DROP NOT NULL;
|
@ -377,14 +377,14 @@ model UserSubscription {
|
|||||||
plan String @db.VarChar(20)
|
plan String @db.VarChar(20)
|
||||||
// yearly/monthly
|
// yearly/monthly
|
||||||
recurring String @db.VarChar(20)
|
recurring String @db.VarChar(20)
|
||||||
// subscription.id
|
// subscription.id, null for linefetime payment
|
||||||
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)
|
||||||
// subscription.current_period_start
|
// subscription.current_period_start
|
||||||
start DateTime @map("start") @db.Timestamptz(6)
|
start DateTime @map("start") @db.Timestamptz(6)
|
||||||
// subscription.current_period_end
|
// subscription.current_period_end, null for lifetime payment
|
||||||
end DateTime @map("end") @db.Timestamptz(6)
|
end DateTime? @map("end") @db.Timestamptz(6)
|
||||||
// subscription.billing_cycle_anchor
|
// subscription.billing_cycle_anchor
|
||||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
|
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
|
||||||
// subscription.canceled_at
|
// subscription.canceled_at
|
||||||
|
@ -155,6 +155,25 @@ export const Quotas: Quota[] = [
|
|||||||
copilotActionLimit: 10,
|
copilotActionLimit: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
feature: QuotaType.LifetimeProPlanV1,
|
||||||
|
type: FeatureKind.Quota,
|
||||||
|
version: 1,
|
||||||
|
configs: {
|
||||||
|
// quota name
|
||||||
|
name: 'Lifetime Pro',
|
||||||
|
// single blob limit 100MB
|
||||||
|
blobLimit: 100 * OneMB,
|
||||||
|
// total blob limit 1TB
|
||||||
|
storageQuota: 1024 * OneGB,
|
||||||
|
// history period of validity 30 days
|
||||||
|
historyPeriod: 30 * OneDay,
|
||||||
|
// member limit 10
|
||||||
|
memberLimit: 10,
|
||||||
|
// copilot action limit 10
|
||||||
|
copilotActionLimit: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getLatestQuota(type: QuotaType) {
|
export function getLatestQuota(type: QuotaType) {
|
||||||
@ -165,6 +184,7 @@ export function getLatestQuota(type: QuotaType) {
|
|||||||
|
|
||||||
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
||||||
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
|
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
|
||||||
|
export const LifetimeProPlan = getLatestQuota(QuotaType.LifetimeProPlanV1);
|
||||||
|
|
||||||
export const Quota_FreePlanV1_1 = {
|
export const Quota_FreePlanV1_1 = {
|
||||||
feature: Quotas[5].feature,
|
feature: Quotas[5].feature,
|
||||||
|
@ -3,7 +3,6 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
import type { EventPayload } from '../../fundamentals';
|
import type { EventPayload } from '../../fundamentals';
|
||||||
import { OnEvent, PrismaTransaction } from '../../fundamentals';
|
import { OnEvent, PrismaTransaction } from '../../fundamentals';
|
||||||
import { SubscriptionPlan } from '../../plugins/payment/types';
|
|
||||||
import { FeatureManagementService } from '../features/management';
|
import { FeatureManagementService } from '../features/management';
|
||||||
import { FeatureKind } from '../features/types';
|
import { FeatureKind } from '../features/types';
|
||||||
import { QuotaConfig } from './quota';
|
import { QuotaConfig } from './quota';
|
||||||
@ -152,15 +151,18 @@ export class QuotaService {
|
|||||||
async onSubscriptionUpdated({
|
async onSubscriptionUpdated({
|
||||||
userId,
|
userId,
|
||||||
plan,
|
plan,
|
||||||
|
recurring,
|
||||||
}: EventPayload<'user.subscription.activated'>) {
|
}: EventPayload<'user.subscription.activated'>) {
|
||||||
switch (plan) {
|
switch (plan) {
|
||||||
case SubscriptionPlan.AI:
|
case 'ai':
|
||||||
await this.feature.addCopilot(userId, 'subscription activated');
|
await this.feature.addCopilot(userId, 'subscription activated');
|
||||||
break;
|
break;
|
||||||
case SubscriptionPlan.Pro:
|
case 'pro':
|
||||||
await this.switchUserQuota(
|
await this.switchUserQuota(
|
||||||
userId,
|
userId,
|
||||||
QuotaType.ProPlanV1,
|
recurring === 'lifetime'
|
||||||
|
? QuotaType.LifetimeProPlanV1
|
||||||
|
: QuotaType.ProPlanV1,
|
||||||
'subscription activated'
|
'subscription activated'
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@ -175,16 +177,22 @@ export class QuotaService {
|
|||||||
plan,
|
plan,
|
||||||
}: EventPayload<'user.subscription.canceled'>) {
|
}: EventPayload<'user.subscription.canceled'>) {
|
||||||
switch (plan) {
|
switch (plan) {
|
||||||
case SubscriptionPlan.AI:
|
case 'ai':
|
||||||
await this.feature.removeCopilot(userId);
|
await this.feature.removeCopilot(userId);
|
||||||
break;
|
break;
|
||||||
case SubscriptionPlan.Pro:
|
case 'pro': {
|
||||||
await this.switchUserQuota(
|
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
|
||||||
userId,
|
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
|
||||||
QuotaType.FreePlanV1,
|
const quota = await this.getUserQuota(userId);
|
||||||
'subscription canceled'
|
if (quota.feature.name !== QuotaType.LifetimeProPlanV1) {
|
||||||
);
|
await this.switchUserQuota(
|
||||||
|
userId,
|
||||||
|
QuotaType.FreePlanV1,
|
||||||
|
'subscription canceled'
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ import { ByteUnit, OneDay, OneKB } from './constant';
|
|||||||
export enum QuotaType {
|
export enum QuotaType {
|
||||||
FreePlanV1 = 'free_plan_v1',
|
FreePlanV1 = 'free_plan_v1',
|
||||||
ProPlanV1 = 'pro_plan_v1',
|
ProPlanV1 = 'pro_plan_v1',
|
||||||
|
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
|
||||||
// only for test, smaller quota
|
// only for test, smaller quota
|
||||||
RestrictedPlanV1 = 'restricted_plan_v1',
|
RestrictedPlanV1 = 'restricted_plan_v1',
|
||||||
}
|
}
|
||||||
@ -25,6 +26,7 @@ const quotaPlan = z.object({
|
|||||||
feature: z.enum([
|
feature: z.enum([
|
||||||
QuotaType.FreePlanV1,
|
QuotaType.FreePlanV1,
|
||||||
QuotaType.ProPlanV1,
|
QuotaType.ProPlanV1,
|
||||||
|
QuotaType.LifetimeProPlanV1,
|
||||||
QuotaType.RestrictedPlanV1,
|
QuotaType.RestrictedPlanV1,
|
||||||
]),
|
]),
|
||||||
configs: z.object({
|
configs: z.object({
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { QuotaType } from '../../core/quota';
|
||||||
|
import { upsertLatestQuotaVersion } from './utils/user-quotas';
|
||||||
|
|
||||||
|
export class LifetimeProQuota1719917815802 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaClient) {
|
||||||
|
await upsertLatestQuotaVersion(db, QuotaType.LifetimeProPlanV1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down(_db: PrismaClient) {}
|
||||||
|
}
|
@ -408,6 +408,10 @@ 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: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'You cannot update a lifetime subscription.',
|
||||||
|
},
|
||||||
|
|
||||||
// Copilot errors
|
// Copilot errors
|
||||||
copilot_session_not_found: {
|
copilot_session_not_found: {
|
||||||
|
@ -350,6 +350,12 @@ export class SubscriptionPlanNotFound extends UserFriendlyError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CantUpdateLifetimeSubscription extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'cant_update_lifetime_subscription', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CopilotSessionNotFound extends UserFriendlyError {
|
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||||
constructor(message?: string) {
|
constructor(message?: string) {
|
||||||
super('resource_not_found', 'copilot_session_not_found', message);
|
super('resource_not_found', 'copilot_session_not_found', message);
|
||||||
@ -521,6 +527,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,
|
||||||
COPILOT_SESSION_NOT_FOUND,
|
COPILOT_SESSION_NOT_FOUND,
|
||||||
COPILOT_SESSION_DELETED,
|
COPILOT_SESSION_DELETED,
|
||||||
NO_COPILOT_PROVIDER_AVAILABLE,
|
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||||
|
@ -53,12 +53,15 @@ class SubscriptionPrice {
|
|||||||
|
|
||||||
@Field(() => Int, { nullable: true })
|
@Field(() => Int, { nullable: true })
|
||||||
yearlyAmount?: number | null;
|
yearlyAmount?: number | null;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
lifetimeAmount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('UserSubscription')
|
@ObjectType('UserSubscription')
|
||||||
export class UserSubscriptionType implements Partial<UserSubscription> {
|
export class UserSubscriptionType implements Partial<UserSubscription> {
|
||||||
@Field({ name: 'id' })
|
@Field(() => String, { name: 'id', nullable: true })
|
||||||
stripeSubscriptionId!: string;
|
stripeSubscriptionId!: string | null;
|
||||||
|
|
||||||
@Field(() => SubscriptionPlan, {
|
@Field(() => SubscriptionPlan, {
|
||||||
description:
|
description:
|
||||||
@ -75,8 +78,8 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
|
|||||||
@Field(() => Date)
|
@Field(() => Date)
|
||||||
start!: Date;
|
start!: Date;
|
||||||
|
|
||||||
@Field(() => Date)
|
@Field(() => Date, { nullable: true })
|
||||||
end!: Date;
|
end!: Date | null;
|
||||||
|
|
||||||
@Field(() => Date, { nullable: true })
|
@Field(() => Date, { nullable: true })
|
||||||
trialStart?: Date | null;
|
trialStart?: Date | null;
|
||||||
@ -187,11 +190,19 @@ export class SubscriptionResolver {
|
|||||||
|
|
||||||
const monthlyPrice = prices.find(p => p.recurring?.interval === 'month');
|
const monthlyPrice = prices.find(p => p.recurring?.interval === 'month');
|
||||||
const yearlyPrice = prices.find(p => p.recurring?.interval === 'year');
|
const yearlyPrice = prices.find(p => p.recurring?.interval === 'year');
|
||||||
|
const lifetimePrice = prices.find(
|
||||||
|
p =>
|
||||||
|
// asserted before
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
decodeLookupKey(p.lookup_key!)[1] === SubscriptionRecurring.Lifetime
|
||||||
|
);
|
||||||
const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
|
const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currency,
|
currency,
|
||||||
amount: monthlyPrice?.unit_amount,
|
amount: monthlyPrice?.unit_amount,
|
||||||
yearlyAmount: yearlyPrice?.unit_amount,
|
yearlyAmount: yearlyPrice?.unit_amount,
|
||||||
|
lifetimeAmount: lifetimePrice?.unit_amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
||||||
import type {
|
import type {
|
||||||
Prisma,
|
|
||||||
User,
|
User,
|
||||||
UserInvoice,
|
UserInvoice,
|
||||||
UserStripeCustomer,
|
UserStripeCustomer,
|
||||||
@ -16,6 +15,7 @@ import { CurrentUser } from '../../core/auth';
|
|||||||
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
||||||
import {
|
import {
|
||||||
ActionForbidden,
|
ActionForbidden,
|
||||||
|
CantUpdateLifetimeSubscription,
|
||||||
Config,
|
Config,
|
||||||
CustomerPortalCreateFailed,
|
CustomerPortalCreateFailed,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
@ -131,7 +131,11 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key);
|
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key);
|
||||||
if (recurring === SubscriptionRecurring.Monthly) {
|
// no variant price should be used for monthly or lifetime subscription
|
||||||
|
if (
|
||||||
|
recurring === SubscriptionRecurring.Monthly ||
|
||||||
|
recurring === SubscriptionRecurring.Lifetime
|
||||||
|
) {
|
||||||
return !variant;
|
return !variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,7 +188,12 @@ export class SubscriptionService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentSubscription) {
|
if (
|
||||||
|
currentSubscription &&
|
||||||
|
// do not allow to re-subscribe unless the new recurring is `Lifetime`
|
||||||
|
(currentSubscription.recurring === recurring ||
|
||||||
|
recurring !== SubscriptionRecurring.Lifetime)
|
||||||
|
) {
|
||||||
throw new SubscriptionAlreadyExists({ plan });
|
throw new SubscriptionAlreadyExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,8 +233,19 @@ export class SubscriptionService {
|
|||||||
tax_id_collection: {
|
tax_id_collection: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
// discount
|
||||||
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
|
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
|
||||||
mode: 'subscription',
|
// mode: 'subscription' or 'payment' for lifetime
|
||||||
|
...(recurring === SubscriptionRecurring.Lifetime
|
||||||
|
? {
|
||||||
|
mode: 'payment',
|
||||||
|
invoice_creation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode: 'subscription',
|
||||||
|
}),
|
||||||
success_url: redirectUrl,
|
success_url: redirectUrl,
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
customer_update: {
|
customer_update: {
|
||||||
@ -264,6 +284,12 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan });
|
throw new SubscriptionNotExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subscriptionInDB.stripeSubscriptionId) {
|
||||||
|
throw new CantUpdateLifetimeSubscription(
|
||||||
|
'Lifetime subscription cannot be canceled.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.canceledAt) {
|
if (subscriptionInDB.canceledAt) {
|
||||||
throw new SubscriptionHasBeenCanceled();
|
throw new SubscriptionHasBeenCanceled();
|
||||||
}
|
}
|
||||||
@ -315,6 +341,12 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan });
|
throw new SubscriptionNotExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
|
||||||
|
throw new CantUpdateLifetimeSubscription(
|
||||||
|
'Lifetime subscription cannot be resumed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscriptionInDB.canceledAt) {
|
if (!subscriptionInDB.canceledAt) {
|
||||||
throw new SubscriptionHasBeenCanceled();
|
throw new SubscriptionHasBeenCanceled();
|
||||||
}
|
}
|
||||||
@ -368,6 +400,12 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan });
|
throw new SubscriptionNotExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subscriptionInDB.stripeSubscriptionId) {
|
||||||
|
throw new CantUpdateLifetimeSubscription(
|
||||||
|
'Can not update lifetime subscription.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.canceledAt) {
|
if (subscriptionInDB.canceledAt) {
|
||||||
throw new SubscriptionHasBeenCanceled();
|
throw new SubscriptionHasBeenCanceled();
|
||||||
}
|
}
|
||||||
@ -422,60 +460,12 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnStripeEvent('customer.subscription.created')
|
|
||||||
@OnStripeEvent('customer.subscription.updated')
|
|
||||||
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
|
||||||
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
|
|
||||||
if (subscription.status === 'active') {
|
|
||||||
const user = await this.retrieveUserFromCustomer(
|
|
||||||
typeof subscription.customer === 'string'
|
|
||||||
? subscription.customer
|
|
||||||
: subscription.customer.id
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.saveSubscription(user, subscription);
|
|
||||||
} else {
|
|
||||||
await this.onSubscriptionDeleted(subscription);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnStripeEvent('customer.subscription.deleted')
|
|
||||||
async onSubscriptionDeleted(subscription: Stripe.Subscription) {
|
|
||||||
const user = await this.retrieveUserFromCustomer(
|
|
||||||
typeof subscription.customer === 'string'
|
|
||||||
? subscription.customer
|
|
||||||
: subscription.customer.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const [plan] = this.decodePlanFromSubscription(subscription);
|
|
||||||
this.event.emit('user.subscription.canceled', {
|
|
||||||
userId: user.id,
|
|
||||||
plan,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.db.userSubscription.deleteMany({
|
|
||||||
where: {
|
|
||||||
stripeSubscriptionId: subscription.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnStripeEvent('invoice.paid')
|
|
||||||
async onInvoicePaid(stripeInvoice: Stripe.Invoice) {
|
|
||||||
stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id);
|
|
||||||
await this.saveInvoice(stripeInvoice);
|
|
||||||
|
|
||||||
const line = stripeInvoice.lines.data[0];
|
|
||||||
|
|
||||||
if (!line.price || line.price.type !== 'recurring') {
|
|
||||||
throw new Error('Unknown invoice with no recurring price');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnStripeEvent('invoice.created')
|
@OnStripeEvent('invoice.created')
|
||||||
|
@OnStripeEvent('invoice.updated')
|
||||||
@OnStripeEvent('invoice.finalization_failed')
|
@OnStripeEvent('invoice.finalization_failed')
|
||||||
@OnStripeEvent('invoice.payment_failed')
|
@OnStripeEvent('invoice.payment_failed')
|
||||||
async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
@OnStripeEvent('invoice.payment_succeeded')
|
||||||
|
async saveInvoice(stripeInvoice: Stripe.Invoice, event: string) {
|
||||||
stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id);
|
stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id);
|
||||||
if (!stripeInvoice.customer) {
|
if (!stripeInvoice.customer) {
|
||||||
throw new Error('Unexpected invoice with no customer');
|
throw new Error('Unexpected invoice with no customer');
|
||||||
@ -487,12 +477,6 @@ export class SubscriptionService {
|
|||||||
: stripeInvoice.customer.id
|
: stripeInvoice.customer.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const invoice = await this.db.userInvoice.findUnique({
|
|
||||||
where: {
|
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: Partial<UserInvoice> = {
|
const data: Partial<UserInvoice> = {
|
||||||
currency: stripeInvoice.currency,
|
currency: stripeInvoice.currency,
|
||||||
amount: stripeInvoice.total,
|
amount: stripeInvoice.total,
|
||||||
@ -524,39 +508,135 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// update invoice
|
// create invoice
|
||||||
if (invoice) {
|
const price = stripeInvoice.lines.data[0].price;
|
||||||
await this.db.userInvoice.update({
|
|
||||||
where: {
|
if (!price) {
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
throw new Error('Unexpected invoice with no price');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!price.lookup_key) {
|
||||||
|
throw new Error('Unexpected subscription with no key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||||
|
|
||||||
|
const invoice = await this.db.userInvoice.upsert({
|
||||||
|
where: {
|
||||||
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
|
},
|
||||||
|
update: data,
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
|
plan,
|
||||||
|
recurring,
|
||||||
|
reason: stripeInvoice.billing_reason ?? 'contact support',
|
||||||
|
...(data as any),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveLifetimeSubscription(user: User, invoice: UserInvoice) {
|
||||||
|
// cancel previous non-lifetime subscription
|
||||||
|
const savedSubscription = await this.db.userSubscription.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_plan: {
|
||||||
|
userId: user.id,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (savedSubscription && savedSubscription.stripeSubscriptionId) {
|
||||||
|
await this.db.userSubscription.update({
|
||||||
|
where: {
|
||||||
|
id: savedSubscription.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
stripeScheduleId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
|
end: null,
|
||||||
},
|
},
|
||||||
data,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.stripe.subscriptions.cancel(
|
||||||
|
savedSubscription.stripeSubscriptionId,
|
||||||
|
{
|
||||||
|
prorate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// create invoice
|
await this.db.userSubscription.create({
|
||||||
const price = stripeInvoice.lines.data[0].price;
|
|
||||||
|
|
||||||
if (!price || price.type !== 'recurring') {
|
|
||||||
throw new Error('Unexpected invoice with no recurring price');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!price.lookup_key) {
|
|
||||||
throw new Error('Unexpected subscription with no key');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
|
||||||
|
|
||||||
await this.db.userInvoice.create({
|
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
stripeSubscriptionId: null,
|
||||||
plan,
|
plan: invoice.plan,
|
||||||
recurring,
|
recurring: invoice.recurring,
|
||||||
reason: stripeInvoice.billing_reason ?? 'contact support',
|
end: null,
|
||||||
...(data as any),
|
start: new Date(),
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
nextBillAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.event.emit('user.subscription.activated', {
|
||||||
|
userId: user.id,
|
||||||
|
plan: invoice.plan as SubscriptionPlan,
|
||||||
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnStripeEvent('customer.subscription.created')
|
||||||
|
@OnStripeEvent('customer.subscription.updated')
|
||||||
|
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
||||||
|
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
|
||||||
|
if (subscription.status === 'active') {
|
||||||
|
const user = await this.retrieveUserFromCustomer(
|
||||||
|
typeof subscription.customer === 'string'
|
||||||
|
? subscription.customer
|
||||||
|
: subscription.customer.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.saveSubscription(user, subscription);
|
||||||
|
} else {
|
||||||
|
await this.onSubscriptionDeleted(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnStripeEvent('customer.subscription.deleted')
|
||||||
|
async onSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||||
|
const user = await this.retrieveUserFromCustomer(
|
||||||
|
typeof subscription.customer === 'string'
|
||||||
|
? subscription.customer
|
||||||
|
: subscription.customer.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const [plan, recurring] = this.decodePlanFromSubscription(subscription);
|
||||||
|
|
||||||
|
this.event.emit('user.subscription.canceled', {
|
||||||
|
userId: user.id,
|
||||||
|
plan,
|
||||||
|
recurring,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.db.userSubscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
stripeSubscriptionId: subscription.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveSubscription(
|
private async saveSubscription(
|
||||||
@ -576,6 +656,7 @@ export class SubscriptionService {
|
|||||||
this.event.emit('user.subscription.activated', {
|
this.event.emit('user.subscription.activated', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
plan,
|
plan,
|
||||||
|
recurring,
|
||||||
});
|
});
|
||||||
|
|
||||||
let nextBillAt: Date | null = null;
|
let nextBillAt: Date | null = null;
|
||||||
@ -600,44 +681,21 @@ export class SubscriptionService {
|
|||||||
: null,
|
: null,
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
plan,
|
plan,
|
||||||
recurring,
|
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
stripeScheduleId: subscription.schedule as string | null,
|
stripeScheduleId: subscription.schedule as string | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentSubscription = await this.db.userSubscription.findUnique({
|
return await this.db.userSubscription.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_plan: {
|
stripeSubscriptionId: subscription.id,
|
||||||
userId: user.id,
|
},
|
||||||
plan,
|
update: commonData,
|
||||||
},
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
recurring,
|
||||||
|
...commonData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentSubscription) {
|
|
||||||
const update: Prisma.UserSubscriptionUpdateInput = {
|
|
||||||
...commonData,
|
|
||||||
};
|
|
||||||
|
|
||||||
// a schedule exists, update the recurring to scheduled one
|
|
||||||
if (update.stripeScheduleId) {
|
|
||||||
delete update.recurring;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.db.userSubscription.update({
|
|
||||||
where: {
|
|
||||||
id: currentSubscription.id,
|
|
||||||
},
|
|
||||||
data: update,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return await this.db.userSubscription.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
...commonData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrCreateCustomer(
|
private async getOrCreateCustomer(
|
||||||
|
@ -5,6 +5,7 @@ import type { Payload } from '../../fundamentals/event/def';
|
|||||||
export enum SubscriptionRecurring {
|
export enum SubscriptionRecurring {
|
||||||
Monthly = 'monthly',
|
Monthly = 'monthly',
|
||||||
Yearly = 'yearly',
|
Yearly = 'yearly',
|
||||||
|
Lifetime = 'lifetime',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SubscriptionPlan {
|
export enum SubscriptionPlan {
|
||||||
@ -46,10 +47,12 @@ declare module '../../fundamentals/event/def' {
|
|||||||
activated: Payload<{
|
activated: Payload<{
|
||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
}>;
|
}>;
|
||||||
canceled: Payload<{
|
canceled: Payload<{
|
||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -45,9 +45,16 @@ export class StripeWebhook {
|
|||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// handle duplicated events?
|
// handle duplicated events?
|
||||||
// see https://stripe.com/docs/webhooks#handle-duplicate-events
|
// see https://stripe.com/docs/webhooks#handle-duplicate-events
|
||||||
this.event.emitAsync(event.type, event.data.object).catch(e => {
|
this.event
|
||||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
.emitAsync(
|
||||||
});
|
event.type,
|
||||||
|
event.data.object,
|
||||||
|
// here to let event listeners know what exactly the event is if a handler can handle multiple events
|
||||||
|
event.type
|
||||||
|
)
|
||||||
|
.catch(e => {
|
||||||
|
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new InternalServerError(err.message);
|
throw new InternalServerError(err.message);
|
||||||
|
@ -189,6 +189,7 @@ enum ErrorNames {
|
|||||||
BLOB_NOT_FOUND
|
BLOB_NOT_FOUND
|
||||||
BLOB_QUOTA_EXCEEDED
|
BLOB_QUOTA_EXCEEDED
|
||||||
CANT_CHANGE_WORKSPACE_OWNER
|
CANT_CHANGE_WORKSPACE_OWNER
|
||||||
|
CANT_UPDATE_LIFETIME_SUBSCRIPTION
|
||||||
COPILOT_ACTION_TAKEN
|
COPILOT_ACTION_TAKEN
|
||||||
COPILOT_FAILED_TO_CREATE_MESSAGE
|
COPILOT_FAILED_TO_CREATE_MESSAGE
|
||||||
COPILOT_FAILED_TO_GENERATE_TEXT
|
COPILOT_FAILED_TO_GENERATE_TEXT
|
||||||
@ -657,12 +658,14 @@ type SubscriptionPlanNotFoundDataType {
|
|||||||
type SubscriptionPrice {
|
type SubscriptionPrice {
|
||||||
amount: Int
|
amount: Int
|
||||||
currency: String!
|
currency: String!
|
||||||
|
lifetimeAmount: Int
|
||||||
plan: SubscriptionPlan!
|
plan: SubscriptionPlan!
|
||||||
type: String!
|
type: String!
|
||||||
yearlyAmount: Int
|
yearlyAmount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionRecurring {
|
enum SubscriptionRecurring {
|
||||||
|
Lifetime
|
||||||
Monthly
|
Monthly
|
||||||
Yearly
|
Yearly
|
||||||
}
|
}
|
||||||
@ -733,8 +736,8 @@ type UserQuotaHumanReadable {
|
|||||||
type UserSubscription {
|
type UserSubscription {
|
||||||
canceledAt: DateTime
|
canceledAt: DateTime
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
end: DateTime!
|
end: DateTime
|
||||||
id: String!
|
id: String
|
||||||
nextBillAt: DateTime
|
nextBillAt: DateTime
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -84,6 +84,7 @@ 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_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`;
|
const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.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}_${SubscriptionPriceVariant.EA}`;
|
||||||
@ -105,6 +106,11 @@ const PRICES = {
|
|||||||
currency: 'usd',
|
currency: 'usd',
|
||||||
lookup_key: PRO_YEARLY,
|
lookup_key: PRO_YEARLY,
|
||||||
},
|
},
|
||||||
|
[PRO_LIFETIME]: {
|
||||||
|
unit_amount: 49900,
|
||||||
|
currency: 'usd',
|
||||||
|
lookup_key: PRO_LIFETIME,
|
||||||
|
},
|
||||||
[PRO_EA_YEARLY]: {
|
[PRO_EA_YEARLY]: {
|
||||||
recurring: {
|
recurring: {
|
||||||
interval: 'year',
|
interval: 'year',
|
||||||
@ -170,10 +176,9 @@ test('should list normal price for unauthenticated user', async t => {
|
|||||||
|
|
||||||
const prices = await service.listPrices();
|
const prices = await service.listPrices();
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
|
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -190,10 +195,9 @@ test('should list normal prices for authenticated user', async t => {
|
|||||||
|
|
||||||
const prices = await service.listPrices(u1);
|
const prices = await service.listPrices(u1);
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
|
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -210,10 +214,9 @@ test('should list early access prices for pro ea user', async t => {
|
|||||||
|
|
||||||
const prices = await service.listPrices(u1);
|
const prices = await service.listPrices(u1);
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_EA_YEARLY, AI_YEARLY])
|
new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -246,10 +249,9 @@ test('should list normal prices for pro ea user with old subscriptions', async t
|
|||||||
|
|
||||||
const prices = await service.listPrices(u1);
|
const prices = await service.listPrices(u1);
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
|
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -266,10 +268,9 @@ test('should list early access prices for ai ea user', async t => {
|
|||||||
|
|
||||||
const prices = await service.listPrices(u1);
|
const prices = await service.listPrices(u1);
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY_EA])
|
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY_EA])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -286,10 +287,9 @@ test('should list early access prices for pro and ai ea user', async t => {
|
|||||||
|
|
||||||
const prices = await service.listPrices(u1);
|
const prices = await service.listPrices(u1);
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_EA_YEARLY, AI_YEARLY_EA])
|
new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY_EA])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -322,10 +322,9 @@ test('should list normal prices for ai ea user with old subscriptions', async t
|
|||||||
|
|
||||||
const prices = await service.listPrices(u1);
|
const prices = await service.listPrices(u1);
|
||||||
|
|
||||||
t.is(prices.length, 3);
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
new Set(prices.map(p => p.lookup_key)),
|
new Set(prices.map(p => p.lookup_key)),
|
||||||
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
|
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -458,6 +457,22 @@ test('should get correct pro plan price for checking out', async t => {
|
|||||||
coupon: undefined,
|
coupon: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// any user, lifetime recurring
|
||||||
|
{
|
||||||
|
feature.isEarlyAccessUser.resolves(false);
|
||||||
|
// @ts-expect-error stub
|
||||||
|
subListStub.resolves({ data: [] });
|
||||||
|
const ret = await getAvailablePrice(
|
||||||
|
customer,
|
||||||
|
SubscriptionPlan.Pro,
|
||||||
|
SubscriptionRecurring.Lifetime
|
||||||
|
);
|
||||||
|
t.deepEqual(ret, {
|
||||||
|
price: PRO_LIFETIME,
|
||||||
|
coupon: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should get correct ai plan price for checking out', async t => {
|
test('should get correct ai plan price for checking out', async t => {
|
||||||
@ -639,6 +654,7 @@ test('should be able to create subscription', async t => {
|
|||||||
emitStub.calledOnceWith('user.subscription.activated', {
|
emitStub.calledOnceWith('user.subscription.activated', {
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -674,6 +690,7 @@ test('should be able to update subscription', async t => {
|
|||||||
emitStub.calledOnceWith('user.subscription.activated', {
|
emitStub.calledOnceWith('user.subscription.activated', {
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -706,6 +723,7 @@ test('should be able to delete subscription', async t => {
|
|||||||
emitStub.calledOnceWith('user.subscription.canceled', {
|
emitStub.calledOnceWith('user.subscription.canceled', {
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -749,6 +767,7 @@ test('should be able to cancel subscription', async t => {
|
|||||||
emitStub.calledOnceWith('user.subscription.activated', {
|
emitStub.calledOnceWith('user.subscription.activated', {
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -785,6 +804,7 @@ test('should be able to resume subscription', async t => {
|
|||||||
emitStub.calledOnceWith('user.subscription.activated', {
|
emitStub.calledOnceWith('user.subscription.activated', {
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -929,3 +949,120 @@ test('should operate with latest subscription status', async t => {
|
|||||||
t.deepEqual(stub.firstCall.args[1], sub);
|
t.deepEqual(stub.firstCall.args[1], sub);
|
||||||
t.deepEqual(stub.secondCall.args[1], sub);
|
t.deepEqual(stub.secondCall.args[1], sub);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============== Lifetime Subscription ===============
|
||||||
|
const invoice: Stripe.Invoice = {
|
||||||
|
id: 'in_xxx',
|
||||||
|
object: 'invoice',
|
||||||
|
amount_paid: 49900,
|
||||||
|
total: 49900,
|
||||||
|
customer: 'cus_1',
|
||||||
|
currency: 'usd',
|
||||||
|
status: 'paid',
|
||||||
|
lines: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
// @ts-expect-error stub
|
||||||
|
price: PRICES[PRO_LIFETIME],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
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.Lifetime,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
t.is(subInDB?.plan, SubscriptionPlan.Pro);
|
||||||
|
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
||||||
|
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||||
|
t.is(subInDB?.stripeSubscriptionId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to subscribe to lifetime recurring with old subscription', async t => {
|
||||||
|
const { service, stripe, db, u1, event } = 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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitStub = Sinon.stub(event, 'emit');
|
||||||
|
Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any);
|
||||||
|
Sinon.stub(stripe.subscriptions, 'cancel').resolves(sub as any);
|
||||||
|
await service.saveInvoice(invoice, '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.Lifetime,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
t.is(subInDB?.plan, SubscriptionPlan.Pro);
|
||||||
|
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
||||||
|
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||||
|
t.is(subInDB?.stripeSubscriptionId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not be able to update lifetime recurring', async t => {
|
||||||
|
const { service, db, u1 } = t.context;
|
||||||
|
|
||||||
|
await db.userSubscription.create({
|
||||||
|
data: {
|
||||||
|
userId: u1.id,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
start: new Date(),
|
||||||
|
end: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
() => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro),
|
||||||
|
{ message: 'Lifetime subscription cannot be canceled.' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
() =>
|
||||||
|
service.updateSubscriptionRecurring(
|
||||||
|
'',
|
||||||
|
u1.id,
|
||||||
|
SubscriptionPlan.Pro,
|
||||||
|
SubscriptionRecurring.Monthly
|
||||||
|
),
|
||||||
|
{ message: 'Can not update lifetime subscription.' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
() => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro),
|
||||||
|
{ message: 'Lifetime subscription cannot be resumed.' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -38,6 +38,7 @@ enum DescriptionI18NKey {
|
|||||||
Basic = 'com.affine.payment.billing-setting.current-plan.description',
|
Basic = 'com.affine.payment.billing-setting.current-plan.description',
|
||||||
Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly',
|
Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly',
|
||||||
Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly',
|
Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly',
|
||||||
|
Lifetime = 'com.affine.payment.billing-setting.current-plan.description.lifetime',
|
||||||
}
|
}
|
||||||
|
|
||||||
const INVOICE_PAGE_SIZE = 12;
|
const INVOICE_PAGE_SIZE = 12;
|
||||||
@ -204,7 +205,7 @@ const SubscriptionSettings = () => {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{proSubscription.canceledAt ? (
|
{proSubscription.end && proSubscription.canceledAt ? (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
name={t['com.affine.payment.billing-setting.expiration-date']()}
|
name={t['com.affine.payment.billing-setting.expiration-date']()}
|
||||||
desc={t[
|
desc={t[
|
||||||
|
@ -259,6 +259,7 @@ export enum ErrorNames {
|
|||||||
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
|
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
|
||||||
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
|
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
|
||||||
CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER',
|
CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER',
|
||||||
|
CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION',
|
||||||
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',
|
||||||
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
|
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
|
||||||
@ -950,12 +951,14 @@ export interface SubscriptionPrice {
|
|||||||
__typename?: 'SubscriptionPrice';
|
__typename?: 'SubscriptionPrice';
|
||||||
amount: Maybe<Scalars['Int']['output']>;
|
amount: Maybe<Scalars['Int']['output']>;
|
||||||
currency: Scalars['String']['output'];
|
currency: Scalars['String']['output'];
|
||||||
|
lifetimeAmount: Maybe<Scalars['Int']['output']>;
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
type: Scalars['String']['output'];
|
type: Scalars['String']['output'];
|
||||||
yearlyAmount: Maybe<Scalars['Int']['output']>;
|
yearlyAmount: Maybe<Scalars['Int']['output']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SubscriptionRecurring {
|
export enum SubscriptionRecurring {
|
||||||
|
Lifetime = 'Lifetime',
|
||||||
Monthly = 'Monthly',
|
Monthly = 'Monthly',
|
||||||
Yearly = 'Yearly',
|
Yearly = 'Yearly',
|
||||||
}
|
}
|
||||||
@ -1027,8 +1030,8 @@ export interface UserSubscription {
|
|||||||
__typename?: 'UserSubscription';
|
__typename?: 'UserSubscription';
|
||||||
canceledAt: Maybe<Scalars['DateTime']['output']>;
|
canceledAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
createdAt: Scalars['DateTime']['output'];
|
createdAt: Scalars['DateTime']['output'];
|
||||||
end: Scalars['DateTime']['output'];
|
end: Maybe<Scalars['DateTime']['output']>;
|
||||||
id: Scalars['String']['output'];
|
id: Maybe<Scalars['String']['output']>;
|
||||||
nextBillAt: Maybe<Scalars['DateTime']['output']>;
|
nextBillAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
/**
|
/**
|
||||||
* The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
|
* The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
|
||||||
@ -1213,7 +1216,7 @@ export type CancelSubscriptionMutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
cancelSubscription: {
|
cancelSubscription: {
|
||||||
__typename?: 'UserSubscription';
|
__typename?: 'UserSubscription';
|
||||||
id: string;
|
id: string | null;
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
nextBillAt: string | null;
|
nextBillAt: string | null;
|
||||||
canceledAt: string | null;
|
canceledAt: string | null;
|
||||||
@ -1347,7 +1350,7 @@ export type EarlyAccessUsersQuery = {
|
|||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -1834,11 +1837,11 @@ export type ResumeSubscriptionMutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
resumeSubscription: {
|
resumeSubscription: {
|
||||||
__typename?: 'UserSubscription';
|
__typename?: 'UserSubscription';
|
||||||
id: string;
|
id: string | null;
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
nextBillAt: string | null;
|
nextBillAt: string | null;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1955,12 +1958,12 @@ export type SubscriptionQuery = {
|
|||||||
id: string;
|
id: string;
|
||||||
subscriptions: Array<{
|
subscriptions: Array<{
|
||||||
__typename?: 'UserSubscription';
|
__typename?: 'UserSubscription';
|
||||||
id: string;
|
id: string | null;
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string | null;
|
||||||
nextBillAt: string | null;
|
nextBillAt: string | null;
|
||||||
canceledAt: string | null;
|
canceledAt: string | null;
|
||||||
}>;
|
}>;
|
||||||
@ -1990,7 +1993,7 @@ export type UpdateSubscriptionMutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
updateSubscriptionRecurring: {
|
updateSubscriptionRecurring: {
|
||||||
__typename?: 'UserSubscription';
|
__typename?: 'UserSubscription';
|
||||||
id: string;
|
id: string | null;
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
nextBillAt: string | null;
|
nextBillAt: string | null;
|
||||||
|
@ -941,6 +941,7 @@
|
|||||||
"com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan</1>.",
|
"com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan</1>.",
|
||||||
"com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan</1>.",
|
"com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan</1>.",
|
||||||
"com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan</1>.",
|
"com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan</1>.",
|
||||||
|
"com.affine.payment.billing-setting.current-plan.description.lifetime": "You are currently on the believer <1>{{planName}} plan</1>.",
|
||||||
"com.affine.payment.billing-setting.expiration-date": "Expiration Date",
|
"com.affine.payment.billing-setting.expiration-date": "Expiration Date",
|
||||||
"com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}",
|
"com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}",
|
||||||
"com.affine.payment.billing-setting.history": "Billing history",
|
"com.affine.payment.billing-setting.history": "Billing history",
|
||||||
|
Loading…
Reference in New Issue
Block a user