Add client referral bonus links

This commit is contained in:
Ruslan Bakiev
2026-04-04 14:59:02 +07:00
parent 1bec782edd
commit 6c5b9ef98e
4 changed files with 280 additions and 22 deletions

View File

@@ -0,0 +1,34 @@
ALTER TABLE "ReferralLink"
ADD COLUMN "createdById" TEXT;
ALTER TABLE "ReferralLink"
ADD COLUMN "bonusPercent" DECIMAL(5,2);
UPDATE "ReferralLink"
SET
"createdById" = "referrerId",
"bonusPercent" = 0;
ALTER TABLE "ReferralLink"
ALTER COLUMN "createdById" SET NOT NULL;
ALTER TABLE "ReferralLink"
ALTER COLUMN "bonusPercent" SET NOT NULL;
ALTER TABLE "ReferralLink"
ADD CONSTRAINT "ReferralLink_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "ReferralLink_referrerId_idx" ON "ReferralLink"("referrerId");
CREATE INDEX "ReferralLink_refereeId_idx" ON "ReferralLink"("refereeId");
ALTER TABLE "BonusTransaction"
ADD COLUMN "referralLinkId" TEXT;
ALTER TABLE "BonusTransaction"
ADD CONSTRAINT "BonusTransaction_referralLinkId_fkey"
FOREIGN KEY ("referralLinkId") REFERENCES "ReferralLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE UNIQUE INDEX "BonusTransaction_orderId_referralLinkId_key"
ON "BonusTransaction"("orderId", "referralLinkId");

View File

