-
Notifications
You must be signed in to change notification settings - Fork 0
feat(gifts): FEAT-14-C — gift purchase, redeem, and status endpoints #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { NextRequest } from "next/server"; | ||
| import { handleGiftsPurchasePost } from "@/infrastructure/api-handlers/gifts-purchase-handler"; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| return handleGiftsPurchasePost(request); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { NextRequest } from "next/server"; | ||
| import { handleGiftsRedeemPost } from "@/infrastructure/api-handlers/gifts-redeem-handler"; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| return handleGiftsRedeemPost(request); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { NextRequest } from "next/server"; | ||
| import { handleGiftsStatusGet } from "@/infrastructure/api-handlers/gifts-status-handler"; | ||
|
|
||
| export async function GET( | ||
| request: NextRequest, | ||
| { params }: { params: Promise<{ code: string }> } | ||
| ) { | ||
| const { code } = await params; | ||
| return handleGiftsStatusGet(request, code); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| -- FEAT-14-A: Add GiftCode model and GiftCodeStatus enum | ||
| -- Add premiumGiftExpiresAt to User for tracking gifted premium expiry | ||
|
|
||
| -- CreateEnum | ||
| CREATE TYPE "GiftCodeStatus" AS ENUM ('PENDING_PAYMENT', 'PAID', 'REDEEMED', 'EXPIRED'); | ||
|
|
||
| -- AlterTable: add gift fields to User | ||
| ALTER TABLE "User" | ||
| ADD COLUMN "premiumGiftExpiresAt" TIMESTAMP(3); | ||
|
|
||
| -- CreateTable | ||
| CREATE TABLE "GiftCode" ( | ||
| "id" TEXT NOT NULL, | ||
| "code" TEXT NOT NULL, | ||
| "status" "GiftCodeStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', | ||
| "durationMonths" INTEGER NOT NULL, | ||
| "purchaserEmail" TEXT, | ||
| "purchaserId" TEXT, | ||
| "recipientEmail" TEXT, | ||
| "redeemedByUserId" TEXT, | ||
| "stripeSessionId" TEXT, | ||
| "expiresAt" TIMESTAMP(3), | ||
| "redeemedAt" TIMESTAMP(3), | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
|
||
| CONSTRAINT "GiftCode_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateIndex: unique code | ||
| CREATE UNIQUE INDEX "GiftCode_code_key" ON "GiftCode"("code"); | ||
|
|
||
| -- CreateIndex: unique stripeSessionId | ||
| CREATE UNIQUE INDEX "GiftCode_stripeSessionId_key" ON "GiftCode"("stripeSessionId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "GiftCode_code_idx" ON "GiftCode"("code"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "GiftCode_status_idx" ON "GiftCode"("status"); | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "GiftCode" ADD CONSTRAINT "GiftCode_purchaserId_fkey" FOREIGN KEY ("purchaserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "GiftCode" ADD CONSTRAINT "GiftCode_redeemedByUserId_fkey" FOREIGN KEY ("redeemedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
| import { randomBytes } from "crypto"; | ||
| import { getSessionUserId } from "@/infrastructure/auth/session"; | ||
| import { repositories } from "@/infrastructure/db/repositories"; | ||
| import { getStripeClient, STRIPE_PRICES, GIFT_PRICE_DURATION_MONTHS } from "@/infrastructure/stripe/stripe-client"; | ||
| import { sendGiftPurchaseConfirmationEmail } from "@/infrastructure/email/gift-emails"; | ||
| import { logger } from "@/infrastructure/logging/logger"; | ||
|
|
||
| const VALID_GIFT_PRICE_IDS = new Set([ | ||
| STRIPE_PRICES.giftPremium1M, | ||
| STRIPE_PRICES.giftPremium3M, | ||
| STRIPE_PRICES.giftPremium6M, | ||
| STRIPE_PRICES.giftPremium12M, | ||
| ].filter(Boolean)); | ||
|
|
||
| function generateGiftCode(): string { | ||
| const bytes = randomBytes(8); | ||
| const hex = bytes.toString("hex").toUpperCase(); | ||
| return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`; | ||
| } | ||
|
|
||
| export async function handleGiftsPurchasePost(request: NextRequest): Promise<NextResponse> { | ||
| const body = await request.json().catch(() => ({})); | ||
| const priceId = String(body.priceId ?? ""); | ||
| const recipientEmail = body.recipientEmail ? String(body.recipientEmail) : undefined; | ||
|
|
||
| if (!priceId || !VALID_GIFT_PRICE_IDS.has(priceId)) { | ||
| return NextResponse.json({ error: "Invalid priceId" }, { status: 400 }); | ||
| } | ||
|
|
||
| const durationMonths = GIFT_PRICE_DURATION_MONTHS[priceId]; | ||
| if (!durationMonths) { | ||
| return NextResponse.json({ error: "Could not resolve gift duration" }, { status: 400 }); | ||
| } | ||
|
|
||
| // Auth is optional — purchaser may or may not be logged in | ||
| const userId = await getSessionUserId(); | ||
| let purchaserEmail: string | undefined; | ||
| let purchaserId: string | undefined; | ||
|
|
||
| if (userId) { | ||
| const user = await repositories.user.getById(userId); | ||
| purchaserEmail = user?.email; | ||
| purchaserId = userId; | ||
| } else if (body.email) { | ||
| purchaserEmail = String(body.email); | ||
| } | ||
|
|
||
| const code = generateGiftCode(); | ||
| const appUrl = process.env.APP_URL ?? "http://localhost:3000"; | ||
|
|
||
| try { | ||
| const stripe = getStripeClient(); | ||
|
|
||
| // Create Stripe Checkout session (mode: payment, not subscription) | ||
| const session = await stripe.checkout.sessions.create({ | ||
| mode: "payment", | ||
| payment_method_types: ["card"], | ||
| line_items: [{ price: priceId, quantity: 1 }], | ||
| ...(purchaserEmail ? { customer_email: purchaserEmail } : {}), | ||
| metadata: { | ||
| giftCode: code, | ||
| durationMonths: String(durationMonths), | ||
| ...(recipientEmail ? { recipientEmail } : {}), | ||
| ...(purchaserId ? { purchaserId } : {}), | ||
| }, | ||
| success_url: `${appUrl}/gift/confirmation?session_id={CHECKOUT_SESSION_ID}`, | ||
| cancel_url: `${appUrl}/gift`, | ||
| }); | ||
|
|
||
| // Persist the GiftCode record with PENDING_PAYMENT status | ||
| await repositories.gift.create({ | ||
| code, | ||
| durationMonths, | ||
| purchaserEmail, | ||
| purchaserId, | ||
| recipientEmail, | ||
| stripeSessionId: session.id, | ||
| }); | ||
|
|
||
| logger.info("Gift checkout session created", { code, priceId, durationMonths }); | ||
| return NextResponse.json({ checkoutUrl: session.url, code }); | ||
| } catch (error) { | ||
| logger.error("Failed to create gift checkout session", { error }); | ||
| return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
| import { requireUserId } from "@/infrastructure/auth/require-user"; | ||
| import { repositories } from "@/infrastructure/db/repositories"; | ||
| import { prisma } from "@/infrastructure/db/prisma-client"; | ||
| import { sendGiftRedemptionEmail } from "@/infrastructure/email/gift-emails"; | ||
| import { logger } from "@/infrastructure/logging/logger"; | ||
|
|
||
| export async function handleGiftsRedeemPost(request: NextRequest): Promise<NextResponse> { | ||
| // Auth required | ||
| const auth = await requireUserId(); | ||
| if ("response" in auth) return auth.response; | ||
|
|
||
| const body = await request.json().catch(() => ({})); | ||
| const code = String(body.code ?? "").trim().toUpperCase(); | ||
|
|
||
| if (!code) { | ||
| return NextResponse.json({ error: "Missing code" }, { status: 400 }); | ||
| } | ||
|
|
||
| const giftCode = await repositories.gift.getByCode(code); | ||
|
|
||
| if (!giftCode) { | ||
| return NextResponse.json({ error: "Invalid gift code" }, { status: 404 }); | ||
| } | ||
|
|
||
| if (giftCode.status === "EXPIRED" || (giftCode.expiresAt && giftCode.expiresAt < new Date())) { | ||
| return NextResponse.json({ error: "Gift code has expired" }, { status: 410 }); | ||
| } | ||
|
|
||
| if (giftCode.status === "REDEEMED") { | ||
| return NextResponse.json({ error: "Gift code has already been redeemed" }, { status: 409 }); | ||
| } | ||
|
|
||
| if (giftCode.status !== "PAID") { | ||
| return NextResponse.json({ error: "Gift code payment is not yet confirmed" }, { status: 402 }); | ||
| } | ||
|
|
||
| // Check if the user is already on a paid tier via a recurring subscription | ||
| const user = await repositories.user.getById(auth.userId); | ||
| if (!user) { | ||
| return NextResponse.json({ error: "User not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| if (user.subscriptionTier === "premium" || user.subscriptionTier === "professional") { | ||
| // User already has an active subscription — still allow redeem; extend gift expiry on top | ||
| logger.info("User already premium, extending gift on top", { userId: auth.userId, code }); | ||
| } | ||
|
|
||
| // Calculate new expiry: start from today or existing gift expiry (whichever is later) | ||
| const now = new Date(); | ||
| const existingRow = await prisma.user.findUnique({ | ||
| where: { id: auth.userId }, | ||
| select: { premiumGiftExpiresAt: true }, | ||
| }); | ||
| const base = existingRow?.premiumGiftExpiresAt && existingRow.premiumGiftExpiresAt > now | ||
| ? existingRow.premiumGiftExpiresAt | ||
| : now; | ||
| const premiumExpiresAt = new Date(base); | ||
| premiumExpiresAt.setMonth(premiumExpiresAt.getMonth() + giftCode.durationMonths); | ||
|
|
||
| // Atomically: mark code redeemed + upgrade user tier + set gift expiry | ||
| await prisma.$transaction([ | ||
| prisma.giftCode.update({ | ||
| where: { code }, | ||
| data: { status: "REDEEMED", redeemedByUserId: auth.userId, redeemedAt: now }, | ||
| }), | ||
| prisma.user.update({ | ||
| where: { id: auth.userId }, | ||
| data: { subscriptionTier: "premium", premiumGiftExpiresAt: premiumExpiresAt }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The user update inside redemption unconditionally sets Useful? React with 👍 / 👎. |
||
| }), | ||
| ]); | ||
|
|
||
| logger.info("Gift code redeemed", { code, userId: auth.userId, premiumExpiresAt }); | ||
|
|
||
| // Send confirmation email (non-blocking) | ||
| const recipientEmail = giftCode.recipientEmail ?? user.email; | ||
| sendGiftRedemptionEmail({ | ||
| recipientEmail, | ||
| durationMonths: giftCode.durationMonths, | ||
| premiumExpiresAt, | ||
| }).catch((err) => logger.error("Failed to send gift redemption email", { err })); | ||
|
|
||
| return NextResponse.json({ | ||
| message: "Gift code redeemed successfully", | ||
| premiumExpiresAt: premiumExpiresAt.toISOString(), | ||
| durationMonths: giftCode.durationMonths, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
| import { repositories } from "@/infrastructure/db/repositories"; | ||
| import { logger } from "@/infrastructure/logging/logger"; | ||
|
|
||
| // Simple in-process rate limiter for the public status endpoint | ||
| // Keyed by IP, max 20 requests per minute | ||
| const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); | ||
| const RATE_LIMIT = 20; | ||
| const WINDOW_MS = 60_000; | ||
|
|
||
| function checkRateLimit(ip: string): boolean { | ||
| const now = Date.now(); | ||
| const entry = rateLimitMap.get(ip); | ||
|
|
||
| if (!entry || now > entry.resetAt) { | ||
| rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS }); | ||
| return true; | ||
| } | ||
|
|
||
| if (entry.count >= RATE_LIMIT) { | ||
| return false; | ||
| } | ||
|
|
||
| entry.count++; | ||
| return true; | ||
| } | ||
|
|
||
| export async function handleGiftsStatusGet( | ||
| _request: NextRequest, | ||
| code: string | ||
| ): Promise<NextResponse> { | ||
| const ip = | ||
| _request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; | ||
|
|
||
| if (!checkRateLimit(ip)) { | ||
| return NextResponse.json({ error: "Too many requests" }, { status: 429 }); | ||
| } | ||
|
|
||
| const normalizedCode = code.trim().toUpperCase(); | ||
| if (!normalizedCode) { | ||
| return NextResponse.json({ error: "Missing code" }, { status: 400 }); | ||
| } | ||
|
|
||
| try { | ||
| const giftCode = await repositories.gift.getByCode(normalizedCode); | ||
|
|
||
| if (!giftCode) { | ||
| return NextResponse.json({ error: "Gift code not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| // Return only public-safe fields — no PII | ||
| return NextResponse.json({ | ||
| code: giftCode.code, | ||
| status: giftCode.status, | ||
| durationMonths: giftCode.durationMonths, | ||
| expiresAt: giftCode.expiresAt?.toISOString() ?? null, | ||
| }); | ||
| } catch (error) { | ||
| logger.error("Failed to fetch gift code status", { code: normalizedCode, error }); | ||
| return NextResponse.json({ error: "Internal server error" }, { status: 500 }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redemption first checks status, but the transactional write updates by
codeonly, so two concurrent requests can both pass the pre-check and both commit, granting premium time to multiple accounts from one gift code. This is reproducible under concurrent redeems of the samePAIDcode; the write should include a status predicate (or equivalent optimistic locking) so only one request can transitionPAID -> REDEEMED.Useful? React with 👍 / 👎.