Files
alrahma_sunday_school_api/apps/api/dist/modules/subscriptions/subscription.service.js
T
2026-06-11 03:22:12 -04:00

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