@@ -77,6 +77,7 @@ model User {
orderStatusEvents OrderStatusEvent[]
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
referralAsReferee ReferralLink[] @relation("ReferralReferee")
createdReferralLinks ReferralLink[] @relation("ReferralCreator")
bonusTransactions BonusTransaction[]
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
@@ -289,24 +290,34 @@ model OrderStatusEvent {
}
model ReferralLink {
id String @id @default(cuid())
referrerId String
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
refereeId String
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
createdAt DateTime @default(now())
id String @id @default(cuid())
referrerId String
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
refereeId String
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
createdById String
createdBy User @relation("ReferralCreator", fields: [createdById], references: [id])
bonusPercent Decimal @db.Decimal(5, 2)
bonusTransactions BonusTransaction[]
createdAt DateTime @default(now())
@@unique([referrerId, refereeId])
@@index([referrerId])
@@index([refereeId])
}
model BonusTransaction {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(14, 2)
reason String
orderId String?
createdAt DateTime @default(now())
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(14, 2)
reason String
orderId String?
referralLinkId String?
referralLink ReferralLink? @relation(fields: [referralLinkId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@unique([orderId, referralLinkId])
}
model RewardWithdrawalRequest {

View File

@@ -72,6 +72,79 @@ async function appendOrderEvent(prisma, orderId, status, actorUserId, note = nul
});
}
function formatPercent(value) {
return Number(value).toFixed(2).replace(/\.?0+$/, '');
}
async function createReferralBonusTransaction(prisma, order) {
const referralLink = await prisma.referralLink.findFirst({
where: {
refereeId: order.customerId,
},
include: {
referrer: {
select: {
fullName: true,
},
},
referee: {
select: {
fullName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
if (!referralLink) {
return null;
}
const totalPrice = Number(order.totalPrice ?? 0);
const bonusPercent = Number(referralLink.bonusPercent ?? 0);
if (!Number.isFinite(totalPrice) || totalPrice <= 0 || !Number.isFinite(bonusPercent) || bonusPercent <= 0) {
return null;
}
const amount = roundMoney((totalPrice * bonusPercent) / 100);
if (amount <= 0) {
return null;
}
const existingTransaction = await prisma.bonusTransaction.findFirst({
where: {
orderId: order.id,
referralLinkId: referralLink.id,
},
});
if (existingTransaction) {
return {
transaction: existingTransaction,
referralLink,
isNew: false,
};
}
const transaction = await prisma.bonusTransaction.create({
data: {
userId: referralLink.referrerId,
amount,
reason: `Бонус ${formatPercent(bonusPercent)}% за заказ ${order.code} клиента ${referralLink.referee.fullName}`,
orderId: order.id,
referralLinkId: referralLink.id,
},
});
return {
transaction,
referralLink,
isNew: true,
};
}
function orderCode() {
return `FR-${Date.now()}-${crypto.randomInt(1000, 9999)}`;
}
@@ -628,6 +701,49 @@ export const resolvers = {
});
},
managerReferralLinks: async (_, __, context) => {
requireManagerAccess(context);
const links = await context.prisma.referralLink.findMany({
include: {
referrer: {
include: {
counterpartyProfile: {
select: {
companyName: true,
},
},
},
},
referee: {
include: {
counterpartyProfile: {
select: {
companyName: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return links.map((link) => ({
id: link.id,
referrerId: link.referrerId,
referrerName: link.referrer.fullName,
referrerEmail: link.referrer.email,
referrerCompanyName: link.referrer.counterpartyProfile?.companyName ?? null,
refereeId: link.refereeId,
refereeName: link.referee.fullName,
refereeEmail: link.referee.email,
refereeCompanyName: link.referee.counterpartyProfile?.companyName ?? null,
createdById: link.createdById,
bonusPercent: Number(link.bonusPercent),
createdAt: link.createdAt,
}));
},
managerBonusBalances: async (_, __, context) => {
const manager = requireManagerAccess(context);
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
@@ -1624,29 +1740,102 @@ export const resolvers = {
throw new Error('Only in-progress order can be completed.');
}
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
status: 'COMPLETED',
},
const { updated, referralBonus } = await context.prisma.$transaction(async (tx) => {
const updatedOrder = await tx.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
status: 'COMPLETED',
},
});
const createdReferralBonus = await createReferralBonusTransaction(tx, updatedOrder);
return {
updated: updatedOrder,
referralBonus: createdReferralBonus,
};
});
await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed');
await notifyOrderStakeholders(context, updated, 'COMPLETED', 'Order completed');
if (referralBonus?.isNew) {
await dispatchToUserConnections(
context.prisma,
referralBonus.transaction.userId,
`Начислен бонус: ${toFloat(referralBonus.transaction.amount)} за заказ ${updated.code}.`,
);
}
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
createReferral: (_, { input }, context) => {
createReferral: async (_, { input }, context) => {
const manager = requireManagerAccess(context);
const referrerUserId = normalizeText(input.referrerUserId);
const refereeUserId = normalizeText(input.refereeUserId);
const bonusPercent = roundMoney(input.bonusPercent);
if (!referrerUserId || !refereeUserId) {
throw new Error('Both linked clients are required.');
}
if (referrerUserId === refereeUserId) {
throw new Error('A client cannot be linked to themselves.');
}
if (!Number.isFinite(bonusPercent) || bonusPercent <= 0 || bonusPercent > 100) {
throw new Error('Bonus percent must be greater than 0 and no more than 100.');
}
await Promise.all([
assertManagerCanAccessUser(context.prisma, manager, referrerUserId),
assertManagerCanAccessUser(context.prisma, manager, refereeUserId),
]);
const users = await context.prisma.user.findMany({
where: {
id: { in: [referrerUserId, refereeUserId] },
},
select: {
id: true,
role: true,
},
});
if (users.length !== 2) {
throw new Error('Both clients must exist.');
}
if (users.some((user) => user.role !== 'CLIENT')) {
throw new Error('Referral links can only be created between client accounts.');
}
const existingRefereeLink = await context.prisma.referralLink.findFirst({
where: {
refereeId: refereeUserId,
},
});
if (existingRefereeLink) {
if (existingRefereeLink.referrerId === referrerUserId) {
throw new Error('These clients are already linked.');
}
throw new Error('The selected client is already linked to another bonus program participant.');
}
return context.prisma.referralLink.create({
data: {
referrerId: manager.id,
refereeId: input.refereeUserId,
referrerId: referrerUserId,
refereeId: refereeUserId,
createdById: manager.id,
bonusPercent,
},
});
},
@@ -1744,6 +1933,10 @@ export const resolvers = {
amount: (tx) => toFloat(tx.amount),
},
ReferralLink: {
bonusPercent: (link) => toFloat(link.bonusPercent),
},
RewardWithdrawalRequest: {
amount: (tx) => toFloat(tx.amount),
},

View File

@@ -269,6 +269,23 @@ type ReferralLink {
id: ID!
referrerId: ID!
refereeId: ID!
createdById: ID!
bonusPercent: Float!
createdAt: DateTime!
}
type ManagerReferralLink {
id: ID!
referrerId: ID!
referrerName: String!
referrerEmail: String!
referrerCompanyName: String
refereeId: ID!
refereeName: String!
refereeEmail: String!
refereeCompanyName: String
createdById: ID!
bonusPercent: Float!
createdAt: DateTime!
}
@@ -339,6 +356,7 @@ type Query {
myCurrentOrders: [Order!]!
managerUsers: [ManagerUser!]!
managerOrders(status: OrderStatus, customerId: ID): [Order!]!
managerReferralLinks: [ManagerReferralLink!]!
managerBonusBalances: [ManagerBonusBalance!]!
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
@@ -447,7 +465,9 @@ input BlockOrderInput {
}
input CreateReferralInput {
referrerUserId: ID!
refereeUserId: ID!
bonusPercent: Float!
}
input AddBonusTransactionInput {