diff --git a/docs/COD_IMPLEMENTATION_GUIDE.md b/docs/COD_IMPLEMENTATION_GUIDE.md new file mode 100644 index 00000000..da4df18d --- /dev/null +++ b/docs/COD_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,568 @@ +# Cash on Delivery (COD) Implementation Guide + +## Overview + +This document provides comprehensive documentation for the Cash on Delivery (COD) payment feature implemented for the Bangladesh market. + +## Features + +### Customer-Facing Features +- **COD Payment Option**: Radio button selection in checkout with Bengali label "ক্যাশ অন ডেলিভারি" +- **Phone Validation**: Bangladesh phone number format validation (+880 or 01XXXXXXXXX) +- **COD Fee Calculation**: + - BDT 50 for orders < 500 + - Free for orders >= 500 +- **Terms & Conditions**: Bengali/English checkbox acceptance +- **Order Confirmation Email**: Bilingual (Bengali + English) with delivery instructions + +### Vendor-Facing Features +- **COD Order Badge**: Visual indicator for COD orders in dashboard +- **Collection Status Tracking**: PENDING → COLLECTED/FAILED +- **Mark as Collected**: Button to confirm cash collection +- **Delivery Failed**: Button to mark failed delivery with inventory restoration +- **Delivery Attempts Counter**: Shows attempts/3 with warnings + +### Fraud Prevention +- **Phone Verification**: Valid Bangladesh number required +- **First-Time Customer Limit**: Max BDT 10,000 for first order +- **Repeat Offender Blocking**: Block after 2+ failed deliveries in 30 days +- **Order Eligibility Check**: Automatic validation before order creation + +## Database Schema + +### Order Model Extensions + +```prisma +model Order { + // ... existing fields ... + + // 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 +} +``` + +### Migration + +Location: `prisma/migrations/20251211120000_add_cod_fields/migration.sql` + +```sql +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); +``` + +## API Endpoints + +### Create COD Order + +**POST** `/api/orders/cod` + +Creates a new COD order with phone verification and fraud checks. + +**Request Body:** +```json +{ + "storeId": "string", + "customerEmail": "string", + "customerName": "string", + "customerPhone": "string", // +880 or 01XXXXXXXXX format + "shippingAddress": { + "address": "string", + "city": "string", + "state": "string (optional)", + "postalCode": "string", + "country": "string" + }, + "billingAddress": "string (optional)", + "items": [ + { + "productId": "string", + "variantId": "string (optional)", + "quantity": number + } + ], + "subtotal": number, + "taxAmount": number, + "shippingAmount": number, + "discountAmount": number, + "codFee": number, + "notes": "string (optional)", + "locale": "en | bn (optional)", + "idempotencyKey": "string (optional)" +} +``` + +**Response (Success):** +```json +{ + "success": true, + "order": { + "id": "string", + "orderNumber": "string", + "status": "PENDING", + "totalAmount": number, + "codFee": number, + "estimatedDelivery": "ISO 8601 date" + } +} +``` + +**Response (Error):** +```json +{ + "success": false, + "error": "Error message", + "details": [] // Validation errors if applicable +} +``` + +**Possible Errors:** +- "Invalid Bangladesh phone number format" +- "COD not available due to previous failed deliveries" +- "COD limit exceeded for first-time customers (max BDT 10,000)" +- "Insufficient stock for {product name}" + +### Update COD Collection Status + +**PUT** `/api/orders/[id]/cod-status` + +Updates the collection status of a COD order (vendor only). + +**Request Body:** +```json +{ + "status": "COLLECTED | FAILED", + "collectedAmount": number (optional), + "failureReason": "string (optional)", + "notes": "string (optional)" +} +``` + +**Response (Success):** +```json +{ + "success": true, + "order": { + "id": "string", + "orderNumber": "string", + "status": "DELIVERED | CANCELED", + "paymentStatus": "PAID | FAILED", + "codCollectionStatus": "COLLECTED | FAILED", + "codCollectedAt": "ISO 8601 date | null" + } +} +``` + +**Authorization:** Requires vendor access (organization membership or staff assignment). + +## Frontend Components + +### COD Payment Component + +**Location:** `src/components/checkout/cod-payment.tsx` + +**Props:** +```typescript +interface CODPaymentProps { + subtotal: number; + shipping: number; + tax: number; + onOrderPlaced?: (orderId: string) => void; +} +``` + +**Features:** +- Phone number input with Bangladesh validation +- Real-time phone validation feedback +- COD fee calculation display +- Terms & conditions checkbox (Bengali/English) +- Order breakdown (subtotal + shipping + tax + COD fee) +- "Place Order" button (disabled until valid) + +### COD Collection Actions Component + +**Location:** `src/components/orders/cod-collection-actions.tsx` + +**Props:** +```typescript +interface CODCollectionActionsProps { + orderId: string; + orderNumber: string; + amount: number; + codFee: number; + currentStatus: 'PENDING' | 'COLLECTED' | 'FAILED'; + deliveryAttempts?: number; + onStatusUpdate?: () => void; +} +``` + +**Features:** +- Visual status badges (green for collected, red for failed) +- COD amount display with fee breakdown +- Delivery attempts warning (shows X/3) +- Mark as Collected button with confirmation dialog +- Delivery Failed button with notes input +- Automatic inventory restoration on failure + +### Payment Method Step Update + +**Location:** `src/components/checkout/payment-method-step.tsx` + +**Changes:** +- Added COD radio option with Banknote icon +- Conditional rendering of COD payment component +- Integrated with existing checkout flow + +## Backend Services + +### COD Service + +**Location:** `src/lib/services/cod.service.ts` + +**Main Methods:** + +#### `validateBangladeshPhone(phone: string): boolean` +Validates phone number against Bangladesh format. +- Format: `+880` or `880` or `0` + `1` + `[3-9]` + 8 digits +- Example valid: +8801712345678, 01712345678 + +#### `calculateCODFee(subtotal: number): number` +Calculates COD fee based on order subtotal. +- Returns 50 if subtotal < 500 +- Returns 0 if subtotal >= 500 + +#### `checkCODEligibility(customerEmail: string, totalAmount: number)` +Checks if customer is eligible for COD. +- Blocks customers with 2+ failed deliveries in last 30 days +- Limits first-time customers to BDT 10,000 +- Returns `{ eligible: boolean, reason?: string }` + +#### `createCODOrder(input: CreateCODOrderInput)` +Creates COD order with full transaction safety. +- Validates phone number +- Checks COD eligibility +- Validates stock availability +- Reserves inventory immediately +- Generates order number (ORD-YYYYMMDD-XXXX) +- Sends confirmation email +- Returns created order + +#### `updateCollectionStatus(update: CODCollectionUpdate)` +Updates COD collection status. +- On COLLECTED: Marks order as DELIVERED, payment as PAID +- On FAILED: Cancels order, restores inventory, logs restoration +- Requires vendor authorization + +#### `getPendingCollections(storeId: string)` +Gets all pending COD orders for a store. + +## Email Templates + +### COD Confirmation Email + +**Location:** `src/lib/email-templates.ts` (function `codConfirmationEmail`) + +**Template Function:** +```typescript +codConfirmationEmail({ + customerName: string, + orderNumber: string, + orderTotal: string, + codFee: string, + orderItems: Array<{ name: string; quantity: number; price: string }>, + shippingAddress: { address, city, state?, postalCode, country }, + estimatedDelivery: string, + storeName: string, + locale?: 'en' | 'bn', + appUrl?: string, +}): string +``` + +**Features:** +- Bilingual support (Bengali and English) +- Order details with line items +- Payment instructions +- COD fee breakdown +- Estimated delivery date +- Support contact information +- Responsive HTML design + +**Send Function:** +```typescript +// src/lib/email-service.ts +sendCODConfirmationEmail( + to: string, + orderData: { /* same as template props */ } +): Promise +``` + +## Usage Examples + +### Customer Placing COD Order + +```typescript +// In checkout page +import { CODPayment } from '@/components/checkout/cod-payment'; + + { + router.push(`/order/${orderId}/success`); + }} +/> +``` + +### Vendor Managing COD Orders + +```typescript +// In order detail page +import { CODCollectionActions } from '@/components/orders/cod-collection-actions'; + +{order.paymentMethod === 'CASH_ON_DELIVERY' && ( + { + // Refresh order data + refetch(); + }} + /> +)} +``` + +### Creating COD Order Programmatically + +```typescript +import { CODService } from '@/lib/services/cod.service'; + +// Check eligibility first +const eligibility = await CODService.checkCODEligibility( + customerEmail, + totalAmount +); + +if (!eligibility.eligible) { + throw new Error(eligibility.reason); +} + +// Calculate COD fee +const codFee = CODService.calculateCODFee(subtotal); + +// Create order +const order = await CODService.createCODOrder({ + storeId, + userId, + customerEmail, + customerName, + customerPhone: '+8801712345678', + shippingAddress: { + address: '123 Main St', + city: 'Dhaka', + postalCode: '1000', + country: 'Bangladesh', + }, + items: [ + { productId: 'prod_123', quantity: 2 }, + ], + subtotal: 450, + taxAmount: 0, + shippingAmount: 60, + discountAmount: 0, + codFee: 50, + locale: 'bn', +}); +``` + +## Configuration + +### COD Fee Rules + +Current implementation: +- Orders < BDT 500: BDT 50 fee +- Orders >= BDT 500: Free + +To change, update `CODService.calculateCODFee()` in `src/lib/services/cod.service.ts`: + +```typescript +static calculateCODFee(subtotal: number): number { + // Customize fee structure here + if (subtotal < 500) return 50; + if (subtotal < 1000) return 30; + return 0; // Free for orders >= 1000 +} +``` + +### Fraud Prevention Limits + +Current limits (in `CODService.checkCODEligibility()`): +- Failed delivery threshold: 2 orders in 30 days +- First-time customer limit: BDT 10,000 + +To change: +```typescript +// Update these lines in checkCODEligibility() +if (failedCODOrders >= 2) { // Change threshold here + return { eligible: false, reason: '...' }; +} + +if (isFirstTime && totalAmount > 10000) { // Change limit here (in BDT) + return { eligible: false, reason: '...' }; +} +``` + +### Phone Validation Regex + +Current regex: `/^(\+880|880|0)?1[3-9]\d{8}$/` + +Matches: +- +8801712345678 +- 8801712345678 +- 01712345678 + +To change, update in `CODService.validateBangladeshPhone()`. + +## Testing + +### Manual Testing Checklist + +1. **Order Creation** + - [ ] Create COD order with valid phone + - [ ] Verify COD fee calculated correctly + - [ ] Confirm email sent (check console logs) + - [ ] Verify inventory reserved + +2. **Phone Validation** + - [ ] Test valid formats: +8801X, 01X, 8801X + - [ ] Test invalid formats: 02X, 8901X, +1234 + - [ ] Verify error messages display + +3. **Fraud Prevention** + - [ ] Test first-time customer limit (try > BDT 10,000) + - [ ] Create 2 failed deliveries, try 3rd order + - [ ] Verify blocked message + +4. **Collection Tracking** + - [ ] Mark order as collected + - [ ] Verify status updates to DELIVERED + - [ ] Mark order as failed + - [ ] Verify inventory restored + +5. **Email Testing** + - [ ] Check Bengali email rendering + - [ ] Check English email rendering + - [ ] Verify all order details present + - [ ] Test estimated delivery date format + +### Database Testing + +```sql +-- Check COD orders +SELECT id, orderNumber, paymentMethod, codFee, phoneVerified, + codCollectionStatus, deliveryAttempts +FROM "Order" +WHERE paymentMethod = 'CASH_ON_DELIVERY' +ORDER BY createdAt DESC; + +-- Check failed COD orders for a customer +SELECT COUNT(*) as failed_count +FROM "Order" +WHERE customerEmail = 'test@example.com' + AND paymentMethod = 'CASH_ON_DELIVERY' + AND status = 'CANCELED' + AND codCollectionStatus = 'FAILED' + AND createdAt >= NOW() - INTERVAL '30 days'; + +-- Check inventory logs for COD orders +SELECT il.*, o.orderNumber, o.status +FROM "InventoryLog" il +JOIN "Order" o ON il.orderId = o.id +WHERE o.paymentMethod = 'CASH_ON_DELIVERY' +ORDER BY il.createdAt DESC; +``` + +## Troubleshooting + +### Common Issues + +#### 1. "Phone verification required for COD orders" +**Cause:** Invalid phone number format +**Solution:** Ensure phone matches Bangladesh format (+8801XXXXXXXXX or 01XXXXXXXXX) + +#### 2. "COD not available due to previous failed deliveries" +**Cause:** Customer has 2+ failed deliveries in last 30 days +**Solution:** Customer must wait or contact support + +#### 3. "COD limit exceeded for first-time customers" +**Cause:** First order exceeds BDT 10,000 +**Solution:** Reduce order amount or use prepaid payment + +#### 4. Email not sending +**Cause:** RESEND_API_KEY not set or invalid +**Solution:** Set valid Resend API key in .env.local + +#### 5. Inventory not restored on failed delivery +**Cause:** Transaction failed or error in update +**Solution:** Check server logs, manually restore if needed + +### Debug Logging + +Enable debug logging: +```typescript +// In CODService methods +console.log('COD Order Creation:', { + customerEmail, + phone: validatedPhone, + eligibility, + codFee, + totalAmount, +}); +``` + +## Performance Considerations + +- **Transaction Safety**: All order operations use Prisma transactions +- **Idempotency**: Duplicate orders prevented via idempotencyKey +- **Inventory Locking**: Inventory decremented in same transaction as order creation +- **Email Async**: Email sending doesn't block order creation (errors logged but not thrown) + +## Security + +- **SQL Injection**: Protected via Prisma parameterized queries +- **XSS**: Email templates use `escapeHtml()` for all user inputs +- **Authorization**: COD status updates require vendor access +- **Phone Validation**: Regex validation prevents invalid data +- **Fraud Prevention**: Multi-layer checks (phone, history, limits) + +## Future Enhancements + +Potential improvements: +1. SMS OTP verification for phone numbers +2. Courier integration (Pathao, Steadfast, RedX) +3. Partial payment support +4. COD analytics dashboard +5. Automated delivery reminders +6. Customer COD history page +7. Dynamic COD fee based on location +8. COD-specific discount restrictions +9. Real-time delivery tracking +10. COD commission tracking for vendors + +## Support + +For issues or questions: +- Check this documentation +- Review code comments in source files +- Check Prisma schema for data structure +- Test with example data before production use diff --git a/prisma/migrations/20251211120000_add_cod_fields/migration.sql b/prisma/migrations/20251211120000_add_cod_fields/migration.sql new file mode 100644 index 00000000..25a4fe8f --- /dev/null +++ b/prisma/migrations/20251211120000_add_cod_fields/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e189ca62..12ede03b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 + items OrderItem[] createdAt DateTime @default(now()) diff --git a/src/app/api/orders/[id]/cod-status/route.ts b/src/app/api/orders/[id]/cod-status/route.ts new file mode 100644 index 00000000..67bbfcc1 --- /dev/null +++ b/src/app/api/orders/[id]/cod-status/route.ts @@ -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, + }, + }); + } 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 } + ); + } +} diff --git a/src/app/api/orders/cod/route.ts b/src/app/api/orders/cod/route.ts new file mode 100644 index 00000000..42fea496 --- /dev/null +++ b/src/app/api/orders/cod/route.ts @@ -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'), + 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'), + 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 } + ); + } +} diff --git a/src/components/checkout/cod-payment.tsx b/src/components/checkout/cod-payment.tsx new file mode 100644 index 00000000..8f051716 --- /dev/null +++ b/src/components/checkout/cod-payment.tsx @@ -0,0 +1,259 @@ +/** + * Cash on Delivery Payment Component + * Handles COD payment option for Bangladesh market + */ + +'use client'; + +import { useState } from 'react'; +import { Banknote, Phone, Calendar, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Separator } from '@/components/ui/separator'; + +interface CodPaymentProps { + subtotal: number; + shipping: number; + tax: number; + onPlaceOrder: (phoneNumber: string) => void; + isSubmitting: boolean; +} + +// Bangladesh phone validation: +880 or 880 or 01XXXXXXXXX +const BD_PHONE_REGEX = /^(\+880|880|0)?1[3-9]\d{8}$/; + +// COD fee: BDT 50 for orders < 500, free for >= 500 +const COD_FEE_THRESHOLD = 500; +const COD_FEE_AMOUNT = 50; + +export function CodPayment({ + subtotal, + shipping, + tax, + onPlaceOrder, + isSubmitting, +}: CodPaymentProps) { + const [phoneNumber, setPhoneNumber] = useState(''); + const [termsAccepted, setTermsAccepted] = useState(false); + const [phoneError, setPhoneError] = useState(''); + + // Calculate COD fee + const codFee = subtotal < COD_FEE_THRESHOLD ? COD_FEE_AMOUNT : 0; + const total = subtotal + shipping + tax + codFee; + + // Calculate estimated delivery date (5-7 days from now) + const estimatedDeliveryStart = new Date(); + estimatedDeliveryStart.setDate(estimatedDeliveryStart.getDate() + 5); + const estimatedDeliveryEnd = new Date(); + estimatedDeliveryEnd.setDate(estimatedDeliveryEnd.getDate() + 7); + + const formatDate = (date: Date) => { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }; + + const validatePhone = (value: string) => { + if (!value) { + setPhoneError('Phone number is required'); + return false; + } + + if (!BD_PHONE_REGEX.test(value)) { + setPhoneError('Please enter a valid Bangladesh phone number (e.g., 01XXXXXXXXX)'); + return false; + } + + setPhoneError(''); + return true; + }; + + const handlePhoneChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setPhoneNumber(value); + + // Clear error when user types + if (phoneError) { + setPhoneError(''); + } + }; + + const handlePhoneBlur = () => { + if (phoneNumber) { + validatePhone(phoneNumber); + } + }; + + const handlePlaceOrder = () => { + if (validatePhone(phoneNumber) && termsAccepted) { + onPlaceOrder(phoneNumber); + } + }; + + const isValid = BD_PHONE_REGEX.test(phoneNumber) && termsAccepted; + + return ( +
+ {/* COD Information */} + + + +
+

