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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Orivex Backend is one of three repos. Together they form the full stack.
- **📚 Modules & Progress** — Catalogue, completion submission, offline sync, idempotent ingestion.
- **🎁 Rewards Engine** — Stellar-based payouts with reconcilable memos and dispute handling.
- **🎓 Verifiable Credentials** — Mint, fetch, and publicly verify credentials by on-chain ID.
- **🤝 Referrals** — Multi-tier tracking, capped rewards, anti-abuse heuristics.
- **🤝 Referrals** — Multi-tier tracking, capped rewards, anti-abuse heuristics, with the referrer bonus credited automatically on the referee's first module completion.

### ⚙️ Operations & Integrations

Expand Down
10 changes: 10 additions & 0 deletions src/controllers/module.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from 'zod'
import { prisma } from '../config/database'
import { NotificationService } from '../services/notification.service'
import { WebhookService } from '../services/webhook.service'
import { ReferralController } from './referral.controller'

const notificationService = new NotificationService()
const webhookService = new WebhookService()
Expand Down Expand Up @@ -467,6 +468,15 @@ export const completeModule = async (req: Request, res: Response) => {
})
}

// Credit the referrer's bonus on the referee's first completion. This is idempotent
// (guarded by the bonusPaid flag and a conditional updateMany) so a second module
// completion never double-pays. A failure here must not fail the user's completion.
try {
await ReferralController.processReferralBonus(req.user.id)
} catch (error) {
console.error('Error processing referral bonus:', error)
}

