diff --git a/prisma/migrations/0009_referral_bonus_links/migration.sql b/prisma/migrations/0009_referral_bonus_links/migration.sql new file mode 100644 index 0000000..1bdfd84 --- /dev/null +++ b/prisma/migrations/0009_referral_bonus_links/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f2a8a4a..98c4dd5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/resolvers.js b/src/resolvers.js index 222e2a5..334bc31 100644 --- a/src/resolvers.js +++ b/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,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), }, diff --git a/src/schema.graphql b/src/schema.graphql index 4303961..b813dae 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -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 {