নগদে পণ্য গ্রহণের সময় পেমেন্ট করুন

+

Pay cash when you receive the products at your doorstep

+
+
+
+ + {/* COD Fee Notice */} + {codFee > 0 && ( + + + +

+ ৳{COD_FEE_AMOUNT} COD charge applies for orders under ৳{COD_FEE_THRESHOLD}. + Orders ৳{COD_FEE_THRESHOLD} and above have free COD. +

+
+
+ )} + + {/* Phone Number Input */} + + +
+
+ + + {phoneError && ( +

+ + {phoneError} +

+ )} +

+ Enter your Bangladesh mobile number (01XXXXXXXXX format) +

+
+
+
+
+ + {/* Estimated Delivery */} + + +
+ +
+

Estimated Delivery

+

+ {formatDate(estimatedDeliveryStart)} - {formatDate(estimatedDeliveryEnd)} (5-7 business days) +

+

+ আনুমানিক ডেলিভারি: ৫-৭ কার্যদিবস +

+
+
+
+
+ + {/* Order Breakdown */} + + +
+
+ Subtotal (সাবটোটাল) + ৳{subtotal.toFixed(2)} +
+
+ Shipping (শিপিং) + ৳{shipping.toFixed(2)} +
+
+ Tax (ট্যাক্স) + ৳{tax.toFixed(2)} +
+ {codFee > 0 && ( +
+ COD Fee (নগদ পেমেন্ট চার্জ) + ৳{codFee.toFixed(2)} +
+ )} + +
+ Total (মোট) + ৳{total.toFixed(2)} +
+
+
+
+ + {/* Terms and Conditions */} + + +
+ setTermsAccepted(checked === true)} + aria-label="Accept terms and conditions" + /> +
+ +

