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>> = {} 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 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 } }