Add client referral bonus links
This commit is contained in:
34
prisma/migrations/0009_referral_bonus_links/migration.sql
Normal file
34
prisma/migrations/0009_referral_bonus_links/migration.sql
Normal 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");
|
||||
@@ -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")
|
||||
@@ -294,9 +295,15 @@ model ReferralLink {
|
||||
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 {
|
||||
@@ -306,7 +313,11 @@ model BonusTransaction {
|
||||
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 {
|
||||
|
||||
201
src/resolvers.js
201
src/resolvers.js
@@ -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,7 +1740,8 @@ export const resolvers = {
|
||||
throw new Error('Only in-progress order can be completed.');
|
||||
}
|
||||
|
||||
const updated = await context.prisma.order.update({
|
||||
const { updated, referralBonus } = await context.prisma.$transaction(async (tx) => {
|
||||
const updatedOrder = await tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
managerId: manager.id,
|
||||
@@ -1632,21 +1749,93 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user