289 lines
9.7 KiB
TypeScript
289 lines
9.7 KiB
TypeScript
import { Router } from 'express'
|
|
import { z } from 'zod'
|
|
import { PLAN_PRICES } from '@rentaldrivego/types'
|
|
import { prisma } from '../lib/prisma'
|
|
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
|
import { requireTenant } from '../middleware/requireTenant'
|
|
import * as amanpay from '../services/amanpayService'
|
|
import * as paypal from '../services/paypalService'
|
|
|
|
const router = Router()
|
|
|
|
router.get('/plans', (_req, res) => {
|
|
res.json({ data: PLAN_PRICES })
|
|
})
|
|
|
|
// ─── AmanPay subscription webhook (no auth) ──────────────────────────────────
|
|
|
|
router.post('/webhooks/amanpay', async (req, res, next) => {
|
|
try {
|
|
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' })
|
|
}
|
|
|
|
const event = req.body
|
|
const transactionId = event.transaction_id ?? event.id
|
|
const status = event.status?.toUpperCase()
|
|
|
|
if (status === 'PAID' || status === 'SUCCEEDED') {
|
|
const invoice = await prisma.subscriptionInvoice.findFirst({
|
|
where: { amanpayTransactionId: transactionId },
|
|
include: { subscription: true },
|
|
})
|
|
if (invoice) {
|
|
await prisma.subscriptionInvoice.update({
|
|
where: { id: invoice.id },
|
|
data: { status: 'PAID', paidAt: new Date() },
|
|
})
|
|
await prisma.subscription.update({
|
|
where: { id: invoice.subscriptionId },
|
|
data: {
|
|
status: 'ACTIVE',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
res.json({ received: true })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
// ─── PayPal subscription webhook (no auth) ───────────────────────────────────
|
|
|
|
router.post('/webhooks/paypal', async (req, res, next) => {
|
|
try {
|
|
const rawBody = JSON.stringify(req.body)
|
|
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
|
if (paypal.isConfigured() && !isValid) {
|
|
return res.status(401).json({ error: 'invalid_signature' })
|
|
}
|
|
|
|
const event = req.body
|
|
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
|
const captureId = event.resource?.id as string
|
|
const invoice = await prisma.subscriptionInvoice.findFirst({
|
|
where: { paypalCaptureId: captureId },
|
|
include: { subscription: true },
|
|
})
|
|
if (invoice) {
|
|
await prisma.subscriptionInvoice.update({
|
|
where: { id: invoice.id },
|
|
data: { status: 'PAID', paidAt: new Date() },
|
|
})
|
|
await prisma.subscription.update({
|
|
where: { id: invoice.subscriptionId },
|
|
data: {
|
|
status: 'ACTIVE',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
res.json({ received: true })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
// ─── PayPal capture redirect (no full auth needed — just valid session) ───────
|
|
|
|
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
|
|
try {
|
|
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
|
|
|
const invoice = await prisma.subscriptionInvoice.findFirstOrThrow({
|
|
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
|
|
include: { subscription: true },
|
|
})
|
|
|
|
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
|
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
|
|
|
await prisma.subscriptionInvoice.update({
|
|
where: { id: invoice.id },
|
|
data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId },
|
|
})
|
|
await prisma.subscription.update({
|
|
where: { id: invoice.subscriptionId },
|
|
data: {
|
|
status: 'ACTIVE',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
|
},
|
|
})
|
|
|
|
res.json({ data: { success: true } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
// ─── Authenticated subscription routes ───────────────────────────────────────
|
|
|
|
router.use(requireCompanyAuth, requireTenant)
|
|
|
|
router.get('/me', async (req, res, next) => {
|
|
try {
|
|
const subscription = await prisma.subscription.findUnique({
|
|
where: { companyId: req.companyId },
|
|
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
|
|
})
|
|
res.json({ data: subscription })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/invoices', async (req, res, next) => {
|
|
try {
|
|
const invoices = await prisma.subscriptionInvoice.findMany({
|
|
where: { companyId: req.companyId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 50,
|
|
})
|
|
res.json({ data: invoices })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
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']),
|
|
successUrl: z.string().url(),
|
|
failureUrl: z.string().url(),
|
|
})
|
|
|
|
router.post('/checkout', async (req, res, next) => {
|
|
try {
|
|
const body = checkoutSchema.parse(req.body)
|
|
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
|
|
if (!prices) return res.status(400).json({ error: 'invalid_plan', message: 'Invalid plan or billing period', statusCode: 400 })
|
|
const amount = prices[body.currency]
|
|
if (!amount) return res.status(400).json({ error: 'invalid_currency', message: 'Currency not supported for this plan', statusCode: 400 })
|
|
|
|
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId } })
|
|
|
|
let subscription = await prisma.subscription.findUnique({ where: { companyId: req.companyId } })
|
|
if (!subscription) {
|
|
subscription = await prisma.subscription.create({
|
|
data: {
|
|
companyId: req.companyId,
|
|
plan: body.plan,
|
|
billingPeriod: body.billingPeriod,
|
|
currency: body.currency,
|
|
status: 'PENDING' as any,
|
|
},
|
|
})
|
|
}
|
|
|
|
const orderId = `sub-${req.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()) {
|
|
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured on this platform', statusCode: 503 })
|
|
}
|
|
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()) {
|
|
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured on this platform', statusCode: 503 })
|
|
}
|
|
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 prisma.subscriptionInvoice.create({
|
|
data: {
|
|
companyId: req.companyId,
|
|
subscriptionId: subscription.id,
|
|
amount,
|
|
currency: body.currency,
|
|
status: 'PENDING',
|
|
paymentProvider: body.provider,
|
|
amanpayTransactionId,
|
|
paypalCaptureId,
|
|
},
|
|
})
|
|
|
|
res.json({ data: { invoice, checkoutUrl } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/change-plan', async (req, res, next) => {
|
|
try {
|
|
const { plan, billingPeriod, currency } = z.object({
|
|
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
|
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
|
currency: z.enum(['MAD', 'USD', 'EUR']),
|
|
}).parse(req.body)
|
|
|
|
const updated = await prisma.subscription.update({
|
|
where: { companyId: req.companyId },
|
|
data: { plan, billingPeriod, currency },
|
|
})
|
|
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/cancel', async (req, res, next) => {
|
|
try {
|
|
const updated = await prisma.subscription.update({
|
|
where: { companyId: req.companyId },
|
|
data: { cancelAtPeriodEnd: true },
|
|
})
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/resume', async (req, res, next) => {
|
|
try {
|
|
const updated = await prisma.subscription.update({
|
|
where: { companyId: req.companyId },
|
|
data: { cancelAtPeriodEnd: false },
|
|
})
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function addPeriod(date: Date, period: string): Date {
|
|
const d = new Date(date)
|
|
if (period === 'ANNUAL') {
|
|
d.setFullYear(d.getFullYear() + 1)
|
|
} else {
|
|
d.setMonth(d.getMonth() + 1)
|
|
}
|
|
return d
|
|
}
|
|
|
|
export default router
|