Skip to content
Draft
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
568 changes: 568 additions & 0 deletions docs/COD_IMPLEMENTATION_GUIDE.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions prisma/migrations/20251211120000_add_cod_fields/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "codFee" DOUBLE PRECISION,
ADD COLUMN "phoneVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "deliveryAttempts" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "codCollectionStatus" TEXT,
ADD COLUMN "codCollectedAt" TIMESTAMP(3);
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,13 @@ model Order {

ipAddress String?

// COD-specific fields
codFee Float? // Cash on Delivery fee
phoneVerified Boolean @default(false) // Phone verification status
deliveryAttempts Int @default(0) // Number of delivery attempts
codCollectionStatus String? // PENDING, COLLECTED, FAILED, PARTIAL
codCollectedAt DateTime? // When cash was collected
Comment on lines +781 to +782
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The codCollectionStatus field is defined as String? in the Prisma schema, which means it can be any string value. This can lead to data inconsistency issues as there are no database-level constraints enforcing valid values like 'PENDING', 'COLLECTED', 'FAILED', or 'PARTIAL'.

Consider using a Prisma enum for codCollectionStatus to ensure type safety and data integrity at both the application and database levels.

Copilot uses AI. Check for mistakes.

items OrderItem[]

createdAt DateTime @default(now())
Expand Down
127 changes: 127 additions & 0 deletions src/app/api/orders/[id]/cod-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* COD Collection Status Update API
* PUT /api/orders/[id]/cod-status
*
* Updates the collection status of a COD order (COLLECTED or FAILED)
*/

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { CODService } from '@/lib/services/cod.service';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

// Request validation schema
const updateCODStatusSchema = z.object({
status: z.enum(['COLLECTED', 'FAILED']),
collectedAmount: z.number().positive().optional(),
failureReason: z.string().optional(),
notes: z.string().optional(),
});

export async function PUT(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
// Get session - must be authenticated
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
);
}

const params = await context.params;
const orderId = params.id;

// Parse and validate request body
const body = await req.json();
const validatedData = updateCODStatusSchema.parse(body);

// Get order and verify access
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
store: {
include: {
organization: {
include: {
memberships: {
where: { userId: session.user.id },
},
},
},
staff: {
where: { userId: session.user.id },
},
},
},
},
});

if (!order) {
return NextResponse.json(
{ success: false, error: 'Order not found' },
{ status: 404 }
);
}

// Check if user has access to this store
const hasOrgAccess = order.store.organization.memberships.length > 0;
const hasStaffAccess = order.store.staff.length > 0;

if (!hasOrgAccess && !hasStaffAccess) {
return NextResponse.json(
{ success: false, error: 'Forbidden - No access to this store' },
{ status: 403 }
);
}

// Update COD collection status
const updatedOrder = await CODService.updateCollectionStatus({
orderId,
status: validatedData.status,
collectedAmount: validatedData.collectedAmount,
failureReason: validatedData.failureReason,
notes: validatedData.notes,
});

return NextResponse.json({
success: true,
order: {
id: updatedOrder!.id,
orderNumber: updatedOrder!.orderNumber,
status: updatedOrder!.status,
paymentStatus: updatedOrder!.paymentStatus,
codCollectionStatus: updatedOrder!.codCollectionStatus,
codCollectedAt: updatedOrder!.codCollectedAt,
Comment on lines +92 to +100
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null check before using the non-null assertion operator. The code uses updatedOrder!.id and other non-null assertions without first checking if updatedOrder is null. While updateCollectionStatus typically returns an order, there's no guarantee at the type level, and if it returns null/undefined, this will cause a runtime error.

Add a null check before accessing properties or handle the null case explicitly.

Suggested change
return NextResponse.json({
success: true,
order: {
id: updatedOrder!.id,
orderNumber: updatedOrder!.orderNumber,
status: updatedOrder!.status,
paymentStatus: updatedOrder!.paymentStatus,
codCollectionStatus: updatedOrder!.codCollectionStatus,
codCollectedAt: updatedOrder!.codCollectedAt,
if (!updatedOrder) {
return NextResponse.json(
{ success: false, error: 'Failed to update COD status' },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
order: {
id: updatedOrder.id,
orderNumber: updatedOrder.orderNumber,
status: updatedOrder.status,
paymentStatus: updatedOrder.paymentStatus,
codCollectionStatus: updatedOrder.codCollectionStatus,
codCollectedAt: updatedOrder.codCollectedAt,

Copilot uses AI. Check for mistakes.
},
});
} catch (error) {
console.error('COD status update error:', error);

// Handle validation errors
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: 'Validation failed',
details: error.issues,
},
{ status: 400 }
);
}

// Handle business logic errors
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update COD status',
},
{ status: 400 }
);
}
}
98 changes: 98 additions & 0 deletions src/app/api/orders/cod/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* COD Order Creation API
* POST /api/orders/cod
*
* Creates a Cash on Delivery order with phone verification and fraud checks
*/

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { CODService } from '@/lib/services/cod.service';
import { z } from 'zod';

