371 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|