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
26 changes: 26 additions & 0 deletions migrations/1748700000000_add-partial-contributions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { MigrationBuilder } from "node-pg-migrate";

export async function up(pgm: MigrationBuilder): Promise<void> {
// Track how much has actually been paid so far (running total)
pgm.addColumn("contributions", {
amount_paid_usdc: {
type: "NUMERIC(20, 7)",
notNull: true,
default: 0,
},
});

// Flag whether this contribution was ever partially paid
pgm.addColumn("contributions", {
is_partial: {
type: "BOOLEAN",
notNull: true,
default: false,
},
});
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.dropColumn("contributions", "amount_paid_usdc");
pgm.dropColumn("contributions", "is_partial");
}
76 changes: 53 additions & 23 deletions src/app/api/v1/circles/[id]/contribute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import { serverConfig } from "@/server/config";
import { withErrorHandler } from "@/server/middleware";
import { query } from "@/lib/db";
import { randomUUID } from "crypto";
import { z } from "zod";
import type { ApiResponse } from "@/types";

export const POST = withErrorHandler(async (_req: NextRequest, ctx: unknown) => {
const bodySchema = z.object({
partialAmountFiat: z.number().positive().optional(),
});

export const POST = withErrorHandler(async (req: NextRequest, ctx: unknown) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json<ApiResponse<never>>(
Expand Down Expand Up @@ -43,51 +48,76 @@ export const POST = withErrorHandler(async (_req: NextRequest, ctx: unknown) =>
);
}

// Deterministic reference: ajo-{circleId}-{memberId}-{cycleNumber}
const reference = `ajo-${params.id}-${member.id}-${circle.currentCycle}`;
const rawBody = await req.json().catch(() => ({}));
const parsed = bodySchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: parsed.error.errors[0].message },
{ status: 400 }
);
}

// Return existing authorizationUrl if a pending contribution already exists for this cycle
const { rows: existing } = await query<{ paystack_reference: string; authorization_url: string }>(
`SELECT paystack_reference, authorization_url
FROM contributions
WHERE member_id = $1 AND cycle_number = $2 AND status = 'pending'
LIMIT 1`,
// Check for existing contribution this cycle
const { rows: existing } = await query<{
id: string;
paystack_reference: string;
authorization_url: string;
amount_paid_usdc: string;
status: string;
}>(
`SELECT id, paystack_reference, authorization_url, amount_paid_usdc, status
FROM contributions WHERE member_id = $1 AND cycle_number = $2 LIMIT 1`,
[member.id, circle.currentCycle]
);
if (existing.length > 0 && existing[0].authorization_url) {

if (existing[0]?.status === "confirmed") {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "Contribution already confirmed for this cycle" },
{ status: 400 }
);
}

// Return existing pending URL if present
if (existing[0]?.status === "pending" && existing[0].authorization_url) {
return NextResponse.json<ApiResponse<{ authorizationUrl: string; reference: string }>>({
success: true,
data: { authorizationUrl: existing[0].authorization_url, reference: existing[0].paystack_reference },
});
}

const fullFiat = circle.contributionFiat;
const fullUsdc = parseFloat(circle.contributionUsdc);
const payFiat = parsed.data.partialAmountFiat
? Math.min(parsed.data.partialAmountFiat, fullFiat)
: fullFiat;
const isPartial = payFiat < fullFiat;
const payUsdc = ((payFiat / fullFiat) * fullUsdc).toFixed(7);

const reference = `ajo-${params.id}-${member.id}-${circle.currentCycle}-${Date.now()}`;
const callbackUrl = `${serverConfig.app.url}/circles/${params.id}/contribute/callback?reference=${reference}`;

const { authorizationUrl } = await initializePayment({
email: (session.user as { email?: string }).email ?? `${userId}@ajosave.app`,
amount: circle.contributionFiat,
amount: payFiat,
currency: circle.contributionCurrency,
reference,
callbackUrl,
metadata: {
circleId: params.id,
memberId: member.id,
cycleNumber: circle.currentCycle,
},
metadata: { circleId: params.id, memberId: member.id, cycleNumber: circle.currentCycle, isPartial, payUsdc },
});

