Files
CRM_Montzar/apps/api/src/loyalty/loyalty.service.ts
T
Mahdi f5d68d83fc feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
2026-10-19 15:55:48 +03:30

858 lines
25 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateLoyaltyRewardRequest,
CreateLoyaltyTierRequest,
LoyaltyLedgerDto,
LoyaltyMemberDto,
LoyaltyMessageResult,
LoyaltyRewardDto,
LoyaltySettingsDto,
LoyaltyStatsDto,
LoyaltyTierDto,
UpdateLoyaltyRewardRequest,
UpdateLoyaltySettingsRequest,
UpdateLoyaltyTierRequest,
} from '@mento/shared';
import ExcelJS from 'exceljs';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { type Actor, ownerWhere } from '../common/record-scope';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
{ name: 'نقره‌ای', minPoints: 500, color: '#8b95a5' },
{ name: 'طلایی', minPoints: 2000, color: '#d4a017' },
] as const;
const DEFAULT_REWARDS = [
{
name: 'تخفیف ویژه',
description: 'اعمال روی فاکتور بعدی',
pointsCost: 500,
color: '#14b8a6',
},
{
name: 'مشاوره رایگان',
description: 'یک جلسه مشاوره اختصاصی',
pointsCost: 1000,
color: '#8b5cf6',
},
{
name: 'هدیه باشگاه',
description: 'هدیه منتخب باشگاه مشتریان',
pointsCost: 2000,
color: '#eab308',
},
] as const;
const DAY_MS = 86_400_000;
@Injectable()
export class LoyaltyService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsGatewayService,
) {}
async ensureDefaults(organizationId: string): Promise<void> {
await Promise.all([
this.ensureDefaultTiers(organizationId),
this.ensureDefaultRewards(organizationId),
this.ensureSettings(organizationId),
]);
const unassigned = await this.prisma.person.count({
where: { organizationId, loyaltyTierId: null },
});
if (unassigned > 0) await this.recomputeAllTiers(organizationId);
}
async ensureDefaultTiers(organizationId: string): Promise<void> {
const count = await this.prisma.loyaltyTier.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.loyaltyTier.createMany({
data: DEFAULT_TIERS.map((t, position) => ({
organizationId,
name: t.name,
minPoints: t.minPoints,
color: t.color,
position,
})),
});
}
async ensureDefaultRewards(organizationId: string): Promise<void> {
const count = await this.prisma.loyaltyReward.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.loyaltyReward.createMany({
data: DEFAULT_REWARDS.map((r, position) => ({
organizationId,
name: r.name,
description: r.description,
pointsCost: r.pointsCost,
color: r.color,
position,
})),
});
}
async ensureSettings(organizationId: string) {
const existing = await this.prisma.loyaltySettings.findUnique({
where: { organizationId },
});
if (existing) return existing;
return this.prisma.loyaltySettings.create({
data: { organizationId },
});
}
async getSettings(organizationId: string): Promise<LoyaltySettingsDto> {
const row = await this.ensureSettings(organizationId);
return {
enabled: row.enabled,
welcomePoints: row.welcomePoints,
notifyOnAdjust: row.notifyOnAdjust,
};
}
async updateSettings(
organizationId: string,
dto: UpdateLoyaltySettingsRequest,
): Promise<LoyaltySettingsDto> {
await this.ensureSettings(organizationId);
const row = await this.prisma.loyaltySettings.update({
where: { organizationId },
data: {
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
...(dto.welcomePoints !== undefined
? { welcomePoints: Math.max(0, Math.floor(dto.welcomePoints)) }
: {}),
...(dto.notifyOnAdjust !== undefined
? { notifyOnAdjust: dto.notifyOnAdjust }
: {}),
},
});
return {
enabled: row.enabled,
welcomePoints: row.welcomePoints,
notifyOnAdjust: row.notifyOnAdjust,
};
}
async getStats(actor: Actor): Promise<LoyaltyStatsDto> {
await this.ensureDefaults(actor.organizationId);
const scope = {
organizationId: actor.organizationId,
...ownerWhere(actor),
};
const now = new Date();
const monthStart = startOfMonth(now);
const prevMonthStart = startOfMonth(addMonths(monthStart, -1));
const prevMonthEnd = monthStart;
const retentionFrom = new Date(now.getTime() - 90 * DAY_MS);
const prevRetentionEnd = monthStart;
const prevRetentionFrom = new Date(prevRetentionEnd.getTime() - 90 * DAY_MS);
const [
memberCount,
membersAtMonthStart,
pointsThis,
pointsPrev,
rewardsThis,
rewardsPrev,
people,
] = await Promise.all([
this.prisma.person.count({ where: scope }),
this.prisma.person.count({
where: { ...scope, createdAt: { lt: monthStart } },
}),
this.prisma.loyaltyLedger.aggregate({
where: {
organizationId: actor.organizationId,
delta: { gt: 0 },
createdAt: { gte: monthStart },
},
_sum: { delta: true },
}),
this.prisma.loyaltyLedger.aggregate({
where: {
organizationId: actor.organizationId,
delta: { gt: 0 },
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
},
_sum: { delta: true },
}),
this.prisma.loyaltyRedemption.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: monthStart },
},
}),
this.prisma.loyaltyRedemption.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
},
}),
this.prisma.person.findMany({
where: scope,
select: { id: true, updatedAt: true },
}),
]);
const lastMap = await this.lastActivityMap(
actor.organizationId,
people.map((p) => p.id),
);
const activeNow = people.filter((p) => {
const at = lastMap.get(p.id) ?? p.updatedAt;
return at >= retentionFrom;
}).length;
const activePrev = people.filter((p) => {
const at = lastMap.get(p.id) ?? p.updatedAt;
return at >= prevRetentionFrom && at < prevRetentionEnd;
}).length;
const retentionRate = memberCount
? Math.round((activeNow / memberCount) * 100)
: 0;
const prevRetention = membersAtMonthStart
? Math.round((activePrev / membersAtMonthStart) * 100)
: 0;
const pointsIssued = pointsThis._sum.delta ?? 0;
const pointsPrevVal = pointsPrev._sum.delta ?? 0;
return {
memberCount,
memberDeltaPct: pctChange(memberCount, membersAtMonthStart),
pointsIssued,
pointsIssuedDeltaPct: pctChange(pointsIssued, pointsPrevVal),
rewardsUsed: rewardsThis,
rewardsUsedDeltaPct: pctChange(rewardsThis, rewardsPrev),
retentionRate,
retentionDeltaPct: pctChange(retentionRate, prevRetention),
};
}
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: [{ minPoints: 'desc' }, { position: 'asc' }],
include: { _count: { select: { people: true } } },
});
return rows.map((r) => ({
id: r.id,
name: r.name,
minPoints: r.minPoints,
position: r.position,
color: r.color,
memberCount: r._count.people,
}));
}
async createTier(
organizationId: string,
dto: CreateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
await this.ensureDefaults(organizationId);
const maxPos = await this.prisma.loyaltyTier.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.loyaltyTier.create({
data: {
organizationId,
name: dto.name.trim(),
minPoints: Math.max(0, Math.floor(dto.minPoints)),
color: emptyToNull(dto.color),
position: (maxPos._max.position ?? -1) + 1,
},
});
await this.recomputeAllTiers(organizationId);
return {
id: row.id,
name: row.name,
minPoints: row.minPoints,
position: row.position,
color: row.color,
memberCount: 0,
};
}
async updateTier(
organizationId: string,
id: string,
dto: UpdateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
const existing = await this.prisma.loyaltyTier.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('سطح پیدا نشد');
const row = await this.prisma.loyaltyTier.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.minPoints !== undefined
? { minPoints: Math.max(0, Math.floor(dto.minPoints)) }
: {}),
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
},
include: { _count: { select: { people: true } } },
});
await this.recomputeAllTiers(organizationId);
return {
id: row.id,
name: row.name,
minPoints: row.minPoints,
position: row.position,
color: row.color,
memberCount: row._count.people,
};
}
async deleteTier(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.loyaltyTier.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('سطح پیدا نشد');
await this.prisma.person.updateMany({
where: { organizationId, loyaltyTierId: id },
data: { loyaltyTierId: null },
});
await this.prisma.loyaltyTier.delete({ where: { id } });
await this.recomputeAllTiers(organizationId);
}
async listMembers(
actor: Actor,
q?: string,
tierId?: string,
): Promise<LoyaltyMemberDto[]> {
await this.ensureDefaults(actor.organizationId);
const rows = await this.prisma.person.findMany({
where: this.memberWhere(actor, q, tierId),
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
take: 400,
});
const lastMap = await this.lastActivityMap(
actor.organizationId,
rows.map((r) => r.id),
);
return rows.map((r) => this.toMemberDto(r, lastMap.get(r.id) ?? r.updatedAt));
}
async exportMembers(
actor: Actor,
q?: string,
tierId?: string,
): Promise<Buffer> {
const members = await this.listMembers(actor, q, tierId);
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet('اعضای باشگاه');
sheet.columns = [
{ header: 'نام', key: 'fullName', width: 24 },
{ header: 'شرکت', key: 'company', width: 24 },
{ header: 'موبایل', key: 'mobile', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'سطح', key: 'tier', width: 14 },
{ header: 'امتیاز', key: 'points', width: 12 },
{ header: 'آخرین فعالیت', key: 'lastActivity', width: 22 },
];
for (const m of members) {
sheet.addRow({
fullName: m.fullName,
company: m.company?.name ?? '',
mobile: m.mobile ?? '',
email: m.email ?? '',
tier: m.loyaltyTier?.name ?? '',
points: m.loyaltyPoints,
lastActivity: m.lastActivityAt ?? '',
});
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
async listLedger(
actor: Actor,
personId: string,
): Promise<LoyaltyLedgerDto[]> {
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
select: { id: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const rows = await this.prisma.loyaltyLedger.findMany({
where: { personId, organizationId: actor.organizationId },
orderBy: { createdAt: 'desc' },
take: 80,
});
return rows.map((r) => ({
id: r.id,
delta: r.delta,
reason: r.reason,
source: r.source,
createdAt: r.createdAt.toISOString(),
}));
}
async adjustPoints(
actor: Actor,
personId: string,
delta: number,
reason?: string,
source = 'manual',
): Promise<LoyaltyMemberDto> {
if (!Number.isFinite(delta) || delta === 0) {
throw new BadRequestException('مقدار امتیاز نامعتبر است');
}
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const updated = await this.applyDelta(
actor.organizationId,
personId,
Math.trunc(delta),
reason,
source,
);
const settings = await this.ensureSettings(actor.organizationId);
if (settings.notifyOnAdjust && updated.mobile) {
const sign = delta > 0 ? '+' : '';
void this.sms.send({
organizationId: actor.organizationId,
phone: updated.mobile,
text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
source: 'loyalty',
});
}
return updated;
}
async bulkAdjust(
actor: Actor,
personIds: string[],
delta: number,
reason?: string,
): Promise<{ updated: number }> {
const unique = [...new Set(personIds.filter(Boolean))];
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
if (!Number.isFinite(delta) || delta === 0) {
throw new BadRequestException('مقدار امتیاز نامعتبر است');
}
let updated = 0;
for (const id of unique) {
try {
await this.adjustPoints(actor, id, delta, reason, 'bulk');
updated += 1;
} catch {
/* skip inaccessible */
}
}
return { updated };
}
async sendMessage(
actor: Actor,
personIds: string[],
text: string,
): Promise<LoyaltyMessageResult> {
const unique = [...new Set(personIds.filter(Boolean))];
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
const body = text.trim();
if (body.length < 2) throw new BadRequestException('متن پیام کوتاه است');
const people = await this.prisma.person.findMany({
where: {
id: { in: unique },
organizationId: actor.organizationId,
...ownerWhere(actor),
},
select: { id: true, mobile: true },
});
let sent = 0;
let skipped = 0;
let failed = 0;
for (const p of people) {
if (!p.mobile) {
skipped += 1;
continue;
}
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: p.mobile,
text: body,
source: 'loyalty',
});
if (result.ok) sent += 1;
else failed += 1;
}
skipped += Math.max(0, unique.length - people.length);
return { sent, skipped, failed };
}
/** برای اتوماسیون — بدون ACL مالکیت */
async addPointsByOrg(
organizationId: string,
personId: string,
points: number,
reason = 'اتوماسیون',
): Promise<void> {
if (!personId || !points) return;
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) return;
await this.applyDelta(organizationId, personId, Math.trunc(points), reason, 'automation');
}
async applyWelcomePoints(organizationId: string, personId: string) {
const settings = await this.ensureSettings(organizationId);
if (!settings.enabled || settings.welcomePoints <= 0) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { loyaltyPoints: true },
});
if (!person) return;
const tierId = await this.resolveTierId(organizationId, person.loyaltyPoints);
await this.prisma.person.update({
where: { id: personId },
data: { loyaltyTierId: tierId },
});
return;
}
await this.applyDelta(
organizationId,
personId,
settings.welcomePoints,
'امتیاز خوش‌آمدگویی',
'welcome',
);
}
async resolveTierId(
organizationId: string,
points: number,
): Promise<string | null> {
await this.ensureDefaultTiers(organizationId);
const tier = await this.prisma.loyaltyTier.findFirst({
where: { organizationId, minPoints: { lte: points } },
orderBy: { minPoints: 'desc' },
});
return tier?.id ?? null;
}
async listRewards(organizationId: string): Promise<LoyaltyRewardDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.loyaltyReward.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { pointsCost: 'asc' }],
include: { _count: { select: { redemptions: true } } },
});
return rows.map((r) => this.toRewardDto(r));
}
async createReward(
organizationId: string,
dto: CreateLoyaltyRewardRequest,
): Promise<LoyaltyRewardDto> {
await this.ensureDefaults(organizationId);
const maxPos = await this.prisma.loyaltyReward.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.loyaltyReward.create({
data: {
organizationId,
name: dto.name.trim(),
description: emptyToNull(dto.description),
pointsCost: Math.max(1, Math.floor(dto.pointsCost)),
isActive: dto.isActive ?? true,
color: emptyToNull(dto.color),
position: (maxPos._max.position ?? -1) + 1,
},
include: { _count: { select: { redemptions: true } } },
});
return this.toRewardDto(row);
}
async updateReward(
organizationId: string,
id: string,
dto: UpdateLoyaltyRewardRequest,
): Promise<LoyaltyRewardDto> {
const existing = await this.prisma.loyaltyReward.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
const row = await this.prisma.loyaltyReward.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...('description' in dto
? { description: emptyToNull(dto.description ?? null) }
: {}),
...(dto.pointsCost !== undefined
? { pointsCost: Math.max(1, Math.floor(dto.pointsCost)) }
: {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
},
include: { _count: { select: { redemptions: true } } },
});
return this.toRewardDto(row);
}
async deleteReward(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.loyaltyReward.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
const used = await this.prisma.loyaltyRedemption.count({
where: { rewardId: id },
});
if (used > 0) {
await this.prisma.loyaltyReward.update({
where: { id },
data: { isActive: false },
});
return;
}
await this.prisma.loyaltyReward.delete({ where: { id } });
}
async redeemReward(
actor: Actor,
rewardId: string,
personId: string,
): Promise<LoyaltyMemberDto> {
const reward = await this.prisma.loyaltyReward.findFirst({
where: { id: rewardId, organizationId: actor.organizationId },
});
if (!reward || !reward.isActive) {
throw new NotFoundException('پاداش پیدا نشد');
}
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
if (person.loyaltyPoints < reward.pointsCost) {
throw new BadRequestException('امتیاز عضو برای این پاداش کافی نیست');
}
await this.prisma.loyaltyRedemption.create({
data: {
organizationId: actor.organizationId,
personId,
rewardId,
pointsSpent: reward.pointsCost,
},
});
return this.applyDelta(
actor.organizationId,
personId,
-reward.pointsCost,
`پاداش: ${reward.name}`,
'reward',
);
}
private async applyDelta(
organizationId: string,
personId: string,
delta: number,
reason: string | undefined,
source: string,
): Promise<LoyaltyMemberDto> {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const next = Math.max(0, person.loyaltyPoints + delta);
const tierId = await this.resolveTierId(organizationId, next);
const [updated] = await this.prisma.$transaction([
this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
}),
this.prisma.loyaltyLedger.create({
data: {
organizationId,
personId,
delta,
reason: emptyToNull(reason),
source,
},
}),
]);
return this.toMemberDto(updated, updated.updatedAt);
}
private memberWhere(
actor: Actor,
q?: string,
tierId?: string,
): Prisma.PersonWhereInput {
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
{ firstName: ci(t) },
{ lastName: ci(t) },
{ mobile: { contains: t } },
{ phone: { contains: t } },
{ email: ci(t) },
{ sourceRef: ci(t) },
{ company: { name: ci(t) } },
]);
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(tierId ? { loyaltyTierId: tierId } : {}),
...(search ? { AND: [search] } : {}),
};
}
private async lastActivityMap(organizationId: string, personIds: string[]) {
const map = new Map<string, Date>();
if (!personIds.length) return map;
const rows = await this.prisma.activity.groupBy({
by: ['personId'],
where: {
organizationId,
personId: { in: personIds },
},
_max: { updatedAt: true, completedAt: true, createdAt: true },
});
for (const r of rows) {
if (!r.personId) continue;
const dates = [r._max.updatedAt, r._max.completedAt, r._max.createdAt].filter(
(d): d is Date => !!d,
);
if (!dates.length) continue;
map.set(r.personId, new Date(Math.max(...dates.map((d) => d.getTime()))));
}
return map;
}
private async recomputeAllTiers(organizationId: string) {
const people = await this.prisma.person.findMany({
where: { organizationId },
select: { id: true, loyaltyPoints: true },
});
const tiers = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: { minPoints: 'desc' },
});
for (const p of people) {
const tier = tiers.find((t) => t.minPoints <= p.loyaltyPoints) ?? null;
await this.prisma.person.update({
where: { id: p.id },
data: { loyaltyTierId: tier?.id ?? null },
});
}
}
private toMemberDto(
r: {
id: string;
firstName: string;
lastName: string;
mobile: string | null;
email: string | null;
loyaltyPoints: number;
loyaltyTier?: LoyaltyMemberDto['loyaltyTier'];
company?: LoyaltyMemberDto['company'];
},
lastActivity: Date,
): LoyaltyMemberDto {
return {
id: r.id,
fullName: `${r.firstName} ${r.lastName}`.trim(),
mobile: r.mobile,
email: r.email,
loyaltyPoints: r.loyaltyPoints,
loyaltyTier: r.loyaltyTier ?? null,
company: r.company ?? null,
lastActivityAt: lastActivity.toISOString(),
};
}
private toRewardDto(r: {
id: string;
name: string;
description: string | null;
pointsCost: number;
isActive: boolean;
position: number;
color: string | null;
_count: { redemptions: number };
}): LoyaltyRewardDto {
return {
id: r.id,
name: r.name,
description: r.description,
pointsCost: r.pointsCost,
isActive: r.isActive,
position: r.position,
color: r.color,
redemptionCount: r._count.redemptions,
};
}
}
function emptyToNull(v?: string | null) {
if (v == null) return null;
const t = v.trim();
return t ? t : null;
}
function startOfMonth(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
function addMonths(d: Date, n: number) {
return new Date(d.getFullYear(), d.getMonth() + n, 1, 0, 0, 0, 0);
}
function pctChange(current: number, previous: number): number | null {
if (previous <= 0 && current <= 0) return 0;
if (previous <= 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100);
}