502 lines
22 KiB
JavaScript
502 lines
22 KiB
JavaScript
"use strict";
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || (function () {
|
|
var ownKeys = function(o) {
|
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
var ar = [];
|
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
return ar;
|
|
};
|
|
return ownKeys(o);
|
|
};
|
|
return function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
})();
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.addPeriod = addPeriod;
|
|
exports.getPlans = getPlans;
|
|
exports.getProviders = getProviders;
|
|
exports.getPlanFeatures = getPlanFeatures;
|
|
exports.getSubscription = getSubscription;
|
|
exports.getInvoices = getInvoices;
|
|
exports.getEvents = getEvents;
|
|
exports.getEventsBySubscriptionId = getEventsBySubscriptionId;
|
|
exports.getEntitlement = getEntitlement;
|
|
exports.startTrial = startTrial;
|
|
exports.handleAmanpayWebhook = handleAmanpayWebhook;
|
|
exports.handlePaypalWebhook = handlePaypalWebhook;
|
|
exports.checkout = checkout;
|
|
exports.capturePaypal = capturePaypal;
|
|
exports.changePlan = changePlan;
|
|
exports.cancel = cancel;
|
|
exports.resume = resume;
|
|
exports.reactivate = reactivate;
|
|
exports.runTrialExpirationJob = runTrialExpirationJob;
|
|
exports.runPaymentPendingTimeoutJob = runPaymentPendingTimeoutJob;
|
|
exports.runPastDueTimeoutJob = runPastDueTimeoutJob;
|
|
exports.runSuspensionTimeoutJob = runSuspensionTimeoutJob;
|
|
exports.runPeriodEndCancellationJob = runPeriodEndCancellationJob;
|
|
exports.adminExtendTrial = adminExtendTrial;
|
|
exports.adminExtendGracePeriod = adminExtendGracePeriod;
|
|
exports.adminCancelSubscription = adminCancelSubscription;
|
|
exports.adminReactivateSubscription = adminReactivateSubscription;
|
|
exports.adminSuspendSubscription = adminSuspendSubscription;
|
|
const types_1 = require("@rentaldrivego/types");
|
|
const prisma_1 = require("../../lib/prisma");
|
|
const errors_1 = require("../../http/errors");
|
|
const amanpay = __importStar(require("../../services/amanpayService"));
|
|
const paypal = __importStar(require("../../services/paypalService"));
|
|
const repo = __importStar(require("./subscription.repo"));
|
|
const subscription_policy_1 = require("./subscription.policy");
|
|
const webhookIdempotency_1 = require("../../security/webhookIdempotency");
|
|
// ─── Helpers ──────────────────────────────────────────────────
|
|
function addPeriod(date, period) {
|
|
const d = new Date(date);
|
|
period === 'ANNUAL' ? d.setFullYear(d.getFullYear() + 1) : d.setMonth(d.getMonth() + 1);
|
|
return d;
|
|
}
|
|
// ─── Plans & providers ────────────────────────────────────────
|
|
async function getPlans() {
|
|
const configs = await prisma_1.prisma.pricingConfig.findMany();
|
|
if (configs.length === 0)
|
|
return types_1.PLAN_PRICES;
|
|
const result = {};
|
|
for (const c of configs) {
|
|
if (!result[c.plan])
|
|
result[c.plan] = {};
|
|
result[c.plan][c.billingPeriod] = { MAD: c.amount };
|
|
}
|
|
return result;
|
|
}
|
|
function getProviders() {
|
|
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() };
|
|
}
|
|
function getPlanFeatures() {
|
|
return prisma_1.prisma.planFeature.findMany({
|
|
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
}
|
|
// ─── Reads ────────────────────────────────────────────────────
|
|
function getSubscription(companyId) {
|
|
return repo.findByCompany(companyId);
|
|
}
|
|
function getInvoices(companyId) {
|
|
return repo.findInvoices(companyId);
|
|
}
|
|
function getEvents(companyId) {
|
|
return repo.findByCompany(companyId).then((sub) => sub ? repo.getEvents(sub.id) : []);
|
|
}
|
|
function getEventsBySubscriptionId(subscriptionId) {
|
|
return repo.getEvents(subscriptionId);
|
|
}
|
|
// ─── Entitlements ─────────────────────────────────────────────
|
|
async function getEntitlement(companyId) {
|
|
const sub = await repo.findByCompany(companyId);
|
|
const status = sub?.status ?? 'EXPIRED';
|
|
const accessLevel = (0, subscription_policy_1.getAccessLevel)(status);
|
|
return {
|
|
companyId,
|
|
subscriptionId: sub?.id ?? null,
|
|
subscriptionStatus: status,
|
|
accessLevel,
|
|
plan: sub?.plan ?? null,
|
|
currentPeriodEnd: sub?.currentPeriodEnd ?? null,
|
|
};
|
|
}
|
|
// ─── Trial management ─────────────────────────────────────────
|
|
async function startTrial(companyId, plan, billingPeriod, currency) {
|
|
if (subscription_policy_1.SUBSCRIPTION_POLICY.trial.oneTrialPerCompany) {
|
|
const existing = await repo.findByCompany(companyId);
|
|
if (existing?.trialUsed) {
|
|
throw new errors_1.ValidationError('This company has already used its free trial');
|
|
}
|
|
}
|
|
const trialEndAt = new Date(Date.now() + subscription_policy_1.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, invoiceId) {
|
|
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, invoiceId, failureCode, failureMessage) {
|
|
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 ─────────────────────────────────────────
|
|
async function applyAmanpayWebhook(event) {
|
|
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);
|
|
}
|
|
}
|
|
async function applyPaypalWebhook(event) {
|
|
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
|
const captureId = event.resource?.id;
|
|
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;
|
|
const invoice = await repo.findInvoiceByPaypal(captureId);
|
|
if (!invoice || invoice.status === 'PAID')
|
|
return;
|
|
await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'capture_denied');
|
|
}
|
|
}
|
|
async function handleAmanpayWebhook(event, rawBody = JSON.stringify(event)) {
|
|
return (0, webhookIdempotency_1.processWebhookOnce)({
|
|
provider: 'amanpay:subscriptions',
|
|
providerEventId: (0, webhookIdempotency_1.getWebhookEventId)('amanpay', event),
|
|
eventType: String(event.status ?? 'unknown'),
|
|
rawBody,
|
|
handle: () => applyAmanpayWebhook(event),
|
|
});
|
|
}
|
|
async function handlePaypalWebhook(event, rawBody = JSON.stringify(event)) {
|
|
return (0, webhookIdempotency_1.processWebhookOnce)({
|
|
provider: 'paypal:subscriptions',
|
|
providerEventId: (0, webhookIdempotency_1.getWebhookEventId)('paypal', event),
|
|
eventType: String(event.event_type ?? 'unknown'),
|
|
rawBody,
|
|
handle: () => applyPaypalWebhook(event),
|
|
});
|
|
}
|
|
// ─── Checkout ─────────────────────────────────────────────────
|
|
async function checkout(companyId, body) {
|
|
const dbPrice = await prisma_1.prisma.pricingConfig.findUnique({
|
|
where: { plan_billingPeriod: { plan: body.plan, billingPeriod: body.billingPeriod } },
|
|
});
|
|
const amount = dbPrice?.amount ?? types_1.PLAN_PRICES[body.plan]?.[body.billingPeriod]?.[body.currency];
|
|
if (!amount)
|
|
throw new errors_1.ValidationError('Invalid plan or billing period');
|
|
const company = await prisma_1.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;
|
|
let amanpayTransactionId = null;
|
|
let paypalCaptureId = null;
|
|
if (body.provider === 'AMANPAY') {
|
|
if (!amanpay.isConfigured())
|
|
throw new errors_1.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 errors_1.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_1.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 };
|
|
}
|
|
async function capturePaypal(companyId, paypalOrderId) {
|
|
const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId);
|
|
if (invoice.status === 'PAID')
|
|
return { success: true }; // idempotent
|
|
const capture = await paypal.captureOrder(paypalOrderId);
|
|
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 ─────────────────────────────────────────────
|
|
function changePlan(companyId, data) {
|
|
return repo.updatePlan(companyId, data);
|
|
}
|
|
// ─── Cancellation ─────────────────────────────────────────────
|
|
async function cancel(companyId, mode = 'period_end', reason) {
|
|
const sub = await repo.findByCompany(companyId);
|
|
if (!sub)
|
|
throw new errors_1.ValidationError('No subscription found');
|
|
const updated = 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 updated;
|
|
}
|
|
async function resume(companyId) {
|
|
const sub = await repo.findByCompany(companyId);
|
|
if (!sub)
|
|
throw new errors_1.ValidationError('No subscription found');
|
|
const updated = await repo.setCancelAtPeriodEnd(companyId, false);
|
|
await repo.createEvent({
|
|
subscriptionId: sub.id,
|
|
companyId,
|
|
eventType: 'subscription.resumed',
|
|
source: 'user',
|
|
payload: {},
|
|
});
|
|
return updated;
|
|
}
|
|
// ─── Reactivation ────────────────────────────────────────────
|
|
async function reactivate(companyId, body) {
|
|
const sub = await repo.findByCompany(companyId);
|
|
if (!sub)
|
|
throw new errors_1.ValidationError('No subscription found');
|
|
const allowedStatuses = ['CANCELLED', 'EXPIRED', 'SUSPENDED', 'PAST_DUE', 'PAYMENT_PENDING'];
|
|
if (!allowedStatuses.includes(sub.status)) {
|
|
throw new errors_1.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 ────────────────────────────────────
|
|
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;
|
|
}
|
|
async function runPaymentPendingTimeoutJob() {
|
|
const cutoff = new Date(Date.now() - subscription_policy_1.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;
|
|
}
|
|
async function runPastDueTimeoutJob() {
|
|
const cutoff = new Date(Date.now() - subscription_policy_1.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;
|
|
}
|
|
async function runSuspensionTimeoutJob() {
|
|
const cutoff = new Date(Date.now() - subscription_policy_1.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;
|
|
}
|
|
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 ──────────────────────────────────────────
|
|
async function adminExtendTrial(subscriptionId, extraDays, adminId, reason) {
|
|
const sub = await repo.findById(subscriptionId);
|
|
if (!sub)
|
|
throw new errors_1.ValidationError('Subscription not found');
|
|
const currentEnd = sub.trialEndAt ?? new Date();
|
|
const newEnd = new Date(currentEnd.getTime() + extraDays * 24 * 60 * 60 * 1000);
|
|
await prisma_1.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 };
|
|
}
|
|
async function adminExtendGracePeriod(subscriptionId, extraDays, adminId, reason) {
|
|
const sub = await repo.findById(subscriptionId);
|
|
if (!sub)
|
|
throw new errors_1.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 };
|
|
}
|
|
async function adminCancelSubscription(subscriptionId, adminId, reason) {
|
|
const sub = await repo.findById(subscriptionId);
|
|
if (!sub)
|
|
throw new errors_1.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 };
|
|
}
|
|
async function adminReactivateSubscription(subscriptionId, adminId, reason) {
|
|
const sub = await repo.findById(subscriptionId);
|
|
if (!sub)
|
|
throw new errors_1.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 };
|
|
}
|
|
async function adminSuspendSubscription(subscriptionId, adminId, reason) {
|
|
const sub = await repo.findById(subscriptionId);
|
|
if (!sub)
|
|
throw new errors_1.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 };
|
|
}
|
|
//# sourceMappingURL=subscription.service.js.map
|