Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
-- CreateTable: ReferralTier
CREATE TABLE "ReferralTier" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"commissionRate" DOUBLE PRECISION NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ReferralTier_pkey" PRIMARY KEY ("id")
);

-- CreateTable: ReferralCode
CREATE TABLE "ReferralCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"tierId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lockedAt" TIMESTAMP(3),
"lockReason" TEXT,
"usageCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ReferralCode_pkey" PRIMARY KEY ("id")
);

-- CreateTable: Referral
CREATE TABLE "Referral" (
"id" TEXT NOT NULL,
"referralCodeId" TEXT NOT NULL,
"refereeId" TEXT NOT NULL,
"depth" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'pending',
"convertedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "Referral_pkey" PRIMARY KEY ("id")
);

-- CreateTable: ReferralCommission
CREATE TABLE "ReferralCommission" (
"id" TEXT NOT NULL,
"referralId" TEXT NOT NULL,
"tipId" TEXT NOT NULL,
"referrerId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"commissionRate" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'XLM',
"status" TEXT NOT NULL DEFAULT 'pending',
"paidAt" TIMESTAMP(3),
"failReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ReferralCommission_pkey" PRIMARY KEY ("id")
);

-- CreateIndex: ReferralTier
CREATE UNIQUE INDEX "ReferralTier_name_key" ON "ReferralTier"("name");
CREATE INDEX "ReferralTier_isDefault_idx" ON "ReferralTier"("isDefault");

-- CreateIndex: ReferralCode
CREATE UNIQUE INDEX "ReferralCode_userId_key" ON "ReferralCode"("userId");
CREATE UNIQUE INDEX "ReferralCode_code_key" ON "ReferralCode"("code");
CREATE INDEX "ReferralCode_userId_isActive_idx" ON "ReferralCode"("userId", "isActive");
CREATE INDEX "ReferralCode_code_idx" ON "ReferralCode"("code");
CREATE INDEX "idx_referralCode_user_createdAt" ON "ReferralCode"("userId", "createdAt" DESC);

-- CreateIndex: Referral
CREATE UNIQUE INDEX "Referral_refereeId_key" ON "Referral"("refereeId");
CREATE INDEX "Referral_referralCodeId_status_idx" ON "Referral"("referralCodeId", "status");
CREATE INDEX "Referral_refereeId_idx" ON "Referral"("refereeId");
CREATE INDEX "Referral_status_createdAt_idx" ON "Referral"("status", "createdAt");
CREATE INDEX "idx_referral_code_createdAt" ON "Referral"("referralCodeId", "createdAt" DESC);

-- CreateIndex: ReferralCommission
CREATE UNIQUE INDEX "ReferralCommission_tipId_key" ON "ReferralCommission"("tipId");
CREATE INDEX "ReferralCommission_referrerId_status_idx" ON "ReferralCommission"("referrerId", "status");
CREATE INDEX "ReferralCommission_referralId_status_idx" ON "ReferralCommission"("referralId", "status");
CREATE INDEX "ReferralCommission_status_createdAt_idx" ON "ReferralCommission"("status", "createdAt");
CREATE INDEX "idx_referralCommission_referrer_status_createdAt" ON "ReferralCommission"("referrerId", "status", "createdAt");