// Fire push notification for quiz pass/fail (non-blocking)
notificationService.queueNotification(
req.user.id,
Expand Down
13 changes: 11 additions & 2 deletions src/controllers/referral.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class ReferralController {
* /referrals/apply:
* post:
* summary: Apply a referral code during signup or onboarding
* description: The referrer's bonus is credited automatically once the referred user completes their first module.
* tags: [Referrals]
* security:
* - bearerAuth: []
Expand Down Expand Up @@ -168,6 +169,11 @@ export class ReferralController {

/**
* Called internally when a referree completes their first module to unlock the referrer bonus.
*
* The bonusPaid transition is performed with a conditional updateMany so that two
* concurrent first-completion requests cannot both credit the bonus: only one call
* can flip bonusPaid from false to true, and the loser observes a zero count and
* returns without writing a reward transaction.
*/
static async processReferralBonus(referreeId: string): Promise<void> {
const referral = await prisma.referral.findUnique({
Expand All @@ -179,15 +185,18 @@ export class ReferralController {
const completionCount = await prisma.completion.count({ where: { userId: referreeId } })
if (completionCount < 1) return

await prisma.referral.update({
where: { id: referral.id },
const result = await prisma.referral.updateMany({
where: { id: referral.id, bonusPaid: false },
data: {
bonusPaid: true,
bonusAmount: REFERRAL_BONUS_AMOUNT,
bonusPaidAt: new Date(),
},
})

// Another request already credited this bonus — do not double-pay.
if (result.count === 0) return

await prisma.transaction.create({
data: {
userId: referral.referrerId,
Expand Down
9 changes: 9 additions & 0 deletions src/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import prisma from '../config/database'
import { asyncHandler } from '../middleware/error.middleware'
import { BadRequestError, UnauthorizedError } from '../utils/errors'
import { ReferralController } from './referral.controller'

interface ProgressEvent {
idempotencyKey: string
Expand Down Expand Up @@ -226,6 +227,14 @@ export class SyncController {
await prisma.completion.create({
data: { userId, moduleId, score },
})

// A first completion arriving via offline sync must also unlock the
// referrer's bonus. The call is idempotent, so later completions are no-ops.
try {
await ReferralController.processReferralBonus(userId)
} catch (error) {
console.error('Error processing referral bonus:', error)
}
}

await prisma.syncEvent.create({
Expand Down
44 changes: 37 additions & 7 deletions tests/referral.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vi.mock('../src/config/database', () => ({
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
completion: {
count: vi.fn(),
Expand Down Expand Up @@ -196,15 +196,27 @@ describe('ReferralController', () => {
id: 'ref-1', referrerId: 'user-2', bonusPaid: false,
} as any)
vi.mocked(prisma.completion.count).mockResolvedValue(1)
vi.mocked(prisma.referral.update).mockResolvedValue({} as any)
vi.mocked(prisma.referral.updateMany).mockResolvedValue({ count: 1 })
vi.mocked(prisma.transaction.create).mockResolvedValue({} as any)

await ReferralController.processReferralBonus('user-1')

expect(prisma.referral.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ bonusPaid: true }) }),
expect(prisma.referral.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }),
data: expect.objectContaining({ bonusPaid: true, bonusAmount: 5.0 }),
}),
)
expect(prisma.transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: 'user-2',
amount: 5.0,
type: 'referral_reward',
status: 'completed',
}),
}),
)
expect(prisma.transaction.create).toHaveBeenCalled()
})

it('skips bonus if already paid', async () => {
Expand All @@ -214,7 +226,8 @@ describe('ReferralController', () => {

await ReferralController.processReferralBonus('user-1')

expect(prisma.referral.update).not.toHaveBeenCalled()
expect(prisma.referral.updateMany).not.toHaveBeenCalled()
expect(prisma.transaction.create).not.toHaveBeenCalled()
})

it('skips bonus if no completions yet', async () => {
Expand All @@ -225,7 +238,24 @@ describe('ReferralController', () => {

await ReferralController.processReferralBonus('user-1')

expect(prisma.referral.update).not.toHaveBeenCalled()
expect(prisma.referral.updateMany).not.toHaveBeenCalled()
expect(prisma.transaction.create).not.toHaveBeenCalled()
})

it('does not double-pay when a concurrent request already credited the bonus', async () => {
vi.mocked(prisma.referral.findUnique).mockResolvedValue({
id: 'ref-1', referrerId: 'user-2', bonusPaid: false,
} as any)
vi.mocked(prisma.completion.count).mockResolvedValue(1)
// The conditional update affects zero rows because the bonus was already marked paid.
vi.mocked(prisma.referral.updateMany).mockResolvedValue({ count: 0 })

await ReferralController.processReferralBonus('user-1')

expect(prisma.referral.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }) }),
)
expect(prisma.transaction.create).not.toHaveBeenCalled()
})
})
})
37 changes: 37 additions & 0 deletions tests/sync.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ vi.mock('../src/config/database', () => ({
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
count: vi.fn(),
},
module: {
findUnique: vi.fn(),
},
referral: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
transaction: {
create: vi.fn(),
},
},
}))

Expand Down Expand Up @@ -245,6 +253,35 @@ describe('SyncController', () => {
expect(call.data.results[0].status).toBe('applied')
})

it('credits the referrer bonus when a first completion is synced offline', async () => {
req.body = { events: [makeCompletionEvent()] }
vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null)
vi.mocked(prisma.module.findUnique).mockResolvedValue({ id: 'module-1' } as any)
vi.mocked(prisma.completion.findUnique).mockResolvedValue(null)
vi.mocked(prisma.completion.create).mockResolvedValue({} as any)
vi.mocked(prisma.syncEvent.create).mockResolvedValue({} as any)
vi.mocked(prisma.referral.findUnique).mockResolvedValue({
id: 'ref-1', referrerId: 'user-2', bonusPaid: false,
} as any)
vi.mocked(prisma.completion.count).mockResolvedValue(1)
vi.mocked(prisma.referral.updateMany).mockResolvedValue({ count: 1 })
vi.mocked(prisma.transaction.create).mockResolvedValue({} as any)

controller.syncCompletions(req as Request, res as Response, next)
await flushPromises()

expect(prisma.referral.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }),
}),
)
expect(prisma.transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'referral_reward' }),
}),
)
})

it('rejects event with invalid score', async () => {
req.body = { events: [makeCompletionEvent({ score: -5 })] }
vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null)
Expand Down
Loading
Loading