412 lines
13 KiB
TypeScript
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}`))
|
|
}
|
|
}
|