// Request validation schema
const createCODOrderSchema = z.object({
storeId: z.string().min(1, 'Store ID is required'),
customerEmail: z.string().email('Invalid email address'),
customerName: z.string().min(1, 'Customer name is required'),
customerPhone: z.string().min(10, 'Phone number is required'),
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent naming pattern in the validation schema. The field customerPhone uses camelCase while the documentation and other parts of the code refer to it as "phone number" or "phoneNumber". While this works, it's inconsistent with the pattern used elsewhere where "phone" is abbreviated in variable names but "customerPhone" is used in the schema.

Consider standardizing on either customerPhone or phoneNumber throughout the codebase for consistency.

Suggested change
customerPhone: z.string().min(10, 'Phone number is required'),
phoneNumber: z.string().min(10, 'Phone number is required'),

Copilot uses AI. Check for mistakes.
shippingAddress: z.object({
address: z.string().min(1, 'Address is required'),
city: z.string().min(1, 'City is required'),
state: z.string().optional(),
postalCode: z.string().min(1, 'Postal code is required'),
country: z.string().min(1, 'Country is required'),
}),
billingAddress: z.string().optional(),
items: z.array(
z.object({
productId: z.string(),
variantId: z.string().optional(),
quantity: z.number().int().positive(),
})
).min(1, 'At least one item is required'),
subtotal: z.number().positive('Subtotal must be positive'),
taxAmount: z.number().nonnegative('Tax amount must be non-negative').default(0),
shippingAmount: z.number().nonnegative('Shipping amount must be non-negative').default(0),
discountAmount: z.number().nonnegative('Discount amount must be non-negative').default(0),
codFee: z.number().nonnegative('COD fee must be non-negative'),
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client-provided COD fee could bypass fee calculation logic. The API accepts codFee as an input parameter rather than calculating it server-side using CODService.calculateCODFee(). This allows clients to submit any COD fee amount, potentially bypassing the business rule of BDT 50 for orders under 500.

The COD fee should be calculated on the server based on the actual order subtotal, not accepted as user input.

Copilot uses AI. Check for mistakes.
notes: z.string().optional(),
locale: z.enum(['en', 'bn']).optional().default('en'),
idempotencyKey: z.string().optional(),
});

export async function POST(req: NextRequest) {
try {
// Get session (optional for guest checkout)
const session = await getServerSession(authOptions);

// Parse and validate request body
const body = await req.json();
const validatedData = createCODOrderSchema.parse(body);

// Create COD order
const order = await CODService.createCODOrder({
...validatedData,
userId: session?.user?.id,
});

return NextResponse.json(
{
success: true,
order: {
id: order.id,
orderNumber: order.orderNumber,
status: order.status,
totalAmount: order.totalAmount,
codFee: order.codFee,
estimatedDelivery: order.estimatedDelivery,
},
},
{ status: 201 }
);
} catch (error) {
console.error('COD order creation error:', error);

// Handle validation errors
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: 'Validation failed',
details: error.issues,
},
{ status: 400 }
);
}

// Handle business logic errors
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create COD order',
},
{ status: 400 }
);
}
}
Loading