npm package: @sftech/nestjs-revenue
Description: RevenueCat subscription management for NestJS applications. Provides entitlement-based access control, a subscription status endpoint, and a guard/decorator pair for protecting routes behind an active subscription check.
Latest version: 0.1.0

# @sftech/nestjs-revenue

RevenueCat subscription management for NestJS applications. Provides entitlement-based access control, a subscription status endpoint, and a guard/decorator pair for protecting routes behind an active subscription check.

## Installation

```bash
pnpm add @sftech/nestjs-revenue
```

## Peer Dependencies

| Package | Required Version |
|---|---|
| `@nestjs/common` | `^10.0.0 \|\| ^11.0.0` |
| `@nestjs/core` | `^10.0.0 \|\| ^11.0.0` |
| `@nestjs/axios` | `>=4.0.1` |
| `@sftech/nestjs-core` | `>=1.0.3` |
| `@sftech/nestjs-auth` | `>=1.5.0` |
| `rxjs` | `^7.0.0` |

> **Note**: `@sftech/nestjs-auth` must be registered in the consuming application. `NestjsRevenueModule` relies on `@sftech/nestjs-auth` for the `@CurrentUser()` decorator and `IS_PUBLIC_KEY` reflection metadata used by `EntitlementGuard`.

## Module Registration

Pass your application configuration object directly to `NestjsRevenueModule.register()`. The module reads the RevenueCat keys from the configuration and validates required keys at startup.

```typescript
import { Module } from '@nestjs/common';
import { NestjsAuthModule } from '@sftech/nestjs-auth';
import { NestjsRevenueModule } from '@sftech/nestjs-revenue';
import { Configuration } from '@sftech/nestjs-core';

@Module({
    imports: [
        NestjsAuthModule.register(Configuration.getConfig()),
        NestjsRevenueModule.register(Configuration.getConfig()),
    ],
})
export class AppModule {}
```

The module is decorated with `@Global()`, so `RevenueService` and `EntitlementGuard` are available application-wide once registered.

## Configuration Keys

| Key | Required | Default | Description |
|---|---|---|---|
| `REVENUECAT_SECRET_KEY` | Yes | - | RevenueCat secret API key (e.g. `sk_live_...`) |
| `REVENUECAT_ENTITLEMENT_ID` | Yes | - | Entitlement identifier to check (e.g. `premium`) |
| `REVENUECAT_API_URL` | No | `https://api.revenuecat.com` | RevenueCat REST API base URL |
| `REVENUECAT_PLATFORM` | No | *(none)* | Platform sent in `X-Platform` header (see `ERevenuePlatform`). When not set, the `X-Platform` header is omitted entirely. Leave unconfigured when serving multiple platforms (web, Android, iOS) from a single backend — RevenueCat resolves subscribers cross-platform via the `app_user_id` alone. |

If `REVENUECAT_SECRET_KEY` or `REVENUECAT_ENTITLEMENT_ID` is missing, `NestjsRevenueModule.register()` throws a `BaseApplicationException` at startup with a descriptive message.

## API Reference

### Interfaces

| Interface | Description |
|---|---|
| `IRevenueConfig` | Internal configuration shape used by the module after mapping |
| `IRevenuePort` | Port interface for the RevenueCat adapter (`getSubscriber(appUserId)`) |
| `ISubscriberStatus` | Shape returned by `RevenueService` and the status endpoint |

#### `IRevenueConfig`

```typescript
interface IRevenueConfig {
    secretKey: string;
    apiUrl: string;
    entitlementId: string;
    platform?: ERevenuePlatform;
}
```

#### `IRevenuePort`

```typescript
interface IRevenuePort {
    getSubscriber(appUserId: string): Promise<ISubscriberStatus>;
}
```

#### `ISubscriberStatus`

```typescript
interface ISubscriberStatus {
    isPremium: boolean;
    entitlement: string | null;
    expiresAt: Date | null;
    activeSubscriptions: string[];
}
```

### Enums

#### `ERevenuePlatform`

| Member | Value |
|---|---|
| `IOS` | `'ios'` |
| `ANDROID` | `'android'` |
| `STRIPE` | `'stripe'` |
| `AMAZON` | `'amazon'` |
| `MACOS` | `'macos'` |
| `PLAY_STORE` | `'play_store'` |

#### `ERevenueConfigKeys`