-- AddForeignKey: ReferralCode
ALTER TABLE "ReferralCode" ADD CONSTRAINT "ReferralCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReferralCode" ADD CONSTRAINT "ReferralCode_tierId_fkey" FOREIGN KEY ("tierId") REFERENCES "ReferralTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey: Referral
ALTER TABLE "Referral" ADD CONSTRAINT "Referral_referralCodeId_fkey" FOREIGN KEY ("referralCodeId") REFERENCES "ReferralCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Referral" ADD CONSTRAINT "Referral_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey: ReferralCommission
ALTER TABLE "ReferralCommission" ADD CONSTRAINT "ReferralCommission_referralId_fkey" FOREIGN KEY ("referralId") REFERENCES "Referral"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReferralCommission" ADD CONSTRAINT "ReferralCommission_tipId_fkey" FOREIGN KEY ("tipId") REFERENCES "Tip"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReferralCommission" ADD CONSTRAINT "ReferralCommission_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
121 changes: 121 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ model Tip {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

referralCommission ReferralCommission?

@@index([creatorId, status, createdAt])
@@index([fromUserId, createdAt])
@@index([status])
Expand Down Expand Up @@ -604,6 +606,125 @@ enum PayoutStatus {
failed
}

// ─── Referral Program ────────────────────────────────────────────────────────

// Commission tier configuration (admin-controlled).
// Admins can define named tiers with different commission rates.
// Each user is assigned a tier; default tier is applied if none specified.
model ReferralTier {
id String @id @default(cuid())
name String @unique // e.g. "standard", "silver", "gold"
description String?
// Commission as a fraction (e.g. 0.05 = 5%)
commissionRate Float
isDefault Boolean @default(false)

referralCodes ReferralCode[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([isDefault])
}

// A unique referral code owned by a user. Each user may have at most one
// active code at a time. The code is a short, URL-safe alphanumeric string.
model ReferralCode {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

code String @unique
tierId String?
tier ReferralTier? @relation(fields: [tierId], references: [id], onDelete: SetNull)

isActive Boolean @default(true)
// Soft fraud-lock: admin or system can deactivate without deleting history
lockedAt DateTime?
lockReason String?

usageCount Int @default(0)

referrals Referral[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([userId, isActive])
@@index([code])
// Dashboard: referrer looks up their own code list newest-first.
@@index([userId, createdAt(sort: Desc)], map: "idx_referralCode_user_createdAt")
}

// A single referral event: a new user who signed up via a referral code.
// One referee -> one referral record.
model Referral {
id String @id @default(cuid())
referralCodeId String
referralCode ReferralCode @relation(fields: [referralCodeId], references: [id], onDelete: Cascade)

// The user who was referred (referee)
refereeId String
referee User @relation("RefereeUser", fields: [refereeId], references: [id], onDelete: Cascade)

// Depth starts at 1 for a direct referral. Used to enforce max-depth guard.
depth Int @default(1)

// Lifecycle: pending → converted → rewarded | fraud
status String @default("pending") // pending, converted, rewarded, fraud

// When the referee completed the qualifying action (e.g. first confirmed tip)
convertedAt DateTime?

commissions ReferralCommission[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// Prevent duplicate referral for the same referee
@@unique([refereeId])
@@index([referralCodeId, status])
@@index([refereeId])
@@index([status, createdAt])
// Referrer dashboard: all referrals for a given code, newest first.
@@index([referralCodeId, createdAt(sort: Desc)], map: "idx_referral_code_createdAt")
}

// A commission earned by a referrer when their referee's tip is confirmed.
// Multiple commissions may exist per referral (one per qualifying tip).
model ReferralCommission {
id String @id @default(cuid())
referralId String
referral Referral @relation(fields: [referralId], references: [id], onDelete: Cascade)

// The tip that triggered this commission
tipId String
tip Tip @relation(fields: [tipId], references: [id], onDelete: Cascade)

// Referrer (denormalized for query efficiency)
referrerId String
referrer User @relation("ReferrerUser", fields: [referrerId], references: [id], onDelete: Cascade)

amount Float // Commission amount (computed from tip.amount * commissionRate)
commissionRate Float // Snapshot of the rate at the time of calculation
currency String @default("XLM")

// pending → paid | failed
status String @default("pending")
paidAt DateTime?
failReason String?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([tipId]) // One commission per tip (idempotent)
@@index([referrerId, status])
@@index([referralId, status])
@@index([status, createdAt])
// Affiliate payout sweep: pending commissions for a referrer, oldest first.
@@index([referrerId, status, createdAt], map: "idx_referralCommission_referrer_status_createdAt")
}

model Payout {
id String @id @default(cuid())
creatorId String
Expand Down
1 change: 1 addition & 0 deletions src/config/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const RATE_LIMIT_RULES: RateLimitRule[] = [
{ method: 'POST', url: '/api/v1/payments', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/payments/:id/refund', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/creators/payout', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/referrals/payout', class: 'sensitive' },

// Resource intensive: Stellar Horizon round-trips, job enqueueing, cache
// warming and EXPLAIN queries.
Expand Down
26 changes: 23 additions & 3 deletions src/domains/payments/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,10 +919,30 @@ export class PaymentService extends BaseService {
throw new ConflictError('Tip update did not complete', { tipId });
}

// Invalidate caches after the successful transaction.
await invalidateCaches(tipCacheKeys(tipId, finalTip.creatorId), 'tip.completed');
// Process referral commission outside transaction (idempotent)
if (shouldDispatchWebhook && finalTip) {
try {
// Look up the sender's userId from the tip (fromUserId is the referee)
const tipRecord = await this.prisma.tip.findUnique({
where: { id: finalTip.id },
select: { fromUserId: true, amount: true },
});
if (tipRecord) {
const { ReferralService } = await import('../referrals/referral.service');
const referralService = new ReferralService(this.prisma);
await referralService.processCommissionForTip({
tipId: finalTip.id,
tipAmount: tipRecord.amount,
refereeUserId: tipRecord.fromUserId,
});
}
} catch (e) {
// Commission failures must never block a confirmed tip
logger.error(`Failed to process referral commission for tip ${finalTip.id}:`, e);
}
}

// Dispatch the webhook outside the transaction only after a successful completion.
// Dispatch webhooks outside transaction
if (shouldDispatchWebhook) {
try {
await new WebhookService(this.prisma).dispatchEvent(
Expand Down
Loading