archetecture security fix
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE';
|
||||
export interface AdditionalDriverInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
driverLicense: string;
|
||||
licenseExpiry?: string | null;
|
||||
licenseIssuedAt?: string | null;
|
||||
dateOfBirth?: string | null;
|
||||
nationality?: string;
|
||||
}
|
||||
export declare function calculateAdditionalDriverCharge(chargeType: AdditionalDriverCharge, chargeValue: number, totalDays: number): number;
|
||||
export declare function applyAdditionalDriversToReservation(reservationId: string, companyId: string, drivers: AdditionalDriverInput[], totalDays: number): Promise<{
|
||||
records: {
|
||||
reservationId: string;
|
||||
companyId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
driverLicense: string;
|
||||
licenseExpiry: Date | null;
|
||||
licenseIssuedAt: Date | null;
|
||||
dateOfBirth: Date | null;
|
||||
nationality: string | null;
|
||||
chargeType: any;
|
||||
chargeValue: any;
|
||||
totalCharge: number;
|
||||
licenseExpired: boolean;
|
||||
licenseExpiringSoon: boolean;
|
||||
requiresApproval: boolean;
|
||||
approvalNote: string | null;
|
||||
}[];
|
||||
additionalDriverTotal: number;
|
||||
requiresManualApproval: boolean;
|
||||
}>;
|
||||
export {};
|
||||
//# sourceMappingURL=additionalDriverService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"additionalDriverService.d.ts","sourceRoot":"","sources":["../../src/services/additionalDriverService.ts"],"names":[],"mappings":"AAEA,KAAK,sBAAsB,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAA;AAGzD,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,wBAAgB,+BAA+B,CAC7C,UAAU,EAAE,sBAAsB,EAClC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,UAUlB;AAED,wBAAsB,mCAAmC,CACvD,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,qBAAqB,EAAE,EAChC,SAAS,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;GA2DlB"}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.calculateAdditionalDriverCharge = calculateAdditionalDriverCharge;
|
||||
exports.applyAdditionalDriversToReservation = applyAdditionalDriversToReservation;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
const licenseValidationService_1 = require("./licenseValidationService");
|
||||
function calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays) {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY':
|
||||
return chargeValue * totalDays;
|
||||
case 'FLAT':
|
||||
return chargeValue;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
async function applyAdditionalDriversToReservation(reservationId, companyId, drivers, totalDays) {
|
||||
if (drivers.length === 0) {
|
||||
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false };
|
||||
}
|
||||
const settings = await prisma_1.prisma.contractSettings.findUnique({ where: { companyId } });
|
||||
const chargeType = settings?.additionalDriverCharge ?? 'FREE';
|
||||
const chargeValue = chargeType === 'PER_DAY'
|
||||
? settings?.additionalDriverDailyRate ?? 0
|
||||
: chargeType === 'FLAT'
|
||||
? settings?.additionalDriverFlatRate ?? 0
|
||||
: 0;
|
||||
const records = drivers.map((driver) => {
|
||||
const licenseResult = (0, licenseValidationService_1.validateLicense)(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null);
|
||||
const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays);
|
||||
return {
|
||||
reservationId,
|
||||
companyId,
|
||||
firstName: driver.firstName,
|
||||
lastName: driver.lastName,
|
||||
email: driver.email ?? null,
|
||||
phone: driver.phone ?? null,
|
||||
driverLicense: driver.driverLicense,
|
||||
licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null,
|
||||
licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null,
|
||||
dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null,
|
||||
nationality: driver.nationality ?? null,
|
||||
chargeType,
|
||||
chargeValue,
|
||||
totalCharge,
|
||||
licenseExpired: licenseResult.status === 'EXPIRED',
|
||||
licenseExpiringSoon: licenseResult.status === 'EXPIRING',
|
||||
requiresApproval: licenseResult.requiresApproval,
|
||||
approvalNote: licenseResult.requiresApproval ? licenseResult.message : null,
|
||||
};
|
||||
});
|
||||
const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0);
|
||||
await prisma_1.prisma.$transaction([
|
||||
prisma_1.prisma.additionalDriver.createMany({ data: records }),
|
||||
prisma_1.prisma.reservation.update({
|
||||
where: { id: reservationId },
|
||||
data: {
|
||||
additionalDriverTotal,
|
||||
totalAmount: { increment: additionalDriverTotal },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
records,
|
||||
additionalDriverTotal,
|
||||
requiresManualApproval: records.some((record) => record.requiresApproval),
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=additionalDriverService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"additionalDriverService.js","sourceRoot":"","sources":["../../src/services/additionalDriverService.ts"],"names":[],"mappings":"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"}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
export interface AmanPayCreateParams {
|
||||
amount: number;
|
||||
currency: string;
|
||||
orderId: string;
|
||||
description: string;
|
||||
customerEmail?: string;
|
||||
customerName?: string;
|
||||
successUrl: string;
|
||||
failureUrl: string;
|
||||
webhookUrl: string;
|
||||
}
|
||||
export interface AmanPayCheckoutResult {
|
||||
transactionId: string;
|
||||
checkoutUrl: string;
|
||||
status: string;
|
||||
}
|
||||
export declare function createCheckout(params: AmanPayCreateParams): Promise<AmanPayCheckoutResult>;
|
||||
export declare function getTransaction(transactionId: string): Promise<unknown>;
|
||||
export declare function refundTransaction(transactionId: string, amount: number, reason?: string): Promise<unknown>;
|
||||
export declare function verifyWebhookSignature(rawBody: string, signature: string): boolean;
|
||||
export declare function isConfigured(): boolean;
|
||||
//# sourceMappingURL=amanpayService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"amanpayService.d.ts","sourceRoot":"","sources":["../../src/services/amanpayService.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;CACf;AAUD,wBAAsB,cAAc,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA6BhG;AAED,wBAAsB,cAAc,CAAC,aAAa,EAAE,MAAM,oBAMzD;AAED,wBAAsB,iBAAiB,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,oBAW7F;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAOlF;AAED,wBAAgB,YAAY,IAAI,OAAO,CAEtC"}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createCheckout = createCheckout;
|
||||
exports.getTransaction = getTransaction;
|
||||
exports.refundTransaction = refundTransaction;
|
||||
exports.verifyWebhookSignature = verifyWebhookSignature;
|
||||
exports.isConfigured = isConfigured;
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net';
|
||||
const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? '';
|
||||
const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? '';
|
||||
const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? '';
|
||||
async function getAuthHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-merchant-id': MERCHANT_ID,
|
||||
'x-api-key': SECRET_KEY,
|
||||
};
|
||||
}
|
||||
async function createCheckout(params) {
|
||||
const res = await fetch(`${BASE_URL}/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: await getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
merchant_id: MERCHANT_ID,
|
||||
amount: params.amount,
|
||||
currency: params.currency,
|
||||
order_id: params.orderId,
|
||||
description: params.description,
|
||||
customer_email: params.customerEmail,
|
||||
customer_name: params.customerName,
|
||||
success_url: params.successUrl,
|
||||
failure_url: params.failureUrl,
|
||||
webhook_url: params.webhookUrl,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(`AmanPay checkout failed: ${err?.message ?? res.statusText}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return {
|
||||
transactionId: (json.transaction_id ?? json.id),
|
||||
checkoutUrl: (json.checkout_url ?? json.payment_url),
|
||||
status: (json.status ?? 'PENDING'),
|
||||
};
|
||||
}
|
||||
async function getTransaction(transactionId) {
|
||||
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, {
|
||||
headers: await getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error('AmanPay: failed to fetch transaction');
|
||||
return res.json();
|
||||
}
|
||||
async function refundTransaction(transactionId, amount, reason) {
|
||||
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, {
|
||||
method: 'POST',
|
||||
headers: await getAuthHeaders(),
|
||||
body: JSON.stringify({ amount, reason }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(`AmanPay refund failed: ${err?.message ?? res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
function verifyWebhookSignature(rawBody, signature) {
|
||||
if (!WEBHOOK_SECRET)
|
||||
return false;
|
||||
const expected = crypto_1.default
|
||||
.createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(rawBody)
|
||||
.digest('hex');
|
||||
return crypto_1.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
}
|
||||
function isConfigured() {
|
||||
return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id');
|
||||
}
|
||||
//# sourceMappingURL=amanpayService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"amanpayService.js","sourceRoot":"","sources":["../../src/services/amanpayService.ts"],"names":[],"mappings":"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"}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export declare function createCompanyContainer(company: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}): Promise<void>;
|
||||
export declare function startCompanyContainer(companyId: string): Promise<void>;
|
||||
export declare function stopCompanyContainer(companyId: string): Promise<void>;
|
||||
export declare function restartCompanyContainer(companyId: string): Promise<void>;
|
||||
export declare function removeCompanyContainer(companyId: string): Promise<void>;
|
||||
export declare function redeployCompanyContainer(company: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}): Promise<void>;
|
||||
export declare function getContainerLogs(companyId: string, tail?: number): Promise<string>;
|
||||
export declare function syncContainerStatuses(): Promise<void>;
|
||||
//# sourceMappingURL=containerService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"containerService.d.ts","sourceRoot":"","sources":["../../src/services/containerService.ts"],"names":[],"mappings":"AA4SA,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb,GAAG,OAAO,CAAC,IAAI,CAAC,CAoChB;AAED,wBAAsB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO5E;AAED,wBAAsB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO3E;AAED,wBAAsB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO9E;AAED,wBAAsB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsB7E;AAED,wBAAsB,wBAAwB,CAAC,OAAO,EAAE;IACtD,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBhB;AAED,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,SAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBrF;AAED,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAkC3D"}
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
"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.createCompanyContainer = createCompanyContainer;
|
||||
exports.startCompanyContainer = startCompanyContainer;
|
||||
exports.stopCompanyContainer = stopCompanyContainer;
|
||||
exports.restartCompanyContainer = restartCompanyContainer;
|
||||
exports.removeCompanyContainer = removeCompanyContainer;
|
||||
exports.redeployCompanyContainer = redeployCompanyContainer;
|
||||
exports.getContainerLogs = getContainerLogs;
|
||||
exports.syncContainerStatuses = syncContainerStatuses;
|
||||
const child_process_1 = require("child_process");
|
||||
const util_1 = require("util");
|
||||
const fs = __importStar(require("fs/promises"));
|
||||
const path = __importStar(require("path"));
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
const db = prisma_1.prisma;
|
||||
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
||||
let composeCommand = null;
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies';
|
||||
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml');
|
||||
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json'); // our source of truth
|
||||
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest';
|
||||
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10);
|
||||
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10);
|
||||
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000';
|
||||
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default';
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
function serviceName(slug) {
|
||||
return `company-${slug}`;
|
||||
}
|
||||
function containerName(slug) {
|
||||
return `rdg-company-${slug}`;
|
||||
}
|
||||
async function ensureDir() {
|
||||
await fs.mkdir(COMPOSE_DIR, { recursive: true });
|
||||
}
|
||||
async function readServices() {
|
||||
try {
|
||||
const raw = await fs.readFile(SERVICES_FILE, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
async function writeServices(services) {
|
||||
await ensureDir();
|
||||
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8');
|
||||
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8');
|
||||
}
|
||||
async function composeFilesExist() {
|
||||
try {
|
||||
await fs.access(COMPOSE_FILE);
|
||||
await fs.access(SERVICES_FILE);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function rebuildServicesFromDatabase() {
|
||||
const records = await db.companyContainer.findMany({
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
return Object.fromEntries(records.map((record) => [
|
||||
serviceName(record.company.slug),
|
||||
{
|
||||
companyId: record.companyId,
|
||||
slug: record.company.slug,
|
||||
image: record.image,
|
||||
port: record.port,
|
||||
},
|
||||
]));
|
||||
}
|
||||
async function ensureComposeFiles() {
|
||||
if (await composeFilesExist())
|
||||
return;
|
||||
const services = await rebuildServicesFromDatabase();
|
||||
await writeServices(services);
|
||||
}
|
||||
function buildComposeYaml(services) {
|
||||
const lines = ['services:'];
|
||||
for (const [name, svc] of Object.entries(services)) {
|
||||
lines.push(` ${name}:`);
|
||||
lines.push(` image: "${svc.image}"`);
|
||||
lines.push(` container_name: "${containerName(svc.slug)}"`);
|
||||
lines.push(` restart: unless-stopped`);
|
||||
lines.push(` environment:`);
|
||||
lines.push(` COMPANY_ID: "${svc.companyId}"`);
|
||||
lines.push(` COMPANY_SLUG: "${svc.slug}"`);
|
||||
lines.push(` API_URL: "${API_INTERNAL_URL}"`);
|
||||
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`);
|
||||
lines.push(` PORT: "3000"`);
|
||||
lines.push(` NODE_ENV: "production"`);
|
||||
lines.push(` ports:`);
|
||||
lines.push(` - "${svc.port}:3000"`);
|
||||
lines.push(` networks:`);
|
||||
lines.push(` - ${CONTAINER_NETWORK}`);
|
||||
lines.push(` labels:`);
|
||||
lines.push(` rdg.managed: "true"`);
|
||||
lines.push(` rdg.company.id: "${svc.companyId}"`);
|
||||
lines.push(` rdg.company.slug: "${svc.slug}"`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('networks:');
|
||||
lines.push(` ${CONTAINER_NETWORK}:`);
|
||||
lines.push(` external: true`);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
async function resolveComposeCommand() {
|
||||
if (composeCommand)
|
||||
return composeCommand;
|
||||
try {
|
||||
await execFileAsync('docker-compose', ['version']);
|
||||
composeCommand = 'docker-compose';
|
||||
return composeCommand;
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
await execFileAsync('docker', ['compose', 'version']);
|
||||
composeCommand = 'docker';
|
||||
return composeCommand;
|
||||
}
|
||||
catch (err) {
|
||||
throw mapDockerError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function compose(...args) {
|
||||
try {
|
||||
await ensureComposeFiles();
|
||||
const command = await resolveComposeCommand();
|
||||
if (command === 'docker-compose') {
|
||||
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args]);
|
||||
}
|
||||
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args]);
|
||||
}
|
||||
catch (err) {
|
||||
throw mapDockerError(err);
|
||||
}
|
||||
}
|
||||
function dockerUnavailableError(message, details) {
|
||||
const err = Object.assign(new Error(message), {
|
||||
statusCode: 503,
|
||||
code: 'docker_unavailable',
|
||||
});
|
||||
if (details) {
|
||||
;
|
||||
err.details = details;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
function mapDockerError(err) {
|
||||
if (err && typeof err === 'object') {
|
||||
const error = err;
|
||||
const stderr = error.stderr?.trim();
|
||||
if (error.code === 'ENOENT' && error.path === 'docker') {
|
||||
return dockerUnavailableError('Docker CLI is not available to the API service.', 'Install Docker in the API container or run the API on a host with Docker available in PATH.');
|
||||
}
|
||||
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
|
||||
return dockerUnavailableError('Docker Compose is not available to the API service.', 'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.');
|
||||
}
|
||||
if (stderr?.includes("docker: 'compose' is not a docker command.") ||
|
||||
stderr?.includes("unknown shorthand flag: 'f' in -f")) {
|
||||
return dockerUnavailableError('Docker Compose is not available to the API service.', 'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.');
|
||||
}
|
||||
if (stderr?.includes('Cannot connect to the Docker daemon') ||
|
||||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
|
||||
stderr?.includes('error during connect')) {
|
||||
return dockerUnavailableError('Docker daemon is not reachable from the API service.', 'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.');
|
||||
}
|
||||
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
|
||||
if (typeof error.code === 'number' && error.code !== 0) {
|
||||
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status';
|
||||
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' });
|
||||
}
|
||||
}
|
||||
return err;
|
||||
}
|
||||
async function setContainerError(companyId, message) {
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: 'ERROR', errorMessage: message },
|
||||
}).catch(() => null);
|
||||
}
|
||||
async function runContainerAction(companyId, slug, command, successStatus, preStatus) {
|
||||
if (preStatus) {
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } });
|
||||
}
|
||||
try {
|
||||
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
|
||||
const args = command === 'start'
|
||||
? ['up', '-d', '--no-recreate', serviceName(slug)]
|
||||
: [command, serviceName(slug)];
|
||||
await compose(...args);
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: successStatus, errorMessage: null },
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : `Failed to ${command} container`;
|
||||
await setContainerError(companyId, message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async function allocatePort() {
|
||||
const last = await db.companyContainer.findFirst({
|
||||
orderBy: { port: 'desc' },
|
||||
select: { port: true },
|
||||
});
|
||||
const next = last ? last.port + 1 : PORT_RANGE_START;
|
||||
if (next > PORT_RANGE_END)
|
||||
throw new Error('No available ports in container port range');
|
||||
return next;
|
||||
}
|
||||
async function getDockerServiceId(slug) {
|
||||
try {
|
||||
const name = serviceName(slug);
|
||||
const { stdout } = await compose('ps', '--format', 'json', name);
|
||||
const line = stdout.trim().split('\n')[0];
|
||||
if (!line)
|
||||
return null;
|
||||
const info = JSON.parse(line);
|
||||
return info.ID ?? info.Id ?? null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Maps docker compose State strings to our DB enum
|
||||
function mapDockerState(state) {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running': return 'RUNNING';
|
||||
case 'restarting': return 'RESTARTING';
|
||||
case 'exited':
|
||||
case 'created':
|
||||
case 'paused': return 'STOPPED';
|
||||
default: return 'ERROR';
|
||||
}
|
||||
}
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
async function createCompanyContainer(company) {
|
||||
const name = serviceName(company.slug);
|
||||
const port = await allocatePort();
|
||||
const record = await db.companyContainer.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
containerName: containerName(company.slug),
|
||||
status: 'CREATING',
|
||||
port,
|
||||
image: DASHBOARD_IMAGE,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const services = await readServices();
|
||||
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port };
|
||||
await writeServices(services);
|
||||
await compose('up', '-d', '--no-recreate', name);
|
||||
const dockerId = await getDockerServiceId(company.slug);
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: { dockerId, status: 'RUNNING' },
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'ERROR',
|
||||
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async function startCompanyContainer(companyId) {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING');
|
||||
}
|
||||
async function stopCompanyContainer(companyId) {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED');
|
||||
}
|
||||
async function restartCompanyContainer(companyId) {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING');
|
||||
}
|
||||
async function removeCompanyContainer(companyId) {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
const name = serviceName(record.company.slug);
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } });
|
||||
try {
|
||||
await compose('stop', name);
|
||||
}
|
||||
catch { /* already stopped */ }
|
||||
try {
|
||||
await compose('rm', '-f', name);
|
||||
}
|
||||
catch { /* already removed */ }
|
||||
const services = await readServices();
|
||||
delete services[name];
|
||||
await writeServices(services);
|
||||
await db.companyContainer.delete({ where: { companyId } });
|
||||
}
|
||||
async function redeployCompanyContainer(company) {
|
||||
const name = serviceName(company.slug);
|
||||
const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } });
|
||||
if (existing) {
|
||||
try {
|
||||
await compose('stop', name);
|
||||
}
|
||||
catch { /* ok */ }
|
||||
try {
|
||||
await compose('rm', '-f', name);
|
||||
}
|
||||
catch { /* ok */ }
|
||||
await db.companyContainer.delete({ where: { companyId: company.id } });
|
||||
}
|
||||
// Remove from compose file too, then recreate
|
||||
const services = await readServices();
|
||||
delete services[name];
|
||||
await writeServices(services);
|
||||
await createCompanyContainer(company);
|
||||
}
|
||||
async function getContainerLogs(companyId, tail = 150) {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
try {
|
||||
const { stdout } = await compose('logs', '--no-log-prefix', `--tail=${tail}`, serviceName(record.company.slug));
|
||||
return stdout;
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
async function syncContainerStatuses() {
|
||||
const records = await db.companyContainer.findMany({
|
||||
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
|
||||
include: { company: { select: { slug: true } } },
|
||||
});
|
||||
try {
|
||||
// Get all compose service states in one shot
|
||||
const { stdout } = await compose('ps', '--format', 'json');
|
||||
const rows = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]));
|
||||
await Promise.allSettled(records.map(async (rec) => {
|
||||
const name = serviceName(rec.company.slug);
|
||||
const dockerState = stateByService[name];
|
||||
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED';
|
||||
if (rec.status !== status) {
|
||||
await db.companyContainer.update({ where: { id: rec.id }, data: { status } });
|
||||
}
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
// Docker daemon unreachable — leave statuses as-is
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=containerService.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
export declare function generateFinancialReport(companyId: string, startDate: Date, endDate: Date): Promise<{
|
||||
summary: {
|
||||
totalReservations: any;
|
||||
totalRentalRevenue: any;
|
||||
totalDiscounts: any;
|
||||
totalInsurance: any;
|
||||
totalAdditionalDrivers: any;
|
||||
totalPricingRulesAdj: any;
|
||||
totalDeposits: any;
|
||||
totalCollected: any;
|
||||
averageRentalDays: number;
|
||||
};
|
||||
rows: any;
|
||||
period: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
}>;
|
||||
export declare function toCsv(rows: Record<string, any>[]): string;
|
||||
//# sourceMappingURL=financialReportService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"financialReportService.d.ts","sourceRoot":"","sources":["../../src/services/financialReportService.ts"],"names":[],"mappings":"AAEA,wBAAsB,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI;;;;;;;;;;;;;;;;;GAiD9F;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAAG,MAAM,CAYzD"}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateFinancialReport = generateFinancialReport;
|
||||
exports.toCsv = toCsv;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
async function generateFinancialReport(companyId, startDate, endDate) {
|
||||
const reservations = await prisma_1.prisma.reservation.findMany({
|
||||
where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, startDate: { gte: startDate }, endDate: { lte: endDate } },
|
||||
include: {
|
||||
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
|
||||
customer: { select: { firstName: true, lastName: true, email: true } },
|
||||
rentalPayments: { where: { status: 'SUCCEEDED' } },
|
||||
insurances: true,
|
||||
additionalDrivers: true,
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
const summary = {
|
||||
totalReservations: reservations.length,
|
||||
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
|
||||
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
|
||||
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
|
||||
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
|
||||
totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
|
||||
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
|
||||
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
|
||||
averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
|
||||
};
|
||||
const rows = reservations.map((r) => ({
|
||||
reservationId: r.id,
|
||||
contractNumber: r.contractNumber ?? '—',
|
||||
invoiceNumber: r.invoiceNumber ?? '—',
|
||||
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
|
||||
vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
|
||||
plate: r.vehicle.licensePlate,
|
||||
startDate: r.startDate.toISOString().split('T')[0],
|
||||
endDate: r.endDate.toISOString().split('T')[0],
|
||||
days: r.totalDays,
|
||||
dailyRate: r.dailyRate,
|
||||
baseAmount: r.dailyRate * r.totalDays,
|
||||
discount: r.discountAmount,
|
||||
insurance: r.insuranceTotal,
|
||||
additionalDriver: r.additionalDriverTotal,
|
||||
pricingAdj: r.pricingRulesTotal,
|
||||
deposit: r.depositAmount,
|
||||
totalAmount: r.totalAmount,
|
||||
paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID',
|
||||
paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—',
|
||||
source: r.source,
|
||||
}));
|
||||
return { summary, rows, period: { startDate, endDate } };
|
||||
}
|
||||
function toCsv(rows) {
|
||||
if (rows.length === 0)
|
||||
return '';
|
||||
const headers = Object.keys(rows[0]);
|
||||
return [
|
||||
headers.join(','),
|
||||
...rows.map((row) => headers.map((h) => {
|
||||
const val = row[h] ?? '';
|
||||
return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val);
|
||||
}).join(',')),
|
||||
].join('\n');
|
||||
}
|
||||
//# sourceMappingURL=financialReportService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"financialReportService.js","sourceRoot":"","sources":["../../src/services/financialReportService.ts"],"names":[],"mappings":"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"}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { InsurancePolicy } from '@rentaldrivego/database';
|
||||
export declare function calculateInsuranceCharge(policy: InsurancePolicy, totalDays: number, baseRentalAmount: number): number;
|
||||
export declare function applyInsurancesToReservation(reservationId: string, companyId: string, selectedPolicyIds: string[], totalDays: number, baseRentalAmount: number): Promise<{
|
||||
records: {
|
||||
reservationId: string;
|
||||
insurancePolicyId: any;
|
||||
policyName: any;
|
||||
policyType: any;
|
||||
chargeType: any;
|
||||
chargeValue: any;
|
||||
totalCharge: number;
|
||||
}[];
|
||||
insuranceTotal: number;
|
||||
}>;
|
||||
//# sourceMappingURL=insuranceService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"insuranceService.d.ts","sourceRoot":"","sources":["../../src/services/insuranceService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAGzD,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM,GACvB,MAAM,CAOR;AAED,wBAAsB,4BAA4B,CAChD,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,MAAM,EAAE,EAC3B,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM;;;;;;;;;;;GA+BzB"}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.calculateInsuranceCharge = calculateInsuranceCharge;
|
||||
exports.applyInsurancesToReservation = applyInsurancesToReservation;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
function calculateInsuranceCharge(policy, totalDays, baseRentalAmount) {
|
||||
switch (policy.chargeType) {
|
||||
case 'PER_DAY': return policy.chargeValue * totalDays;
|
||||
case 'PER_RENTAL': return policy.chargeValue;
|
||||
case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * policy.chargeValue / 100);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
async function applyInsurancesToReservation(reservationId, companyId, selectedPolicyIds, totalDays, baseRentalAmount) {
|
||||
const allPolicies = await prisma_1.prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } });
|
||||
const required = allPolicies.filter((p) => p.isRequired);
|
||||
const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired);
|
||||
const toApply = [...required, ...selected];
|
||||
const records = toApply.map((policy) => ({
|
||||
reservationId,
|
||||
insurancePolicyId: policy.id,
|
||||
policyName: policy.name,
|
||||
policyType: policy.type,
|
||||
chargeType: policy.chargeType,
|
||||
chargeValue: policy.chargeValue,
|
||||
totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount),
|
||||
}));
|
||||
const insuranceTotal = records.reduce((s, r) => s + r.totalCharge, 0);
|
||||
await prisma_1.prisma.$transaction([
|
||||
prisma_1.prisma.reservationInsurance.createMany({ data: records }),
|
||||
prisma_1.prisma.reservation.update({
|
||||
where: { id: reservationId },
|
||||
data: {
|
||||
insuranceTotal,
|
||||
totalAmount: { increment: insuranceTotal },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { records, insuranceTotal };
|
||||
}
|
||||
//# sourceMappingURL=insuranceService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"insuranceService.js","sourceRoot":"","sources":["../../src/services/insuranceService.ts"],"names":[],"mappings":";;AAGA,4DAWC;AAED,oEAoCC;AAnDD,0CAAsC;AAEtC,SAAgB,wBAAwB,CACtC,MAAuB,EACvB,SAAiB,EACjB,gBAAwB;IAExB,QAAQ,MAAM,CAAC,UAAU,EAAE,CAAC;QAC1B,KAAK,SAAS,CAAC,CAAe,OAAO,MAAM,CAAC,WAAW,GAAG,SAAS,CAAA;QACnE,KAAK,YAAY,CAAC,CAAY,OAAO,MAAM,CAAC,WAAW,CAAA;QACvD,KAAK,sBAAsB,CAAC,CAAE,OAAO,IAAI,CAAC,KAAK,CAAC,gBAAgB,GAAG,MAAM,CAAC,WAAW,GAAG,GAAG,CAAC,CAAA;QAC5F,OAAO,CAAC,CAAsB,OAAO,CAAC,CAAA;IACxC,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,4BAA4B,CAChD,aAAqB,EACrB,SAAiB,EACjB,iBAA2B,EAC3B,SAAiB,EACjB,gBAAwB;IAExB,MAAM,WAAW,GAAG,MAAM,eAAM,CAAC,eAAe,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,CAAC,CAAA;IACnG,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,CAAkB,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAA;IACzE,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,CAAkB,EAAE,EAAE,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAA;IAC9G,MAAM,OAAO,GAAG,CAAC,GAAG,QAAQ,EAAE,GAAG,QAAQ,CAAC,CAAA;IAE1C,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACvC,aAAa;QACb,iBAAiB,EAAE,MAAM,CAAC,EAAE;QAC5B,UAAU,EAAE,MAAM,CAAC,IAAI;QACvB,UAAU,EAAE,MAAM,CAAC,IAAI;QACvB,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,WAAW,EAAE,wBAAwB,CAAC,MAAM,EAAE,SAAS,EAAE,gBAAgB,CAAC;KAC3E,CAAC,CAAC,CAAA;IAEH,MAAM,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAA;IAErE,MAAM,eAAM,CAAC,YAAY,CAAC;QACxB,eAAM,CAAC,oBAAoB,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;QACzD,eAAM,CAAC,WAAW,CAAC,MAAM,CAAC;YACxB,KAAK,EAAE,EAAE,EAAE,EAAE,aAAa,EAAE;YAC5B,IAAI,EAAE;gBACJ,cAAc;gBACd,WAAW,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE;aAC3C;SACF,CAAC;KACH,CAAC,CAAA;IAEF,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,CAAA;AACpC,CAAC"}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
issueDate: string;
|
||||
dueDate: string | null;
|
||||
company: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
address?: any;
|
||||
};
|
||||
subscription?: {
|
||||
plan: string;
|
||||
billingPeriod: string;
|
||||
currentPeriodStart?: string | null;
|
||||
currentPeriodEnd?: string | null;
|
||||
currency: string;
|
||||
};
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
paymentProvider: string;
|
||||
transactionId?: string | null;
|
||||
paidAt?: string | null;
|
||||
lineItems?: Array<{
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
quantity?: number;
|
||||
unitAmount?: number;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
}>;
|
||||
totals?: {
|
||||
subtotalAmount: number;
|
||||
discountAmount: number;
|
||||
creditAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
amountPaid: number;
|
||||
amountDue: number;
|
||||
};
|
||||
}
|
||||
export declare function generateInvoicePdf(data: InvoiceData): Promise<Buffer>;
|
||||
export declare function buildInvoiceNumber(invoiceId: string, createdAt: Date): string;
|
||||
export {};
|
||||
//# sourceMappingURL=invoicePdfService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"invoicePdfService.d.ts","sourceRoot":"","sources":["../../src/services/invoicePdfService.ts"],"names":[],"mappings":"AAGA,UAAU,WAAW;IACnB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAA;QACZ,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QACrB,OAAO,CAAC,EAAE,GAAG,CAAA;KACd,CAAA;IACD,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAA;QACZ,aAAa,EAAE,MAAM,CAAA;QACrB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QAClC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QAChC,QAAQ,EAAE,MAAM,CAAA;KACjB,CAAA;IACD,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,eAAe,EAAE,MAAM,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,WAAW,EAAE,MAAM,CAAA;QACnB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QAC3B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAC1B,CAAC,CAAA;IACF,MAAM,CAAC,EAAE;QACP,cAAc,EAAE,MAAM,CAAA;QACtB,cAAc,EAAE,MAAM,CAAA;QACtB,YAAY,EAAE,MAAM,CAAA;QACpB,SAAS,EAAE,MAAM,CAAA;QACjB,WAAW,EAAE,MAAM,CAAA;QACnB,UAAU,EAAE,MAAM,CAAA;QAClB,SAAS,EAAE,MAAM,CAAA;KAClB,CAAA;CACF;AAmgBD,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAI3E;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,GAAG,MAAM,CAI7E"}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateInvoicePdf = generateInvoicePdf;
|
||||
exports.buildInvoiceNumber = buildInvoiceNumber;
|
||||
const react_1 = __importDefault(require("react"));
|
||||
const renderer_1 = require("@react-pdf/renderer");
|
||||
const PLAN_LABEL = {
|
||||
STARTER: 'Starter',
|
||||
GROWTH: 'Growth',
|
||||
PRO: 'Pro',
|
||||
};
|
||||
const PERIOD_LABEL = {
|
||||
MONTHLY: 'Monthly',
|
||||
ANNUAL: 'Annual',
|
||||
};
|
||||
const STATUS_COLORS = {
|
||||
PAID: '#10b981',
|
||||
PENDING: '#f59e0b',
|
||||
OPEN: '#f59e0b',
|
||||
PAYMENT_PENDING: '#f59e0b',
|
||||
PARTIALLY_PAID: '#f59e0b',
|
||||
PAST_DUE: '#ef4444',
|
||||
FAILED: '#ef4444',
|
||||
REFUNDED: '#6b7280',
|
||||
PARTIALLY_REFUNDED: '#6b7280',
|
||||
VOID: '#6b7280',
|
||||
UNCOLLECTIBLE: '#6b7280',
|
||||
};
|
||||
const colors = {
|
||||
bg: '#0f0f11',
|
||||
surface: '#18181b',
|
||||
border: '#27272a',
|
||||
textPrimary: '#f4f4f5',
|
||||
textSecondary: '#a1a1aa',
|
||||
textMuted: '#71717a',
|
||||
accent: '#10b981',
|
||||
white: '#ffffff',
|
||||
};
|
||||
const s = renderer_1.StyleSheet.create({
|
||||
page: {
|
||||
backgroundColor: colors.bg,
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 48,
|
||||
fontFamily: 'Helvetica',
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 40,
|
||||
paddingBottom: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
brandName: {
|
||||
fontSize: 22,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.accent,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
brandTagline: {
|
||||
fontSize: 9,
|
||||
color: colors.textMuted,
|
||||
marginTop: 3,
|
||||
},
|
||||
invoiceMeta: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
invoiceTitle: {
|
||||
fontSize: 26,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.white,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 11,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 8,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
col: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
colLabel: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
},
|
||||
companyName: {
|
||||
fontSize: 13,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
companyDetail: {
|
||||
fontSize: 10,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
},
|
||||
metaKey: {
|
||||
fontSize: 9,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
metaValue: {
|
||||
fontSize: 9,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
},
|
||||
table: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 20,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#1c1c1f',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tableCell: {
|
||||
fontSize: 10,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
tableCellMuted: {
|
||||
fontSize: 10,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
col60: { flex: 6 },
|
||||
col20: { flex: 2, textAlign: 'right' },
|
||||
col20Center: { flex: 2, textAlign: 'center' },
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 6,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textSecondary,
|
||||
marginRight: 32,
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.white,
|
||||
minWidth: 100,
|
||||
textAlign: 'right',
|
||||
},
|
||||
grandTotalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 8,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
grandTotalLabel: {
|
||||
fontSize: 13,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textSecondary,
|
||||
marginRight: 32,
|
||||
},
|
||||
grandTotalValue: {
|
||||
fontSize: 15,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.accent,
|
||||
minWidth: 100,
|
||||
textAlign: 'right',
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 9,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
letterSpacing: 0.5,
|
||||
color: colors.white,
|
||||
},
|
||||
paymentBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: 24,
|
||||
},
|
||||
paymentRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
paymentKey: {
|
||||
fontSize: 9,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
paymentValue: {
|
||||
fontSize: 9,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
maxWidth: 280,
|
||||
textAlign: 'right',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 48,
|
||||
right: 48,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
function fmt(amount, currency) {
|
||||
return `${(amount / 100).toFixed(2)} ${currency}`;
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
if (!iso)
|
||||
return '—';
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
function formatAddress(address) {
|
||||
if (!address)
|
||||
return '';
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const parts = [address.street, address.city, address.region, address.country, address.zip];
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
function InvoiceDocument({ data }) {
|
||||
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280';
|
||||
const addressStr = formatAddress(data.company.address);
|
||||
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual';
|
||||
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom';
|
||||
const lineItems = data.lineItems?.length
|
||||
? data.lineItems
|
||||
: [{
|
||||
description: `${planLabel} Plan — ${periodLabel} Subscription`,
|
||||
amount: data.amount,
|
||||
currency: data.currency,
|
||||
periodStart: data.subscription?.currentPeriodStart ?? null,
|
||||
periodEnd: data.subscription?.currentPeriodEnd ?? null,
|
||||
}];
|
||||
const totals = data.totals ?? {
|
||||
subtotalAmount: data.amount,
|
||||
discountAmount: 0,
|
||||
creditAmount: 0,
|
||||
taxAmount: 0,
|
||||
totalAmount: data.amount,
|
||||
amountPaid: data.paidAt ? data.amount : 0,
|
||||
amountDue: data.paidAt ? 0 : data.amount,
|
||||
};
|
||||
return react_1.default.createElement(renderer_1.Document, { author: 'RentalDriveGo', title: `Invoice ${data.invoiceNumber}`, subject: 'Subscription Invoice' }, react_1.default.createElement(renderer_1.Page, { size: 'A4', style: s.page },
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
react_1.default.createElement(renderer_1.View, { style: s.header }, react_1.default.createElement(renderer_1.View, null, react_1.default.createElement(renderer_1.Text, { style: s.brandName }, 'RentalDriveGo'), react_1.default.createElement(renderer_1.Text, { style: s.brandTagline }, 'Car Rental Management Platform')), react_1.default.createElement(renderer_1.View, { style: s.invoiceMeta }, react_1.default.createElement(renderer_1.Text, { style: s.invoiceTitle }, 'INVOICE'), react_1.default.createElement(renderer_1.Text, { style: s.invoiceNumber }, data.invoiceNumber), react_1.default.createElement(renderer_1.View, { style: [s.statusBadge, { backgroundColor: statusColor }] }, react_1.default.createElement(renderer_1.Text, { style: s.statusText }, data.status)))),
|
||||
// ── Bill To / Invoice Dates ──────────────────────────────────
|
||||
react_1.default.createElement(renderer_1.View, { style: s.row }, react_1.default.createElement(renderer_1.View, { style: s.col }, react_1.default.createElement(renderer_1.Text, { style: s.colLabel }, 'Bill To'), react_1.default.createElement(renderer_1.Text, { style: s.companyName }, data.company.name), react_1.default.createElement(renderer_1.Text, { style: s.companyDetail }, data.company.email), data.company.phone
|
||||
? react_1.default.createElement(renderer_1.Text, { style: s.companyDetail }, data.company.phone)
|
||||
: null, addressStr
|
||||
? react_1.default.createElement(renderer_1.Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
|
||||
: null), react_1.default.createElement(renderer_1.View, { style: s.col }, react_1.default.createElement(renderer_1.Text, { style: s.colLabel }, 'Invoice Details'), react_1.default.createElement(renderer_1.View, { style: s.metaRow }, react_1.default.createElement(renderer_1.Text, { style: s.metaKey }, 'Invoice Number'), react_1.default.createElement(renderer_1.Text, { style: s.metaValue }, data.invoiceNumber)), react_1.default.createElement(renderer_1.View, { style: s.metaRow }, react_1.default.createElement(renderer_1.Text, { style: s.metaKey }, 'Issue Date'), react_1.default.createElement(renderer_1.Text, { style: s.metaValue }, fmtDate(data.issueDate))), data.dueDate
|
||||
? react_1.default.createElement(renderer_1.View, { style: s.metaRow }, react_1.default.createElement(renderer_1.Text, { style: s.metaKey }, 'Due Date'), react_1.default.createElement(renderer_1.Text, { style: s.metaValue }, fmtDate(data.dueDate)))
|
||||
: null, react_1.default.createElement(renderer_1.View, { style: s.metaRow }, react_1.default.createElement(renderer_1.Text, { style: s.metaKey }, 'Currency'), react_1.default.createElement(renderer_1.Text, { style: s.metaValue }, data.currency)), react_1.default.createElement(renderer_1.View, { style: s.metaRow }, react_1.default.createElement(renderer_1.Text, { style: s.metaKey }, 'Billing Period'), react_1.default.createElement(renderer_1.Text, { style: s.metaValue }, periodLabel)), data.subscription
|
||||
? react_1.default.createElement(renderer_1.View, { style: s.metaRow }, react_1.default.createElement(renderer_1.Text, { style: s.metaKey }, 'Plan'), react_1.default.createElement(renderer_1.Text, { style: s.metaValue }, planLabel))
|
||||
: null)),
|
||||
// ── Line Items ───────────────────────────────────────────────
|
||||
react_1.default.createElement(renderer_1.View, { style: s.table }, react_1.default.createElement(renderer_1.View, { style: s.tableHeader }, react_1.default.createElement(renderer_1.Text, { style: [s.tableHeaderCell, s.col60] }, 'Description'), react_1.default.createElement(renderer_1.Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'), react_1.default.createElement(renderer_1.Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount')), ...lineItems.map((item) => {
|
||||
const periodStr = item.periodStart && item.periodEnd
|
||||
? `${fmtDate(item.periodStart)} – ${fmtDate(item.periodEnd)}`
|
||||
: '—';
|
||||
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
|
||||
? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})`
|
||||
: item.description;
|
||||
return react_1.default.createElement(renderer_1.View, { key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow }, react_1.default.createElement(renderer_1.Text, { style: [s.tableCell, s.col60] }, description), react_1.default.createElement(renderer_1.Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr), react_1.default.createElement(renderer_1.Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)));
|
||||
})),
|
||||
// ── Totals ───────────────────────────────────────────────────
|
||||
react_1.default.createElement(renderer_1.View, { style: s.totalRow }, react_1.default.createElement(renderer_1.Text, { style: s.totalLabel }, 'Subtotal'), react_1.default.createElement(renderer_1.Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency))), totals.discountAmount > 0
|
||||
? react_1.default.createElement(renderer_1.View, { style: s.totalRow }, react_1.default.createElement(renderer_1.Text, { style: s.totalLabel }, 'Discounts'), react_1.default.createElement(renderer_1.Text, { style: s.totalValue }, `- ${fmt(totals.discountAmount, data.currency)}`))
|
||||
: null, totals.creditAmount > 0
|
||||
? react_1.default.createElement(renderer_1.View, { style: s.totalRow }, react_1.default.createElement(renderer_1.Text, { style: s.totalLabel }, 'Credits'), react_1.default.createElement(renderer_1.Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`))
|
||||
: null, totals.taxAmount > 0
|
||||
? react_1.default.createElement(renderer_1.View, { style: s.totalRow }, react_1.default.createElement(renderer_1.Text, { style: s.totalLabel }, 'Tax'), react_1.default.createElement(renderer_1.Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)))
|
||||
: null, react_1.default.createElement(renderer_1.View, { style: s.grandTotalRow }, react_1.default.createElement(renderer_1.Text, { style: s.grandTotalLabel }, 'Total Due'), react_1.default.createElement(renderer_1.Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency))),
|
||||
// ── Payment Info ─────────────────────────────────────────────
|
||||
react_1.default.createElement(renderer_1.View, { style: [s.paymentBox, { marginTop: 24 }] }, react_1.default.createElement(renderer_1.Text, { style: [s.colLabel, { marginBottom: 10 }] }, 'Payment Information'), react_1.default.createElement(renderer_1.View, { style: s.paymentRow }, react_1.default.createElement(renderer_1.Text, { style: s.paymentKey }, 'Payment Provider'), react_1.default.createElement(renderer_1.Text, { style: s.paymentValue }, data.paymentProvider)), data.transactionId
|
||||
? react_1.default.createElement(renderer_1.View, { style: s.paymentRow }, react_1.default.createElement(renderer_1.Text, { style: s.paymentKey }, 'Transaction ID'), react_1.default.createElement(renderer_1.Text, { style: s.paymentValue }, data.transactionId))
|
||||
: null, react_1.default.createElement(renderer_1.View, { style: s.paymentRow }, react_1.default.createElement(renderer_1.Text, { style: s.paymentKey }, 'Payment Date'), react_1.default.createElement(renderer_1.Text, { style: s.paymentValue }, fmtDate(data.paidAt))), react_1.default.createElement(renderer_1.View, { style: s.paymentRow }, react_1.default.createElement(renderer_1.Text, { style: s.paymentKey }, 'Status'), react_1.default.createElement(renderer_1.Text, { style: [s.paymentValue, { color: statusColor }] }, data.status))),
|
||||
// ── Footer ───────────────────────────────────────────────────
|
||||
react_1.default.createElement(renderer_1.View, { style: s.footer }, react_1.default.createElement(renderer_1.Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'), react_1.default.createElement(renderer_1.Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`))));
|
||||
}
|
||||
async function generateInvoicePdf(data) {
|
||||
const doc = react_1.default.createElement(InvoiceDocument, { data });
|
||||
const buffer = await (0, renderer_1.renderToBuffer)(doc);
|
||||
return buffer;
|
||||
}
|
||||
function buildInvoiceNumber(invoiceId, createdAt) {
|
||||
const year = createdAt.getFullYear();
|
||||
const short = invoiceId.slice(-6).toUpperCase();
|
||||
return `INV-${year}-${short}`;
|
||||
}
|
||||
//# sourceMappingURL=invoicePdfService.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
export interface LicenseValidationResult {
|
||||
status: 'VALID' | 'EXPIRING' | 'EXPIRED';
|
||||
daysUntilExpiry: number | null;
|
||||
requiresApproval: boolean;
|
||||
message: string;
|
||||
}
|
||||
export declare function validateLicense(licenseExpiry: Date | null): LicenseValidationResult;
|
||||
export declare function validateAndFlagLicense(customerId: string, companyId?: string): Promise<LicenseValidationResult>;
|
||||
//# sourceMappingURL=licenseValidationService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"licenseValidationService.d.ts","sourceRoot":"","sources":["../../src/services/licenseValidationService.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,CAAA;IACxC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,gBAAgB,EAAE,OAAO,CAAA;IACzB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wBAAgB,eAAe,CAAC,aAAa,EAAE,IAAI,GAAG,IAAI,GAAG,uBAAuB,CAiBnF;AAED,wBAAsB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,oCAkClF"}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.validateLicense = validateLicense;
|
||||
exports.validateAndFlagLicense = validateAndFlagLicense;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000;
|
||||
function validateLicense(licenseExpiry) {
|
||||
if (!licenseExpiry) {
|
||||
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' };
|
||||
}
|
||||
const now = new Date();
|
||||
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (licenseExpiry <= now) {
|
||||
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` };
|
||||
}
|
||||
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) {
|
||||
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` };
|
||||
}
|
||||
return { status: 'VALID', daysUntilExpiry, requiresApproval: false, message: `Valid — expires in ${daysUntilExpiry} day(s)` };
|
||||
}
|
||||
async function validateAndFlagLicense(customerId, companyId) {
|
||||
const where = companyId ? { id: customerId, companyId } : { id: customerId };
|
||||
const customer = await prisma_1.prisma.customer.findFirstOrThrow({ where });
|
||||
const result = validateLicense(customer.licenseExpiry);
|
||||
const status = result.status === 'EXPIRED' ? 'EXPIRED' :
|
||||
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID';
|
||||
if (companyId) {
|
||||
await prisma_1.prisma.customer.updateMany({
|
||||
where: { id: customerId, companyId },
|
||||
data: {
|
||||
licenseExpired: result.status === 'EXPIRED',
|
||||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||||
licenseValidationStatus: status,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
await prisma_1.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
licenseExpired: result.status === 'EXPIRED',
|
||||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||||
licenseValidationStatus: status,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
//# sourceMappingURL=licenseValidationService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"licenseValidationService.js","sourceRoot":"","sources":["../../src/services/licenseValidationService.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { NotificationChannel } from '@rentaldrivego/database';
|
||||
import { prisma } from '../lib/prisma';
|
||||
export declare const SUPPORTED_NOTIFICATION_LOCALES: readonly ["en", "fr", "ar"];
|
||||
export type NotificationLocale = (typeof SUPPORTED_NOTIFICATION_LOCALES)[number];
|
||||
export declare const DEFAULT_NOTIFICATION_LOCALE: NotificationLocale;
|
||||
type TemplateDateValue = {
|
||||
type: 'date';
|
||||
value: Date | string | number;
|
||||
options?: Intl.DateTimeFormatOptions;
|
||||
};
|
||||
type TemplateCurrencyValue = {
|
||||
type: 'currency';
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
};
|
||||
type TemplateSubscriptionStatusValue = {
|
||||
type: 'subscription_status';
|
||||
status: string;
|
||||
};
|
||||
type TemplateInvoiceStatusValue = {
|
||||
type: 'invoice_status';
|
||||
status: string;
|
||||
};
|
||||
export type NotificationTemplateValue = string | number | boolean | Date | null | undefined | TemplateDateValue | TemplateCurrencyValue | TemplateSubscriptionStatusValue | TemplateInvoiceStatusValue;
|
||||
export type NotificationTemplateVariables = Record<string, NotificationTemplateValue>;
|
||||
type NotificationLocalizationDb = Pick<typeof prisma, 'brandSettings' | 'billingAccount' | 'employee' | 'notificationTemplate' | 'renter'>;
|
||||
export declare function coerceNotificationLocale(locale?: string | null): NotificationLocale;
|
||||
export declare function getNotificationTextDirection(locale: NotificationLocale): "ltr" | "rtl";
|
||||
export declare function formatLocalizedDate(value: Date | string | number, locale: NotificationLocale, options?: Intl.DateTimeFormatOptions): string;
|
||||
export declare function formatLocalizedCurrency(amountMinor: number, currency: string, locale: NotificationLocale): string;
|
||||
export declare function localizeSubscriptionStatus(status: string, locale: NotificationLocale): string;
|
||||
export declare function localizeInvoiceStatus(status: string, locale: NotificationLocale): string;
|
||||
export declare function localizeBillingPeriod(period: string, locale: NotificationLocale): string;
|
||||
export declare function localizePlanName(plan: string, locale: NotificationLocale): string;
|
||||
export declare function resolveNotificationLocale(input: {
|
||||
companyId?: string | null;
|
||||
employeeId?: string | null;
|
||||
renterId?: string | null;
|
||||
billingAccountId?: string | null;
|
||||
locale?: string | null;
|
||||
}, db?: NotificationLocalizationDb): Promise<NotificationLocale>;
|
||||
export declare function resolveNotificationTemplate(input: {
|
||||
templateKey: string;
|
||||
channel: NotificationChannel;
|
||||
locale: NotificationLocale;
|
||||
variables?: NotificationTemplateVariables;
|
||||
}, db?: NotificationLocalizationDb): Promise<{
|
||||
locale: "en" | "fr" | "ar";
|
||||
subject: string | null;
|
||||
body: string | null;
|
||||
templateKey: string;
|
||||
usedFallback: boolean;
|
||||
}>;
|
||||
export declare function renderNotificationText(template: string | null, variables: NotificationTemplateVariables | undefined, requiredVariables: string[] | undefined, locale: NotificationLocale): string | null;
|
||||
export declare function renderLocalizedEmailHtml(body: string, locale: NotificationLocale): string;
|
||||
export {};
|
||||
//# sourceMappingURL=notificationLocalizationService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"notificationLocalizationService.d.ts","sourceRoot":"","sources":["../../src/services/notificationLocalizationService.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_NOTIFICATION_LOCALE = exports.SUPPORTED_NOTIFICATION_LOCALES = void 0;
|
||||
exports.coerceNotificationLocale = coerceNotificationLocale;
|
||||
exports.getNotificationTextDirection = getNotificationTextDirection;
|
||||
exports.formatLocalizedDate = formatLocalizedDate;
|
||||
exports.formatLocalizedCurrency = formatLocalizedCurrency;
|
||||
exports.localizeSubscriptionStatus = localizeSubscriptionStatus;
|
||||
exports.localizeInvoiceStatus = localizeInvoiceStatus;
|
||||
exports.localizeBillingPeriod = localizeBillingPeriod;
|
||||
exports.localizePlanName = localizePlanName;
|
||||
exports.resolveNotificationLocale = resolveNotificationLocale;
|
||||
exports.resolveNotificationTemplate = resolveNotificationTemplate;
|
||||
exports.renderNotificationText = renderNotificationText;
|
||||
exports.renderLocalizedEmailHtml = renderLocalizedEmailHtml;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
exports.SUPPORTED_NOTIFICATION_LOCALES = ['en', 'fr', 'ar'];
|
||||
exports.DEFAULT_NOTIFICATION_LOCALE = 'en';
|
||||
const localeConfig = {
|
||||
en: { intl: 'en-US', direction: 'ltr' },
|
||||
fr: { intl: 'fr-FR', direction: 'ltr' },
|
||||
ar: { intl: 'ar-MA', direction: 'rtl' },
|
||||
};
|
||||
const subscriptionStatusLabels = {
|
||||
en: {
|
||||
trialing: 'Trial active',
|
||||
active: 'Active',
|
||||
payment_pending: 'Payment pending',
|
||||
past_due: 'Payment overdue',
|
||||
suspended: 'Suspended',
|
||||
cancelled: 'Canceled',
|
||||
canceled: 'Canceled',
|
||||
expired: 'Expired',
|
||||
},
|
||||
fr: {
|
||||
trialing: 'Essai actif',
|
||||
active: 'Actif',
|
||||
payment_pending: 'Paiement en attente',
|
||||
past_due: 'Paiement en retard',
|
||||
suspended: 'Suspendu',
|
||||
cancelled: 'Annule',
|
||||
canceled: 'Annule',
|
||||
expired: 'Expire',
|
||||
},
|
||||
ar: {
|
||||
trialing: 'الفترة التجريبية نشطة',
|
||||
active: 'نشط',
|
||||
payment_pending: 'الدفع قيد الانتظار',
|
||||
past_due: 'الدفع متأخر',
|
||||
suspended: 'معلّق',
|
||||
cancelled: 'ملغى',
|
||||
canceled: 'ملغى',
|
||||
expired: 'منتهي',
|
||||
},
|
||||
};
|
||||
const invoiceStatusLabels = {
|
||||
en: {
|
||||
draft: 'Preparing',
|
||||
open: 'Due',
|
||||
payment_pending: 'Payment processing',
|
||||
paid: 'Paid',
|
||||
partially_paid: 'Partially paid',
|
||||
past_due: 'Past due',
|
||||
void: 'Canceled',
|
||||
uncollectible: 'Contact support',
|
||||
refunded: 'Refunded',
|
||||
partially_refunded: 'Partially refunded',
|
||||
},
|
||||
fr: {
|
||||
draft: 'En preparation',
|
||||
open: 'A payer',
|
||||
payment_pending: 'Paiement en cours',
|
||||
paid: 'Payee',
|
||||
partially_paid: 'Partiellement payee',
|
||||
past_due: 'En retard',
|
||||
void: 'Annulee',
|
||||
uncollectible: 'Contacter le support',
|
||||
refunded: 'Remboursee',
|
||||
partially_refunded: 'Partiellement remboursee',
|
||||
},
|
||||
ar: {
|
||||
draft: 'قيد الإعداد',
|
||||
open: 'مستحقة الدفع',
|
||||
payment_pending: 'الدفع قيد المعالجة',
|
||||
paid: 'مدفوعة',
|
||||
partially_paid: 'مدفوعة جزئياً',
|
||||
past_due: 'متأخرة',
|
||||
void: 'ملغاة',
|
||||
uncollectible: 'تواصل مع الدعم',
|
||||
refunded: 'مستردة',
|
||||
partially_refunded: 'مستردة جزئياً',
|
||||
},
|
||||
};
|
||||
const billingPeriodLabels = {
|
||||
en: {
|
||||
monthly: 'monthly',
|
||||
annual: 'annual',
|
||||
},
|
||||
fr: {
|
||||
monthly: 'mensuel',
|
||||
annual: 'annuel',
|
||||
},
|
||||
ar: {
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي',
|
||||
},
|
||||
};
|
||||
const planLabels = {
|
||||
en: {
|
||||
starter: 'Starter',
|
||||
growth: 'Growth',
|
||||
pro: 'Pro',
|
||||
},
|
||||
fr: {
|
||||
starter: 'Starter',
|
||||
growth: 'Growth',
|
||||
pro: 'Pro',
|
||||
},
|
||||
ar: {
|
||||
starter: 'Starter',
|
||||
growth: 'Growth',
|
||||
pro: 'Pro',
|
||||
},
|
||||
};
|
||||
function coerceNotificationLocale(locale) {
|
||||
if (locale && exports.SUPPORTED_NOTIFICATION_LOCALES.includes(locale)) {
|
||||
return locale;
|
||||
}
|
||||
return exports.DEFAULT_NOTIFICATION_LOCALE;
|
||||
}
|
||||
function getNotificationTextDirection(locale) {
|
||||
return localeConfig[locale].direction;
|
||||
}
|
||||
function formatLocalizedDate(value, locale, options) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return new Intl.DateTimeFormat(localeConfig[locale].intl, options ?? {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
function formatLocalizedCurrency(amountMinor, currency, locale) {
|
||||
return new Intl.NumberFormat(localeConfig[locale].intl, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amountMinor / 100);
|
||||
}
|
||||
function localizeSubscriptionStatus(status, locale) {
|
||||
const normalized = status.toLowerCase();
|
||||
return subscriptionStatusLabels[locale][normalized] ?? status;
|
||||
}
|
||||
function localizeInvoiceStatus(status, locale) {
|
||||
const normalized = status.toLowerCase();
|
||||
return invoiceStatusLabels[locale][normalized] ?? status;
|
||||
}
|
||||
function localizeBillingPeriod(period, locale) {
|
||||
const normalized = period.toLowerCase();
|
||||
return billingPeriodLabels[locale][normalized] ?? period;
|
||||
}
|
||||
function localizePlanName(plan, locale) {
|
||||
const normalized = plan.toLowerCase();
|
||||
return planLabels[locale][normalized] ?? plan;
|
||||
}
|
||||
function isTemplateDateValue(value) {
|
||||
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'date';
|
||||
}
|
||||
function isTemplateCurrencyValue(value) {
|
||||
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'currency';
|
||||
}
|
||||
function isTemplateSubscriptionStatusValue(value) {
|
||||
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'subscription_status';
|
||||
}
|
||||
function isTemplateInvoiceStatusValue(value) {
|
||||
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'invoice_status';
|
||||
}
|
||||
function formatTemplateValue(value, locale) {
|
||||
if (value instanceof Date)
|
||||
return formatLocalizedDate(value, locale);
|
||||
if (isTemplateDateValue(value))
|
||||
return formatLocalizedDate(value.value, locale, value.options);
|
||||
if (isTemplateCurrencyValue(value))
|
||||
return formatLocalizedCurrency(value.amountMinor, value.currency, locale);
|
||||
if (isTemplateSubscriptionStatusValue(value))
|
||||
return localizeSubscriptionStatus(value.status, locale);
|
||||
if (isTemplateInvoiceStatusValue(value))
|
||||
return localizeInvoiceStatus(value.status, locale);
|
||||
if (value === null || value === undefined)
|
||||
return '';
|
||||
return String(value);
|
||||
}
|
||||
function normalizeTemplateVariableList(value) {
|
||||
if (!Array.isArray(value))
|
||||
return [];
|
||||
return value.filter((item) => typeof item === 'string');
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
async function findTemplate(db, templateKey, channel, locale) {
|
||||
return db.notificationTemplate.findFirst({
|
||||
where: { templateKey, channel, locale, isActive: true },
|
||||
orderBy: { version: 'desc' },
|
||||
select: {
|
||||
templateKey: true,
|
||||
channel: true,
|
||||
locale: true,
|
||||
subject: true,
|
||||
body: true,
|
||||
requiredVariables: true,
|
||||
optionalVariables: true,
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async function resolveNotificationLocale(input, db = prisma_1.prisma) {
|
||||
if (input.locale)
|
||||
return coerceNotificationLocale(input.locale);
|
||||
const employeePromise = input.employeeId
|
||||
? db.employee.findUnique({
|
||||
where: { id: input.employeeId },
|
||||
select: { preferredLanguage: true },
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const renterPromise = input.renterId
|
||||
? db.renter.findUnique({
|
||||
where: { id: input.renterId },
|
||||
select: { preferredLocale: true },
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const workspacePromise = input.companyId
|
||||
? db.brandSettings.findUnique({
|
||||
where: { companyId: input.companyId },
|
||||
select: { defaultLocale: true },
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const billingPromise = input.billingAccountId
|
||||
? db.billingAccount.findUnique({
|
||||
where: { id: input.billingAccountId },
|
||||
select: { preferredLanguage: true },
|
||||
})
|
||||
: input.companyId
|
||||
? db.billingAccount.findFirst({
|
||||
where: { companyId: input.companyId, isPrimary: true },
|
||||
select: { preferredLanguage: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const [employee, renter, workspace, billingAccount] = await Promise.all([
|
||||
employeePromise,
|
||||
renterPromise,
|
||||
workspacePromise,
|
||||
billingPromise,
|
||||
]);
|
||||
return coerceNotificationLocale(employee?.preferredLanguage ??
|
||||
renter?.preferredLocale ??
|
||||
workspace?.defaultLocale ??
|
||||
billingAccount?.preferredLanguage ??
|
||||
exports.DEFAULT_NOTIFICATION_LOCALE);
|
||||
}
|
||||
async function resolveNotificationTemplate(input, db = prisma_1.prisma) {
|
||||
const requested = await findTemplate(db, input.templateKey, input.channel, input.locale);
|
||||
if (requested) {
|
||||
return {
|
||||
locale: coerceNotificationLocale(requested.locale),
|
||||
subject: renderNotificationText(requested.subject, input.variables, normalizeTemplateVariableList(requested.requiredVariables), input.locale),
|
||||
body: renderNotificationText(requested.body, input.variables, normalizeTemplateVariableList(requested.requiredVariables), input.locale),
|
||||
templateKey: requested.templateKey,
|
||||
usedFallback: false,
|
||||
};
|
||||
}
|
||||
if (input.locale !== exports.DEFAULT_NOTIFICATION_LOCALE) {
|
||||
console.warn(`[Notifications] Missing ${input.locale} template for ${input.templateKey}/${input.channel}; falling back to ${exports.DEFAULT_NOTIFICATION_LOCALE}.`);
|
||||
}
|
||||
const fallback = await findTemplate(db, input.templateKey, input.channel, exports.DEFAULT_NOTIFICATION_LOCALE);
|
||||
if (!fallback) {
|
||||
throw new Error(`Missing notification template for ${input.templateKey}/${input.channel} in ${input.locale} and ${exports.DEFAULT_NOTIFICATION_LOCALE}`);
|
||||
}
|
||||
return {
|
||||
locale: exports.DEFAULT_NOTIFICATION_LOCALE,
|
||||
subject: renderNotificationText(fallback.subject, input.variables, normalizeTemplateVariableList(fallback.requiredVariables), exports.DEFAULT_NOTIFICATION_LOCALE),
|
||||
body: renderNotificationText(fallback.body, input.variables, normalizeTemplateVariableList(fallback.requiredVariables), exports.DEFAULT_NOTIFICATION_LOCALE),
|
||||
templateKey: fallback.templateKey,
|
||||
usedFallback: true,
|
||||
};
|
||||
}
|
||||
function renderNotificationText(template, variables = {}, requiredVariables = [], locale) {
|
||||
if (!template)
|
||||
return null;
|
||||
for (const variableName of requiredVariables) {
|
||||
if (variables[variableName] === undefined || variables[variableName] === null) {
|
||||
throw new Error(`Missing notification template variable: ${variableName}`);
|
||||
}
|
||||
}
|
||||
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, variableName) => formatTemplateValue(variables[variableName], locale));
|
||||
}
|
||||
function renderLocalizedEmailHtml(body, locale) {
|
||||
const direction = getNotificationTextDirection(locale);
|
||||
const textAlign = direction === 'rtl' ? 'right' : 'left';
|
||||
const paragraphs = body
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<p style="margin:0 0 16px;text-align:${textAlign};">${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('');
|
||||
return [
|
||||
`<html lang="${locale}" dir="${direction}">`,
|
||||
`<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:32px;color:#1c1917;direction:${direction};text-align:${textAlign};">`,
|
||||
paragraphs,
|
||||
'</body>',
|
||||
'</html>',
|
||||
].join('');
|
||||
}
|
||||
//# sourceMappingURL=notificationLocalizationService.js.map
|
||||
File diff suppressed because one or more lines are too long
+32
@@ -0,0 +1,32 @@
|
||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database';
|
||||
import type { NotificationTemplateVariables } from './notificationLocalizationService';
|
||||
interface SendNotificationOptions {
|
||||
type: NotificationType;
|
||||
title?: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
companyId?: string;
|
||||
employeeId?: string;
|
||||
renterId?: string;
|
||||
billingAccountId?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
fcmToken?: string;
|
||||
channels: NotificationChannel[];
|
||||
locale?: string;
|
||||
templateKey?: string;
|
||||
templateVariables?: NotificationTemplateVariables;
|
||||
}
|
||||
export declare function sendNotification(opts: SendNotificationOptions): Promise<{
|
||||
channel: NotificationChannel;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}[]>;
|
||||
export declare function sendTransactionalEmail(opts: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}): Promise<void>;
|
||||
export {};
|
||||
//# sourceMappingURL=notificationService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"notificationService.d.ts","sourceRoot":"","sources":["../../src/services/notificationService.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;AAM/E,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,mCAAmC,CAAA;AA+FtF,UAAU,uBAAuB;IAC/B,IAAI,EAAE,gBAAgB,CAAA;IACtB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,mBAAmB,EAAE,CAAA;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iBAAiB,CAAC,EAAE,6BAA6B,CAAA;CAClD;AAiFD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,uBAAuB;aAClC,mBAAmB;aAAW,OAAO;YAAU,MAAM;KAwHtF;AAED,wBAAsB,sBAAsB,CAAC,IAAI,EAAE;IACjD,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb,iBAEA"}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendNotification = sendNotification;
|
||||
exports.sendTransactionalEmail = sendTransactionalEmail;
|
||||
const resend_1 = require("resend");
|
||||
const twilio_1 = __importDefault(require("twilio"));
|
||||
const firebase_admin_1 = __importDefault(require("firebase-admin"));
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
const redis_1 = require("../lib/redis");
|
||||
const notificationLocalizationService_1 = require("./notificationLocalizationService");
|
||||
const resendApiKey = process.env.RESEND_API_KEY &&
|
||||
process.env.RESEND_API_KEY !== 're_...' &&
|
||||
process.env.RESEND_API_KEY.startsWith('re_')
|
||||
? process.env.RESEND_API_KEY
|
||||
: null;
|
||||
const resend = resendApiKey ? new resend_1.Resend(resendApiKey) : null;
|
||||
const emailFromAddress = process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
|
||||
? process.env.EMAIL_FROM
|
||||
: null;
|
||||
const emailFromName = process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
|
||||
? process.env.EMAIL_FROM_NAME
|
||||
: null;
|
||||
const smtpHost = process.env.MAIL_HOST;
|
||||
const smtpPort = Number(process.env.MAIL_PORT ?? 0);
|
||||
const smtpUser = process.env.MAIL_USERNAME;
|
||||
const smtpPass = process.env.MAIL_PASSWORD;
|
||||
const smtpSecure = process.env.MAIL_SCHEME === 'smtps' ||
|
||||
process.env.MAIL_ENCRYPTION === 'ssl' ||
|
||||
smtpPort === 465;
|
||||
const smtpFromAddress = process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
|
||||
? process.env.MAIL_FROM_ADDRESS
|
||||
: null;
|
||||
const smtpFromName = process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
|
||||
? process.env.MAIL_FROM_NAME
|
||||
: null;
|
||||
let smtpTransport = null;
|
||||
if (smtpHost && smtpPort && smtpUser && smtpPass) {
|
||||
try {
|
||||
const nodemailer = require('nodemailer');
|
||||
smtpTransport = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err));
|
||||
}
|
||||
}
|
||||
const twilioClient = process.env.TWILIO_ACCOUNT_SID &&
|
||||
process.env.TWILIO_AUTH_TOKEN &&
|
||||
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
|
||||
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
|
||||
? (0, twilio_1.default)(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
|
||||
: null;
|
||||
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||
const hasFirebaseConfig = !!process.env.FIREBASE_PROJECT_ID &&
|
||||
!!process.env.FIREBASE_CLIENT_EMAIL &&
|
||||
!!firebasePrivateKey &&
|
||||
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY');
|
||||
if (hasFirebaseConfig && !firebase_admin_1.default.apps.length) {
|
||||
try {
|
||||
firebase_admin_1.default.initializeApp({
|
||||
credential: firebase_admin_1.default.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
privateKey: firebasePrivateKey,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
}),
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[Notifications] Firebase init skipped:', err.message);
|
||||
}
|
||||
}
|
||||
function resolveSmtpReplyTo() {
|
||||
if (!process.env.MAIL_REPLY_TO_ADDRESS || process.env.MAIL_REPLY_TO_ADDRESS.includes('${')) {
|
||||
return undefined;
|
||||
}
|
||||
if (process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')) {
|
||||
return `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`;
|
||||
}
|
||||
return process.env.MAIL_REPLY_TO_ADDRESS;
|
||||
}
|
||||
async function sendEmailWithProviders(opts) {
|
||||
const errors = [];
|
||||
if (resend) {
|
||||
try {
|
||||
if (!emailFromAddress || !emailFromName) {
|
||||
throw new Error('Email sender identity is not configured');
|
||||
}
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `${emailFromName} <${emailFromAddress}>`,
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
html: opts.html,
|
||||
text: opts.text,
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
return {
|
||||
provider: 'resend',
|
||||
providerMessageId: data?.id ?? null,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
errors.push(`Resend: ${err?.message ?? String(err)}`);
|
||||
console.warn('[Notifications] Resend delivery failed, falling back if possible:', err?.message ?? String(err));
|
||||
}
|
||||
}
|
||||
if (smtpTransport) {
|
||||
try {
|
||||
if (!smtpFromAddress) {
|
||||
throw new Error('SMTP sender identity is not configured');
|
||||
}
|
||||
const info = await smtpTransport.sendMail({
|
||||
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
html: opts.html,
|
||||
text: opts.text,
|
||||
replyTo: resolveSmtpReplyTo(),
|
||||
});
|
||||
return {
|
||||
provider: 'smtp',
|
||||
providerMessageId: info.messageId ?? null,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
errors.push(`SMTP: ${err?.message ?? String(err)}`);
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error(errors.join(' | '));
|
||||
}
|
||||
throw new Error('No email provider is configured');
|
||||
}
|
||||
async function sendNotification(opts) {
|
||||
const results = [];
|
||||
const resolvedLocale = await (0, notificationLocalizationService_1.resolveNotificationLocale)({
|
||||
companyId: opts.companyId,
|
||||
employeeId: opts.employeeId,
|
||||
renterId: opts.renterId,
|
||||
billingAccountId: opts.billingAccountId,
|
||||
locale: opts.locale,
|
||||
});
|
||||
for (const channel of opts.channels) {
|
||||
try {
|
||||
const rendered = opts.templateKey
|
||||
? await (0, notificationLocalizationService_1.resolveNotificationTemplate)({
|
||||
templateKey: opts.templateKey,
|
||||
channel,
|
||||
locale: resolvedLocale,
|
||||
variables: opts.templateVariables,
|
||||
})
|
||||
: null;
|
||||
const title = rendered?.subject ?? opts.title;
|
||||
const body = rendered?.body ?? opts.body;
|
||||
if (!title || !body) {
|
||||
throw new Error(`Notification content is incomplete for channel ${channel}`);
|
||||
}
|
||||
const notification = await prisma_1.prisma.notification.create({
|
||||
data: {
|
||||
type: opts.type,
|
||||
title,
|
||||
body,
|
||||
data: (opts.data ?? {}),
|
||||
channel,
|
||||
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
|
||||
locale: rendered?.locale ?? resolvedLocale,
|
||||
status: 'PENDING',
|
||||
companyId: opts.companyId ?? null,
|
||||
employeeId: opts.employeeId ?? null,
|
||||
renterId: opts.renterId ?? null,
|
||||
},
|
||||
});
|
||||
let providerMessageId = null;
|
||||
let success = false;
|
||||
if (channel === 'EMAIL' && opts.email) {
|
||||
const emailResult = await sendEmailWithProviders({
|
||||
to: opts.email,
|
||||
subject: title,
|
||||
html: (0, notificationLocalizationService_1.renderLocalizedEmailHtml)(body, rendered?.locale ?? resolvedLocale),
|
||||
text: body,
|
||||
});
|
||||
providerMessageId = emailResult.providerMessageId;
|
||||
success = true;
|
||||
}
|
||||
if (channel === 'SMS' && opts.phone) {
|
||||
if (!twilioClient)
|
||||
throw new Error('Twilio is not configured');
|
||||
const msg = await twilioClient.messages.create({
|
||||
body,
|
||||
from: process.env.TWILIO_PHONE_NUMBER,
|
||||
to: opts.phone,
|
||||
});
|
||||
providerMessageId = msg.sid;
|
||||
success = true;
|
||||
}
|
||||
if (channel === 'WHATSAPP' && opts.phone) {
|
||||
if (!twilioClient)
|
||||
throw new Error('Twilio is not configured');
|
||||
const msg = await twilioClient.messages.create({
|
||||
body,
|
||||
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
|
||||
to: `whatsapp:${opts.phone}`,
|
||||
});
|
||||
providerMessageId = msg.sid;
|
||||
success = true;
|
||||
}
|
||||
if (channel === 'PUSH' && opts.fcmToken) {
|
||||
if (!firebase_admin_1.default.apps.length)
|
||||
throw new Error('Firebase is not configured');
|
||||
const response = await firebase_admin_1.default.messaging().send({
|
||||
token: opts.fcmToken,
|
||||
notification: { title, body },
|
||||
data: Object.fromEntries(Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
providerMessageId = response;
|
||||
success = true;
|
||||
}
|
||||
if (channel === 'IN_APP') {
|
||||
// Emit via Socket.io through Redis pub/sub
|
||||
const targetId = opts.employeeId ?? opts.renterId;
|
||||
if (targetId) {
|
||||
await redis_1.redis.publish(`notifications:${targetId}`, JSON.stringify({ ...notification, status: 'DELIVERED' }));
|
||||
}
|
||||
success = true;
|
||||
}
|
||||
await prisma_1.prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
data: {
|
||||
status: success ? 'SENT' : 'FAILED',
|
||||
sentAt: success ? new Date() : null,
|
||||
providerMessageId,
|
||||
},
|
||||
});
|
||||
results.push({ channel, success });
|
||||
}
|
||||
catch (err) {
|
||||
results.push({ channel, success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
async function sendTransactionalEmail(opts) {
|
||||
await sendEmailWithProviders(opts);
|
||||
}
|
||||
//# sourceMappingURL=notificationService.js.map
|
||||
File diff suppressed because one or more lines are too long
+19
@@ -0,0 +1,19 @@
|
||||
export interface PayPalCreateOrderParams {
|
||||
amount: number;
|
||||
currency: string;
|
||||
orderId: string;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
cancelUrl: string;
|
||||
}
|
||||
export interface PayPalOrderResult {
|
||||
orderId: string;
|
||||
approveUrl: string;
|
||||
status: string;
|
||||
}
|
||||
export declare function createOrder(params: PayPalCreateOrderParams): Promise<PayPalOrderResult>;
|
||||
export declare function captureOrder(paypalOrderId: string): Promise<Record<string, unknown>>;
|
||||
export declare function refundCapture(captureId: string, amount: number, currency: string, note?: string): Promise<Record<string, unknown>>;
|
||||
export declare function verifyWebhookEvent(headers: Record<string, string | undefined>, rawBody: string): Promise<boolean>;
|
||||
export declare function isConfigured(): boolean;
|
||||
//# sourceMappingURL=paypalService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"paypalService.d.ts","sourceRoot":"","sources":["../../src/services/paypalService.ts"],"names":[],"mappings":"AA2CA,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACf;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA8B7F;AAED,wBAAsB,YAAY,CAAC,aAAa,EAAE,MAAM,oCAEvD;AAED,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,oCAQrG;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAAE,OAAO,EAAE,MAAM,oBAkBpG;AAED,wBAAgB,YAAY,IAAI,OAAO,CAEtC"}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createOrder = createOrder;
|
||||
exports.captureOrder = captureOrder;
|
||||
exports.refundCapture = refundCapture;
|
||||
exports.verifyWebhookEvent = verifyWebhookEvent;
|
||||
exports.isConfigured = isConfigured;
|
||||
const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com';
|
||||
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? '';
|
||||
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? '';
|
||||
let cachedToken = null;
|
||||
async function getAccessToken() {
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
|
||||
return cachedToken.token;
|
||||
}
|
||||
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error('PayPal: failed to obtain access token');
|
||||
const json = await res.json();
|
||||
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 };
|
||||
return cachedToken.token;
|
||||
}
|
||||
async function ppFetch(path, init) {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(`PayPal ${path} failed: ${err?.message ?? res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async function createOrder(params) {
|
||||
const json = await ppFetch('/v2/checkout/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [
|
||||
{
|
||||
reference_id: params.orderId,
|
||||
description: params.description,
|
||||
amount: {
|
||||
currency_code: params.currency,
|
||||
value: (params.amount / 100).toFixed(2),
|
||||
},
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
return_url: params.returnUrl,
|
||||
cancel_url: params.cancelUrl,
|
||||
brand_name: 'RentalDriveGo',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const approveLink = json.links?.find((l) => l.rel === 'approve');
|
||||
return {
|
||||
orderId: json.id,
|
||||
approveUrl: approveLink?.href ?? '',
|
||||
status: json.status,
|
||||
};
|
||||
}
|
||||
async function captureOrder(paypalOrderId) {
|
||||
return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' });
|
||||
}
|
||||
async function refundCapture(captureId, amount, currency, note) {
|
||||
return ppFetch(`/v2/payments/captures/${captureId}/refund`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
amount: { currency_code: currency, value: (amount / 100).toFixed(2) },
|
||||
note_to_payer: note,
|
||||
}),
|
||||
});
|
||||
}
|
||||
async function verifyWebhookEvent(headers, rawBody) {
|
||||
try {
|
||||
const json = await ppFetch('/v1/notifications/verify-webhook-signature', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
auth_algo: headers['paypal-auth-algo'],
|
||||
cert_url: headers['paypal-cert-url'],
|
||||
transmission_id: headers['paypal-transmission-id'],
|
||||
transmission_sig: headers['paypal-transmission-sig'],
|
||||
transmission_time: headers['paypal-transmission-time'],
|
||||
webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '',
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
return json.verification_status === 'SUCCESS';
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function isConfigured() {
|
||||
return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id');
|
||||
}
|
||||
//# sourceMappingURL=paypalService.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"paypalService.js","sourceRoot":"","sources":["../../src/services/paypalService.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type MarketplaceHomepageConfig } from '@rentaldrivego/types';
|
||||
export declare function getMarketplaceHomepageContent(): Promise<MarketplaceHomepageConfig>;
|
||||
export declare function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig): Promise<MarketplaceHomepageConfig>;
|
||||
//# sourceMappingURL=platformContentService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"platformContentService.d.ts","sourceRoot":"","sources":["../../src/services/platformContentService.ts"],"names":[],"mappings":"AAEA,OAAO,EAAmC,KAAK,yBAAyB,EAAE,MAAM,sBAAsB,CAAA;AA6BtG,wBAAsB,6BAA6B,uCAGlD;AAED,wBAAsB,8BAA8B,CAAC,QAAQ,EAAE,yBAAyB,sCAOvF"}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getMarketplaceHomepageContent = getMarketplaceHomepageContent;
|
||||
exports.saveMarketplaceHomepageContent = saveMarketplaceHomepageContent;
|
||||
const promises_1 = require("fs/promises");
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const types_1 = require("@rentaldrivego/types");
|
||||
function resolveContentPath() {
|
||||
const cwd = process.cwd();
|
||||
const appScoped = path_1.default.resolve(cwd, 'src/data/platform-content.json');
|
||||
const repoScoped = path_1.default.resolve(cwd, 'apps/api/src/data/platform-content.json');
|
||||
return cwd.includes('/apps/api') ? appScoped : repoScoped;
|
||||
}
|
||||
const platformContentPath = resolveContentPath();
|
||||
async function readPlatformContentFile() {
|
||||
try {
|
||||
const raw = await (0, promises_1.readFile)(platformContentPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
async function writePlatformContentFile(content) {
|
||||
await (0, promises_1.mkdir)(path_1.default.dirname(platformContentPath), { recursive: true });
|
||||
await (0, promises_1.writeFile)(platformContentPath, JSON.stringify(content, null, 2));
|
||||
}
|
||||
async function getMarketplaceHomepageContent() {
|
||||
const content = await readPlatformContentFile();
|
||||
return content.marketplaceHomepage ?? (0, types_1.cloneMarketplaceHomepageContent)();
|
||||
}
|
||||
async function saveMarketplaceHomepageContent(homepage) {
|
||||
const content = await readPlatformContentFile();
|
||||
await writePlatformContentFile({
|
||||
...content,
|
||||
marketplaceHomepage: homepage,
|
||||
});
|
||||
return homepage;
|
||||
}
|
||||
//# sourceMappingURL=platformContentService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"platformContentService.js","sourceRoot":"","sources":["../../src/services/platformContentService.ts"],"names":[],"mappings":";;;;;AA+BA,sEAGC;AAED,wEAOC;AA3CD,0CAAwD;AACxD,gDAAuB;AACvB,gDAAsG;AAEtG,SAAS,kBAAkB;IACzB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,CAAA;IACzB,MAAM,SAAS,GAAG,cAAI,CAAC,OAAO,CAAC,GAAG,EAAE,gCAAgC,CAAC,CAAA;IACrE,MAAM,UAAU,GAAG,cAAI,CAAC,OAAO,CAAC,GAAG,EAAE,yCAAyC,CAAC,CAAA;IAC/E,OAAO,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAA;AAC3D,CAAC;AAED,MAAM,mBAAmB,GAAG,kBAAkB,EAAE,CAAA;AAMhD,KAAK,UAAU,uBAAuB;IACpC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,IAAA,mBAAQ,EAAC,mBAAmB,EAAE,MAAM,CAAC,CAAA;QACvD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAwB,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED,KAAK,UAAU,wBAAwB,CAAC,OAA4B;IAClE,MAAM,IAAA,gBAAK,EAAC,cAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACnE,MAAM,IAAA,oBAAS,EAAC,mBAAmB,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAA;AACxE,CAAC;AAEM,KAAK,UAAU,6BAA6B;IACjD,MAAM,OAAO,GAAG,MAAM,uBAAuB,EAAE,CAAA;IAC/C,OAAO,OAAO,CAAC,mBAAmB,IAAI,IAAA,uCAA+B,GAAE,CAAA;AACzE,CAAC;AAEM,KAAK,UAAU,8BAA8B,CAAC,QAAmC;IACtF,MAAM,OAAO,GAAG,MAAM,uBAAuB,EAAE,CAAA;IAC/C,MAAM,wBAAwB,CAAC;QAC7B,GAAG,OAAO;QACV,mBAAmB,EAAE,QAAQ;KAC9B,CAAC,CAAA;IACF,OAAO,QAAQ,CAAA;AACjB,CAAC"}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
interface DriverInfo {
|
||||
dateOfBirth?: Date | null;
|
||||
licenseIssuedAt?: Date | null;
|
||||
}
|
||||
export declare function applyPricingRules(companyId: string, customerId: string, additionalDrivers: DriverInfo[], dailyRate: number, totalDays: number): Promise<{
|
||||
applied: any[];
|
||||
total: number;
|
||||
}>;
|
||||
export {};
|
||||
//# sourceMappingURL=pricingRuleService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pricingRuleService.d.ts","sourceRoot":"","sources":["../../src/services/pricingRuleService.ts"],"names":[],"mappings":"AAEA,UAAU,UAAU;IAClB,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAA;IACzB,eAAe,CAAC,EAAE,IAAI,GAAG,IAAI,CAAA;CAC9B;AAED,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,iBAAiB,EAAE,UAAU,EAAE,EAC/B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAE,OAAO,EAAE,GAAG,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA0C5C"}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.applyPricingRules = applyPricingRules;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
async function applyPricingRules(companyId, customerId, additionalDrivers, dailyRate, totalDays) {
|
||||
const rules = await prisma_1.prisma.pricingRule.findMany({ where: { companyId, isActive: true } });
|
||||
const customer = await prisma_1.prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } });
|
||||
const allDrivers = [customer, ...additionalDrivers];
|
||||
const applied = [];
|
||||
const baseAmount = dailyRate * totalDays;
|
||||
for (const driver of allDrivers) {
|
||||
for (const rule of rules) {
|
||||
const driverAge = driver.dateOfBirth
|
||||
? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
const licenseYears = driver.licenseIssuedAt
|
||||
? Math.floor((Date.now() - driver.licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
let conditionMet = false;
|
||||
if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null)
|
||||
conditionMet = driverAge < rule.conditionValue;
|
||||
else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null)
|
||||
conditionMet = driverAge > rule.conditionValue;
|
||||
else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null)
|
||||
conditionMet = licenseYears < rule.conditionValue;
|
||||
else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null)
|
||||
conditionMet = licenseYears > rule.conditionValue;
|
||||
if (!conditionMet)
|
||||
continue;
|
||||
let amount = 0;
|
||||
if (rule.adjustmentType === 'PERCENTAGE')
|
||||
amount = Math.round(baseAmount * rule.adjustmentValue / 100);
|
||||
else if (rule.adjustmentType === 'FLAT_PER_DAY')
|
||||
amount = rule.adjustmentValue * totalDays;
|
||||
else
|
||||
amount = rule.adjustmentValue;
|
||||
if (rule.type === 'DISCOUNT')
|
||||
amount = -amount;
|
||||
applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount });
|
||||
}
|
||||
}
|
||||
// Deduplicate: each rule applied once even if multiple drivers qualify
|
||||
const deduped = applied.reduce((acc, item) => {
|
||||
if (!acc.find((a) => a.ruleId === item.ruleId))
|
||||
acc.push(item);
|
||||
return acc;
|
||||
}, []);
|
||||
const total = deduped.reduce((s, r) => s + r.amount, 0);
|
||||
return { applied: deduped, total };
|
||||
}
|
||||
//# sourceMappingURL=pricingRuleService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pricingRuleService.js","sourceRoot":"","sources":["../../src/services/pricingRuleService.ts"],"names":[],"mappings":"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"}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { EmployeeRole } from '@rentaldrivego/database';
|
||||
export interface InvitePayload {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: EmployeeRole;
|
||||
}
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
clerkUserId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
role: EmployeeRole;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastActiveAt?: Date | null;
|
||||
invitationStatus?: 'accepted' | 'pending' | 'revoked';
|
||||
}
|
||||
export declare function listEmployees(companyId: string): Promise<TeamMember[]>;
|
||||
export declare function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload): Promise<{
|
||||
employee: any;
|
||||
invitationId: any;
|
||||
invitedBy: string;
|
||||
}>;
|
||||
export declare function updateEmployeeRole(companyId: string, requesterId: string, requesterRole: EmployeeRole, employeeId: string, payload: {
|
||||
role: EmployeeRole;
|
||||
}): Promise<any>;
|
||||
export declare function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string): Promise<any>;
|
||||
export declare function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string): Promise<any>;
|
||||
export declare function removeEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=teamService.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"teamService.d.ts","sourceRoot":"","sources":["../../src/services/teamService.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAOtD,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,YAAY,CAAA;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,EAAE,YAAY,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;IACf,YAAY,CAAC,EAAE,IAAI,GAAG,IAAI,CAAA;IAC1B,gBAAgB,CAAC,EAAE,UAAU,GAAG,SAAS,GAAG,SAAS,CAAA;CACtD;AAkED,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAW5E;AAED,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa;;;;GA6DhG;AAED,wBAAsB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,gBAShK;AAED,wBAAsB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,gBAM1G;AAED,wBAAsB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,gBAK1G;AAED,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM;;GAOtG"}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.listEmployees = listEmployees;
|
||||
exports.inviteEmployee = inviteEmployee;
|
||||
exports.updateEmployeeRole = updateEmployeeRole;
|
||||
exports.deactivateEmployee = deactivateEmployee;
|
||||
exports.reactivateEmployee = reactivateEmployee;
|
||||
exports.removeEmployee = removeEmployee;
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
const notificationService_1 = require("./notificationService");
|
||||
const notificationLocalizationService_1 = require("./notificationLocalizationService");
|
||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7;
|
||||
function ensureDashboardBasePath(baseUrl) {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const pathname = url.pathname.replace(/\/$/, '');
|
||||
if (!pathname.endsWith('/dashboard'))
|
||||
url.pathname = `${pathname}/dashboard`;
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
catch {
|
||||
const trimmed = baseUrl.replace(/\/$/, '');
|
||||
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`;
|
||||
}
|
||||
}
|
||||
function buildInviteEmail(resetUrl, companyName, firstName, role, locale) {
|
||||
const greetingName = firstName.trim() || 'there';
|
||||
if (locale === 'fr') {
|
||||
return {
|
||||
subject: `Vous avez ete invite chez ${companyName}`,
|
||||
html: `
|
||||
<p>Bonjour ${greetingName},</p>
|
||||
<p>Vous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Definir votre mot de passe</a></p>
|
||||
<p>Ce lien d'invitation expire dans 7 jours.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
`,
|
||||
text: `Bonjour ${greetingName},\n\nVous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.\n\nDefinissez votre mot de passe ici : ${resetUrl}\n\nCe lien d'invitation expire dans 7 jours.\n\nRentalDriveGo`,
|
||||
};
|
||||
}
|
||||
if (locale === 'ar') {
|
||||
return {
|
||||
subject: `تمت دعوتك إلى ${companyName}`,
|
||||
html: `
|
||||
<div dir="rtl" style="text-align:right">
|
||||
<p>مرحباً ${greetingName}،</p>
|
||||
<p>تمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">عيّن كلمة المرور</a></p>
|
||||
<p>تنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>
|
||||
`,
|
||||
text: `مرحباً ${greetingName}،\n\nتمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.\n\nعيّن كلمة المرور هنا: ${resetUrl}\n\nتنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.\n\nRentalDriveGo`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
subject: `You have been invited to ${companyName}`,
|
||||
html: `
|
||||
<p>Hi ${greetingName},</p>
|
||||
<p>You were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Set your password</a></p>
|
||||
<p>This invitation link expires in 7 days.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
`,
|
||||
text: `Hi ${greetingName},\n\nYou were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.\n\nSet your password here: ${resetUrl}\n\nThis invitation link expires in 7 days.\n\nRentalDriveGo`,
|
||||
};
|
||||
}
|
||||
async function listEmployees(companyId) {
|
||||
const employees = await prisma_1.prisma.employee.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return employees.map((employee) => ({
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||
}));
|
||||
}
|
||||
async function inviteEmployee(companyId, inviterId, payload) {
|
||||
if (payload.role === 'OWNER') {
|
||||
throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' });
|
||||
}
|
||||
const existing = await prisma_1.prisma.employee.findFirst({ where: { companyId, email: payload.email } });
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' });
|
||||
}
|
||||
const company = await prisma_1.prisma.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
include: { brand: { select: { defaultLocale: true, displayName: true } } },
|
||||
});
|
||||
const rawToken = crypto_1.default.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000);
|
||||
const locale = (0, notificationLocalizationService_1.coerceNotificationLocale)(company.brand?.defaultLocale);
|
||||
const employee = await prisma_1.prisma.employee.create({
|
||||
data: {
|
||||
companyId,
|
||||
clerkUserId: `local_member_${crypto_1.default.randomUUID()}`,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
isActive: true,
|
||||
preferredLanguage: locale,
|
||||
passwordResetToken: rawToken,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
const dashboardUrl = ensureDashboardBasePath(process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard');
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`;
|
||||
const message = buildInviteEmail(resetUrl, company.brand?.displayName ?? company.name, payload.firstName, payload.role, locale);
|
||||
await (0, notificationService_1.sendTransactionalEmail)({
|
||||
to: payload.email,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text,
|
||||
});
|
||||
return {
|
||||
employee: {
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'pending',
|
||||
},
|
||||
invitationId: employee.id,
|
||||
invitedBy: inviterId,
|
||||
};
|
||||
}
|
||||
async function updateEmployeeRole(companyId, requesterId, requesterRole, employeeId, payload) {
|
||||
const target = await prisma_1.prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } });
|
||||
if (requesterRole !== 'OWNER')
|
||||
throw Object.assign(new Error('Only the account owner can change team member roles'), { statusCode: 403, code: 'forbidden' });
|
||||
if (target.role === 'OWNER')
|
||||
throw Object.assign(new Error('Cannot change the role of the account owner'), { statusCode: 400 });
|
||||
if (payload.role === 'OWNER')
|
||||
throw Object.assign(new Error('Cannot assign the OWNER role via this endpoint'), { statusCode: 400 });
|
||||
if (target.id === requesterId)
|
||||
throw Object.assign(new Error('You cannot change your own role'), { statusCode: 400 });
|
||||
return prisma_1.prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } });
|
||||
}
|
||||
async function deactivateEmployee(companyId, requesterRole, employeeId) {
|
||||
if (requesterRole !== 'OWNER')
|
||||
throw Object.assign(new Error('Only the account owner can deactivate team members'), { statusCode: 403 });
|
||||
const target = await prisma_1.prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } });
|
||||
if (target.role === 'OWNER')
|
||||
throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 });
|
||||
return prisma_1.prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } });
|
||||
}
|
||||
async function reactivateEmployee(companyId, requesterRole, employeeId) {
|
||||
if (requesterRole !== 'OWNER')
|
||||
throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 });
|
||||
await prisma_1.prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } });
|
||||
return prisma_1.prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } });
|
||||
}
|
||||
async function removeEmployee(companyId, requesterRole, employeeId) {
|
||||
if (requesterRole !== 'OWNER')
|
||||
throw Object.assign(new Error('Only the account owner can remove team members'), { statusCode: 403 });
|
||||
const target = await prisma_1.prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } });
|
||||
if (target.role === 'OWNER')
|
||||
throw Object.assign(new Error('Cannot remove the account owner'), { statusCode: 400 });
|
||||
await prisma_1.prisma.employee.delete({ where: { id: employeeId } });
|
||||
return { success: true };
|
||||
}
|
||||
//# sourceMappingURL=teamService.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE';
|
||||
type DateRange = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
export interface VehicleAvailabilitySummary {
|
||||
available: boolean;
|
||||
status: AvailabilityStatus;
|
||||
nextAvailableAt: Date | null;
|
||||
blockingReason: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' | 'STATUS' | null;
|
||||
}
|
||||
export declare function getVehicleAvailabilitySummary(vehicleId: string, { range, includeDraftReservations, companyId, }?: {
|
||||
range?: DateRange;
|
||||
includeDraftReservations?: boolean;
|
||||
companyId?: string;
|
||||
}): Promise<VehicleAvailabilitySummary>;
|
||||
export {};
|
||||
//# sourceMappingURL=vehicleAvailabilityService.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"vehicleAvailabilityService.d.ts","sourceRoot":"","sources":["../../src/services/vehicleAvailabilityService.ts"],"names":[],"mappings":"AAIA,KAAK,kBAAkB,GAAG,WAAW,GAAG,UAAU,GAAG,aAAa,GAAG,aAAa,CAAA;AAElF,KAAK,SAAS,GAAG;IACf,SAAS,EAAE,IAAI,CAAA;IACf,OAAO,EAAE,IAAI,CAAA;CACd,CAAA;AAQD,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,EAAE,kBAAkB,CAAA;IAC1B,eAAe,EAAE,IAAI,GAAG,IAAI,CAAA;IAC5B,cAAc,EAAE,aAAa,GAAG,aAAa,GAAG,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAA;CAC1E;AAiCD,wBAAsB,6BAA6B,CACjD,SAAS,EAAE,MAAM,EACjB,EACE,KAAK,EACL,wBAA+B,EAC/B,SAAS,GACV,GAAE;IACD,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,wBAAwB,CAAC,EAAE,OAAO,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;CACd,GACL,OAAO,CAAC,0BAA0B,CAAC,CAqFrC"}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getVehicleAvailabilitySummary = getVehicleAvailabilitySummary;
|
||||
const prisma_1 = require("../lib/prisma");
|
||||
const BLOCKING_RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE'];
|
||||
function startOfTodayUtc() {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
}
|
||||
function overlaps(range, source) {
|
||||
return source.startDate < range.endDate && source.endDate > range.startDate;
|
||||
}
|
||||
function getNextAvailableAt(anchor, sources) {
|
||||
const sorted = sources
|
||||
.filter((source) => source.endDate > anchor)
|
||||
.sort((a, b) => {
|
||||
const startDelta = a.startDate.getTime() - b.startDate.getTime();
|
||||
if (startDelta !== 0)
|
||||
return startDelta;
|
||||
return a.endDate.getTime() - b.endDate.getTime();
|
||||
});
|
||||
let candidate = anchor;
|
||||
let advanced = false;
|
||||
for (const source of sorted) {
|
||||
if (source.endDate <= candidate)
|
||||
continue;
|
||||
if (source.startDate > candidate)
|
||||
break;
|
||||
candidate = new Date(Math.max(candidate.getTime(), source.endDate.getTime()));
|
||||
advanced = true;
|
||||
}
|
||||
return advanced ? candidate : null;
|
||||
}
|
||||
async function getVehicleAvailabilitySummary(vehicleId, { range, includeDraftReservations = true, companyId, } = {}) {
|
||||
const vehicle = await prisma_1.prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, ...(companyId ? { companyId } : {}) },
|
||||
select: { status: true },
|
||||
});
|
||||
if (vehicle.status === 'OUT_OF_SERVICE') {
|
||||
return {
|
||||
available: false,
|
||||
status: 'UNAVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: 'STATUS',
|
||||
};
|
||||
}
|
||||
const anchor = range?.startDate ?? startOfTodayUtc();
|
||||
const reservationStatuses = includeDraftReservations
|
||||
? [...BLOCKING_RESERVATION_STATUSES]
|
||||
: BLOCKING_RESERVATION_STATUSES.filter((status) => status !== 'DRAFT');
|
||||
const [reservations, blocks] = await Promise.all([
|
||||
prisma_1.prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId,
|
||||
status: { in: reservationStatuses },
|
||||
endDate: { gt: anchor },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
prisma_1.prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId,
|
||||
endDate: { gt: anchor },
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
]);
|
||||
const sources = [
|
||||
...reservations.map((reservation) => ({
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
kind: 'RESERVATION',
|
||||
})),
|
||||
...blocks.map((block) => ({
|
||||
startDate: block.startDate,
|
||||
endDate: block.endDate,
|
||||
kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' : 'BLOCK',
|
||||
})),
|
||||
];
|
||||
const overlappingSources = range ? sources.filter((source) => overlaps(range, source)) : sources.filter((source) => source.startDate <= anchor && source.endDate > anchor);
|
||||
const currentSource = overlappingSources.sort((a, b) => {
|
||||
const priority = { MAINTENANCE: 0, BLOCK: 1, RESERVATION: 2 };
|
||||
return priority[a.kind] - priority[b.kind];
|
||||
})[0];
|
||||
if (vehicle.status === 'MAINTENANCE') {
|
||||
return {
|
||||
available: false,
|
||||
status: 'MAINTENANCE',
|
||||
nextAvailableAt: getNextAvailableAt(anchor, sources) ?? null,
|
||||
blockingReason: currentSource?.kind ?? 'STATUS',
|
||||
};
|
||||
}
|
||||
if (!currentSource) {
|
||||
return {
|
||||
available: true,
|
||||
status: 'AVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: null,
|
||||
};
|
||||
}
|
||||
const nextAvailableAnchor = currentSource.startDate > anchor ? currentSource.startDate : anchor;
|
||||
return {
|
||||
available: false,
|
||||
status: currentSource.kind === 'RESERVATION' ? 'RESERVED' : currentSource.kind === 'MAINTENANCE' ? 'MAINTENANCE' : 'UNAVAILABLE',
|
||||
nextAvailableAt: getNextAvailableAt(nextAvailableAnchor, sources),
|
||||
blockingReason: currentSource.kind,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=vehicleAvailabilityService.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"vehicleAvailabilityService.js","sourceRoot":"","sources":["../../src/services/vehicleAvailabilityService.ts"],"names":[],"mappings":"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"}
|
||||
Reference in New Issue
Block a user