| Member | Value |
|---|---|
| `SECRET_KEY` | `'REVENUECAT_SECRET_KEY'` |
| `API_URL` | `'REVENUECAT_API_URL'` |
| `ENTITLEMENT_ID` | `'REVENUECAT_ENTITLEMENT_ID'` |
| `PLATFORM` | `'REVENUECAT_PLATFORM'` |

### Classes

| Class | Layer | Description |
|---|---|---|
| `RevenueService` | Application | Facade service; inject this to query subscription status |
| `RevenueConfigMapper` | Infrastructure | Static mapper that transforms raw config into `IRevenueConfig` |
| `EntitlementGuard` | Presentation | NestJS guard that blocks non-premium users with `403 Forbidden` |
| `SubscriptionController` | Presentation | Registers `GET /subscription/status` endpoint automatically |

### Decorator

| Decorator | Description |
|---|---|
| `@RequireEntitlement()` | Shorthand for `@UseGuards(EntitlementGuard)` |

## REST Endpoint

### GET /subscription/status

Returns the authenticated user's subscription status. Authentication is required and handled by `@sftech/nestjs-auth`. The endpoint is registered automatically when `NestjsRevenueModule` is imported.

**Response (200 OK, active subscription):**

```json
{
    "isPremium": true,
    "entitlement": "premium",
    "expiresAt": "2026-12-31T23:59:59.000Z",
    "activeSubscriptions": ["monthly_premium"]
}
```

**Response (200 OK, no active subscription):**

```json
{
    "isPremium": false,
    "entitlement": null,
    "expiresAt": null,
    "activeSubscriptions": []
}
```

**Response (200 OK, lifetime subscription):**

```json
{
    "isPremium": true,
    "entitlement": "premium",
    "expiresAt": null,
    "activeSubscriptions": ["lifetime_premium"]
}
```

**Error responses:**

| Status | Condition |
|---|---|
| `401 Unauthorized` | User is not authenticated (handled by `@sftech/nestjs-auth`) |

## Usage Examples

### Protecting a Route with EntitlementGuard

```typescript
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@sftech/nestjs-auth';
import { EntitlementGuard } from '@sftech/nestjs-revenue';

@Controller('premium-content')
@UseGuards(JwtAuthGuard, EntitlementGuard)
export class PremiumContentController {
    @Get()
    getContent() {
        return { data: 'This is premium content' };
    }
}
```

Non-premium users receive `403 Forbidden` with the message `"Active entitlement required"`.

### Using the @RequireEntitlement() Decorator

`@RequireEntitlement()` is a convenience wrapper for `@UseGuards(EntitlementGuard)` and behaves identically:

```typescript
import { Controller, Get } from '@nestjs/common';
import { JwtAuthGuard, Public } from '@sftech/nestjs-auth';
import { RequireEntitlement } from '@sftech/nestjs-revenue';

@Controller('premium-content')
export class PremiumContentController {
    @Get('protected')
    @RequireEntitlement()
    getProtected() {
        return { data: 'Premium only' };
    }

    @Get('free')
    @Public()
    getFree() {
        return { data: 'Free for everyone' };
    }
}
```

Routes decorated with `@Public()` from `@sftech/nestjs-auth` bypass `EntitlementGuard` entirely.

### Injecting RevenueService

Use `RevenueService` directly when you need programmatic access to subscription status:

```typescript
import { Injectable } from '@nestjs/common';
import { RevenueService, ISubscriberStatus } from '@sftech/nestjs-revenue';

@Injectable()
export class MyFeatureService {
    constructor(private readonly revenueService: RevenueService) {}

    async getFeatureAccess(userId: string): Promise<ISubscriberStatus> {
        return this.revenueService.getSubscriberStatus(userId);
    }

    async canAccessPremiumFeature(userId: string): Promise<boolean> {
        return this.revenueService.hasActiveEntitlement(userId);
    }
}
```

## Edge Case Behavior

### Fail-Open on API Errors

If the RevenueCat API is unreachable, returns a non-200 status, or the response cannot be parsed, the adapter logs a warning and returns a safe non-premium status:

```json
{
    "isPremium": false,
    "entitlement": null,
    "expiresAt": null,
    "activeSubscriptions": []
}
```

This means API errors do not cause `500` responses or surface to the end user. The endpoint always returns `200 OK`.

### Lifetime Subscriptions

When RevenueCat reports `expires_date: null` for an entitlement or subscription, it indicates a lifetime (non-expiring) purchase. The adapter treats these as always active:

- `isPremium` is `true`
- `expiresAt` is `null` (no expiry date)
- The subscription key appears in `activeSubscriptions`