Files
carmanagement/apps/api/src/modules/subscriptions/subscription.service.ts
T
2026-05-25 18:29:05 -04:00

502 lines
17 KiB
TypeScript

import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ValidationError } from '../../http/errors'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
// ─── Helpers ──────────────────────────────────────────────────
export function addPeriod(date: Date, period: string): Date {
const d = new Date(date)
period === 'ANNUAL' ? d.setFullYear(d.getFullYear() + 1) : d.setMonth(d.getMonth() + 1)
return d
}
// ─── Plans & providers ────────────────────────────────────────
export async function getPlans() {
const configs = await prisma.pricingConfig.findMany()
if (configs.length === 0) return PLAN_PRICES
const result: Record<string, Record<string, Record<string, number>>> = {}
for (const c of configs) {
if (!result[c.plan]) result[c.plan] = {}
result[c.plan]![c.billingPeriod] = { MAD: c.amount }
}
return result
}
export function getProviders() {
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
}
export function getPlanFeatures() {
return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
// ─── Reads ────────────────────────────────────────────────────
export function getSubscription(companyId: string) {
return repo.findByCompany(companyId)
}
export function getInvoices(companyId: string) {
return repo.findInvoices(companyId)
}
export function getEvents(companyId: string) {
return repo.findByCompany(companyId).then((sub) =>
sub ? repo.getEvents(sub.id) : [],
)
}
export function getEventsBySubscriptionId(subscriptionId: string) {
return repo.getEvents(subscriptionId)
}
// ─── Entitlements ─────────────────────────────────────────────
export async function getEntitlement(companyId: string) {
const sub = await repo.findByCompany(companyId)
const status = sub?.status ?? 'EXPIRED'
const accessLevel = getAccessLevel(status)
return {
companyId,
subscriptionId: sub?.id ?? null,
subscriptionStatus: status,
accessLevel,
plan: sub?.plan ?? null,
currentPeriodEnd: sub?.currentPeriodEnd ?? null,
}
}
// ─── Trial management ─────────────────────────────────────────
export async function startTrial(
companyId: string,
plan: string,
billingPeriod: string,
currency: string,
) {
if (SUBSCRIPTION_POLICY.trial.oneTrialPerCompany) {
const existing = await repo.findByCompany(companyId)
if (existing?.trialUsed) {
throw new ValidationError('This company has already used its free trial')
}
}
const trialEndAt = new Date(
Date.now() + SUBSCRIPTION_POLICY.trial.durationDays * 24 * 60 * 60 * 1000,
)
const sub = await repo.startTrial(companyId, plan, billingPeriod, currency, trialEndAt)
await repo.createEvent({
subscriptionId: sub.id,
companyId,
eventType: 'trial.started',
payload: { plan, billingPeriod, trialEndAt },
})
return sub
}
// ─── Payment success (shared by all providers) ───────────────
async function handlePaymentSuccess(subscriptionId: string, invoiceId: string) {
const sub = await repo.findById(subscriptionId)
if (!sub) return
await repo.markInvoicePaid(invoiceId)
await repo.createPaymentAttempt({
invoiceId,
subscriptionId,
status: 'succeeded',
})
const periodEnd = addPeriod(new Date(), sub.billingPeriod)
await repo.activateSubscription(subscriptionId, periodEnd)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: sub.status === 'TRIALING' ? 'trial.converted' : 'subscription.activated',
source: 'webhook',
payload: { invoiceId, periodEnd },
})
}
// ─── Payment failure ──────────────────────────────────────────
async function handlePaymentFailure(
subscriptionId: string,
invoiceId: string,
failureCode?: string,
failureMessage?: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) return
await repo.markInvoiceFailed(invoiceId)
await repo.createPaymentAttempt({
invoiceId,
subscriptionId,
status: 'failed',
failureCode,
failureMessage,
})
await repo.incrementRetryCount(sub.id)
if (sub.status !== 'PAYMENT_PENDING') {
await repo.setPaymentPending(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.payment_pending',
source: 'webhook',
payload: { invoiceId, failureCode },
})
}
}
// ─── Webhook handlers ─────────────────────────────────────────
export async function handleAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const invoice = await repo.findInvoiceByAmanpay(transactionId)
if (!invoice || invoice.status === 'PAID') return // idempotent
await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
} else if (status === 'FAILED' || status === 'DECLINED') {
const invoice = await repo.findInvoiceByAmanpay(transactionId)
if (!invoice || invoice.status === 'PAID') return
await handlePaymentFailure(invoice.subscriptionId, invoice.id, status, event.failure_reason)
}
}
export async function handlePaypalWebhook(event: any) {
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const invoice = await repo.findInvoiceByPaypal(captureId)
if (!invoice || invoice.status === 'PAID') return // idempotent
await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
} else if (event.event_type === 'PAYMENT.CAPTURE.DENIED') {
const captureId = event.resource?.id as string
const invoice = await repo.findInvoiceByPaypal(captureId)
if (!invoice || invoice.status === 'PAID') return
await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'capture_denied')
}
}
// ─── Checkout ─────────────────────────────────────────────────
export async function checkout(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
successUrl: string; failureUrl: string
}) {
const dbPrice = await prisma.pricingConfig.findUnique({
where: { plan_billingPeriod: { plan: body.plan, billingPeriod: body.billingPeriod } },
})
const amount = dbPrice?.amount ?? (PLAN_PRICES[body.plan]?.[body.billingPeriod] as any)?.[body.currency]
if (!amount) throw new ValidationError('Invalid plan or billing period')
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
const orderId = `sub-${companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
if (body.provider === 'AMANPAY') {
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured on this platform')
const result = await amanpay.createCheckout({
amount, currency: body.currency, orderId, description,
customerEmail: company.email, customerName: company.name,
successUrl: body.successUrl, failureUrl: body.failureUrl,
webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured on this platform')
const result = await paypal.createOrder({
amount, currency: body.currency, orderId, description,
returnUrl: body.successUrl, cancelUrl: body.failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
const invoice = await repo.createInvoice({
companyId, subscriptionId: subscription.id, amount, currency: body.currency,
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, dueAt,
})
return { invoice, checkoutUrl }
}
export async function capturePaypal(companyId: string, paypalOrderId: string) {
const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId)
if (invoice.status === 'PAID') return { success: true } // idempotent
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await repo.updateInvoicePaypal(invoice.id, captureId)
const periodEnd = addPeriod(new Date(), invoice.subscription.billingPeriod)
await repo.activateSubscription(invoice.subscriptionId, periodEnd)
await repo.createEvent({
subscriptionId: invoice.subscriptionId,
companyId,
eventType: 'subscription.activated',
source: 'user',
payload: { invoiceId: invoice.id },
})
return { success: true }
}
// ─── Plan changes ─────────────────────────────────────────────
export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return repo.updatePlan(companyId, data)
}
// ─── Cancellation ─────────────────────────────────────────────
export async function cancel(companyId: string, mode: 'period_end' | 'immediate' = 'period_end', reason?: string) {
const sub = await repo.findByCompany(companyId)
if (!sub) throw new ValidationError('No subscription found')
await repo.setCancelled(sub.id, mode === 'immediate')
await repo.createEvent({
subscriptionId: sub.id,
companyId,
eventType: 'subscription.canceled',
source: 'user',
payload: { mode, reason: reason ?? null },
})
return { success: true }
}
export function resume(companyId: string) {
return repo.setCancelAtPeriodEnd(companyId, false)
}
// ─── Reactivation ────────────────────────────────────────────
export async function reactivate(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
successUrl: string; failureUrl: string
}) {
const sub = await repo.findByCompany(companyId)
if (!sub) throw new ValidationError('No subscription found')
const allowedStatuses = ['CANCELLED', 'EXPIRED', 'SUSPENDED', 'PAST_DUE', 'PAYMENT_PENDING']
if (!allowedStatuses.includes(sub.status)) {
throw new ValidationError(`Cannot reactivate a subscription with status ${sub.status}`)
}
await repo.setPaymentPending(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId,
eventType: 'subscription.reactivated',
source: 'user',
payload: { plan: body.plan, billingPeriod: body.billingPeriod },
})
return checkout(companyId, body)
}
// ─── Scheduled job actions ────────────────────────────────────
export async function runTrialExpirationJob() {
const expired = await repo.findTrialsEndedWithoutConversion()
for (const sub of expired) {
await repo.setExpired(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'trial.expired',
payload: { trialEndAt: sub.trialEndAt },
})
}
return expired.length
}
export async function runPaymentPendingTimeoutJob() {
const cutoff = new Date(
Date.now() - SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000,
)
const subs = await repo.findPaymentPendingTimedOut(cutoff)
for (const sub of subs) {
await repo.setPastDue(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.past_due',
payload: { paymentPendingSince: sub.paymentPendingSince },
})
}
return subs.length
}
export async function runPastDueTimeoutJob() {
const cutoff = new Date(
Date.now() - SUBSCRIPTION_POLICY.pastDue.timeoutDays * 24 * 60 * 60 * 1000,
)
const subs = await repo.findPastDueTimedOut(cutoff)
for (const sub of subs) {
await repo.setSuspended(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.suspended',
payload: { pastDueSince: sub.pastDueSince },
})
}
return subs.length
}
export async function runSuspensionTimeoutJob() {
const cutoff = new Date(
Date.now() - SUBSCRIPTION_POLICY.suspension.cancelAfterDays * 24 * 60 * 60 * 1000,
)
const subs = await repo.findSuspendedTimedOut(cutoff)
for (const sub of subs) {
await repo.setCancelled(sub.id, true)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.canceled',
payload: { reason: 'suspension_timeout', suspendedAt: sub.suspendedAt },
})
}
return subs.length
}
export async function runPeriodEndCancellationJob() {
const subs = await repo.findSubscriptionsToExpireAtPeriodEnd()
for (const sub of subs) {
await repo.setCancelled(sub.id, true)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.canceled',
payload: { reason: 'period_end', currentPeriodEnd: sub.currentPeriodEnd },
})
}
return subs.length
}
// ─── Admin overrides ──────────────────────────────────────────
export async function adminExtendTrial(
subscriptionId: string,
extraDays: number,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
const currentEnd = sub.trialEndAt ?? new Date()
const newEnd = new Date(currentEnd.getTime() + extraDays * 24 * 60 * 60 * 1000)
await prisma.subscription.update({
where: { id: subscriptionId },
data: { trialEndAt: newEnd },
})
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'admin.override_created',
source: 'admin',
payload: { action: 'extend_trial', extraDays, newEnd, adminId, reason },
})
return { trialEndAt: newEnd }
}
export async function adminExtendGracePeriod(
subscriptionId: string,
extraDays: number,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
await repo.setPaymentPending(sub.id)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'admin.override_created',
source: 'admin',
payload: { action: 'extend_grace_period', extraDays, adminId, reason },
})
return { success: true }
}
export async function adminCancelSubscription(
subscriptionId: string,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
await repo.setCancelled(sub.id, true)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'subscription.canceled',
source: 'admin',
payload: { adminId, reason },
})
return { success: true }
}
export async function adminReactivateSubscription(
subscriptionId: string,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
const periodEnd = addPeriod(new Date(), sub.billingPeriod)
await repo.activateSubscription(subscriptionId, periodEnd)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'subscription.reactivated',
source: 'admin',
payload: { adminId, reason, periodEnd },
})
return { success: true }
}
export async function adminSuspendSubscription(
subscriptionId: string,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
await repo.setSuspended(sub.id)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'subscription.suspended',
source: 'admin',
payload: { adminId, reason },
})
return { success: true }
}