502 lines
17 KiB
TypeScript
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 }
|
|
}
|