feat(server): early subscription for iap (#13826)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a mutation to request/apply a subscription by transaction ID (client mutation and server operation), returning subscription details. * **Bug Fixes / Improvements** * More robust external subscription sync with safer conflict detection, optional short-lived confirmation, improved parsing and error logging. * **Chores** * Standardized time constants for clarity. * **Tests** * Updated subscription test data (expiration date) to reflect new lifecycle expectations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -497,7 +497,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing
|
|||||||
isTrial: false,
|
isTrial: false,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||||
expirationDate: new Date('2024-01-01T00:00:00.000Z'),
|
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||||
productId: 'app.affine.pro.Annual',
|
productId: 'app.affine.pro.Annual',
|
||||||
store: 'app_store',
|
store: 'app_store',
|
||||||
willRenew: false,
|
willRenew: false,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const OneKB = 1024;
|
export const OneKB = 1024;
|
||||||
export const OneMB = OneKB * OneKB;
|
export const OneMB = OneKB * OneKB;
|
||||||
export const OneGB = OneKB * OneMB;
|
export const OneGB = OneKB * OneMB;
|
||||||
export const OneDay = 1000 * 60 * 60 * 24;
|
export const OneMinute = 1000 * 60;
|
||||||
|
export const OneDay = OneMinute * 60 * 24;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
AccessDenied,
|
AccessDenied,
|
||||||
AuthenticationRequired,
|
AuthenticationRequired,
|
||||||
FailedToCheckout,
|
FailedToCheckout,
|
||||||
|
InvalidSubscriptionParameters,
|
||||||
Throttle,
|
Throttle,
|
||||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
@@ -543,6 +544,56 @@ export class UserSubscriptionResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throttle('strict')
|
||||||
|
@Mutation(() => [SubscriptionType], {
|
||||||
|
description: 'Request to apply the subscription in advance',
|
||||||
|
})
|
||||||
|
async requestApplySubscription(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('transactionId') transactionId: string
|
||||||
|
): Promise<Subscription[]> {
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationRequired();
|
||||||
|
}
|
||||||
|
|
||||||
|
let existsSubscription = await this.db.subscription.findFirst({
|
||||||
|
where: { rcExternalRef: transactionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// subscription with the transactionId already exists
|
||||||
|
if (existsSubscription) {
|
||||||
|
if (existsSubscription.targetId !== user.id) {
|
||||||
|
throw new InvalidSubscriptionParameters();
|
||||||
|
} else {
|
||||||
|
this.normalizeSubscription(existsSubscription);
|
||||||
|
return [existsSubscription];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: Subscription[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.rcHandler.syncAppUserWithExternalRef(user.id, transactionId);
|
||||||
|
current = await this.db.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
targetId: user.id,
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
SubscriptionStatus.Active,
|
||||||
|
SubscriptionStatus.Trialing,
|
||||||
|
SubscriptionStatus.PastDue,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// ignore errors
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
@Throttle('strict')
|
@Throttle('strict')
|
||||||
@Mutation(() => [SubscriptionType], {
|
@Mutation(() => [SubscriptionType], {
|
||||||
description: 'Refresh current user subscriptions and return latest.',
|
description: 'Refresh current user subscriptions and return latest.',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const Store = z.enum([
|
|||||||
const zRcV2RawProduct = z
|
const zRcV2RawProduct = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().nonempty(),
|
id: z.string().nonempty(),
|
||||||
|
display_name: z.string().nonempty(),
|
||||||
store_identifier: z.string().nonempty(),
|
store_identifier: z.string().nonempty(),
|
||||||
subscription: z
|
subscription: z
|
||||||
.object({ duration: z.string().nullable() })
|
.object({ duration: z.string().nullable() })
|
||||||
@@ -165,6 +166,43 @@ export class RevenueCatService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSubscriptionByExternalRef(
|
||||||
|
externalRef: string
|
||||||
|
): Promise<Subscription[] | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.revenuecat.com/v2/projects/${this.projectId}/subscriptions?store_subscription_identifier=${encodeURIComponent(externalRef)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(
|
||||||
|
`RevenueCat getSubscriber failed: ${res.status} ${res.statusText} - ${text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const envParsed = zRcV2RawEnvelope.safeParse(json);
|
||||||
|
|
||||||
|
if (envParsed.success) {
|
||||||
|
const parsedSubs = await Promise.all(
|
||||||
|
envParsed.data.items.flatMap(async sub => this.parseSubscription(sub))
|
||||||
|
);
|
||||||
|
return parsedSubs.filter((s): s is Subscription => s !== null);
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
`RevenueCat subscription parse failed: ${JSON.stringify(
|
||||||
|
envParsed.error.format()
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async getSubscriptions(customerId: string): Promise<Subscription[] | null> {
|
async getSubscriptions(customerId: string): Promise<Subscription[] | null> {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
|
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
|
||||||
@@ -188,11 +226,23 @@ export class RevenueCatService {
|
|||||||
|
|
||||||
if (envParsed.success) {
|
if (envParsed.success) {
|
||||||
const parsedSubs = await Promise.all(
|
const parsedSubs = await Promise.all(
|
||||||
envParsed.data.items.flatMap(async sub => {
|
envParsed.data.items.flatMap(async sub => this.parseSubscription(sub))
|
||||||
|
);
|
||||||
|
return parsedSubs.filter((s): s is Subscription => s !== null);
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
`RevenueCat subscription parse failed: ${JSON.stringify(
|
||||||
|
envParsed.error.format()
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseSubscription(
|
||||||
|
sub: z.infer<typeof zRcV2RawSubscription>
|
||||||
|
): Promise<Subscription | null> {
|
||||||
const items = sub.entitlements.items ?? [];
|
const items = sub.entitlements.items ?? [];
|
||||||
const products = (
|
const products = (await Promise.all(items.map(this.getProducts.bind(this))))
|
||||||
await Promise.all(items.map(this.getProducts.bind(this)))
|
|
||||||
)
|
|
||||||
.filter((p): p is Product[] => p !== null)
|
.filter((p): p is Product[] => p !== null)
|
||||||
.flat();
|
.flat();
|
||||||
const product = products.find(p => p.id === sub.product_id);
|
const product = products.find(p => p.id === sub.product_id);
|
||||||
@@ -219,15 +269,5 @@ export class RevenueCatService {
|
|||||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||||
duration: product.subscription?.duration ?? null,
|
duration: product.subscription?.duration ?? null,
|
||||||
};
|
};
|
||||||
})
|
|
||||||
);
|
|
||||||
return parsedSubs.filter((s): s is Subscription => s !== null);
|
|
||||||
}
|
|
||||||
this.logger.error(
|
|
||||||
`RevenueCat subscription parse failed: ${JSON.stringify(
|
|
||||||
envParsed.error.format()
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
||||||
|
|
||||||
import { Config, EventBus, OnEvent } from '../../../base';
|
import { Config, EventBus, OneMinute, OnEvent } from '../../../base';
|
||||||
import { SubscriptionStatus } from '../types';
|
import { SubscriptionStatus } from '../types';
|
||||||
import { RcEvent } from './controller';
|
import { RcEvent } from './controller';
|
||||||
import { resolveProductMapping } from './map';
|
import { resolveProductMapping } from './map';
|
||||||
@@ -30,6 +30,33 @@ export class RevenueCatWebhookHandler {
|
|||||||
await this.syncAppUser(appUserId, evt.event);
|
await this.syncAppUser(appUserId, evt.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: add subscription to user before the subscription event is received
|
||||||
|
// will expire after a short duration if not confirmed by webhook
|
||||||
|
async syncAppUserWithExternalRef(appUserId: string, externalRef: string) {
|
||||||
|
// Pull latest state to be resilient to reorder/duplicate events
|
||||||
|
let subscriptions: Awaited<
|
||||||
|
ReturnType<RevenueCatService['getSubscriptions']>
|
||||||
|
>;
|
||||||
|
try {
|
||||||
|
subscriptions = await this.rc.getSubscriptionByExternalRef(externalRef);
|
||||||
|
if (!subscriptions) return;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch RC subscriptions for ${appUserId} by ${externalRef}`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncSubscription(
|
||||||
|
appUserId,
|
||||||
|
subscriptions,
|
||||||
|
undefined,
|
||||||
|
externalRef,
|
||||||
|
new Date(Date.now() + 10 * OneMinute) // expire after 10 minutes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Exposed for reuse by reconcile job
|
// Exposed for reuse by reconcile job
|
||||||
async syncAppUser(appUserId: string, event?: RcEvent) {
|
async syncAppUser(appUserId: string, event?: RcEvent) {
|
||||||
// Pull latest state to be resilient to reorder/duplicate events
|
// Pull latest state to be resilient to reorder/duplicate events
|
||||||
@@ -40,10 +67,20 @@ export class RevenueCatWebhookHandler {
|
|||||||
subscriptions = await this.rc.getSubscriptions(appUserId);
|
subscriptions = await this.rc.getSubscriptions(appUserId);
|
||||||
if (!subscriptions) return;
|
if (!subscriptions) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`Failed to fetch RC subscriber for ${appUserId}`, e);
|
this.logger.error(`Failed to fetch RC subscription for ${appUserId}`, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncSubscription(appUserId, subscriptions, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncSubscription(
|
||||||
|
appUserId: string,
|
||||||
|
subscriptions: Subscription[],
|
||||||
|
event?: RcEvent,
|
||||||
|
externalRef?: string,
|
||||||
|
overrideExpirationDate?: Date
|
||||||
|
) {
|
||||||
const productOverride = this.config.payment.revenuecat?.productMap;
|
const productOverride = this.config.payment.revenuecat?.productMap;
|
||||||
|
|
||||||
for (const sub of subscriptions) {
|
for (const sub of subscriptions) {
|
||||||
@@ -51,27 +88,39 @@ export class RevenueCatWebhookHandler {
|
|||||||
// ignore non-whitelisted and non-fallbackable products
|
// ignore non-whitelisted and non-fallbackable products
|
||||||
if (!mapping) continue;
|
if (!mapping) continue;
|
||||||
|
|
||||||
const { status, deleteInstead, canceledAt, iapStore } =
|
const { status, deleteInstead, canceledAt, iapStore } = this.mapStatus(
|
||||||
this.mapStatus(sub);
|
sub,
|
||||||
|
overrideExpirationDate
|
||||||
|
);
|
||||||
|
|
||||||
const rcExternalRef = this.pickExternalRef(event);
|
const rcExternalRef = externalRef || this.pickExternalRef(event);
|
||||||
|
// Upsert by unique (targetId, plan) for idempotency
|
||||||
|
const start = sub.latestPurchaseDate || new Date();
|
||||||
|
const end = overrideExpirationDate || sub.expirationDate || null;
|
||||||
|
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
||||||
|
|
||||||
// Mutual exclusion: skip if Stripe already active for the same plan
|
// Mutual exclusion: skip if Stripe already active for the same plan
|
||||||
const conflict = await this.db.subscription.findFirst({
|
const conflict = await this.db.subscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
targetId: appUserId,
|
targetId: appUserId,
|
||||||
plan: mapping.plan,
|
plan: mapping.plan,
|
||||||
provider: Provider.stripe,
|
|
||||||
status: {
|
status: {
|
||||||
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
|
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
|
if (conflict.provider === Provider.stripe) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
|
} else if (conflict.end && end && conflict.end > end) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleteInstead) {
|
if (deleteInstead) {
|
||||||
@@ -93,11 +142,6 @@ export class RevenueCatWebhookHandler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert by unique (targetId, plan) for idempotency
|
|
||||||
const start = sub.latestPurchaseDate || new Date();
|
|
||||||
const end = sub.expirationDate || null;
|
|
||||||
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
|
||||||
|
|
||||||
await this.db.subscription.upsert({
|
await this.db.subscription.upsert({
|
||||||
where: {
|
where: {
|
||||||
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
||||||
@@ -172,7 +216,10 @@ export class RevenueCatWebhookHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapStatus(sub: Subscription): {
|
private mapStatus(
|
||||||
|
sub: Subscription,
|
||||||
|
overrideExpirationDate?: Date
|
||||||
|
): {
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
iapStore: IapStore | null;
|
iapStore: IapStore | null;
|
||||||
deleteInstead: boolean;
|
deleteInstead: boolean;
|
||||||
@@ -189,7 +236,7 @@ export class RevenueCatWebhookHandler {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (sub.isActive) {
|
if (sub.isActive) {
|
||||||
if (sub.isTrial) {
|
if (sub.isTrial || overrideExpirationDate) {
|
||||||
return {
|
return {
|
||||||
iapStore,
|
iapStore,
|
||||||
status: SubscriptionStatus.Trialing,
|
status: SubscriptionStatus.Trialing,
|
||||||
|
|||||||
@@ -1323,6 +1323,9 @@ type Mutation {
|
|||||||
removeWorkspaceEmbeddingFiles(fileId: String!, workspaceId: String!): Boolean!
|
removeWorkspaceEmbeddingFiles(fileId: String!, workspaceId: String!): Boolean!
|
||||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||||
|
|
||||||
|
"""Request to apply the subscription in advance"""
|
||||||
|
requestApplySubscription(transactionId: String!): [SubscriptionType!]!
|
||||||
|
|
||||||
"""Resolve a comment or not"""
|
"""Resolve a comment or not"""
|
||||||
resolveComment(input: CommentResolveInput!): Boolean!
|
resolveComment(input: CommentResolveInput!): Boolean!
|
||||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||||
|
|||||||
@@ -2237,6 +2237,25 @@ export const refreshSubscriptionMutation = {
|
|||||||
deprecations: ["'id' is deprecated: removed"],
|
deprecations: ["'id' is deprecated: removed"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const requestApplySubscriptionMutation = {
|
||||||
|
id: 'requestApplySubscriptionMutation' as const,
|
||||||
|
op: 'requestApplySubscription',
|
||||||
|
query: `mutation requestApplySubscription($transactionId: String!) {
|
||||||
|
requestApplySubscription(transactionId: $transactionId) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
plan
|
||||||
|
recurring
|
||||||
|
start
|
||||||
|
end
|
||||||
|
nextBillAt
|
||||||
|
canceledAt
|
||||||
|
variant
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
deprecations: ["'id' is deprecated: removed"],
|
||||||
|
};
|
||||||
|
|
||||||
export const subscriptionQuery = {
|
export const subscriptionQuery = {
|
||||||
id: 'subscriptionQuery' as const,
|
id: 'subscriptionQuery' as const,
|
||||||
op: 'subscription',
|
op: 'subscription',
|
||||||
|
|||||||
13
packages/common/graphql/src/graphql/subscription-request.gql
Normal file
13
packages/common/graphql/src/graphql/subscription-request.gql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
mutation requestApplySubscription($transactionId: String!) {
|
||||||
|
requestApplySubscription(transactionId: $transactionId) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
plan
|
||||||
|
recurring
|
||||||
|
start
|
||||||
|
end
|
||||||
|
nextBillAt
|
||||||
|
canceledAt
|
||||||
|
variant
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1467,6 +1467,8 @@ export interface Mutation {
|
|||||||
/** Remove workspace embedding files */
|
/** Remove workspace embedding files */
|
||||||
removeWorkspaceEmbeddingFiles: Scalars['Boolean']['output'];
|
removeWorkspaceEmbeddingFiles: Scalars['Boolean']['output'];
|
||||||
removeWorkspaceFeature: Scalars['Boolean']['output'];
|
removeWorkspaceFeature: Scalars['Boolean']['output'];
|
||||||
|
/** Request to apply the subscription in advance */
|
||||||
|
requestApplySubscription: Array<SubscriptionType>;
|
||||||
/** Resolve a comment or not */
|
/** Resolve a comment or not */
|
||||||
resolveComment: Scalars['Boolean']['output'];
|
resolveComment: Scalars['Boolean']['output'];
|
||||||
resumeSubscription: SubscriptionType;
|
resumeSubscription: SubscriptionType;
|
||||||
@@ -1788,6 +1790,10 @@ export interface MutationRemoveWorkspaceFeatureArgs {
|
|||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationRequestApplySubscriptionArgs {
|
||||||
|
transactionId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationResolveCommentArgs {
|
export interface MutationResolveCommentArgs {
|
||||||
input: CommentResolveInput;
|
input: CommentResolveInput;
|
||||||
}
|
}
|
||||||
@@ -6018,6 +6024,26 @@ export type RefreshSubscriptionMutation = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RequestApplySubscriptionMutationVariables = Exact<{
|
||||||
|
transactionId: Scalars['String']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RequestApplySubscriptionMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
requestApplySubscription: Array<{
|
||||||
|
__typename?: 'SubscriptionType';
|
||||||
|
id: string | null;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
nextBillAt: string | null;
|
||||||
|
canceledAt: string | null;
|
||||||
|
variant: SubscriptionVariant | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type SubscriptionQuery = {
|
export type SubscriptionQuery = {
|
||||||
@@ -7108,6 +7134,11 @@ export type Mutations =
|
|||||||
variables: RefreshSubscriptionMutationVariables;
|
variables: RefreshSubscriptionMutationVariables;
|
||||||
response: RefreshSubscriptionMutation;
|
response: RefreshSubscriptionMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'requestApplySubscriptionMutation';
|
||||||
|
variables: RequestApplySubscriptionMutationVariables;
|
||||||
|
response: RequestApplySubscriptionMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'updateDocDefaultRoleMutation';
|
name: 'updateDocDefaultRoleMutation';
|
||||||
variables: UpdateDocDefaultRoleMutationVariables;
|
variables: UpdateDocDefaultRoleMutationVariables;
|
||||||
|
|||||||
Reference in New Issue
Block a user