Skip to content

Commit 056cf52

Browse files
authored
Add PaymentAttempt, InventoryReservation, and Fulfillment models for Order Management Epic (#124)
2 parents dcdbca8 + ee73ea9 commit 056cf52

File tree

12 files changed

+2366
-20
lines changed

12 files changed

+2366
-20
lines changed

docs/ORDER_MANAGEMENT_IMPLEMENTATION.md

Lines changed: 653 additions & 0 deletions
Large diffs are not rendered by default.

docs/complete-implementations/ORDERS_PAGE_AUTO_REFRESH_FIX.md

Lines changed: 456 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
-- AlterEnum for PaymentGateway
2+
ALTER TYPE "PaymentGateway" ADD VALUE 'BKASH';
3+
ALTER TYPE "PaymentGateway" ADD VALUE 'NAGAD';
4+
5+
-- CreateEnum for PaymentAttemptStatus
6+
CREATE TYPE "PaymentAttemptStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED', 'REFUNDED', 'PARTIALLY_REFUNDED');
7+
8+
-- CreateEnum for ReservationStatus
9+
CREATE TYPE "ReservationStatus" AS ENUM ('PENDING', 'CONFIRMED', 'EXPIRED', 'RELEASED');
10+
11+
-- CreateEnum for FulfillmentStatus
12+
CREATE TYPE "FulfillmentStatus" AS ENUM ('PENDING', 'PROCESSING', 'SHIPPED', 'IN_TRANSIT', 'OUT_FOR_DELIVERY', 'DELIVERED', 'FAILED', 'RETURNED', 'CANCELLED');
13+
14+
-- AlterTable Order - Add new columns
15+
ALTER TABLE "Order" ADD COLUMN "correlationId" TEXT;
16+
ALTER TABLE "Order" ADD COLUMN "refundableBalance" DOUBLE PRECISION;
17+
18+
-- CreateIndex for Order
19+
CREATE INDEX "Order_storeId_customerEmail_idx" ON "Order"("storeId", "customerEmail");
20+
21+
-- CreateTable PaymentAttempt
22+
CREATE TABLE "PaymentAttempt" (
23+
"id" TEXT NOT NULL,
24+
"orderId" TEXT NOT NULL,
25+
"provider" "PaymentGateway" NOT NULL,
26+
"status" "PaymentAttemptStatus" NOT NULL DEFAULT 'PENDING',
27+
"amount" DOUBLE PRECISION NOT NULL,
28+
"currency" TEXT NOT NULL DEFAULT 'BDT',
29+
"stripePaymentIntentId" TEXT,
30+
"bkashPaymentId" TEXT,
31+
"nagadPaymentId" TEXT,
32+
"metadata" TEXT,
33+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34+
"updatedAt" TIMESTAMP(3) NOT NULL,
35+
36+
CONSTRAINT "PaymentAttempt_pkey" PRIMARY KEY ("id")
37+
);
38+
39+
-- CreateTable InventoryReservation
40+
CREATE TABLE "InventoryReservation" (
41+
"id" TEXT NOT NULL,
42+
"orderId" TEXT,
43+
"productId" TEXT NOT NULL,
44+
"variantId" TEXT,
45+
"quantity" INTEGER NOT NULL,
46+
"status" "ReservationStatus" NOT NULL DEFAULT 'PENDING',
47+
"expiresAt" TIMESTAMP(3) NOT NULL,
48+
"reservedBy" TEXT,
49+
"releasedAt" TIMESTAMP(3),
50+
"releaseReason" TEXT,
51+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
52+
"updatedAt" TIMESTAMP(3) NOT NULL,
53+
54+
CONSTRAINT "InventoryReservation_pkey" PRIMARY KEY ("id")
55+
);
56+
57+
-- CreateTable Fulfillment
58+
CREATE TABLE "Fulfillment" (
59+
"id" TEXT NOT NULL,
60+
"orderId" TEXT NOT NULL,
61+
"trackingNumber" TEXT,
62+
"trackingUrl" TEXT,
63+
"carrier" TEXT,
64+
"status" "FulfillmentStatus" NOT NULL DEFAULT 'PENDING',
65+
"items" TEXT,
66+
"shippedAt" TIMESTAMP(3),
67+
"deliveredAt" TIMESTAMP(3),
68+
"notes" TEXT,
69+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
70+
"updatedAt" TIMESTAMP(3) NOT NULL,
71+
72+
CONSTRAINT "Fulfillment_pkey" PRIMARY KEY ("id")
73+
);
74+
75+
-- CreateIndex for PaymentAttempt
76+
CREATE UNIQUE INDEX "PaymentAttempt_stripePaymentIntentId_key" ON "PaymentAttempt"("stripePaymentIntentId");
77+
CREATE UNIQUE INDEX "PaymentAttempt_bkashPaymentId_key" ON "PaymentAttempt"("bkashPaymentId");
78+
CREATE UNIQUE INDEX "PaymentAttempt_nagadPaymentId_key" ON "PaymentAttempt"("nagadPaymentId");
79+
CREATE INDEX "PaymentAttempt_orderId_status_idx" ON "PaymentAttempt"("orderId", "status");
80+
CREATE INDEX "PaymentAttempt_stripePaymentIntentId_idx" ON "PaymentAttempt"("stripePaymentIntentId");
81+
82+
-- CreateIndex for InventoryReservation
83+
CREATE INDEX "InventoryReservation_orderId_idx" ON "InventoryReservation"("orderId");
84+
CREATE INDEX "InventoryReservation_productId_variantId_status_idx" ON "InventoryReservation"("productId", "variantId", "status");
85+
CREATE INDEX "InventoryReservation_expiresAt_status_idx" ON "InventoryReservation"("expiresAt", "status");
86+
87+
-- CreateIndex for Fulfillment
88+
CREATE INDEX "Fulfillment_orderId_status_idx" ON "Fulfillment"("orderId", "status");
89+
CREATE INDEX "Fulfillment_trackingNumber_idx" ON "Fulfillment"("trackingNumber");
90+
91+
-- AddForeignKey
92+
ALTER TABLE "PaymentAttempt" ADD CONSTRAINT "PaymentAttempt_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
93+
94+
ALTER TABLE "InventoryReservation" ADD CONSTRAINT "InventoryReservation_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
95+
96+
ALTER TABLE "InventoryReservation" ADD CONSTRAINT "InventoryReservation_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
97+
98+
ALTER TABLE "InventoryReservation" ADD CONSTRAINT "InventoryReservation_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
99+
100+
ALTER TABLE "Fulfillment" ADD CONSTRAINT "Fulfillment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ enum PaymentMethod {
236236
enum PaymentGateway {
237237
STRIPE
238238
SSLCOMMERZ
239+
BKASH
240+
NAGAD
239241
MANUAL
240242
}
241243

@@ -508,6 +510,7 @@ model Product {
508510
attributes ProductAttributeValue[]
509511
reviews Review[]
510512
inventoryLogs InventoryLog[] @relation("InventoryLogs")
513+
inventoryReservations InventoryReservation[]
511514
512515
createdAt DateTime @default(now())
513516
updatedAt DateTime @updatedAt
@@ -547,6 +550,7 @@ model ProductVariant {
547550
548551
orderItems OrderItem[]
549552
inventoryLogs InventoryLog[] @relation("VariantInventoryLogs")
553+
inventoryReservations InventoryReservation[]
550554
551555
createdAt DateTime @default(now())
552556
updatedAt DateTime @updatedAt
@@ -777,7 +781,14 @@ model Order {
777781
778782
ipAddress String?
779783
780-
items OrderItem[]
784+
// Observability and financial tracking
785+
correlationId String?
786+
refundableBalance Float?
787+
788+
items OrderItem[]
789+
paymentAttempts PaymentAttempt[]
790+
inventoryReservations InventoryReservation[]
791+
fulfillments Fulfillment[]
781792
782793
createdAt DateTime @default(now())
783794
updatedAt DateTime @updatedAt
@@ -788,6 +799,7 @@ model Order {
788799
@@index([storeId, customerId])
789800
@@index([storeId, status])
790801
@@index([storeId, createdAt])
802+
@@index([storeId, customerEmail]) // Search by email for guest orders
791803
// Note: Removed @@index([orderNumber]) to prevent cross-tenant queries
792804
// Note: Removed @@index([paymentStatus]) to prevent cross-tenant queries
793805
@@index([storeId, customerId, createdAt]) // Customer order history (tenant-isolated)
@@ -823,6 +835,122 @@ model OrderItem {
823835
@@index([productId])
824836
}
825837

838+
// Payment attempt tracking for financial integrity
839+
enum PaymentAttemptStatus {
840+
PENDING
841+
SUCCEEDED
842+
FAILED
843+
REFUNDED
844+
PARTIALLY_REFUNDED
845+
}
846+
847+
model PaymentAttempt {
848+
id String @id @default(cuid())
849+
orderId String
850+
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
851+
852+
provider PaymentGateway
853+
status PaymentAttemptStatus @default(PENDING)
854+
855+
amount Float // Amount in smallest currency unit (e.g., paisa for BDT)
856+
currency String @default("BDT")
857+
858+
// External payment gateway IDs for idempotency and reconciliation
859+
stripePaymentIntentId String? @unique
860+
bkashPaymentId String? @unique
861+
nagadPaymentId String? @unique
862+
863+
// Metadata for debugging and audit trail
864+
metadata String? // JSON object with error codes, refund reasons, etc.
865+
866+
createdAt DateTime @default(now())
867+
updatedAt DateTime @updatedAt
868+
869+
@@index([orderId, status])
870+
@@index([stripePaymentIntentId])
871+
}
872+
873+
// Inventory reservation for oversell prevention
874+
enum ReservationStatus {
875+
PENDING
876+
CONFIRMED
877+
EXPIRED
878+
RELEASED
879+
}
880+
881+
model InventoryReservation {
882+
id String @id @default(cuid())
883+
orderId String?
884+
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
885+
886+
productId String
887+
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
888+
889+
variantId String?
890+
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
891+
892+
quantity Int
893+
status ReservationStatus @default(PENDING)
894+
895+
// 15-minute hold for checkout completion
896+
expiresAt DateTime
897+
898+
// Tracking
899+
reservedBy String? // User ID who created the reservation
900+
releasedAt DateTime?
901+
releaseReason String? // "expired", "order_completed", "order_cancelled", "manual"
902+
903+
createdAt DateTime @default(now())
904+
updatedAt DateTime @updatedAt
905+
906+
@@index([orderId])
907+
@@index([productId, variantId, status])
908+
@@index([expiresAt, status]) // For cleanup job
909+
}
910+
911+
// Fulfillment tracking for shipments
912+
enum FulfillmentStatus {
913+
PENDING
914+
PROCESSING
915+
SHIPPED
916+
IN_TRANSIT
917+
OUT_FOR_DELIVERY
918+
DELIVERED
919+
FAILED
920+
RETURNED
921+
CANCELLED
922+
}
923+
924+
model Fulfillment {
925+
id String @id @default(cuid())
926+
orderId String
927+
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
928+
929+
// Tracking information
930+
trackingNumber String?
931+
trackingUrl String?
932+
carrier String? // "Pathao", "Steadfast", "DHL", "Manual", etc.
933+
934+
status FulfillmentStatus @default(PENDING)
935+
936+
// Items included in this fulfillment (JSON array of {orderItemId, quantity})
937+
// Supports partial shipments
938+
items String? // JSON: [{orderItemId: "xxx", quantity: 2}]
939+
940+
// Timestamps
941+
shippedAt DateTime?
942+
deliveredAt DateTime?
943+
944+
// Notes
945+
notes String?
946+
947+
createdAt DateTime @default(now())
948+
updatedAt DateTime @updatedAt
949+
950+
@@index([orderId, status])
951+
@@index([trackingNumber])
952+
}
953+
826954
// Webhook configuration for external integrations
827955
model Webhook {
828956
id String @id @default(cuid())

scripts/migrate-postgres.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ try {
3737
// when the migration is not failed and prevents noisy errors in the build log.
3838
const knownMigrations = [
3939
'20251205014330_add_discount_codes_and_webhooks',
40+
'20251213000000_add_payment_attempt_inventory_reservation_fulfillment',
4041
];
4142

4243
console.log('\n🧹 Checking known migration statuses before attempting resolves...');
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Fulfillment Status Update API Route
3+
*
4+
* Handles updating fulfillment status (e.g., SHIPPED, DELIVERED).
5+
* Auto-updates order status based on fulfillment status.
6+
*
7+
* @module app/api/fulfillments/[fulfillmentId]/route
8+
*/
9+
10+
import { NextRequest, NextResponse } from 'next/server';
11+
import { getServerSession } from 'next-auth/next';
12+
import { authOptions } from '@/lib/auth';
13+
import { OrderService } from '@/lib/services/order.service';
14+
import { prisma } from '@/lib/prisma';
15+
import { FulfillmentStatus } from '@prisma/client';
16+
import { z } from 'zod';
17+
18+
export const dynamic = 'force-dynamic';
19+
20+
type RouteContext = {
21+
params: Promise<{ fulfillmentId: string }>;
22+
};
23+
24+
/**
25+
* Fulfillment update schema
26+
*/
27+
const UpdateFulfillmentSchema = z.object({
28+
storeId: z.string().cuid(),
29+
status: z.nativeEnum(FulfillmentStatus),
30+
});
31+
32+
/**
33+
* PATCH /api/fulfillments/[fulfillmentId]
34+
*
35+
* Update fulfillment status
36+
*
37+
* @returns 200 - Fulfillment updated successfully
38+
* @returns 401 - Unauthorized
39+
* @returns 400 - Bad Request
40+
* @returns 404 - Fulfillment not found
41+
* @returns 500 - Internal Server Error
42+
*/
43+
export async function PATCH(request: NextRequest, context: RouteContext) {
44+
try {
45+
const session = await getServerSession(authOptions);
46+
if (!session?.user) {
47+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
48+
}
49+
50+
const params = await context.params;
51+
const body = await request.json();
52+
53+
const validationResult = UpdateFulfillmentSchema.safeParse(body);
54+
if (!validationResult.success) {
55+
return NextResponse.json(
56+
{
57+
error: 'Invalid request data',
58+
details: validationResult.error.flatten().fieldErrors,
59+
},
60+
{ status: 400 }
61+
);
62+
}
63+
64+
const { storeId, status } = validationResult.data;
65+
66+
// Verify user has access to this store
67+
const store = await prisma.store.findFirst({
68+
where: {
69+
id: storeId,
70+
OR: [
71+
{ organization: { memberships: { some: { userId: session.user.id } } } },
72+
{ staff: { some: { userId: session.user.id, isActive: true } } },
73+
],
74+
},
75+
});
76+
77+
if (!store) {
78+
return NextResponse.json(
79+
{ error: 'Access denied. You do not have access to this store.' },
80+
{ status: 403 }
81+
);
82+
}
83+
84+
// Update fulfillment status
85+
const orderService = OrderService.getInstance();
86+
const fulfillment = await orderService.updateFulfillmentStatus({
87+
fulfillmentId: params.fulfillmentId,
88+
storeId,
89+
status,
90+
});
91+
92+
return NextResponse.json({
93+
data: fulfillment,
94+
message: 'Fulfillment status updated successfully',
95+
});
96+
} catch (error) {
97+
console.error('Error updating fulfillment:', error);
98+
99+
if (error instanceof Error && error.message.includes('not found')) {
100+
return NextResponse.json({ error: error.message }, { status: 404 });
101+
}
102+
103+
return NextResponse.json({ error: 'Failed to update fulfillment' }, { status: 500 });
104+
}
105+
}

0 commit comments

Comments
 (0)