Files
AFFiNE/packages/backend/server/src/plugins/payment/revenuecat/service.ts
T

371 lines
10 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
import { Config } from '../../../base';
const Store = z.enum([
'amazon',
'app_store',
'mac_app_store',
'play_store',
'promotional',
'stripe',
'rc_billing',
'roku',
'paddle',
]);
const zRcV2RawProduct = z
.object({
id: z.string().nonempty(),
display_name: z.string().nonempty(),
store_identifier: z.string().nonempty(),
subscription: z
.object({ duration: z.string().nullable() })
.partial()
.nullable(),
app: z.object({ type: Store }).partial().nullish(),
})
.passthrough();
const zRcV2RawEntitlementItem = z
.object({
id: z.string().nonempty(),
lookup_key: z.string().nonempty(),
display_name: z.string().nonempty(),
products: z
.object({ items: z.array(zRcV2RawProduct).default([]) })
.partial()
.nullish(),
})
.passthrough();
const zRcV2RawEntitlements = z
.object({ items: z.array(zRcV2RawEntitlementItem).default([]) })
.partial();
const zRcV2RawSubscription = z
.object({
object: z.enum(['subscription']),
id: z.string().nonempty(),
customer_id: z.string().nonempty().nullish(),
product_id: z.string().nonempty().nullable(),
entitlements: zRcV2RawEntitlements,
starts_at: z.number(),
current_period_ends_at: z.number().nullable(),
store: Store,
auto_renewal_status: z.enum([
'will_renew',
'will_not_renew',
'will_change_product',
'will_pause',
'requires_price_increase_consent',
'has_already_renewed',
]),
status: z.enum([
'trialing',
'active',
'expired',
'in_grace_period',
'in_billing_retry',
'paused',
'unknown',
'incomplete',
]),
gives_access: z.boolean(),
})
.passthrough();
const zRcV2RawSubscriptionEnvelope = z
.object({
app_user_id: z.string().optional(),
id: z.string().optional(),
items: z.array(zRcV2RawSubscription).default([]),
})
.passthrough();
const zRcV2RawCustomerAlias = z
.object({
object: z.literal('customer.alias'),
id: z.string().nonempty(),
created_at: z.number(),
})
.passthrough();
const zRcV2RawCustomerAliasEnvelope = z
.object({
items: z.array(zRcV2RawCustomerAlias).default([]),
})
.passthrough();
// v2 minimal, simplified structure exposed to callers
export const Subscription = z.object({
identifier: z.string(),
isTrial: z.boolean(),
isActive: z.boolean(),
latestPurchaseDate: z.date().nullable(),
expirationDate: z.date().nullable(),
customerId: z.string().optional(),
productId: z.string(),
store: Store,
willRenew: z.boolean(),
duration: z.string().nullable(),
});
const IdentifyUserResponse = z.object({
was_created: z.boolean(),
});
export type Subscription = z.infer<typeof Subscription>;
type Entitlement = z.infer<typeof zRcV2RawEntitlementItem>;
type Product = z.infer<typeof zRcV2RawProduct>;
@Injectable()
export class RevenueCatService {
private readonly logger = new Logger(RevenueCatService.name);
private readonly productsCache = new Map<string, Product[]>();
constructor(private readonly config: Config) {}
private get apiKey(): string {
const key = this.config.payment.revenuecat?.apiKey;
if (!key) {
throw new Error('RevenueCat API key is not configured');
}
return key;
}
private get projectId(): string {
const id = this.config.payment.revenuecat?.projectId;
if (!id) {
throw new Error('RevenueCat Project ID is not configured');
}
return id;
}
async identifyUser(userId: string, newUserId: string): Promise<boolean> {
try {
const res = await fetch(
`https://api.revenuecat.com/v1/subscribers/identify`,
{
method: 'POST',
body: JSON.stringify({
app_user_id: userId,
new_app_user_id: newUserId,
}),
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
}
);
const json = await res.json();
const parsed = IdentifyUserResponse.safeParse(json);
if (parsed.success) {
return parsed.data.was_created;
} else {
this.logger.error(
`RevenueCat identifyUser parse failed: ${JSON.stringify(
parsed.error.format()
)}`
);
return false;
}
} catch (e: any) {
this.logger.error(`RevenueCat identifyUser failed: ${e.message}`);
return false;
}
}
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 getCustomerAlias(
customerId: string,
filterAlias = true
): Promise<string[] | null> {
const res = await fetch(
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/aliases`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
const text = await res.text();
throw new Error(
`RevenueCat getCustomerAlias failed: ${res.status} ${res.statusText} - ${text}`
);
}
const json = await res.json();
const customerParsed = zRcV2RawCustomerAliasEnvelope.safeParse(json);
if (customerParsed.success) {
const customer = customerParsed.data.items.map(alias => alias.id);
if (filterAlias) {
return customer.filter(id => !id.startsWith('$RCAnonymousID:'));
} else {
return customer;
}
}
this.logger.error(
`RevenueCat customer ${customerId} parse failed: ${JSON.stringify(
customerParsed.error.format()
)}`
);
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 getSubscriptionByExternalRef failed: ${res.status} ${res.statusText} - ${text}`
);
}
const json = await res.json();
const envParsed = zRcV2RawSubscriptionEnvelope.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> {
const res = await fetch(
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
{
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 = zRcV2RawSubscriptionEnvelope.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;
}
private async parseSubscription(
sub: z.infer<typeof zRcV2RawSubscription>
): Promise<Subscription | null> {
const items = sub.entitlements.items ?? [];
const products = (await Promise.all(items.map(this.getProducts.bind(this))))
.filter((p): p is Product[] => p !== null)
.flat();
const product = products.find(p => p.id === sub.product_id);
if (!product) {
this.logger.warn(
`RevenueCat subscription ${sub.id} missing product for product_id=${sub.product_id}`,
products
);
return null;
}
return {
identifier: product.display_name,
isTrial: sub.status === 'trialing',
isActive:
sub.gives_access === true ||
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,
customerId: sub.customer_id || undefined,
productId: product.store_identifier,
store: sub.store ?? product.app?.type,
willRenew: sub.auto_renewal_status === 'will_renew',
duration: product.subscription?.duration ?? null,
};
}
}