+ I agree to pay cash upon receiving the products +

+
+
+
+
+ + {/* Place Order Button */} + + + {/* Security Notice */} +

+ আপনার অর্ডার নিশ্চিত করার জন্য আপনি আমাদের শর্তাবলী এবং গোপনীয়তা নীতির সাথে সম্মত হচ্ছেন +
+ By placing your order, you agree to our Terms of Service and Privacy Policy +

+
+ ); +} diff --git a/src/components/checkout/payment-method-step.tsx b/src/components/checkout/payment-method-step.tsx index 2d1d1277..df7beafc 100644 --- a/src/components/checkout/payment-method-step.tsx +++ b/src/components/checkout/payment-method-step.tsx @@ -6,13 +6,14 @@ 'use client'; import { useState } from 'react'; -import { Loader2, CreditCard, Building2 } from 'lucide-react'; +import { Loader2, CreditCard, Building2, Banknote } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import type { ShippingAddress } from '@/app/checkout/page'; +import { CodPayment } from './cod-payment'; interface PaymentMethodStepProps { shippingAddress: ShippingAddress; @@ -29,8 +30,9 @@ export function PaymentMethodStep({ }: PaymentMethodStepProps) { const [paymentMethod, setPaymentMethod] = useState('card'); const [isSubmitting, setIsSubmitting] = useState(false); + const [codPhoneNumber, setCodPhoneNumber] = useState(''); - const handlePlaceOrder = async () => { + const handlePlaceOrder = async (phoneNumber?: string) => { setIsSubmitting(true); try { @@ -84,6 +86,8 @@ export function PaymentMethodStep({ // body: JSON.stringify({ // paymentIntentId: paymentIntent.id, // shippingAddress, + // paymentMethod, + // ...(paymentMethod === 'cod' && phoneNumber ? { codPhoneNumber: phoneNumber } : {}), // }), // }); @@ -175,6 +179,26 @@ export function PaymentMethodStep({ + + + +
+ + +
+
+
@@ -215,62 +239,81 @@ export function PaymentMethodStep({ )} + {paymentMethod === 'cod' && ( + { + setCodPhoneNumber(phoneNumber); + handlePlaceOrder(phoneNumber); + }} + isSubmitting={isSubmitting} + /> + )} + {/* Order Total */} - - -
-
- Subtotal - $99.98 -
-
- Shipping - $10.00 -
-
- Tax - $8.80 -
- -
- Total - $118.78 + {paymentMethod !== 'cod' && ( + + +
+
+ Subtotal + $99.98 +
+
+ Shipping + $10.00 +
+
+ Tax + $8.80 +
+ +
+ Total + $118.78 +
-
- - + + + )} {/* Actions */} -
- - -
+ {paymentMethod !== 'cod' && ( +
+ + +
+ )} {/* Security Notice */} -

- By placing your order, you agree to our Terms of Service and Privacy Policy. - Your payment information is encrypted and secure. -

+ {paymentMethod !== 'cod' && ( +

+ By placing your order, you agree to our Terms of Service and Privacy Policy. + Your payment information is encrypted and secure. +

+ )}
); } diff --git a/src/components/orders/cod-collection-actions.tsx b/src/components/orders/cod-collection-actions.tsx new file mode 100644 index 00000000..ffa73633 --- /dev/null +++ b/src/components/orders/cod-collection-actions.tsx @@ -0,0 +1,293 @@ +/** + * COD Collection Actions Component + * + * Vendor dashboard component for managing COD order collection status + * Allows vendors to mark orders as collected or failed + */ + +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { CheckCircle2, XCircle, Banknote, AlertCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +export type CODCollectionStatus = 'PENDING' | 'COLLECTED' | 'FAILED'; + +interface CODCollectionActionsProps { + orderId: string; + orderNumber: string; + amount: number; + codFee: number; + currentStatus: CODCollectionStatus; + deliveryAttempts?: number; + onStatusUpdate?: () => void; +} + +export function CODCollectionActions({ + orderId, + orderNumber, + amount, + codFee, + currentStatus, + deliveryAttempts = 0, + onStatusUpdate, +}: CODCollectionActionsProps) { + const [showDialog, setShowDialog] = useState(false); + const [action, setAction] = useState<'COLLECTED' | 'FAILED' | null>(null); + const [loading, setLoading] = useState(false); + const [notes, setNotes] = useState(''); + + const totalAmount = amount + codFee; + + const handleAction = async () => { + if (!action) return; + + setLoading(true); + + try { + const response = await fetch(`/api/orders/${orderId}/cod-status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: action, + collectedAmount: action === 'COLLECTED' ? totalAmount : undefined, + failureReason: + action === 'FAILED' ? 'Customer refused to pay' : undefined, + notes: notes || undefined, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to update collection status'); + } + + const data = await response.json(); + void data; // Unused, but response is valid + + const title = action === 'COLLECTED' ? 'Cash Collected' : 'Delivery Failed'; + const description = + action === 'COLLECTED' + ? `Successfully recorded cash collection for order ${orderNumber}` + : `Order ${orderNumber} marked as failed. Inventory has been restored.`; + + toast.success(title, { description }); + + setShowDialog(false); + setNotes(''); + onStatusUpdate?.(); + } catch (error) { + console.error('COD status update error:', error); + toast.error('Error', { + description: error instanceof Error ? error.message : 'Failed to update collection status', + }); + } finally { + setLoading(false); + } + }; + + // Render status badge for completed orders + if (currentStatus === 'COLLECTED') { + return ( +
+ + + Cash Collected + +

+ Payment received: ৳{totalAmount.toFixed(2)} +

+
+ ); + } + + if (currentStatus === 'FAILED') { + return ( +
+ + + Delivery Failed + +

+ Inventory has been restored +

+
+ ); + } + + // Render action buttons for pending orders + return ( + <> +
+ {/* COD Order Alert */} + + + + Cash on Delivery Order + + + Collect ৳{totalAmount.toFixed(2)} from customer upon delivery + {codFee > 0 && ( + + (Subtotal: ৳{amount.toFixed(2)} + COD Fee: ৳{codFee.toFixed(2)}) + + )} + + + + {/* Delivery Attempts Warning */} + {deliveryAttempts > 0 && ( + + + + Delivery attempts: {deliveryAttempts}/3 + {deliveryAttempts >= 2 && ( + + Warning: Final attempt - order will be canceled if failed + + )} + + + )} + + {/* Action Buttons */} +
+ + +
+
+ + {/* Confirmation Dialog */} + + + + + {action === 'COLLECTED' + ? 'Confirm Cash Collection' + : 'Confirm Delivery Failure'} + + + {action === 'COLLECTED' ? ( + <> + Confirm that you have collected{' '} + + ৳{totalAmount.toFixed(2)} + {' '} + cash from the customer for order{' '} + {orderNumber}. +
+
+ This will mark the order as completed and the payment as + received. + + ) : ( + <> + Mark order{' '} + {orderNumber} as + failed delivery. +
+
+ + Inventory will be restored automatically + {' '} + and the order will be canceled. + + )} +
+
+ + {/* Notes Textarea */} +
+ +