mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 18:40:00 +08:00
feat(server): fetch missing field from rcat (#13794)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Performance** * Added caching mechanism for subscription product data to reduce API calls and improve response times. * **Improvements** * Enhanced asynchronous processing of subscription entitlements for more reliable product data retrieval from the payment provider. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -23,12 +23,13 @@ const zRcV2RawProduct = z
|
|||||||
.object({ duration: z.string().nullable() })
|
.object({ duration: z.string().nullable() })
|
||||||
.partial()
|
.partial()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
app: z.object({ type: Store }).partial(),
|
app: z.object({ type: Store }).partial().nullish(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
const zRcV2RawEntitlementItem = z
|
const zRcV2RawEntitlementItem = z
|
||||||
.object({
|
.object({
|
||||||
|
id: z.string().nonempty(),
|
||||||
lookup_key: z.string().nonempty(),
|
lookup_key: z.string().nonempty(),
|
||||||
display_name: z.string().nonempty(),
|
display_name: z.string().nonempty(),
|
||||||
products: z
|
products: z
|
||||||
@@ -45,6 +46,8 @@ const zRcV2RawEntitlements = z
|
|||||||
const zRcV2RawSubscription = z
|
const zRcV2RawSubscription = z
|
||||||
.object({
|
.object({
|
||||||
object: z.enum(['subscription']),
|
object: z.enum(['subscription']),
|
||||||
|
id: z.string().nonempty(),
|
||||||
|
product_id: z.string().nonempty().nullable(),
|
||||||
entitlements: zRcV2RawEntitlements,
|
entitlements: zRcV2RawEntitlements,
|
||||||
starts_at: z.number(),
|
starts_at: z.number(),
|
||||||
current_period_ends_at: z.number().nullable(),
|
current_period_ends_at: z.number().nullable(),
|
||||||
@@ -93,10 +96,13 @@ export const Subscription = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Subscription = z.infer<typeof Subscription>;
|
export type Subscription = z.infer<typeof Subscription>;
|
||||||
|
type Entitlement = z.infer<typeof zRcV2RawEntitlementItem>;
|
||||||
|
type Product = z.infer<typeof zRcV2RawProduct>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RevenueCatService {
|
export class RevenueCatService {
|
||||||
private readonly logger = new Logger(RevenueCatService.name);
|
private readonly logger = new Logger(RevenueCatService.name);
|
||||||
|
private readonly productsCache = new Map<string, Product[]>();
|
||||||
|
|
||||||
constructor(private readonly config: Config) {}
|
constructor(private readonly config: Config) {}
|
||||||
|
|
||||||
@@ -116,6 +122,49 @@ export class RevenueCatService {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProducts(ent: Entitlement): Promise<Product[] | null> {
|
||||||
|
if (ent.products?.items && ent.products.items.length > 0) {
|
||||||
|
return ent.products.items;
|
||||||
|
}
|
||||||
|
const entId = ent.id;
|
||||||
|
if (this.productsCache.has(entId)) {
|
||||||
|
return this.productsCache.get(entId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.revenuecat.com/v2/projects/${this.projectId}/entitlements/${entId}?expand=product`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
this.logger.warn(
|
||||||
|
`RevenueCat getProducts failed: ${res.status} ${res.statusText} - ${text}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const entParsed = zRcV2RawEntitlementItem.safeParse(json);
|
||||||
|
if (entParsed.success) {
|
||||||
|
const products = entParsed.data.products?.items || null;
|
||||||
|
if (products) {
|
||||||
|
this.productsCache.set(entId, products);
|
||||||
|
}
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
`RevenueCat entitlement ${entId} parse failed: ${JSON.stringify(
|
||||||
|
entParsed.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`,
|
||||||
@@ -138,32 +187,41 @@ export class RevenueCatService {
|
|||||||
const envParsed = zRcV2RawEnvelope.safeParse(json);
|
const envParsed = zRcV2RawEnvelope.safeParse(json);
|
||||||
|
|
||||||
if (envParsed.success) {
|
if (envParsed.success) {
|
||||||
return envParsed.data.items
|
const parsedSubs = await Promise.all(
|
||||||
.flatMap(sub => {
|
envParsed.data.items.flatMap(async sub => {
|
||||||
const items = sub.entitlements.items ?? [];
|
const items = sub.entitlements.items ?? [];
|
||||||
return items.map(ent => {
|
const products = (
|
||||||
const product = ent.products?.items?.[0];
|
await Promise.all(items.map(this.getProducts.bind(this)))
|
||||||
return {
|
)
|
||||||
identifier: ent.lookup_key,
|
.filter((p): p is Product[] => p !== null)
|
||||||
isTrial: sub.status === 'trialing',
|
.flat();
|
||||||
isActive:
|
const product = products.find(p => p.id === sub.product_id);
|
||||||
sub.gives_access === true ||
|
if (!product) {
|
||||||
sub.status === 'active' ||
|
this.logger.warn(
|
||||||
sub.status === 'trialing',
|
`RevenueCat subscription ${sub.id} missing product for product_id=${sub.product_id}`
|
||||||
latestPurchaseDate: sub.starts_at
|
);
|
||||||
? new Date(sub.starts_at)
|
return null;
|
||||||
: null,
|
}
|
||||||
expirationDate: sub.current_period_ends_at
|
|
||||||
? new Date(sub.current_period_ends_at)
|
return {
|
||||||
: null,
|
identifier: product.display_name,
|
||||||
productId: product?.store_identifier,
|
isTrial: sub.status === 'trialing',
|
||||||
store: sub.store ?? product?.app.type,
|
isActive:
|
||||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
sub.gives_access === true ||
|
||||||
duration: product?.subscription?.duration ?? null,
|
sub.status === 'active' ||
|
||||||
};
|
sub.status === 'trialing',
|
||||||
});
|
latestPurchaseDate: sub.starts_at ? new Date(sub.starts_at) : null,
|
||||||
|
expirationDate: sub.current_period_ends_at
|
||||||
|
? new Date(sub.current_period_ends_at)
|
||||||
|
: null,
|
||||||
|
productId: product.store_identifier,
|
||||||
|
store: sub.store ?? product.app?.type,
|
||||||
|
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||||
|
duration: product.subscription?.duration ?? null,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter((s): s is Subscription => s !== null);
|
);
|
||||||
|
return parsedSubs.filter((s): s is Subscription => s !== null);
|
||||||
}
|
}
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`RevenueCat subscription parse failed: ${JSON.stringify(
|
`RevenueCat subscription parse failed: ${JSON.stringify(
|
||||||
|
|||||||
Reference in New Issue
Block a user