// Upsert pending contribution with paystack_reference and authorization_url
await query(
`INSERT INTO contributions (id, circle_id, member_id, cycle_number, amount_usdc, status, paystack_reference, authorization_url)
VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7)
`INSERT INTO contributions
(id, circle_id, member_id, cycle_number, amount_usdc, amount_paid_usdc, is_partial, status, paystack_reference, authorization_url)
VALUES ($1,$2,$3,$4,$5,0,$6,'pending',$7,$8)
ON CONFLICT (member_id, cycle_number) DO UPDATE
SET paystack_reference = EXCLUDED.paystack_reference,
authorization_url = EXCLUDED.authorization_url`,
[randomUUID(), params.id, member.id, circle.currentCycle, circle.contributionUsdc, reference, authorizationUrl]
authorization_url = EXCLUDED.authorization_url,
is_partial = EXCLUDED.is_partial`,
[randomUUID(), params.id, member.id, circle.currentCycle, circle.contributionUsdc, isPartial, reference, authorizationUrl]
);

return NextResponse.json<ApiResponse<{ authorizationUrl: string; reference: string }>>({
return NextResponse.json<ApiResponse<{ authorizationUrl: string; reference: string; isPartial: boolean; remainingUsdc: string }>>({
success: true,
data: { authorizationUrl, reference },
data: { authorizationUrl, reference, isPartial, remainingUsdc: circle.contributionUsdc },
});
});
136 changes: 136 additions & 0 deletions src/app/api/v1/circles/[id]/topup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getCircleById, getMembersByCircle } from "@/server/services/circle.service";
import { initializePayment } from "@/lib/paystack";
import { serverConfig } from "@/server/config";
import { withErrorHandler } from "@/server/middleware";
import { query } from "@/lib/db";
import { z } from "zod";
import type { ApiResponse } from "@/types";

const bodySchema = z.object({
amountFiat: z.number().positive(),
});

/**
* POST /api/v1/circles/:id/topup
* Pay the remaining balance on a partial contribution for the current cycle.
*/
export const POST = withErrorHandler(async (req: NextRequest, ctx: unknown) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "Unauthorized" },
{ status: 401 }
);
}

const { params } = ctx as { params: { id: string } };
const circle = await getCircleById(params.id);
if (!circle) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "Circle not found" },
{ status: 404 }
);
}

const circleMembers = await getMembersByCircle(params.id);
const userId = (session.user as { id: string; email?: string }).id;
const member = circleMembers.find((m) => m.userId === userId);
if (!member) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "You are not a member of this circle" },
{ status: 403 }
);
}

const rawBody = await req.json().catch(() => ({}));
const parsed = bodySchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: parsed.error.errors[0].message },
{ status: 400 }
);
}

// Fetch existing partial contribution
const { rows } = await query<{
id: string;
amount_usdc: string;
amount_paid_usdc: string;
status: string;
}>(
`SELECT id, amount_usdc, amount_paid_usdc, status
FROM contributions WHERE member_id = $1 AND cycle_number = $2 LIMIT 1`,
[member.id, circle.currentCycle]
);

if (!rows[0]) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "No contribution found for this cycle. Use the contribute endpoint first." },
{ status: 404 }
);
}

if (rows[0].status === "confirmed") {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "Contribution already fully paid" },
{ status: 400 }
);
}

const fullUsdc = parseFloat(rows[0].amount_usdc);
const paidUsdc = parseFloat(rows[0].amount_paid_usdc);
const remainingUsdc = fullUsdc - paidUsdc;

if (remainingUsdc <= 0) {
return NextResponse.json<ApiResponse<never>>(
{ success: false, error: "No remaining balance to top up" },
{ status: 400 }
);
}

const fullFiat = circle.contributionFiat;
// Cap top-up at remaining balance
const remainingFiat = (remainingUsdc / fullUsdc) * fullFiat;
const topUpFiat = Math.min(parsed.data.amountFiat, remainingFiat);
const topUpUsdc = ((topUpFiat / fullFiat) * fullUsdc).toFixed(7);

