diff --git a/server/package-lock.json b/server/package-lock.json index 2b3360d..68222f8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -17,6 +17,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "redis": "^4.6.13", "uuid": "^9.0.1", "zod": "^3.22.4" }, @@ -162,6 +163,65 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", @@ -890,6 +950,15 @@ "node": ">=10" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1418,6 +1487,15 @@ "node": ">=10" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2369,6 +2447,23 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/require-addon": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", diff --git a/server/package.json b/server/package.json index 082178b..a6414bf 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@prisma/client": "^5.10.2", "@stellar/stellar-sdk": "^11.3.0", "bcrypt": "^5.1.1", + "redis": "^4.6.13", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", @@ -36,4 +37,4 @@ "ts-node-dev": "^2.0.0", "typescript": "^5.3.3" } -} \ No newline at end of file +} diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a812706..f9003eb 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -7,6 +7,32 @@ datasource db { url = env("DATABASE_URL") } +enum Currency { + NGN + USDC + XLM +} + +enum TransactionStatus { + PENDING + SUCCESS + FAILED +} + +enum TransactionType { + CREDIT + DEBIT + LOCK + RELEASE + SLASH +} + +enum EscrowStatus { + LOCKED + RELEASED + SLASHED +} + model User { id String @id @default(uuid()) email String @unique @@ -15,6 +41,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt projects Project[] + wallet Wallet? } model Project { @@ -51,3 +78,47 @@ model BlockchainEvent { data Json createdAt DateTime @default(now()) } + +model Wallet { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) + balanceNGN Decimal @default(0) @db.Decimal(65, 30) + balanceUSDC Decimal @default(0) @db.Decimal(65, 30) + balanceXLM Decimal @default(0) @db.Decimal(65, 30) + escrowNGN Decimal @default(0) @db.Decimal(65, 30) + escrowUSDC Decimal @default(0) @db.Decimal(65, 30) + escrowXLM Decimal @default(0) @db.Decimal(65, 30) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + transactions WalletTransaction[] + escrows Escrow[] +} + +model WalletTransaction { + id String @id @default(uuid()) + walletId String + wallet Wallet @relation(fields: [walletId], references: [id]) + userId String + currency Currency + type TransactionType + amount Decimal @db.Decimal(65, 30) + status TransactionStatus @default(PENDING) + reference String? + metadata Json? + matchId String? + createdAt DateTime @default(now()) +} + +model Escrow { + id String @id @default(uuid()) + matchId String + userId String + walletId String + wallet Wallet @relation(fields: [walletId], references: [id]) + currency Currency + amount Decimal @db.Decimal(65, 30) + status EscrowStatus @default(LOCKED) + createdAt DateTime @default(now()) + releasedAt DateTime? +} diff --git a/server/src/app.ts b/server/src/app.ts index 80bcbf0..fb2302b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,7 @@ import morgan from 'morgan'; import dotenv from 'dotenv'; import routes from './routes/index'; import { errorHandler } from './middleware/error.middleware'; +import webhookRoutes from './routes/webhooks.routes'; dotenv.config(); @@ -15,6 +16,7 @@ const port = process.env.PORT || 3000; app.use(helmet()); app.use(cors()); app.use(morgan('dev')); +app.use('/api/webhooks', webhookRoutes); app.use(express.json()); // Routes diff --git a/server/src/controllers/paystack.controller.ts b/server/src/controllers/paystack.controller.ts new file mode 100644 index 0000000..bebb8ff --- /dev/null +++ b/server/src/controllers/paystack.controller.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import { Decimal } from '@prisma/client/runtime/library'; +import { WalletService } from '../services/wallet.service'; + +const walletService = new WalletService(); + +export const paystackWebhook = async (req: Request, res: Response, next: NextFunction) => { + try { + const secret = process.env.PAYSTACK_SECRET_KEY || process.env.PAYSTACK_SECRET || ''; + if (!secret) return res.status(500).send('misconfigured'); + const signature = (req.headers['x-paystack-signature'] as string) || ''; + const rawBody: Buffer = (req as any).body; + const computed = crypto.createHmac('sha512', secret).update(rawBody).digest('hex'); + if (signature !== computed) return res.status(401).send('invalid signature'); + const payload = JSON.parse(rawBody.toString('utf8')); + if (payload?.event === 'charge.success') { + const data = payload?.data; + const userId = data?.metadata?.userId as string | undefined; + if (userId) { + const amount = new Decimal(data.amount).div(100); + await walletService.addBalance(userId, 'NGN' as any, amount, data.reference, { source: 'paystack_webhook' }); + } + } + res.json({ received: true }); + } catch (e) { + next(e); + } +}; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index fe2b477..82adaf7 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,9 +1,11 @@ import { Router } from 'express'; import authRoutes from './auth.routes'; +import webhookRoutes from './webhooks.routes'; const router = Router(); router.use('/auth', authRoutes); +router.use('/webhooks', webhookRoutes); // Placeholder for other routes // router.use('/projects', projectRoutes); diff --git a/server/src/routes/webhooks.routes.ts b/server/src/routes/webhooks.routes.ts new file mode 100644 index 0000000..e1ba68d --- /dev/null +++ b/server/src/routes/webhooks.routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import { paystackWebhook } from '../controllers/paystack.controller'; + +const router = express.Router(); + +router.post('/paystack', express.raw({ type: 'application/json' }), paystackWebhook); + +export default router; diff --git a/server/src/services/paystack.service.ts b/server/src/services/paystack.service.ts new file mode 100644 index 0000000..9009419 --- /dev/null +++ b/server/src/services/paystack.service.ts @@ -0,0 +1,33 @@ +import { Decimal } from '@prisma/client/runtime/library'; + +export class PaystackService { + private readonly secret = process.env.PAYSTACK_SECRET_KEY || process.env.PAYSTACK_SECRET || ''; + private readonly baseUrl = process.env.PAYSTACK_BASE_URL || 'https://api.paystack.co'; + + async verifyTransaction(reference: string) { + if (!this.secret) throw new Error('PAYSTACK_SECRET_KEY missing'); + const res = await fetch(`${this.baseUrl}/transaction/verify/${encodeURIComponent(reference)}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.secret}`, + Accept: 'application/json', + }, + }); + if (!res.ok) throw new Error(`paystack verify failed ${res.status}`); + const json: any = await res.json(); + const status = json?.data?.status; + const amountKobo = json?.data?.amount; + const amount = amountKobo != null ? new Decimal(amountKobo).div(100) : null; + return { + ok: status === 'success', + raw: json, + status, + amount, + currency: (json?.data?.currency as string | undefined) || 'NGN', + reference: json?.data?.reference as string | undefined, + customer: json?.data?.customer, + }; + } +} + +export default new PaystackService(); diff --git a/server/src/services/wallet.service.ts b/server/src/services/wallet.service.ts index 043fe8e..09b1dba 100644 --- a/server/src/services/wallet.service.ts +++ b/server/src/services/wallet.service.ts @@ -1,20 +1,253 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, Prisma, $Enums } from '@prisma/client'; import { Decimal } from '@prisma/client/runtime/library'; +import { createClient, RedisClientType } from 'redis'; export class WalletService { private prisma = new PrismaClient(); + private redis: RedisClientType | null = null; + + private balanceField(currency: $Enums.Currency) { + if (currency === 'NGN') return 'balanceNGN' as const; + if (currency === 'USDC') return 'balanceUSDC' as const; + return 'balanceXLM' as const; + } + + private escrowField(currency: $Enums.Currency) { + if (currency === 'NGN') return 'escrowNGN' as const; + if (currency === 'USDC') return 'escrowUSDC' as const; + return 'escrowXLM' as const; + } + + private async ensureRedis() { + if (!this.redis) { + const url = process.env.REDIS_URL || 'redis://localhost:6379'; + this.redis = createClient({ url }); + await this.redis.connect(); + } + return this.redis!; + } + + private async publishBalance(userId: string) { + const wallet = await this.prisma.wallet.findUnique({ where: { userId } }); + if (!wallet) return; + const redis = await this.ensureRedis(); + const payload = { + userId, + balances: { + NGN: wallet.balanceNGN.toString(), + USDC: wallet.balanceUSDC.toString(), + XLM: wallet.balanceXLM.toString(), + }, + escrow: { + NGN: wallet.escrowNGN.toString(), + USDC: wallet.escrowUSDC.toString(), + XLM: wallet.escrowXLM.toString(), + }, + ts: Date.now(), + }; + await redis.publish(`wallet:${userId}:update`, JSON.stringify(payload)); + } /** * Get or create a wallet for a user. */ async getOrCreateWallet(userId: string) { - // Placeholder for internal ledger logic + const existing = await this.prisma.wallet.findUnique({ where: { userId } }); + if (existing) return existing; + const created = await this.prisma.wallet.create({ + data: { + userId, + balanceNGN: new Decimal(0), + balanceUSDC: new Decimal(0), + balanceXLM: new Decimal(0), + escrowNGN: new Decimal(0), + escrowUSDC: new Decimal(0), + escrowXLM: new Decimal(0), + }, + }); + await this.publishBalance(userId); + return created; } - /** - * Move funds to escrow for an active match. - */ - async lockFunds(userId: string, amount: number) { - // Placeholder for escrow locking + async addBalance(userId: string, currency: $Enums.Currency, amount: Decimal | number | string, reference?: string, metadata?: any) { + const amt = new Decimal(amount as any); + if (amt.lte(0)) throw new Error('amount must be positive'); + const field = this.balanceField(currency); + const result = await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const wallet = await tx.wallet.upsert({ + where: { userId }, + update: {}, + create: { userId }, + }); + const updated = await tx.wallet.update({ + where: { id: wallet.id }, + data: { [field]: { increment: amt } }, + }); + const txn = await tx.walletTransaction.create({ + data: { + walletId: updated.id, + userId, + currency, + type: 'CREDIT', + amount: amt, + status: 'SUCCESS', + reference, + metadata, + }, + }); + return { updated, txn }; + }); + await this.publishBalance(userId); + return result.txn; + } + + async deductBalance(userId: string, currency: $Enums.Currency, amount: Decimal | number | string, reference?: string, metadata?: any) { + const amt = new Decimal(amount as any); + if (amt.lte(0)) throw new Error('amount must be positive'); + const field = this.balanceField(currency); + const result = await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const wallet = await tx.wallet.findUnique({ where: { userId } }); + if (!wallet) throw new Error('wallet not found'); + const current = new Decimal((wallet as any)[field] as any); + if (current.lt(amt)) throw new Error('insufficient balance'); + const updated = await tx.wallet.update({ + where: { id: wallet.id }, + data: { [field]: { decrement: amt } }, + }); + const txn = await tx.walletTransaction.create({ + data: { + walletId: updated.id, + userId, + currency, + type: 'DEBIT', + amount: amt, + status: 'SUCCESS', + reference, + metadata, + }, + }); + return { updated, txn }; + }); + await this.publishBalance(userId); + return result.txn; + } + + async lockFundsToEscrow(userId: string, currency: $Enums.Currency, amount: Decimal | number | string, matchId: string, reference?: string, metadata?: any) { + const amt = new Decimal(amount as any); + if (amt.lte(0)) throw new Error('amount must be positive'); + const balField = this.balanceField(currency); + const escField = this.escrowField(currency); + const result = await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const wallet = await tx.wallet.findUnique({ where: { userId } }); + if (!wallet) throw new Error('wallet not found'); + const current = new Decimal((wallet as any)[balField] as any); + if (current.lt(amt)) throw new Error('insufficient balance'); + const updated = await tx.wallet.update({ + where: { id: wallet.id }, + data: { + [balField]: { decrement: amt }, + [escField]: { increment: amt }, + }, + }); + const esc = await tx.escrow.create({ + data: { + matchId, + userId, + walletId: updated.id, + currency, + amount: amt, + status: 'LOCKED', + }, + }); + const txn = await tx.walletTransaction.create({ + data: { + walletId: updated.id, + userId, + currency, + type: 'LOCK', + amount: amt, + status: 'SUCCESS', + reference, + metadata, + matchId, + }, + }); + return { esc, txn }; + }); + await this.publishBalance(userId); + return result.esc; + } + + async releaseEscrow(matchId: string) { + const result = await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const escrows = await tx.escrow.findMany({ + where: { matchId, status: 'LOCKED' }, + include: { wallet: true }, + }); + for (const e of escrows as any[]) { + const balField = this.balanceField(e.currency); + const escField = this.escrowField(e.currency); + await tx.wallet.update({ + where: { id: e.walletId }, + data: { + [escField]: { decrement: e.amount }, + [balField]: { increment: e.amount }, + }, + }); + await tx.walletTransaction.create({ + data: { + walletId: e.walletId, + userId: e.userId, + currency: e.currency, + type: 'RELEASE', + amount: e.amount, + status: 'SUCCESS', + matchId, + }, + }); + await tx.escrow.update({ + where: { id: e.id }, + data: { status: 'RELEASED', releasedAt: new Date() }, + }); + } + return escrows.map((e: any) => e.userId); + }); + const uniqueUsers = Array.from(new Set(result as string[])); + await Promise.all(uniqueUsers.map(u => this.publishBalance(u))); + return true; + } + + async slashEscrow(matchId: string) { + const result = await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const escrows = await tx.escrow.findMany({ + where: { matchId, status: 'LOCKED' }, + }); + for (const e of escrows as any[]) { + const escField = this.escrowField(e.currency); + await tx.wallet.update({ + where: { id: e.walletId }, + data: { [escField]: { decrement: e.amount } }, + }); + await tx.walletTransaction.create({ + data: { + walletId: e.walletId, + userId: e.userId, + currency: e.currency, + type: 'SLASH', + amount: e.amount, + status: 'SUCCESS', + matchId, + }, + }); + await tx.escrow.update({ + where: { id: e.id }, + data: { status: 'SLASHED', releasedAt: new Date() }, + }); + } + return escrows.map((e: any) => e.userId); + }); + const uniqueUsers = Array.from(new Set(result as string[])); + await Promise.all(uniqueUsers.map(u => this.publishBalance(u))); + return true; } } diff --git a/server/tsconfig.json b/server/tsconfig.json index d0ac2e8..f29cc79 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -11,7 +11,9 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, - "sourceMap": true + "sourceMap": true, + "types": ["node", "express"], + "typeRoots": ["./node_modules/@types"] }, "include": [ "src/**/*" @@ -20,4 +22,4 @@ "node_modules", "**/*.spec.ts" ] -} \ No newline at end of file +}