update subscription algorithm
This commit is contained in:
@@ -4,6 +4,9 @@ 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)
|
||||
@@ -11,14 +14,25 @@ export function addPeriod(date: Date, period: string): Date {
|
||||
return d
|
||||
}
|
||||
|
||||
export function getPlans() {
|
||||
return PLAN_PRICES
|
||||
// ─── 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() }
|
||||
}
|
||||
|
||||
// ─── Reads ────────────────────────────────────────────────────
|
||||
|
||||
export function getSubscription(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
}
|
||||
@@ -27,15 +41,131 @@ 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) {
|
||||
await repo.markInvoicePaid(invoice.id)
|
||||
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,22 +173,28 @@ 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) {
|
||||
await repo.markInvoicePaid(invoice.id)
|
||||
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
|
||||
}
|
||||
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' | 'USD' | 'EUR'; provider: 'AMANPAY' | 'PAYPAL'
|
||||
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
|
||||
if (!prices) throw new ValidationError('Invalid plan or billing period')
|
||||
const amount = (prices as any)[body.currency]
|
||||
if (!amount) throw new ValidationError('Currency not supported for this plan')
|
||||
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)
|
||||
@@ -83,32 +219,277 @@ export async function checkout(companyId: string, body: {
|
||||
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 })
|
||||
const result = await paypal.createOrder({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
returnUrl: body.successUrl, cancelUrl: body.failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const invoice = await repo.createInvoice({ companyId, subscriptionId: subscription.id, amount, currency: body.currency, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
|
||||
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)
|
||||
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
|
||||
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)
|
||||
}
|
||||
|
||||
export function cancel(companyId: string) {
|
||||
return repo.setCancelAtPeriodEnd(companyId, true)
|
||||
// ─── 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user