const reference = `ajo-topup-${params.id}-${member.id}-${circle.currentCycle}-${Date.now()}`;
const callbackUrl = `${serverConfig.app.url}/circles/${params.id}/contribute/callback?reference=${reference}`;

const { authorizationUrl } = await initializePayment({
email: (session.user as { email?: string }).email ?? `${userId}@ajosave.app`,
amount: topUpFiat,
currency: circle.contributionCurrency,
reference,
callbackUrl,
metadata: {
circleId: params.id,
memberId: member.id,
cycleNumber: circle.currentCycle,
isTopUp: true,
topUpUsdc,
contributionId: rows[0].id,
},
});

// Store the top-up reference so the webhook can credit it
await query(
`UPDATE contributions
SET paystack_reference = $1, authorization_url = $2, updated_at = NOW()
WHERE id = $3`,
[reference, authorizationUrl, rows[0].id]
);

return NextResponse.json<ApiResponse<{ authorizationUrl: string; reference: string; remainingUsdc: string; topUpUsdc: string }>>({
success: true,
data: {
authorizationUrl,
reference,
remainingUsdc: remainingUsdc.toFixed(7),
topUpUsdc,
},
});
});
42 changes: 35 additions & 7 deletions src/app/api/v1/webhooks/paystack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,46 @@ export async function POST(req: NextRequest) {
[eventId, event.event, event]
);

// Confirm the pending contribution matching this paystack_reference
const { rowCount } = await q(
`UPDATE contributions
SET status = 'confirmed', tx_hash = $1, updated_at = NOW()
WHERE paystack_reference = $1 AND status = 'pending'`,
// Fetch the contribution for this reference
const { rows: contribRows } = await q<{
id: string;
amount_usdc: string;
amount_paid_usdc: string;
is_partial: boolean;
}>(
`SELECT id, amount_usdc, amount_paid_usdc, is_partial
FROM contributions WHERE paystack_reference = $1 AND status = 'pending' LIMIT 1`,
[reference]
);

if (rowCount === 0) {
if (contribRows.length === 0) {
logger.info({ reference }, "Paystack reference not found or already confirmed");
} else {
logger.info({ reference }, "Contribution confirmed via Paystack webhook");
const contrib = contribRows[0];
// Amount paid in this transaction (from Paystack event, in kobo → convert to USDC proportionally)
// We use the metadata.payUsdc or topUpUsdc if present, otherwise treat as full payment
const meta = event.data?.metadata ?? {};
const creditUsdc = parseFloat(meta.payUsdc ?? meta.topUpUsdc ?? contrib.amount_usdc);
const newPaidUsdc = parseFloat(contrib.amount_paid_usdc) + creditUsdc;
const fullUsdc = parseFloat(contrib.amount_usdc);
const isFullyPaid = newPaidUsdc >= fullUsdc - 0.0000001; // float tolerance

await q(
`UPDATE contributions
SET amount_paid_usdc = $1,
status = $2,
tx_hash = $3,
updated_at = NOW()
WHERE id = $4`,
[
Math.min(newPaidUsdc, fullUsdc).toFixed(7),
isFullyPaid ? "confirmed" : "pending",
reference,
contrib.id,
]
);

logger.info({ reference, isFullyPaid, newPaidUsdc, fullUsdc }, "Contribution payment credited");
}
});

Expand Down
18 changes: 18 additions & 0 deletions src/server/services/scheduler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@ export async function processDueCycles(): Promise<void> {
);
if (existing.length > 0) continue;

// Gate: only proceed if all active members have a confirmed contribution this cycle
const { rows: unpaid } = await query(
`SELECT m.id FROM members m
WHERE m.circle_id = $1 AND m.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM contributions c
WHERE c.member_id = m.id
AND c.cycle_number = $2
AND c.status = 'confirmed'
)`,
[circle.id, circle.currentCycle]
);

if (unpaid.length > 0) {
console.log(`[scheduler] Skipping payout for circle ${circle.id} — ${unpaid.length} member(s) have not fully paid`);
continue;
}

await addPayoutJob(circle.id, circle.currentCycle);
console.log(`[scheduler] Enqueued payout job for circle ${circle.id} cycle ${circle.currentCycle}`);
} catch (err) {
Expand Down