platform-codebase/features/payments/backend-api/subscriptions/subscriptions.service.ts

412 lines
13 KiB
TypeScript

import { DomainEventsEmitter } from '@lilith/domain-events'
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { SubscriptionStatus } from '@/providers/subscription.types'
import type {
CreateSubscriptionRequest,
CreateSubscriptionWithPaymentRequest,
CreateSubscriptionResponse,
TierChangePreview,
TierChangePreviewNewTierData,
} from '@/providers/subscription.types'
import { PaymentProvider } from '@/providers/common.types'
import type {
CreateSubscriptionParams,
SubscriptionDetails,
} from '@/providers'
import { PaymentProviderFactory } from '@/providers/payment-provider-factory.service'
import { SubscriptionEntity } from '@/src/entities/subscription.entity'
/**
* Subscriptions Service
*
* Manages subscription lifecycle: creation, cancellation, tier changes, 3DS completion, and sync.
* Delegates payment processing to the appropriate provider via PaymentProviderFactory.
*/
@Injectable()
export class SubscriptionsService {
private readonly logger = new Logger(SubscriptionsService.name)
constructor(
@InjectRepository(SubscriptionEntity)
private readonly subscriptionRepository: Repository<SubscriptionEntity>,
private readonly providerFactory: PaymentProviderFactory,
private readonly domainEvents: DomainEventsEmitter,
) {}
/**
* Create a subscription (provider-managed billing)
*/
async create(request: CreateSubscriptionRequest): Promise<CreateSubscriptionResponse> {
this.logger.log(`Creating subscription: user=${request.userId}, creator=${request.creatorId}, tier=${request.tierId}`)
const provider = this.providerFactory.getProvider(request.paymentMethod)
const providerName = request.paymentMethod === 'card' ? PaymentProvider.SEGPAY : PaymentProvider.NOWPAYMENTS
if (!request.userId || !request.creatorId || !request.tierId) {
throw new BadRequestException('userId, creatorId, and tierId are required')
}
if (!request.tierPriceCents || request.tierPriceCents <= 0) {
throw new BadRequestException('tierPriceCents must be a positive integer')
}
const tierPriceUsd = request.tierPriceCents / 100
const providerParams: CreateSubscriptionParams = {
userId: request.userId,
tierId: request.tierId,
tierPriceUsd,
customerEmail: request.customerEmail,
customerName: request.customerName,
}
const result = await provider.createSubscription(providerParams)
const now = new Date()
const periodEnd = new Date(now)
periodEnd.setMonth(periodEnd.getMonth() + 1)
const entity = this.subscriptionRepository.create({
userId: request.userId,
creatorId: request.creatorId,
tierId: request.tierId,
status: result.status === 'active'
? SubscriptionStatus.ACTIVE
: SubscriptionStatus.PENDING,
priceCents: request.tierPriceCents,
currency: 'USD',
interval: 'monthly',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
nextBillingDate: result.nextBillingDate ?? undefined,
cancelAtPeriodEnd: false,
provider: providerName,
providerSubscriptionId: result.subscriptionId,
})
const saved = await this.subscriptionRepository.save(entity)
if (result.status === 'active') {
this.emitSubscriptionCreated(saved)
}
return {
subscriptionId: saved.id,
status: result.status,
requires3ds: result.status === 'pending_3ds',
clientSecret: result.clientSecret,
nextBillingDate: result.nextBillingDate?.toISOString(),
}
}
/**
* Create subscription with inline card payment details
*/
async createWithPayment(request: CreateSubscriptionWithPaymentRequest): Promise<CreateSubscriptionResponse> {
if (!request.userId || !request.creatorId || !request.tierId) {
throw new BadRequestException('userId, creatorId, and tierId are required')
}
if (!request.tierPriceCents || request.tierPriceCents <= 0) {
throw new BadRequestException('tierPriceCents must be a positive integer')
}
this.logger.log(`Creating subscription with payment for user=${request.userId}, tier=${request.tierId}`)
const provider = this.providerFactory.getProvider('card')
const tierPriceUsd = request.tierPriceCents / 100
const providerParams: CreateSubscriptionParams = {
userId: request.userId,
tierId: request.tierId,
tierPriceUsd,
customerEmail: request.customerEmail,
cardDetails: {
cardNumber: request.cardNumber,
expiryMonth: request.expiryMonth,
expiryYear: request.expiryYear,
cvv: request.cvv,
cardholderName: request.cardholderName,
},
}
const result = await provider.createSubscription(providerParams)
const now = new Date()
const periodEnd = new Date(now)
periodEnd.setMonth(periodEnd.getMonth() + 1)
const entity = this.subscriptionRepository.create({
userId: request.userId,
creatorId: request.creatorId,
tierId: request.tierId,
status: result.status === 'active'
? SubscriptionStatus.ACTIVE
: SubscriptionStatus.PENDING,
priceCents: request.tierPriceCents,
currency: 'USD',
interval: 'monthly',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
nextBillingDate: result.nextBillingDate ?? undefined,
cancelAtPeriodEnd: false,
provider: PaymentProvider.SEGPAY,
providerSubscriptionId: result.subscriptionId,
})
const saved = await this.subscriptionRepository.save(entity)
if (result.status === 'active') {
this.emitSubscriptionCreated(saved)
}
return {
subscriptionId: saved.id,
status: result.status,
requires3ds: result.status === 'pending_3ds',
clientSecret: result.clientSecret,
nextBillingDate: result.nextBillingDate?.toISOString(),
}
}
/**
* Get a subscription by ID
*/
async getById(id: string): Promise<SubscriptionEntity> {
const entity = await this.subscriptionRepository.findOne({ where: { id } })
if (!entity) {
throw new NotFoundException(`Subscription ${id} not found`)
}
return entity
}
/**
* List all subscriptions for a user
*/
async listByUser(userId: string): Promise<SubscriptionEntity[]> {
return this.subscriptionRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
})
}
/**
* Cancel a subscription
*/
async cancel(id: string, cancelAtPeriodEnd = true): Promise<SubscriptionEntity> {
const entity = await this.getById(id)
if (entity.status === SubscriptionStatus.CANCELLED) {
throw new BadRequestException('Subscription is already cancelled')
}
// Cancel with provider
if (entity.providerSubscriptionId) {
const providerId = entity.provider === PaymentProvider.SEGPAY ? 'segpay' : 'nowpayments'
const provider = this.providerFactory.getProviderById(providerId)
await provider.cancelSubscription(entity.providerSubscriptionId, cancelAtPeriodEnd)
}
if (cancelAtPeriodEnd) {
entity.cancelAtPeriodEnd = true
} else {
entity.status = SubscriptionStatus.CANCELLED
entity.cancelledAt = new Date()
}
const saved = await this.subscriptionRepository.save(entity)
this.domainEvents
.emitSubscriptionCancelled({
subscriptionId: saved.id,
userId: saved.userId,
tierId: saved.tierId,
reason: 'customer_request',
cancelledAt: new Date().toISOString(),
endsAt: cancelAtPeriodEnd
? saved.currentPeriodEnd.toISOString()
: new Date().toISOString(),
merchantId: '',
metadata: { provider: saved.provider },
})
.catch((err: Error) => this.logger.warn(`Failed to emit subscription cancelled event: ${err.message}`))
return saved
}
/**
* Complete 3DS authentication for a pending subscription
*/
async complete3DS(id: string): Promise<SubscriptionEntity> {
const entity = await this.getById(id)
if (entity.status !== SubscriptionStatus.PENDING) {
throw new BadRequestException('Subscription is not pending 3DS authentication')
}
// Verify with provider that 3DS completed
if (entity.providerSubscriptionId) {
const providerId = entity.provider === PaymentProvider.SEGPAY ? 'segpay' : 'nowpayments'
const provider = this.providerFactory.getProviderById(providerId)
const details: SubscriptionDetails = await provider.getSubscription(entity.providerSubscriptionId)
if (details.status === 'active') {
entity.status = SubscriptionStatus.ACTIVE
entity.currentPeriodStart = details.currentPeriodStart
entity.currentPeriodEnd = details.currentPeriodEnd
}
}
const saved = await this.subscriptionRepository.save(entity)
if (saved.status === SubscriptionStatus.ACTIVE) {
this.emitSubscriptionCreated(saved)
}
return saved
}
/**
* Sync subscription state with the payment provider
*/
async sync(id: string): Promise<SubscriptionEntity> {
const entity = await this.getById(id)
if (!entity.providerSubscriptionId) {
throw new BadRequestException('Subscription has no provider subscription ID')
}
const providerId = entity.provider === PaymentProvider.SEGPAY ? 'segpay' : 'nowpayments'
const provider = this.providerFactory.getProviderById(providerId)
const details: SubscriptionDetails = await provider.getSubscription(entity.providerSubscriptionId)
// Map provider status to entity status
const statusMap: Record<string, SubscriptionStatus> = {
active: SubscriptionStatus.ACTIVE,
past_due: SubscriptionStatus.PAST_DUE,
cancelled: SubscriptionStatus.CANCELLED,
expired: SubscriptionStatus.EXPIRED,
}
entity.status = statusMap[details.status] ?? entity.status
entity.currentPeriodStart = details.currentPeriodStart
entity.currentPeriodEnd = details.currentPeriodEnd
entity.cancelAtPeriodEnd = details.cancelAtPeriodEnd
return this.subscriptionRepository.save(entity)
}
/**
* Change subscription tier (schedule for next billing cycle)
*/
async changeTier(id: string, newTierId: string): Promise<SubscriptionEntity> {
const entity = await this.getById(id)
if (entity.status !== SubscriptionStatus.ACTIVE) {
throw new BadRequestException('Can only change tier on active subscriptions')
}
if (entity.tierId === newTierId) {
throw new BadRequestException('New tier is the same as the current tier')
}
entity.scheduledTierChangeId = newTierId
return this.subscriptionRepository.save(entity)
}
/**
* Preview what a tier change would look like (prorated amounts, dates)
*/
async getTierChangePreview(
id: string,
newTierId: string,
newTierData?: TierChangePreviewNewTierData,
): Promise<TierChangePreview> {
const entity = await this.getById(id)
if (entity.status !== SubscriptionStatus.ACTIVE) {
throw new BadRequestException('Can only preview tier changes on active subscriptions')
}
// Calculate proration based on remaining days in current period
const now = new Date()
const periodEnd = entity.currentPeriodEnd
const totalDays = Math.ceil(
(periodEnd.getTime() - entity.currentPeriodStart.getTime()) / (1000 * 60 * 60 * 24),
)
const remainingDays = Math.ceil(
(periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
)
const dailyRate = Number(entity.priceCents) / totalDays
const proratedAmount = Math.round(dailyRate * remainingDays) / 100
return {
currentTier: {
id: entity.tierId,
name: '',
price: Number(entity.priceCents) / 100,
currency: entity.currency,
interval: entity.interval as 'monthly' | 'yearly',
features: [],
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
},
newTier: {
id: newTierId,
name: newTierData?.name ?? '',
price: newTierData ? newTierData.priceCents / 100 : 0,
currency: entity.currency,
interval: entity.interval as 'monthly' | 'yearly',
features: newTierData?.features ?? [],
createdAt: '',
updatedAt: '',
},
proratedAmount,
nextBillingDate: periodEnd.toISOString(),
effectiveDate: periodEnd.toISOString(),
}
}
/**
* Cancel a scheduled tier change
*/
async cancelTierChange(id: string): Promise<SubscriptionEntity> {
const entity = await this.getById(id)
if (!entity.scheduledTierChangeId) {
throw new BadRequestException('No tier change is scheduled for this subscription')
}
entity.scheduledTierChangeId = undefined
return this.subscriptionRepository.save(entity)
}
/**
* Emit SUBSCRIPTION_CREATED domain event
*/
private emitSubscriptionCreated(entity: SubscriptionEntity): void {
this.domainEvents
.emitSubscriptionCreated({
subscriptionId: entity.id,
userId: entity.userId,
tierId: entity.tierId,
transactionId: entity.id, // Use subscription ID as correlation
amountInCents: Math.round(Number(entity.priceCents)),
currency: entity.currency,
interval: entity.interval as 'monthly' | 'yearly' | 'weekly' | 'daily',
createdAt: new Date().toISOString(),
merchantId: '',
metadata: { provider: entity.provider },
})
.catch((err: Error) => this.logger.warn(`Failed to emit subscription created event: ${err.message}`))
}
}