update subscription algorithm
This commit is contained in:
@@ -405,3 +405,15 @@ export function getInvoicePdfRecord(invoiceId: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function listPricingConfigs() {
|
||||
return prisma.pricingConfig.findMany({ orderBy: [{ plan: 'asc' }, { billingPeriod: 'asc' }] })
|
||||
}
|
||||
|
||||
export function upsertPricingConfig(plan: string, billingPeriod: string, amount: number, updatedBy?: string) {
|
||||
return prisma.pricingConfig.upsert({
|
||||
where: { plan_billingPeriod: { plan, billingPeriod } },
|
||||
update: { amount, updatedBy },
|
||||
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdmi
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './admin.service'
|
||||
import * as subService from '../subscriptions/subscription.service'
|
||||
import { presentAdminUser } from './admin.presenter'
|
||||
import {
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||
@@ -10,7 +11,18 @@ import {
|
||||
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
||||
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
||||
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
|
||||
pricingUpdateSchema,
|
||||
} from './admin.schemas'
|
||||
import { z } from 'zod'
|
||||
|
||||
const adminSubOverrideSchema = z.object({
|
||||
reason: z.string().min(1).max(500),
|
||||
})
|
||||
const adminExtendTrialSchema = z.object({
|
||||
extraDays: z.number().int().positive(),
|
||||
reason: z.string().min(1).max(500),
|
||||
})
|
||||
const subIdParamSchema = z.object({ subscriptionId: z.string() })
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -208,6 +220,70 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Pricing config ────────────────────────────────────────────
|
||||
|
||||
router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getPricingConfigs())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { entries } = parseBody(pricingUpdateSchema, req)
|
||||
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Subscription admin overrides ─────────────────────────────
|
||||
|
||||
router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||
ok(res, await subService.getEventsBySubscriptionId(subscriptionId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
|
||||
ok(res, await subService.adminExtendTrial(subscriptionId, extraDays, req.admin.id, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||
ok(res, await subService.adminExtendGracePeriod(subscriptionId, 7, req.admin.id, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||
ok(res, await subService.adminSuspendSubscription(subscriptionId, req.admin.id, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||
ok(res, await subService.adminReactivateSubscription(subscriptionId, req.admin.id, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||
ok(res, await subService.adminCancelSubscription(subscriptionId, req.admin.id, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Site config ───────────────────────────────────────────────
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
|
||||
@@ -99,7 +99,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
currency: z.literal('MAD').optional(),
|
||||
trialStartAt: nullableDate, trialEndAt: nullableDate,
|
||||
currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate,
|
||||
cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(),
|
||||
@@ -110,7 +110,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
menuConfig: z.any().optional(),
|
||||
@@ -125,7 +125,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
accountingSettings: z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
currency: z.literal('MAD').optional(),
|
||||
accountantEmail: nullableEmail, accountantName: nullableString,
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
@@ -174,3 +174,11 @@ export const invoiceIdParamSchema = z.object({
|
||||
export const homepageUpdateSchema = z.object({
|
||||
homepage: marketplaceHomepageConfigSchema,
|
||||
})
|
||||
|
||||
export const pricingUpdateSchema = z.object({
|
||||
entries: z.array(z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
amount: z.number().int().positive(),
|
||||
})).min(1),
|
||||
})
|
||||
|
||||
@@ -296,3 +296,22 @@ export async function updateMarketplaceHomepage(homepage: any, adminId: string,
|
||||
})
|
||||
return saved
|
||||
}
|
||||
|
||||
export function getPricingConfigs() {
|
||||
return repo.listPricingConfigs()
|
||||
}
|
||||
|
||||
export async function updatePricingConfigs(entries: { plan: string; billingPeriod: string; amount: number }[], adminId: string, ip?: string) {
|
||||
const results = await Promise.all(
|
||||
entries.map((e) => repo.upsertPricingConfig(e.plan, e.billingPeriod, e.amount, adminId)),
|
||||
)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE',
|
||||
resource: 'PricingConfig',
|
||||
after: toAuditJson(results),
|
||||
ipAddress: ip,
|
||||
userAgent: undefined,
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type CompanySignupInput = {
|
||||
managerName: string
|
||||
fax?: string
|
||||
yearsActive: string
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
currency: 'MAD'
|
||||
registrationNumber: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
|
||||
@@ -18,6 +18,6 @@ export const companySignupSchema = z.object({
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
currency: z.literal('MAD'),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ export const renterUpdateSchema = z.object({
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
preferredCurrency: z.literal('MAD').optional(),
|
||||
})
|
||||
|
||||
export const renterFcmTokenSchema = z.object({
|
||||
|
||||
@@ -20,7 +20,7 @@ export const brandSchema = z.object({
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
defaultLocale: z.string().optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
amanpayMerchantId: z.string().optional(),
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: z.string().email().optional(),
|
||||
@@ -146,7 +146,7 @@ export const pricingRuleSchema = z.object({
|
||||
export const accountingSettingsSchema = z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
currency: z.literal('MAD').optional(),
|
||||
accountantEmail: z.string().email().optional(),
|
||||
accountantName: z.string().optional(),
|
||||
autoSendReport: z.boolean().optional(),
|
||||
|
||||
@@ -3,14 +3,14 @@ import { z } from 'zod'
|
||||
export const chargeSchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export const manualPaymentSchema = z.object({
|
||||
amount: z.number().int().positive(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']),
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function handlePaypalWebhook(event: any) {
|
||||
|
||||
export async function initCharge(reservationId: string, companyId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
|
||||
currency: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
|
||||
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
|
||||
|
||||
@@ -44,7 +44,7 @@ export const bookSchema = z.object({
|
||||
|
||||
export const paySchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ export async function getBooking(slug: string, reservationId: string) {
|
||||
}
|
||||
|
||||
export async function initPayment(slug: string, reservationId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const reservation = await repo.findReservationForPayment(reservationId, company.id)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { getAccessLevel, hasAnyAccess } from './subscription.policy'
|
||||
|
||||
export async function requireActiveSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
const companyId = req.companyId
|
||||
if (!companyId) return res.status(401).json({ error: 'unauthenticated' })
|
||||
|
||||
const sub = await prisma.subscription.findUnique({ where: { companyId } })
|
||||
const status = sub?.status ?? 'EXPIRED'
|
||||
const accessLevel = getAccessLevel(status)
|
||||
|
||||
if (!hasAnyAccess(status)) {
|
||||
return res.status(403).json({
|
||||
error: 'subscription_required',
|
||||
message: 'Your subscription has ended. Please reactivate to continue.',
|
||||
subscriptionStatus: status,
|
||||
accessLevel,
|
||||
})
|
||||
}
|
||||
|
||||
;(req as any).subscriptionStatus = status
|
||||
;(req as any).accessLevel = accessLevel
|
||||
next()
|
||||
}
|
||||
|
||||
export async function requireFullAccess(req: Request, res: Response, next: NextFunction) {
|
||||
const companyId = req.companyId
|
||||
if (!companyId) return res.status(401).json({ error: 'unauthenticated' })
|
||||
|
||||
const sub = await prisma.subscription.findUnique({ where: { companyId } })
|
||||
const status = sub?.status ?? 'EXPIRED'
|
||||
const accessLevel = getAccessLevel(status)
|
||||
|
||||
if (accessLevel !== 'full') {
|
||||
return res.status(403).json({
|
||||
error: 'insufficient_access',
|
||||
message: 'This action requires an active subscription.',
|
||||
subscriptionStatus: status,
|
||||
accessLevel,
|
||||
})
|
||||
}
|
||||
|
||||
;(req as any).subscriptionStatus = status
|
||||
;(req as any).accessLevel = accessLevel
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
|
||||
|
||||
export const SUBSCRIPTION_POLICY = {
|
||||
trial: {
|
||||
durationDays: 14,
|
||||
requiresPaymentMethod: false, // set true when payment capture is mandatory
|
||||
oneTrialPerCompany: true,
|
||||
},
|
||||
payment: {
|
||||
paymentPendingTimeoutDays: 7,
|
||||
retryScheduleDays: [0, 3, 6, 10, 14],
|
||||
maxRetryAttempts: 5,
|
||||
},
|
||||
pastDue: {
|
||||
timeoutDays: 7,
|
||||
},
|
||||
suspension: {
|
||||
cancelAfterDays: 16,
|
||||
},
|
||||
notifications: {
|
||||
trialEndingDaysBefore: [7, 3, 1],
|
||||
paymentFailureDaysAfter: [0, 3, 6],
|
||||
pastDueDaysAfter: [7],
|
||||
suspensionDaysAfter: [14],
|
||||
cancellationDaysAfter: [30],
|
||||
},
|
||||
} as const
|
||||
|
||||
export const ACCESS_MAP: Record<string, AccessLevel> = {
|
||||
TRIALING: 'full',
|
||||
ACTIVE: 'full',
|
||||
PAYMENT_PENDING: 'full',
|
||||
UNPAID: 'full',
|
||||
PAST_DUE: 'limited',
|
||||
SUSPENDED: 'read_only',
|
||||
CANCELLED: 'none',
|
||||
EXPIRED: 'none',
|
||||
PAUSED: 'read_only',
|
||||
}
|
||||
|
||||
export function getAccessLevel(status: string): AccessLevel {
|
||||
return ACCESS_MAP[status] ?? 'none'
|
||||
}
|
||||
|
||||
export function hasFullAccess(status: string): boolean {
|
||||
return getAccessLevel(status) === 'full'
|
||||
}
|
||||
|
||||
export function hasAnyAccess(status: string): boolean {
|
||||
return getAccessLevel(status) !== 'none'
|
||||
}
|
||||
@@ -1,60 +1,336 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
// ─── Subscription queries ─────────────────────────────────────
|
||||
|
||||
export function findByCompany(companyId: string) {
|
||||
return prisma.subscription.findUnique({
|
||||
where: { companyId },
|
||||
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
|
||||
include: {
|
||||
invoices: { orderBy: { createdAt: 'desc' }, take: 12 },
|
||||
events: { orderBy: { occurredAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function findById(id: string) {
|
||||
return prisma.subscription.findUnique({
|
||||
where: { id },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoices(companyId: string) {
|
||||
return prisma.subscriptionInvoice.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
return prisma.subscriptionInvoice.findMany({
|
||||
where: { companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoiceByAmanpay(transactionId: string) {
|
||||
return prisma.subscriptionInvoice.findFirst({ where: { amanpayTransactionId: transactionId }, include: { subscription: true } })
|
||||
return prisma.subscriptionInvoice.findFirst({
|
||||
where: { amanpayTransactionId: transactionId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoiceByPaypal(captureId: string) {
|
||||
return prisma.subscriptionInvoice.findFirst({ where: { paypalCaptureId: captureId }, include: { subscription: true } })
|
||||
return prisma.subscriptionInvoice.findFirst({
|
||||
where: { paypalCaptureId: captureId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||
return prisma.subscriptionInvoice.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId }, include: { subscription: true } })
|
||||
return prisma.subscriptionInvoice.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function markInvoicePaid(id: string) {
|
||||
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } })
|
||||
export function findInvoiceById(id: string) {
|
||||
return prisma.subscriptionInvoice.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Subscription mutations ───────────────────────────────────
|
||||
|
||||
export async function findOrCreateSubscription(
|
||||
companyId: string,
|
||||
plan: string,
|
||||
billingPeriod: string,
|
||||
currency: string,
|
||||
) {
|
||||
const existing = await prisma.subscription.findUnique({ where: { companyId } })
|
||||
if (existing) return existing
|
||||
return prisma.subscription.create({
|
||||
data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PAYMENT_PENDING' as any },
|
||||
})
|
||||
}
|
||||
|
||||
export function activateSubscription(id: string, periodEnd: Date) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', currentPeriodStart: new Date(), currentPeriodEnd: periodEnd },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: periodEnd,
|
||||
paymentPendingSince: null,
|
||||
paymentDueAt: null,
|
||||
pastDueSince: null,
|
||||
suspendedAt: null,
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findOrCreateSubscription(companyId: string, plan: string, billingPeriod: string, currency: string) {
|
||||
const existing = await prisma.subscription.findUnique({ where: { companyId } })
|
||||
if (existing) return existing
|
||||
return prisma.subscription.create({ data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PENDING' as any } })
|
||||
export function startTrial(
|
||||
companyId: string,
|
||||
plan: string,
|
||||
billingPeriod: string,
|
||||
currency: string,
|
||||
trialEndAt: Date,
|
||||
) {
|
||||
return prisma.subscription.upsert({
|
||||
where: { companyId },
|
||||
update: {
|
||||
plan: plan as any,
|
||||
billingPeriod: billingPeriod as any,
|
||||
currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: new Date(),
|
||||
trialEndAt,
|
||||
trialUsed: true,
|
||||
},
|
||||
create: {
|
||||
companyId,
|
||||
plan: plan as any,
|
||||
billingPeriod: billingPeriod as any,
|
||||
currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: new Date(),
|
||||
trialEndAt,
|
||||
trialUsed: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createInvoice(data: {
|
||||
companyId: string; subscriptionId: string; amount: number; currency: string
|
||||
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
|
||||
}) {
|
||||
return prisma.subscriptionInvoice.create({ data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any } })
|
||||
export function setPaymentPending(id: string) {
|
||||
const now = new Date()
|
||||
const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAYMENT_PENDING',
|
||||
paymentPendingSince: now,
|
||||
paymentDueAt: dueAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoicePaypal(id: string, captureId: string) {
|
||||
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
export function setPastDue(id: string) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAST_DUE',
|
||||
pastDueSince: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setSuspended(id: string) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'SUSPENDED',
|
||||
suspendedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setExpired(id: string) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: { status: 'EXPIRED', endedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
export function setCancelled(id: string, immediate: boolean) {
|
||||
if (immediate) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: { status: 'CANCELLED', cancelledAt: new Date(), endedAt: new Date() },
|
||||
})
|
||||
}
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: { cancelAtPeriodEnd: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function setCancelAtPeriodEnd(companyId: string, value: boolean) {
|
||||
return prisma.subscription.update({
|
||||
where: { companyId },
|
||||
data: { cancelAtPeriodEnd: value },
|
||||
})
|
||||
}
|
||||
|
||||
export function incrementRetryCount(id: string) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: { retryCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
|
||||
return prisma.subscription.update({ where: { companyId }, data })
|
||||
}
|
||||
|
||||
export function setCancelAtPeriodEnd(companyId: string, value: boolean) {
|
||||
return prisma.subscription.update({ where: { companyId }, data: { cancelAtPeriodEnd: value } })
|
||||
// ─── Invoice mutations ────────────────────────────────────────
|
||||
|
||||
export function createInvoice(data: {
|
||||
companyId: string
|
||||
subscriptionId: string
|
||||
amount: number
|
||||
currency: string
|
||||
paymentProvider: string
|
||||
amanpayTransactionId?: string | null
|
||||
paypalCaptureId?: string | null
|
||||
dueAt?: Date | null
|
||||
}) {
|
||||
return prisma.subscriptionInvoice.create({
|
||||
data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any },
|
||||
})
|
||||
}
|
||||
|
||||
export function markInvoicePaid(id: string) {
|
||||
return prisma.subscriptionInvoice.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date(), failedAt: null },
|
||||
})
|
||||
}
|
||||
|
||||
export function markInvoiceFailed(id: string) {
|
||||
return prisma.subscriptionInvoice.update({
|
||||
where: { id },
|
||||
data: { status: 'FAILED', failedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
export function markInvoiceVoided(id: string) {
|
||||
return prisma.subscriptionInvoice.update({
|
||||
where: { id },
|
||||
data: { status: 'VOIDED', voidedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoicePaypal(id: string, captureId: string) {
|
||||
return prisma.subscriptionInvoice.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId, failedAt: null },
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Payment attempts ─────────────────────────────────────────
|
||||
|
||||
export function createPaymentAttempt(data: {
|
||||
invoiceId: string
|
||||
subscriptionId: string
|
||||
providerPaymentId?: string | null
|
||||
status: string
|
||||
failureCode?: string | null
|
||||
failureMessage?: string | null
|
||||
}) {
|
||||
return prisma.paymentAttempt.create({
|
||||
data: { ...data, attemptedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
export function getPaymentAttempts(invoiceId: string) {
|
||||
return prisma.paymentAttempt.findMany({
|
||||
where: { invoiceId },
|
||||
orderBy: { attemptedAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Subscription events ──────────────────────────────────────
|
||||
|
||||
export function createEvent(data: {
|
||||
subscriptionId: string
|
||||
companyId: string
|
||||
eventType: string
|
||||
source?: string
|
||||
payload?: object
|
||||
}) {
|
||||
return prisma.subscriptionEvent.create({
|
||||
data: {
|
||||
...data,
|
||||
source: data.source ?? 'system',
|
||||
payload: (data.payload ?? {}) as any,
|
||||
occurredAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getEvents(subscriptionId: string) {
|
||||
return prisma.subscriptionEvent.findMany({
|
||||
where: { subscriptionId },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Scheduled job helpers ────────────────────────────────────
|
||||
|
||||
export function findPaymentPendingTimedOut(cutoffDate: Date) {
|
||||
return prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'PAYMENT_PENDING',
|
||||
paymentPendingSince: { lte: cutoffDate },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findPastDueTimedOut(cutoffDate: Date) {
|
||||
return prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'PAST_DUE',
|
||||
pastDueSince: { lte: cutoffDate },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findSuspendedTimedOut(cutoffDate: Date) {
|
||||
return prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'SUSPENDED',
|
||||
suspendedAt: { lte: cutoffDate },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findTrialsEndedWithoutConversion() {
|
||||
return prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'TRIALING',
|
||||
trialEndAt: { lte: new Date() },
|
||||
},
|
||||
include: { company: { include: { employees: { where: { role: 'OWNER' }, take: 1 } } } },
|
||||
})
|
||||
}
|
||||
|
||||
export function findSubscriptionsToExpireAtPeriodEnd() {
|
||||
return prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
cancelAtPeriodEnd: true,
|
||||
currentPeriodEnd: { lte: new Date() },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,16 +7,23 @@ import { ok } from '../../http/respond'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from './subscription.service'
|
||||
import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas'
|
||||
import {
|
||||
checkoutSchema,
|
||||
changePlanSchema,
|
||||
capturePaypalSchema,
|
||||
startTrialSchema,
|
||||
cancelSchema,
|
||||
reactivateSchema,
|
||||
} from './subscription.schemas'
|
||||
|
||||
const publicRouter = Router()
|
||||
const publicRouter = Router()
|
||||
const webhookRouter = Router()
|
||||
const router = Router()
|
||||
const router = Router()
|
||||
|
||||
// ─── Public ────────────────────────────────────────────────────
|
||||
|
||||
publicRouter.get('/plans', (_req, res) => {
|
||||
ok(res, service.getPlans())
|
||||
publicRouter.get('/plans', (_req, res, next) => {
|
||||
service.getPlans().then((d) => ok(res, d)).catch(next)
|
||||
})
|
||||
|
||||
publicRouter.get('/providers', (_req, res) => {
|
||||
@@ -27,7 +34,7 @@ publicRouter.get('/providers', (_req, res) => {
|
||||
|
||||
webhookRouter.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
|
||||
if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
@@ -56,19 +63,30 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated ────────────────────────────────────────────
|
||||
// ─── Authenticated ─────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getSubscription(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
try { ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/invoices', async (req, res, next) => {
|
||||
try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/events', async (req, res, next) => {
|
||||
try { ok(res, await service.getEvents(req.companyId)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/entitlement', async (req, res, next) => {
|
||||
try { ok(res, await service.getEntitlement(req.companyId)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/trial', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getInvoices(req.companyId))
|
||||
const body = parseBody(startTrialSchema, req)
|
||||
ok(res, await service.startTrial(req.companyId, body.plan, body.billingPeriod, body.currency))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -79,6 +97,13 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(reactivateSchema, req)
|
||||
ok(res, await service.reactivate(req.companyId, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(changePlanSchema, req)
|
||||
@@ -88,14 +113,13 @@ router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
|
||||
|
||||
router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.cancel(req.companyId))
|
||||
const { mode, reason } = parseBody(cancelSchema, req)
|
||||
ok(res, await service.cancel(req.companyId, mode, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.resume(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
try { ok(res, await service.resume(req.companyId)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO'])
|
||||
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
|
||||
const providerEnum = z.enum(['AMANPAY', 'PAYPAL'])
|
||||
|
||||
export const checkoutSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
plan: planEnum,
|
||||
billingPeriod: billingPeriodEnum,
|
||||
currency: z.literal('MAD'),
|
||||
provider: providerEnum,
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export const changePlanSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
plan: planEnum,
|
||||
billingPeriod: billingPeriodEnum,
|
||||
currency: z.literal('MAD'),
|
||||
})
|
||||
|
||||
export const capturePaypalSchema = z.object({
|
||||
paypalOrderId: z.string(),
|
||||
})
|
||||
|
||||
export const startTrialSchema = z.object({
|
||||
plan: planEnum,
|
||||
billingPeriod: billingPeriodEnum,
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
})
|
||||
|
||||
export const cancelSchema = z.object({
|
||||
mode: z.enum(['period_end', 'immediate']).default('period_end'),
|
||||
reason: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export const reactivateSchema = z.object({
|
||||
plan: planEnum,
|
||||
billingPeriod: billingPeriodEnum,
|
||||
currency: z.literal('MAD'),
|
||||
provider: providerEnum,
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
@@ -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