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[]
|
orderStatusEvents OrderStatusEvent[]
|
||||||
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
|
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
|
||||||
referralAsReferee ReferralLink[] @relation("ReferralReferee")
|
referralAsReferee ReferralLink[] @relation("ReferralReferee")
|
||||||
|
createdReferralLinks ReferralLink[] @relation("ReferralCreator")
|
||||||
bonusTransactions BonusTransaction[]
|
bonusTransactions BonusTransaction[]
|
||||||
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
|
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
|
||||||
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
|
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
|
||||||
@@ -294,9 +295,15 @@ model ReferralLink {
|
|||||||
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
|
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
|
||||||
refereeId String
|
refereeId String
|
||||||
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([referrerId, refereeId])
|
@@unique([referrerId, refereeId])
|
||||||
|
@@index([referrerId])
|
||||||
|
@@index([refereeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BonusTransaction {
|
model BonusTransaction {
|
||||||
@@ -306,7 +313,11 @@ model BonusTransaction {
|
|||||||
amount Decimal @db.Decimal(14, 2)
|
amount Decimal @db.Decimal(14, 2)
|
||||||
reason String
|
reason String
|
||||||
orderId String?
|
orderId String?
|
||||||
|
referralLinkId String?
|
||||||
|
referralLink ReferralLink? @relation(fields: [referralLinkId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([orderId, referralLinkId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model RewardWithdrawalRequest {
|
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() {
|
function orderCode() {
|
||||||
return `FR-${Date.now()}-${crypto.randomInt(1000, 9999)}`;
|
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) => {
|
managerBonusBalances: async (_, __, context) => {
|
||||||
const manager = requireManagerAccess(context);
|
const manager = requireManagerAccess(context);
|
||||||
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
|
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
|
||||||
@@ -1624,7 +1740,8 @@ export const resolvers = {
|
|||||||
throw new Error('Only in-progress order can be completed.');
|
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 },
|
where: { id: orderId },
|
||||||
data: {
|
data: {
|
||||||
managerId: manager.id,
|
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 appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed');
|
||||||
await notifyOrderStakeholders(context, updated, 'COMPLETED', '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({
|
return context.prisma.order.findUnique({
|
||||||
where: { id: updated.id },
|
where: { id: updated.id },
|
||||||
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
|
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createReferral: (_, { input }, context) => {
|
createReferral: async (_, { input }, context) => {
|
||||||
const manager = requireManagerAccess(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({
|
return context.prisma.referralLink.create({
|
||||||
data: {
|
data: {
|
||||||
referrerId: manager.id,
|
referrerId: referrerUserId,
|
||||||
refereeId: input.refereeUserId,
|
refereeId: refereeUserId,
|
||||||
|
createdById: manager.id,
|
||||||
|
bonusPercent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1744,6 +1933,10 @@ export const resolvers = {
|
|||||||
amount: (tx) => toFloat(tx.amount),
|
amount: (tx) => toFloat(tx.amount),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ReferralLink: {
|
||||||
|
bonusPercent: (link) => toFloat(link.bonusPercent),
|
||||||
|
},
|
||||||
|
|
||||||
RewardWithdrawalRequest: {
|
RewardWithdrawalRequest: {
|
||||||
amount: (tx) => toFloat(tx.amount),
|
amount: (tx) => toFloat(tx.amount),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -269,6 +269,23 @@ type ReferralLink {
|
|||||||
id: ID!
|
id: ID!
|
||||||
referrerId: ID!
|
referrerId: ID!
|
||||||
refereeId: 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!
|
createdAt: DateTime!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +356,7 @@ type Query {
|
|||||||
myCurrentOrders: [Order!]!
|
myCurrentOrders: [Order!]!
|
||||||
managerUsers: [ManagerUser!]!
|
managerUsers: [ManagerUser!]!
|
||||||
managerOrders(status: OrderStatus, customerId: ID): [Order!]!
|
managerOrders(status: OrderStatus, customerId: ID): [Order!]!
|
||||||
|
managerReferralLinks: [ManagerReferralLink!]!
|
||||||
managerBonusBalances: [ManagerBonusBalance!]!
|
managerBonusBalances: [ManagerBonusBalance!]!
|
||||||
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
|
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
|
||||||
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
|
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
|
||||||
@@ -447,7 +465,9 @@ input BlockOrderInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input CreateReferralInput {
|
input CreateReferralInput {
|
||||||
|
referrerUserId: ID!
|
||||||
refereeUserId: ID!
|
refereeUserId: ID!
|
||||||
|
bonusPercent: Float!
|
||||||
}
|
}
|
||||||
|
|
||||||
input AddBonusTransactionInput {
|
input AddBonusTransactionInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user