diff --git a/apps/commerce-api/src/app/api/orders/[orderId]/confirm/route.ts b/apps/commerce-api/src/app/api/orders/[orderId]/confirm/route.ts index 5e7eca2..0218248 100644 --- a/apps/commerce-api/src/app/api/orders/[orderId]/confirm/route.ts +++ b/apps/commerce-api/src/app/api/orders/[orderId]/confirm/route.ts @@ -13,10 +13,10 @@ export async function POST(_request: Request, context: RouteContext) { try { const { orderId } = await context.params; - const order = await OrderService.confirm(orderId); + const result = await OrderService.confirm(orderId); return NextResponse.json({ - data: order, + data: result, }); } catch (error) { return createApiErrorResponse(error); diff --git a/apps/commerce-api/src/app/api/payments/by-order/[orderId]/route.ts b/apps/commerce-api/src/app/api/payments/by-order/[orderId]/route.ts new file mode 100644 index 0000000..09cec0c --- /dev/null +++ b/apps/commerce-api/src/app/api/payments/by-order/[orderId]/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +import { createApiErrorResponse } from "@/lib/api-error"; +import { PaymentService } from "@/services/PaymentService"; + +type RouteContext = { + params: Promise<{ + orderId: string; + }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + try { + const { orderId } = await context.params; + + const payment = await PaymentService.findByOrderId(orderId); + + return NextResponse.json({ + data: payment, + }); + } catch (error) { + return createApiErrorResponse(error); + } +} diff --git a/apps/commerce-api/src/db/seed.ts b/apps/commerce-api/src/db/seed.ts index d3ecce1..90c0aad 100644 --- a/apps/commerce-api/src/db/seed.ts +++ b/apps/commerce-api/src/db/seed.ts @@ -173,9 +173,9 @@ const orderSeed = [ discount: 8_000_000, total: 160_000_000, currency: "IDR", - status: "PENDING", + status: "CONFIRMED", createdAt: new Date("2026-08-27T03:00:00.000Z"), - updatedAt: new Date("2026-08-27T03:00:00.000Z"), + updatedAt: new Date("2026-08-27T03:15:00.000Z"), }, { id: "ORD-2026-0180", @@ -418,15 +418,10 @@ async function seedDatabase() { console.log(""); console.log(` Customers: ${customerSeed.length}`); - console.log(` Quotations: ${quotationSeed.length}`); - console.log(` Quotation items: ${quotationItemSeed.length}`); - console.log(` Orders: ${orderSeed.length}`); - console.log(` Order items: ${orderItemSeed.length}`); - console.log(` Payments: ${paymentSeed.length}`); } finally { await pool.end(); diff --git a/apps/commerce-api/src/db/verify-persistence.ts b/apps/commerce-api/src/db/verify-persistence.ts index 61244ea..d0aad36 100644 --- a/apps/commerce-api/src/db/verify-persistence.ts +++ b/apps/commerce-api/src/db/verify-persistence.ts @@ -101,9 +101,9 @@ async function verifyPersistence() { throw new Error("Expected ORD-2026-0181 to exist."); } - if (order.status !== "PENDING") { + if (order.status !== "CONFIRMED") { throw new Error( - `Expected ORD-2026-0181 to be PENDING, received ${order.status}.`, + `Expected ORD-2026-0181 to be CONFIRMED, received ${order.status}.`, ); } @@ -113,12 +113,30 @@ async function verifyPersistence() { throw new Error("Expected PAY-2026-0091 to exist."); } + if (payment.orderId !== order.id) { + throw new Error( + `Expected PAY-2026-0091 to belong to ${order.id}, received ${payment.orderId}.`, + ); + } + + if (payment.amount !== order.total) { + throw new Error( + `Expected PAY-2026-0091 amount to equal order total ${order.total}, received ${payment.amount}.`, + ); + } + if (payment.status !== "PENDING") { throw new Error( `Expected PAY-2026-0091 to be PENDING, received ${payment.status}.`, ); } + if (payment.reference !== "INV-2026-0181") { + throw new Error( + `Expected PAY-2026-0091 reference to be INV-2026-0181, received ${payment.reference}.`, + ); + } + if (payment.paidAt !== null) { throw new Error("Expected PAY-2026-0091 paidAt to be null."); } diff --git a/apps/commerce-api/src/lib/payments.ts b/apps/commerce-api/src/lib/payments.ts index 5ceaf04..87439fd 100644 --- a/apps/commerce-api/src/lib/payments.ts +++ b/apps/commerce-api/src/lib/payments.ts @@ -1,4 +1,9 @@ -export type PaymentStatus = "PENDING" | "PAID" | "FAILED" | "REFUNDED"; +export type PaymentStatus = + | "PENDING" + | "PAID" + | "FAILED" + | "REFUNDED" + | "CANCELLED"; export type PaymentMethod = "BANK_TRANSFER" | "VIRTUAL_ACCOUNT" | "CARD"; @@ -18,6 +23,12 @@ export type Payment = { refundedAt: string | null; }; +export type CreatePaymentForOrderInput = { + orderId: string; + amount: number; + currency: Currency; +}; + export class PaymentDomainError extends Error { statusCode: number; diff --git a/apps/commerce-api/src/repositories/OrderRepository.ts b/apps/commerce-api/src/repositories/OrderRepository.ts index af98314..cc2c189 100644 --- a/apps/commerce-api/src/repositories/OrderRepository.ts +++ b/apps/commerce-api/src/repositories/OrderRepository.ts @@ -237,6 +237,33 @@ async function insertInTransaction( } } +async function updateStatusInTransaction( + transaction: DatabaseTransaction, + orderId: string, + expectedStatuses: OrderStatus[], + nextStatus: OrderStatus, + updatedAt: string, +): Promise { + if (expectedStatuses.length === 0) { + return false; + } + + const result = await transaction + .update(orders) + .set({ + status: nextStatus, + updatedAt: new Date(updatedAt), + }) + .where( + and(eq(orders.id, orderId), inArray(orders.status, expectedStatuses)), + ) + .returning({ + id: orders.id, + }); + + return result.length > 0; +} + async function updateStatus( orderId: string, expectedStatuses: OrderStatus[], @@ -272,5 +299,6 @@ export const OrderRepository = { findBySourceQuotationIdInTransaction, allocateIdentityInTransaction, insertInTransaction, + updateStatusInTransaction, updateStatus, }; diff --git a/apps/commerce-api/src/repositories/PaymentRepository.ts b/apps/commerce-api/src/repositories/PaymentRepository.ts index 0e703ec..5a4dbdd 100644 --- a/apps/commerce-api/src/repositories/PaymentRepository.ts +++ b/apps/commerce-api/src/repositories/PaymentRepository.ts @@ -1,7 +1,8 @@ -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, like, sql } from "drizzle-orm"; import { getDatabase } from "@/db/client"; import { payments } from "@/db/schema"; +import type { DatabaseTransaction } from "@/db/types"; import type { Currency, Payment, @@ -11,30 +12,61 @@ import type { type PaymentRow = typeof payments.$inferSelect; +export type PaymentIdentity = { + paymentId: string; +}; + function mapPaymentRow(row: PaymentRow): Payment { return { id: row.id, orderId: row.orderId, amount: row.amount, - currency: row.currency as Currency, - method: row.method as PaymentMethod, - status: row.status as PaymentStatus, - reference: row.reference, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - paidAt: row.paidAt?.toISOString() ?? null, - refundedAt: row.refundedAt?.toISOString() ?? null, }; } +function getSequenceNumber(id: string | undefined) { + if (!id) { + return 0; + } + + const value = id.split("-").at(-1); + const sequence = Number(value); + + return Number.isInteger(sequence) ? sequence : 0; +} + +function getStatusUpdateValues(nextStatus: PaymentStatus, updatedAt: string) { + const timestamp = new Date(updatedAt); + + const values: { + status: PaymentStatus; + updatedAt: Date; + paidAt?: Date; + refundedAt?: Date; + } = { + status: nextStatus, + updatedAt: timestamp, + }; + + if (nextStatus === "PAID") { + values.paidAt = timestamp; + } + + if (nextStatus === "REFUNDED") { + values.refundedAt = timestamp; + } + + return values; +} + async function list(): Promise { const db = getDatabase(); @@ -62,7 +94,89 @@ async function findById(paymentId: string): Promise { return mapPaymentRow(row); } -async function updateStatus( +async function findByOrderId(orderId: string): Promise { + const db = getDatabase(); + + const [row] = await db + .select() + .from(payments) + .where(eq(payments.orderId, orderId)) + .limit(1); + + if (!row) { + return null; + } + + return mapPaymentRow(row); +} + +async function findByOrderIdInTransaction( + transaction: DatabaseTransaction, + orderId: string, +): Promise { + const [row] = await transaction + .select() + .from(payments) + .where(eq(payments.orderId, orderId)) + .limit(1); + + if (!row) { + return null; + } + + return mapPaymentRow(row); +} + +async function allocateIdentityInTransaction( + transaction: DatabaseTransaction, +): Promise { + await transaction.execute( + sql` + SELECT pg_advisory_xact_lock( + hashtext('commerce:payment-identity') + ) + `, + ); + + const year = new Date().getUTCFullYear(); + + const [latestPayment] = await transaction + .select({ + id: payments.id, + }) + .from(payments) + .where(like(payments.id, `PAY-${year}-%`)) + .orderBy(desc(payments.id)) + .limit(1); + + const paymentSequence = getSequenceNumber(latestPayment?.id) + 1; + + return { + paymentId: `PAY-${year}-${String(paymentSequence).padStart(4, "0")}`, + }; +} + +async function insertInTransaction( + transaction: DatabaseTransaction, + payment: Payment, +) { + await transaction.insert(payments).values({ + id: payment.id, + orderId: payment.orderId, + amount: payment.amount, + currency: payment.currency, + method: payment.method, + status: payment.status, + reference: payment.reference, + createdAt: new Date(payment.createdAt), + updatedAt: new Date(payment.updatedAt), + paidAt: payment.paidAt ? new Date(payment.paidAt) : null, + refundedAt: payment.refundedAt ? new Date(payment.refundedAt) : null, + }); +} + +async function updateStatusInTransaction( + transaction: DatabaseTransaction, paymentId: string, expectedStatuses: PaymentStatus[], nextStatus: PaymentStatus, @@ -72,35 +186,40 @@ async function updateStatus( return false; } - const db = getDatabase(); - - const timestamp = new Date(updatedAt); + const rows = await transaction + .update(payments) + .set(getStatusUpdateValues(nextStatus, updatedAt)) + .where( + and( + eq(payments.id, paymentId), + inArray(payments.status, expectedStatuses), + ), + ) + .returning({ + id: payments.id, + }); - const values: { - status: PaymentStatus; - updatedAt: Date; - paidAt?: Date; - refundedAt?: Date; - } = { - status: nextStatus, - updatedAt: timestamp, - }; + return rows.length > 0; +} - if (nextStatus === "PAID") { - values.paidAt = timestamp; +async function updateStatus( + paymentId: string, + expectedStatuses: PaymentStatus[], + nextStatus: PaymentStatus, + updatedAt: string, +): Promise { + if (expectedStatuses.length === 0) { + return false; } - if (nextStatus === "REFUNDED") { - values.refundedAt = timestamp; - } + const db = getDatabase(); const rows = await db .update(payments) - .set(values) + .set(getStatusUpdateValues(nextStatus, updatedAt)) .where( and( eq(payments.id, paymentId), - inArray(payments.status, expectedStatuses), ), ) @@ -114,5 +233,10 @@ async function updateStatus( export const PaymentRepository = { list, findById, + findByOrderId, + findByOrderIdInTransaction, + allocateIdentityInTransaction, + insertInTransaction, + updateStatusInTransaction, updateStatus, }; diff --git a/apps/commerce-api/src/services/OrderService.test.ts b/apps/commerce-api/src/services/OrderService.test.ts new file mode 100644 index 0000000..7a8b84c --- /dev/null +++ b/apps/commerce-api/src/services/OrderService.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Order } from "@/lib/orders"; +import type { Payment } from "@/lib/payments"; + +const mocks = vi.hoisted(() => ({ + transaction: vi.fn(), + + findOrderById: vi.fn(), + findOrderByIdInTransaction: vi.fn(), + + updateOrderStatus: vi.fn(), + updateOrderStatusInTransaction: vi.fn(), + + findPaymentByOrderId: vi.fn(), + ensurePaymentForOrder: vi.fn(), + handleOrderCancellation: vi.fn(), +})); + +vi.mock("@/db/client", () => ({ + getDatabase: () => ({ + transaction: mocks.transaction, + }), +})); + +vi.mock("@/repositories/OrderRepository", () => ({ + OrderRepository: { + list: vi.fn(), + + findById: mocks.findOrderById, + + findByIdInTransaction: mocks.findOrderByIdInTransaction, + + findBySourceQuotationIdInTransaction: vi.fn(), + + allocateIdentityInTransaction: vi.fn(), + + insertInTransaction: vi.fn(), + + updateStatus: mocks.updateOrderStatus, + + updateStatusInTransaction: mocks.updateOrderStatusInTransaction, + }, +})); + +vi.mock("@/services/PaymentService", () => ({ + PaymentService: { + findByOrderId: mocks.findPaymentByOrderId, + + ensureForOrderInTransaction: mocks.ensurePaymentForOrder, + + handleOrderCancellationInTransaction: mocks.handleOrderCancellation, + }, +})); + +const transaction = {}; + +const confirmedOrder: Order = { + id: "ORD-2026-0182", + sourceQuotationId: "QT-2026-0043", + customerId: "CUST-0192", + customerName: "PT Nusantara Teknologi", + items: [ + { + id: "ORDER-ITEM-005", + sku: "E2E-MONITOR-01", + name: "E2E Test Monitor", + quantity: 2, + unitPrice: 5_000_000, + lineTotal: 10_000_000, + }, + ], + subtotal: 10_000_000, + discount: 1_000_000, + total: 9_000_000, + currency: "IDR", + status: "CONFIRMED", + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:01:00.000Z", +}; + +const processingOrder: Order = { + ...confirmedOrder, + status: "PROCESSING", + updatedAt: "2026-09-10T00:02:00.000Z", +}; + +const cancelledOrder: Order = { + ...confirmedOrder, + status: "CANCELLED", + updatedAt: "2026-09-10T00:02:00.000Z", +}; + +const pendingPayment: Payment = { + id: "PAY-2026-0092", + orderId: confirmedOrder.id, + amount: confirmedOrder.total, + currency: "IDR", + method: "BANK_TRANSFER", + status: "PENDING", + reference: "INV-2026-0182", + createdAt: "2026-09-10T00:01:00.000Z", + updatedAt: "2026-09-10T00:01:00.000Z", + paidAt: null, + refundedAt: null, +}; + +const paidPayment: Payment = { + ...pendingPayment, + status: "PAID", + updatedAt: "2026-09-10T00:02:00.000Z", + paidAt: "2026-09-10T00:02:00.000Z", +}; + +const cancelledPayment: Payment = { + ...pendingPayment, + status: "CANCELLED", + updatedAt: "2026-09-10T00:02:00.000Z", +}; + +describe("OrderService payment workflow", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.transaction.mockImplementation( + async (callback: (transaction: object) => Promise) => + callback(transaction), + ); + }); + + it("returns the payment created when an order is confirmed", async () => { + mocks.updateOrderStatusInTransaction.mockResolvedValue(true); + + mocks.findOrderByIdInTransaction.mockResolvedValue(confirmedOrder); + + mocks.ensurePaymentForOrder.mockResolvedValue(pendingPayment); + + const { OrderService } = await import("@/services/OrderService"); + + const result = await OrderService.confirm(confirmedOrder.id); + + expect(result).toEqual({ + order: confirmedOrder, + paymentId: pendingPayment.id, + }); + + expect(mocks.ensurePaymentForOrder).toHaveBeenCalledWith(transaction, { + orderId: confirmedOrder.id, + amount: confirmedOrder.total, + currency: confirmedOrder.currency, + }); + }); + + it("blocks processing while payment is pending", async () => { + mocks.findOrderById.mockResolvedValue(confirmedOrder); + + mocks.findPaymentByOrderId.mockResolvedValue(pendingPayment); + + const { OrderService } = await import("@/services/OrderService"); + + await expect(OrderService.process(confirmedOrder.id)).rejects.toThrow( + `Order ${confirmedOrder.id} cannot start processing until payment is PAID. Current payment status: PENDING.`, + ); + + expect(mocks.updateOrderStatus).not.toHaveBeenCalled(); + }); + + it("allows processing after payment is paid", async () => { + mocks.findOrderById + .mockResolvedValueOnce(confirmedOrder) + .mockResolvedValueOnce(processingOrder); + + mocks.findPaymentByOrderId.mockResolvedValue(paidPayment); + + mocks.updateOrderStatus.mockResolvedValue(true); + + const { OrderService } = await import("@/services/OrderService"); + + const result = await OrderService.process(confirmedOrder.id); + + expect(result).toEqual(processingOrder); + + expect(mocks.updateOrderStatus).toHaveBeenCalledWith( + confirmedOrder.id, + ["CONFIRMED"], + "PROCESSING", + expect.any(String), + ); + }); + + it("cancels the related pending payment with a confirmed order", async () => { + mocks.findOrderByIdInTransaction + .mockResolvedValueOnce(confirmedOrder) + .mockResolvedValueOnce(cancelledOrder); + + mocks.handleOrderCancellation.mockResolvedValue(cancelledPayment); + + mocks.updateOrderStatusInTransaction.mockResolvedValue(true); + + const { OrderService } = await import("@/services/OrderService"); + + const result = await OrderService.cancel(confirmedOrder.id); + + expect(result).toEqual(cancelledOrder); + + expect(mocks.handleOrderCancellation).toHaveBeenCalledWith( + transaction, + confirmedOrder.id, + ); + + expect(mocks.updateOrderStatusInTransaction).toHaveBeenCalledWith( + transaction, + confirmedOrder.id, + ["CONFIRMED"], + "CANCELLED", + expect.any(String), + ); + }); + + it("does not cancel the order when payment cancellation fails", async () => { + mocks.findOrderByIdInTransaction.mockResolvedValue(confirmedOrder); + + mocks.handleOrderCancellation.mockRejectedValue( + new Error( + `Payment ${paidPayment.id} must be refunded before order ${confirmedOrder.id} can be cancelled.`, + ), + ); + + const { OrderService } = await import("@/services/OrderService"); + + await expect(OrderService.cancel(confirmedOrder.id)).rejects.toThrow( + `Payment ${paidPayment.id} must be refunded before order ${confirmedOrder.id} can be cancelled.`, + ); + + expect(mocks.updateOrderStatusInTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/commerce-api/src/services/OrderService.ts b/apps/commerce-api/src/services/OrderService.ts index 1e48d79..4227062 100644 --- a/apps/commerce-api/src/services/OrderService.ts +++ b/apps/commerce-api/src/services/OrderService.ts @@ -9,6 +9,12 @@ import { type OrderStatus, } from "@/lib/orders"; import { OrderRepository } from "@/repositories/OrderRepository"; +import { PaymentService } from "@/services/PaymentService"; + +export type ConfirmOrderResult = { + order: Order; + paymentId: string; +}; async function list(): Promise { return OrderRepository.list(); @@ -47,7 +53,6 @@ export async function createOrderFromQuotationInTransaction( const items: OrderItem[] = input.items.map((item, index) => ({ id: identity.itemIds[index], - sku: item.sku, name: item.name, quantity: item.quantity, @@ -57,25 +62,15 @@ export async function createOrderFromQuotationInTransaction( const order: Order = { id: identity.orderId, - sourceQuotationId: input.sourceQuotationId, - customerId: input.customerId, - customerName: input.customerName, - items, - subtotal: input.subtotal, - discount: input.discount, - total: input.total, - currency: input.currency, - status: "PENDING", - createdAt: timestamp, updatedAt: timestamp, }; @@ -127,11 +122,85 @@ async function transition( return getById(orderId); } -async function confirm(orderId: string) { - return transition(orderId, ["PENDING"], "CONFIRMED"); +async function confirm(orderId: string): Promise { + const db = getDatabase(); + + return db.transaction(async (transaction) => { + const updated = await OrderRepository.updateStatusInTransaction( + transaction, + orderId, + ["PENDING"], + "CONFIRMED", + new Date().toISOString(), + ); + + if (!updated) { + const current = await OrderRepository.findByIdInTransaction( + transaction, + orderId, + ); + + if (!current) { + throw new OrderDomainError(`Order ${orderId} was not found.`, 404); + } + + assertOrderTransition(orderId, current.status, ["PENDING"], "CONFIRMED"); + + throw new OrderDomainError( + `Order ${orderId} could not be confirmed.`, + 409, + ); + } + + const confirmedOrder = await OrderRepository.findByIdInTransaction( + transaction, + orderId, + ); + + if (!confirmedOrder) { + throw new OrderDomainError( + `Order ${orderId} could not be loaded after confirmation.`, + 409, + ); + } + + const payment = await PaymentService.ensureForOrderInTransaction( + transaction, + { + orderId: confirmedOrder.id, + amount: confirmedOrder.total, + currency: confirmedOrder.currency, + }, + ); + + return { + order: confirmedOrder, + paymentId: payment.id, + }; + }); } -async function process(orderId: string) { +async function process(orderId: string): Promise { + const order = await getById(orderId); + + assertOrderTransition(orderId, order.status, ["CONFIRMED"], "PROCESSING"); + + const payment = await PaymentService.findByOrderId(orderId); + + if (!payment) { + throw new OrderDomainError( + `Order ${orderId} cannot start processing because its payment record was not found.`, + 409, + ); + } + + if (payment.status !== "PAID") { + throw new OrderDomainError( + `Order ${orderId} cannot start processing until payment is PAID. Current payment status: ${payment.status}.`, + 409, + ); + } + return transition(orderId, ["CONFIRMED"], "PROCESSING"); } @@ -143,8 +212,83 @@ async function complete(orderId: string) { return transition(orderId, ["SHIPPED"], "COMPLETED"); } -async function cancel(orderId: string) { - return transition(orderId, ["PENDING", "CONFIRMED"], "CANCELLED"); +async function cancel(orderId: string): Promise { + const db = getDatabase(); + + return db.transaction(async (transaction) => { + const currentOrder = await OrderRepository.findByIdInTransaction( + transaction, + orderId, + ); + + if (!currentOrder) { + throw new OrderDomainError(`Order ${orderId} was not found.`, 404); + } + + assertOrderTransition( + orderId, + currentOrder.status, + ["PENDING", "CONFIRMED"], + "CANCELLED", + ); + + const payment = await PaymentService.handleOrderCancellationInTransaction( + transaction, + orderId, + ); + + if (currentOrder.status === "CONFIRMED" && !payment) { + throw new OrderDomainError( + `Confirmed order ${orderId} cannot be cancelled because its payment record was not found.`, + 409, + ); + } + + const updated = await OrderRepository.updateStatusInTransaction( + transaction, + orderId, + [currentOrder.status], + "CANCELLED", + new Date().toISOString(), + ); + + if (!updated) { + const latestOrder = await OrderRepository.findByIdInTransaction( + transaction, + orderId, + ); + + if (!latestOrder) { + throw new OrderDomainError(`Order ${orderId} was not found.`, 404); + } + + assertOrderTransition( + orderId, + latestOrder.status, + ["PENDING", "CONFIRMED"], + "CANCELLED", + ); + + throw new OrderDomainError( + `Order ${orderId} could not be cancelled.`, + 409, + ); + } + + const cancelledOrder = await OrderRepository.findByIdInTransaction( + transaction, + orderId, + ); + + if (!cancelledOrder) { + throw new OrderDomainError( + `Order ${orderId} could not be loaded after cancellation.`, + 409, + ); + } + + return cancelledOrder; + }); } export const OrderService = { diff --git a/apps/commerce-api/src/services/PaymentService.test.ts b/apps/commerce-api/src/services/PaymentService.test.ts new file mode 100644 index 0000000..b51ea8f --- /dev/null +++ b/apps/commerce-api/src/services/PaymentService.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DatabaseTransaction } from "@/db/types"; +import type { Payment } from "@/lib/payments"; + +const mocks = vi.hoisted(() => ({ + findByOrderIdInTransaction: vi.fn(), + updateStatusInTransaction: vi.fn(), +})); + +vi.mock("@/repositories/PaymentRepository", () => ({ + PaymentRepository: { + list: vi.fn(), + findById: vi.fn(), + findByOrderId: vi.fn(), + + findByOrderIdInTransaction: mocks.findByOrderIdInTransaction, + + allocateIdentityInTransaction: vi.fn(), + insertInTransaction: vi.fn(), + + updateStatusInTransaction: mocks.updateStatusInTransaction, + + updateStatus: vi.fn(), + }, +})); + +const transaction = {} as DatabaseTransaction; + +const pendingPayment: Payment = { + id: "PAY-2026-0092", + orderId: "ORD-2026-0182", + amount: 9_000_000, + currency: "IDR", + method: "BANK_TRANSFER", + status: "PENDING", + reference: "INV-2026-0182", + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:00:00.000Z", + paidAt: null, + refundedAt: null, +}; + +const cancelledPayment: Payment = { + ...pendingPayment, + status: "CANCELLED", + updatedAt: "2026-09-10T00:01:00.000Z", +}; + +const paidPayment: Payment = { + ...pendingPayment, + status: "PAID", + updatedAt: "2026-09-10T00:01:00.000Z", + paidAt: "2026-09-10T00:01:00.000Z", +}; + +describe("PaymentService order cancellation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("cancels a pending payment with its order", async () => { + mocks.findByOrderIdInTransaction + .mockResolvedValueOnce(pendingPayment) + .mockResolvedValueOnce(cancelledPayment); + + mocks.updateStatusInTransaction.mockResolvedValue(true); + + const { PaymentService } = await import("@/services/PaymentService"); + + const result = await PaymentService.handleOrderCancellationInTransaction( + transaction, + pendingPayment.orderId, + ); + + expect(result).toEqual(cancelledPayment); + + expect(mocks.updateStatusInTransaction).toHaveBeenCalledWith( + transaction, + pendingPayment.id, + ["PENDING"], + "CANCELLED", + expect.any(String), + ); + }); + + it("requires a paid payment to be refunded before cancellation", async () => { + mocks.findByOrderIdInTransaction.mockResolvedValue(paidPayment); + + const { PaymentService } = await import("@/services/PaymentService"); + + await expect( + PaymentService.handleOrderCancellationInTransaction( + transaction, + paidPayment.orderId, + ), + ).rejects.toThrow( + `Payment ${paidPayment.id} must be refunded before order ${paidPayment.orderId} can be cancelled.`, + ); + + expect(mocks.updateStatusInTransaction).not.toHaveBeenCalled(); + }); + + it("preserves a failed payment when its order is cancelled", async () => { + const failedPayment: Payment = { + ...pendingPayment, + status: "FAILED", + }; + + mocks.findByOrderIdInTransaction.mockResolvedValue(failedPayment); + + const { PaymentService } = await import("@/services/PaymentService"); + + const result = await PaymentService.handleOrderCancellationInTransaction( + transaction, + failedPayment.orderId, + ); + + expect(result).toEqual(failedPayment); + + expect(mocks.updateStatusInTransaction).not.toHaveBeenCalled(); + }); + + it("preserves a refunded payment when its order is cancelled", async () => { + const refundedPayment: Payment = { + ...paidPayment, + status: "REFUNDED", + refundedAt: "2026-09-10T00:02:00.000Z", + }; + + mocks.findByOrderIdInTransaction.mockResolvedValue(refundedPayment); + + const { PaymentService } = await import("@/services/PaymentService"); + + const result = await PaymentService.handleOrderCancellationInTransaction( + transaction, + refundedPayment.orderId, + ); + + expect(result).toEqual(refundedPayment); + + expect(mocks.updateStatusInTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/commerce-api/src/services/PaymentService.ts b/apps/commerce-api/src/services/PaymentService.ts index 62d7bb4..c8982c6 100644 --- a/apps/commerce-api/src/services/PaymentService.ts +++ b/apps/commerce-api/src/services/PaymentService.ts @@ -1,5 +1,7 @@ +import type { DatabaseTransaction } from "@/db/types"; import { assertPaymentTransition, + type CreatePaymentForOrderInput, type Payment, PaymentDomainError, type PaymentStatus, @@ -20,6 +22,132 @@ async function getById(paymentId: string): Promise { return payment; } +async function findByOrderId(orderId: string): Promise { + return PaymentRepository.findByOrderId(orderId); +} + +export async function ensurePaymentForOrderInTransaction( + transaction: DatabaseTransaction, + input: CreatePaymentForOrderInput, +): Promise { + const existingPayment = await PaymentRepository.findByOrderIdInTransaction( + transaction, + input.orderId, + ); + + if (existingPayment) { + return existingPayment; + } + + const identity = + await PaymentRepository.allocateIdentityInTransaction(transaction); + + const timestamp = new Date().toISOString(); + + const payment: Payment = { + id: identity.paymentId, + orderId: input.orderId, + amount: input.amount, + currency: input.currency, + method: "BANK_TRANSFER", + status: "PENDING", + reference: input.orderId.replace(/^ORD-/, "INV-"), + createdAt: timestamp, + updatedAt: timestamp, + paidAt: null, + refundedAt: null, + }; + + await PaymentRepository.insertInTransaction(transaction, payment); + + return payment; +} + +async function handleOrderCancellationInTransaction( + transaction: DatabaseTransaction, + orderId: string, +): Promise { + const payment = await PaymentRepository.findByOrderIdInTransaction( + transaction, + orderId, + ); + + if (!payment) { + return null; + } + + if (payment.status === "PAID") { + throw new PaymentDomainError( + `Payment ${payment.id} must be refunded before order ${orderId} can be cancelled.`, + 409, + ); + } + + if ( + payment.status === "FAILED" || + payment.status === "REFUNDED" || + payment.status === "CANCELLED" + ) { + return payment; + } + + const updated = await PaymentRepository.updateStatusInTransaction( + transaction, + payment.id, + ["PENDING"], + "CANCELLED", + new Date().toISOString(), + ); + + if (!updated) { + const currentPayment = await PaymentRepository.findByOrderIdInTransaction( + transaction, + orderId, + ); + + if (!currentPayment) { + throw new PaymentDomainError( + `Payment for order ${orderId} was not found.`, + 404, + ); + } + + if (currentPayment.status === "PAID") { + throw new PaymentDomainError( + `Payment ${currentPayment.id} must be refunded before order ${orderId} can be cancelled.`, + 409, + ); + } + + if ( + currentPayment.status === "FAILED" || + currentPayment.status === "REFUNDED" || + currentPayment.status === "CANCELLED" + ) { + return currentPayment; + } + + throw new PaymentDomainError( + `Payment ${currentPayment.id} could not be cancelled.`, + 409, + ); + } + + const cancelledPayment = await PaymentRepository.findByOrderIdInTransaction( + transaction, + orderId, + ); + + if (!cancelledPayment) { + throw new PaymentDomainError( + `Payment for order ${orderId} could not be loaded after cancellation.`, + 409, + ); + } + + return cancelledPayment; +} + async function transition( paymentId: string, expectedStatuses: PaymentStatus[], @@ -70,6 +198,9 @@ async function refund(paymentId: string) { export const PaymentService = { list, getById, + findByOrderId, + ensureForOrderInTransaction: ensurePaymentForOrderInTransaction, + handleOrderCancellationInTransaction, markPaid, markFailed, refund, diff --git a/apps/commerce-api/vercel.json b/apps/commerce-api/vercel.json new file mode 100644 index 0000000..5d7b761 --- /dev/null +++ b/apps/commerce-api/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["sin1"] +} diff --git a/apps/customers/src/app/favicon.ico b/apps/customers/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/customers/src/app/favicon.ico and /dev/null differ diff --git a/apps/customers/src/app/layout.tsx b/apps/customers/src/app/layout.tsx index 219263a..8a8c079 100644 --- a/apps/customers/src/app/layout.tsx +++ b/apps/customers/src/app/layout.tsx @@ -1,11 +1,9 @@ +import { commerceMetadata } from "@commerce/platform-ui"; import type { Metadata } from "next"; import "./globals.css"; -export const metadata: Metadata = { - title: "Customers | Commerce Operations", - description: "Customer management domain for Commerce Operations.", -}; +export const metadata: Metadata = commerceMetadata; export default function RootLayout({ children, diff --git a/apps/customers/vercel.json b/apps/customers/vercel.json new file mode 100644 index 0000000..5d7b761 --- /dev/null +++ b/apps/customers/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["sin1"] +} diff --git a/apps/orders/src/app/favicon.ico b/apps/orders/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/orders/src/app/favicon.ico and /dev/null differ diff --git a/apps/orders/src/app/layout.tsx b/apps/orders/src/app/layout.tsx index bbf9ecf..8a8c079 100644 --- a/apps/orders/src/app/layout.tsx +++ b/apps/orders/src/app/layout.tsx @@ -1,11 +1,9 @@ +import { commerceMetadata } from "@commerce/platform-ui"; import type { Metadata } from "next"; import "./globals.css"; -export const metadata: Metadata = { - title: "Orders | Commerce Operations", - description: "Order operations domain for Commerce Operations.", -}; +export const metadata: Metadata = commerceMetadata; export default function RootLayout({ children, diff --git a/apps/orders/src/app/orders/[orderId]/page.tsx b/apps/orders/src/app/orders/[orderId]/page.tsx index 88b3047..4df4f1f 100644 --- a/apps/orders/src/app/orders/[orderId]/page.tsx +++ b/apps/orders/src/app/orders/[orderId]/page.tsx @@ -1,9 +1,12 @@ +import { createCommerceHref } from "@commerce/platform-ui"; import { + Badge, PageHeader, Panel, PanelHeader, ProgressSteps, - buttonClassName, + SubmitButton, + type BadgeVariant, type ProgressStep, } from "@commerce/ui"; import Link from "next/link"; @@ -17,8 +20,12 @@ import { shipOrderAction, } from "@/app/orders/actions"; import { OrderStatusBadge } from "@/components/OrderStatusBadge"; -import { getOrder } from "@/lib/commerce-api"; -import { formatCurrency, formatDateTime } from "@/lib/order"; +import { getOrder, getOrderPayment } from "@/lib/commerce-api"; +import { + formatCurrency, + formatDateTime, + type OrderPaymentStatus, +} from "@/lib/order"; export const dynamic = "force-dynamic"; @@ -39,15 +46,45 @@ const lifecycle = [ "COMPLETED", ] as const; +const paymentStatusVariants: Record = { + PENDING: "warning", + PAID: "success", + FAILED: "danger", + REFUNDED: "accent", + CANCELLED: "neutral", +}; + +function getPaymentMessage(status: OrderPaymentStatus) { + if (status === "PAID") { + return "Payment has cleared. This order is ready for fulfillment. Refund the payment first if the confirmed order needs to be cancelled."; + } + + if (status === "FAILED") { + return "Payment failed. Fulfillment remains locked, but the order can still be cancelled."; + } + + if (status === "REFUNDED") { + return "Payment has been refunded. Fulfillment is locked and the confirmed order can now be cancelled."; + } + + if (status === "CANCELLED") { + return "This payment was cancelled because the related order was cancelled."; + } + + return "Waiting for payment clearance. Fulfillment remains locked. Cancelling the order will also cancel this pending payment."; +} + export default async function OrderDetailPage({ params, searchParams, }: OrderDetailPageProps) { const { orderId } = await params; - const { error } = await searchParams; - const order = await getOrder(orderId); + const [order, payment] = await Promise.all([ + getOrder(orderId), + getOrderPayment(orderId), + ]); if (!order) { notFound(); @@ -69,6 +106,20 @@ export default async function OrderDetailPage({ : "upcoming", })); + const paymentCleared = payment?.status === "PAID"; + + const canStartProcessing = order.status === "CONFIRMED" && paymentCleared; + + const canCancelOrder = + order.status === "PENDING" || + (order.status === "CONFIRMED" && + payment !== null && + payment.status !== "PAID"); + + const processingBlockedMessage = payment + ? `Payment ${payment.id} is ${payment.status}. Payment must be PAID before fulfillment can begin.` + : "A payment must exist and be PAID before fulfillment can begin."; + return ( <> - + )} @@ -100,7 +155,9 @@ export default async function OrderDetailPage({
- + + Confirm order +
) : null} @@ -108,7 +165,17 @@ export default async function OrderDetailPage({
- + + Start processing +
) : null} @@ -116,7 +183,9 @@ export default async function OrderDetailPage({
- + + Mark shipped +
) : null} @@ -124,7 +193,9 @@ export default async function OrderDetailPage({
- + + Complete order +
) : null} @@ -142,7 +213,7 @@ export default async function OrderDetailPage({
@@ -154,7 +225,9 @@ export default async function OrderDetailPage({
@@ -227,6 +300,89 @@ export default async function OrderDetailPage({
+ +
+

+ Payment clearance +

+ + {payment ? ( + <> +
+

+ {payment.id} +

+ + + {payment.status} + +
+ +
+
+
Amount
+ +
+ {formatCurrency(payment.amount, payment.currency)} +
+
+ +
+
Method
+ +
+ {payment.method.replaceAll("_", " ")} +
+
+ +
+
Reference
+ +
+ {payment.reference} +
+
+
+ +

+ {getPaymentMessage(payment.status)} +

+ + + View payment → + + + ) : ( + <> +
+ NOT CREATED +
+ +

+ {order.status === "PENDING" + ? "A payment record will be created automatically when this order is confirmed." + : "No payment record was found for this order. Fulfillment cannot begin until payment clearance exists."} +

+ + )} +
+
+

diff --git a/apps/orders/src/app/orders/actions.ts b/apps/orders/src/app/orders/actions.ts index c5a59f0..7ad538d 100644 --- a/apps/orders/src/app/orders/actions.ts +++ b/apps/orders/src/app/orders/actions.ts @@ -42,6 +42,10 @@ function getOrderUrl(orderId: string) { return `${getCommercePlatformUrl()}/orders/${orderId}`; } +function getPaymentUrl(paymentId: string) { + return `${getCommercePlatformUrl()}/payments/${paymentId}`; +} + async function runOrderTransition( orderId: string, transition: () => Promise, @@ -63,7 +67,22 @@ async function runOrderTransition( export async function confirmOrderAction(formData: FormData) { const orderId = getString(formData, "orderId"); - await runOrderTransition(orderId, () => confirmOrder(orderId)); + let paymentId: string; + + try { + const result = await confirmOrder(orderId); + + paymentId = result.paymentId; + } catch (error) { + const message = encodeURIComponent(getErrorMessage(error)); + + redirect(`${getOrderUrl(orderId)}?error=${message}`); + } + + revalidatePath("/orders"); + revalidatePath(`/orders/${orderId}`); + + redirect(getPaymentUrl(paymentId)); } export async function processOrderAction(formData: FormData) { diff --git a/apps/orders/src/lib/commerce-api.ts b/apps/orders/src/lib/commerce-api.ts index 85cf461..1a70fad 100644 --- a/apps/orders/src/lib/commerce-api.ts +++ b/apps/orders/src/lib/commerce-api.ts @@ -1,4 +1,4 @@ -import type { Order } from "@/lib/order"; +import type { Order, OrderPayment } from "@/lib/order"; type ApiSuccess = { data: T; @@ -10,6 +10,11 @@ type ApiFailure = { }; }; +export type ConfirmOrderResult = { + order: Order; + paymentId: string; +}; + export class CommerceApiError extends Error { statusCode: number; @@ -72,8 +77,12 @@ export async function getOrder(orderId: string) { } } +export function getOrderPayment(orderId: string) { + return request(`/api/payments/by-order/${orderId}`); +} + export function confirmOrder(orderId: string) { - return request(`/api/orders/${orderId}/confirm`, { + return request(`/api/orders/${orderId}/confirm`, { method: "POST", }); } diff --git a/apps/orders/src/lib/order.ts b/apps/orders/src/lib/order.ts index 34aba70..548d260 100644 --- a/apps/orders/src/lib/order.ts +++ b/apps/orders/src/lib/order.ts @@ -32,6 +32,29 @@ export type Order = { updatedAt: string; }; +export type OrderPaymentStatus = + | "PENDING" + | "PAID" + | "FAILED" + | "REFUNDED" + | "CANCELLED"; + +export type OrderPaymentMethod = "BANK_TRANSFER" | "VIRTUAL_ACCOUNT" | "CARD"; + +export type OrderPayment = { + id: string; + orderId: string; + amount: number; + currency: Currency; + method: OrderPaymentMethod; + status: OrderPaymentStatus; + reference: string; + createdAt: string; + updatedAt: string; + paidAt: string | null; + refundedAt: string | null; +}; + export function formatCurrency(amount: number, currency: Currency) { return new Intl.NumberFormat("id-ID", { style: "currency", diff --git a/apps/orders/vercel.json b/apps/orders/vercel.json new file mode 100644 index 0000000..5d7b761 --- /dev/null +++ b/apps/orders/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["sin1"] +} diff --git a/apps/payments/src/app/favicon.ico b/apps/payments/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/payments/src/app/favicon.ico and /dev/null differ diff --git a/apps/payments/src/app/layout.tsx b/apps/payments/src/app/layout.tsx index 12dc101..8a8c079 100644 --- a/apps/payments/src/app/layout.tsx +++ b/apps/payments/src/app/layout.tsx @@ -1,11 +1,9 @@ +import { commerceMetadata } from "@commerce/platform-ui"; import type { Metadata } from "next"; import "./globals.css"; -export const metadata: Metadata = { - title: "Payments | Commerce Operations", - description: "Payment operations domain for Commerce Operations.", -}; +export const metadata: Metadata = commerceMetadata; export default function RootLayout({ children, diff --git a/apps/payments/src/app/payments/[paymentId]/page.tsx b/apps/payments/src/app/payments/[paymentId]/page.tsx index 1eb7b48..2d0b59c 100644 --- a/apps/payments/src/app/payments/[paymentId]/page.tsx +++ b/apps/payments/src/app/payments/[paymentId]/page.tsx @@ -1,5 +1,5 @@ import { createCommerceHref } from "@commerce/platform-ui"; -import { PageHeader, Panel, PanelHeader, buttonClassName } from "@commerce/ui"; +import { PageHeader, Panel, PanelHeader, SubmitButton } from "@commerce/ui"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -28,7 +28,6 @@ export default async function PaymentDetailPage({ searchParams, }: PaymentDetailPageProps) { const { paymentId } = await params; - const { error } = await searchParams; const payment = await getPayment(paymentId); @@ -59,15 +58,20 @@ export default async function PaymentDetailPage({

- +
- + + Mark as paid +
) : null} @@ -76,9 +80,9 @@ export default async function PaymentDetailPage({
- +
) : null} @@ -135,7 +139,7 @@ export default async function PaymentDetailPage({ Related order

-

+

{payment.orderId}

diff --git a/apps/payments/src/app/payments/actions.ts b/apps/payments/src/app/payments/actions.ts index e301289..0f35396 100644 --- a/apps/payments/src/app/payments/actions.ts +++ b/apps/payments/src/app/payments/actions.ts @@ -40,6 +40,10 @@ function getPaymentUrl(paymentId: string) { return `${getCommercePlatformUrl()}/payments/${paymentId}`; } +function getOrderUrl(orderId: string) { + return `${getCommercePlatformUrl()}/orders/${orderId}`; +} + async function runPaymentTransition( paymentId: string, transition: () => Promise, @@ -61,7 +65,22 @@ async function runPaymentTransition( export async function markPaymentPaidAction(formData: FormData) { const paymentId = getString(formData, "paymentId"); - await runPaymentTransition(paymentId, () => markPaymentPaid(paymentId)); + let orderId: string; + + try { + const payment = await markPaymentPaid(paymentId); + + orderId = payment.orderId; + } catch (error) { + const message = encodeURIComponent(getErrorMessage(error)); + + redirect(`${getPaymentUrl(paymentId)}?error=${message}`); + } + + revalidatePath("/payments"); + revalidatePath(`/payments/${paymentId}`); + + redirect(getOrderUrl(orderId)); } export async function markPaymentFailedAction(formData: FormData) { diff --git a/apps/payments/src/components/PaymentStatusBadge.tsx b/apps/payments/src/components/PaymentStatusBadge.tsx index 7a6f46d..a54269a 100644 --- a/apps/payments/src/components/PaymentStatusBadge.tsx +++ b/apps/payments/src/components/PaymentStatusBadge.tsx @@ -11,6 +11,7 @@ const statusVariants: Record = { PAID: "success", FAILED: "danger", REFUNDED: "accent", + CANCELLED: "neutral", }; export function PaymentStatusBadge({ status }: PaymentStatusBadgeProps) { diff --git a/apps/payments/src/lib/payment.ts b/apps/payments/src/lib/payment.ts index 9f11064..3b8ba49 100644 --- a/apps/payments/src/lib/payment.ts +++ b/apps/payments/src/lib/payment.ts @@ -1,4 +1,9 @@ -export type PaymentStatus = "PENDING" | "PAID" | "FAILED" | "REFUNDED"; +export type PaymentStatus = + | "PENDING" + | "PAID" + | "FAILED" + | "REFUNDED" + | "CANCELLED"; export type PaymentMethod = "BANK_TRANSFER" | "VIRTUAL_ACCOUNT" | "CARD"; diff --git a/apps/payments/vercel.json b/apps/payments/vercel.json new file mode 100644 index 0000000..5d7b761 --- /dev/null +++ b/apps/payments/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["sin1"] +} diff --git a/apps/quotations/src/app/favicon.ico b/apps/quotations/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/quotations/src/app/favicon.ico and /dev/null differ diff --git a/apps/quotations/src/app/layout.tsx b/apps/quotations/src/app/layout.tsx index b977914..8a8c079 100644 --- a/apps/quotations/src/app/layout.tsx +++ b/apps/quotations/src/app/layout.tsx @@ -1,11 +1,9 @@ +import { commerceMetadata } from "@commerce/platform-ui"; import type { Metadata } from "next"; import "./globals.css"; -export const metadata: Metadata = { - title: "Quotations | Commerce Operations", - description: "Quotation management domain for Commerce Operations.", -}; +export const metadata: Metadata = commerceMetadata; export default function RootLayout({ children, diff --git a/apps/quotations/src/app/quotations/[quotationId]/page.tsx b/apps/quotations/src/app/quotations/[quotationId]/page.tsx index d53b6e4..4ec487f 100644 --- a/apps/quotations/src/app/quotations/[quotationId]/page.tsx +++ b/apps/quotations/src/app/quotations/[quotationId]/page.tsx @@ -5,6 +5,7 @@ import { Panel, PanelHeader, ProgressSteps, + SubmitButton, buttonClassName, type ProgressStep, } from "@commerce/ui"; @@ -39,7 +40,6 @@ export default async function QuotationDetailPage({ searchParams, }: QuotationDetailPageProps) { const { quotationId } = await params; - const { error } = await searchParams; const quotation = await getQuotation(quotationId); @@ -85,7 +85,9 @@ export default async function QuotationDetailPage({
- + + Send quotation +
) : null} @@ -98,7 +100,9 @@ export default async function QuotationDetailPage({ value={quotation.id} /> - + + Reject +
@@ -108,9 +112,9 @@ export default async function QuotationDetailPage({ value={quotation.id} /> - +
) : null} @@ -119,9 +123,9 @@ export default async function QuotationDetailPage({
- +
) : null} @@ -168,7 +172,9 @@ export default async function QuotationDetailPage({
@@ -251,8 +257,9 @@ export default async function QuotationDetailPage({

- This quotation was converted into
- + This quotation was converted into +
+ {quotation.convertedOrderId}
diff --git a/apps/quotations/src/app/quotations/new/page.tsx b/apps/quotations/src/app/quotations/new/page.tsx index d87e4f8..8114947 100644 --- a/apps/quotations/src/app/quotations/new/page.tsx +++ b/apps/quotations/src/app/quotations/new/page.tsx @@ -1,4 +1,10 @@ -import { PageHeader, Panel, PanelHeader, buttonClassName } from "@commerce/ui"; +import { + PageHeader, + Panel, + PanelHeader, + SubmitButton, + buttonClassName, +} from "@commerce/ui"; import Link from "next/link"; import { createQuotationAction } from "@/app/quotations/actions"; @@ -221,12 +227,12 @@ export default async function NewQuotationPage({

- +
diff --git a/apps/quotations/vercel.json b/apps/quotations/vercel.json new file mode 100644 index 0000000..5d7b761 --- /dev/null +++ b/apps/quotations/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["sin1"] +} diff --git a/apps/shell/src/app/favicon.ico b/apps/shell/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/shell/src/app/favicon.ico and /dev/null differ diff --git a/apps/shell/src/app/layout.tsx b/apps/shell/src/app/layout.tsx index 0b9566e..8a8c079 100644 --- a/apps/shell/src/app/layout.tsx +++ b/apps/shell/src/app/layout.tsx @@ -1,11 +1,9 @@ +import { commerceMetadata } from "@commerce/platform-ui"; import type { Metadata } from "next"; import "./globals.css"; -export const metadata: Metadata = { - title: "Commerce Operations", - description: "A B2B commerce operations platform built with micro-frontends.", -}; +export const metadata: Metadata = commerceMetadata; export default function RootLayout({ children, diff --git a/apps/shell/src/app/page.tsx b/apps/shell/src/app/page.tsx index a62b62b..100a264 100644 --- a/apps/shell/src/app/page.tsx +++ b/apps/shell/src/app/page.tsx @@ -15,7 +15,7 @@ const domains = [ { title: "Orders", description: - "Manage order confirmation, fulfillment, shipping, and completion after commercial handover.", + "Manage confirmed customer orders through payment clearance, fulfillment, shipping, and completion.", href: "/orders", ownership: "Order Operations", accent: "from-blue-500 to-cyan-500", @@ -35,7 +35,7 @@ const domains = [ { title: "Payments", description: - "Monitor payment settlement, failed transactions, completed payments, and refunds.", + "Track payment settlement for confirmed orders, including pending, paid, failed, and refunded transactions.", href: "/payments", ownership: "Finance Operations", accent: "from-emerald-500 to-teal-500", @@ -48,8 +48,8 @@ const workflow = [ "Quotation", "Approval", "Order", - "Fulfillment", "Payment", + "Fulfillment", "Completed", ]; @@ -59,7 +59,7 @@ export default function Home() {
Microfrontend architecture @@ -94,8 +94,8 @@ export default function Home() {
@@ -103,7 +103,7 @@ export default function Home() { Operational} /> diff --git a/apps/shell/vercel.json b/apps/shell/vercel.json new file mode 100644 index 0000000..5d7b761 --- /dev/null +++ b/apps/shell/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["sin1"] +} diff --git a/e2e/order-cancellation.spec.ts b/e2e/order-cancellation.spec.ts new file mode 100644 index 0000000..8e95393 --- /dev/null +++ b/e2e/order-cancellation.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +const COMMERCE_BASE_PATH = "/work/commerce-operations"; +const COMMERCE_API_ORIGIN = "http://localhost:3003"; + +test.describe("order cancellation workflow", () => { + test("cancels a pending payment when its confirmed order is cancelled", async ({ + page, + request, + }) => { + const orderId = "ORD-2026-0181"; + const paymentId = "PAY-2026-0091"; + + await page.goto(`${COMMERCE_BASE_PATH}/orders/${orderId}`); + + await expect( + page.getByRole("heading", { + name: orderId, + }), + ).toBeVisible(); + + await expect( + page.getByText(paymentId, { + exact: true, + }), + ).toBeVisible(); + + await expect( + page + .getByText("PENDING", { + exact: true, + }) + .first(), + ).toBeVisible(); + + await expect( + page.getByRole("button", { + name: "Start processing", + }), + ).toBeDisabled(); + + const cancelButton = page.getByRole("button", { + name: "Cancel order", + }); + + await expect(cancelButton).toBeEnabled(); + + await cancelButton.click(); + + await expect( + page + .getByText("CANCELLED", { + exact: true, + }) + .first(), + ).toBeVisible(); + + await expect( + page.getByRole("button", { + name: "Start processing", + }), + ).not.toBeVisible(); + + await Promise.all([ + page.waitForURL( + new RegExp(`/work/commerce-operations/payments/${paymentId}$`), + { + timeout: 20_000, + waitUntil: "commit", + }, + ), + + page + .getByRole("link", { + name: /View payment/i, + }) + .click(), + ]); + + await expect( + page.getByRole("heading", { + name: paymentId, + }), + ).toBeVisible(); + + await expect( + page + .getByText("CANCELLED", { + exact: true, + }) + .first(), + ).toBeVisible(); + + await expect( + page.getByRole("button", { + name: "Mark as paid", + }), + ).not.toBeVisible(); + + const blockedPaymentResponse = await request.post( + `${COMMERCE_API_ORIGIN}/api/payments/${paymentId}/pay`, + ); + + expect(blockedPaymentResponse.status()).toBe(409); + + const blockedPaymentBody = (await blockedPaymentResponse.json()) as { + error?: { + message?: string; + }; + }; + + expect(blockedPaymentBody.error?.message).toContain( + "cannot transition from CANCELLED to PAID", + ); + }); +}); diff --git a/e2e/quote-to-cash.spec.ts b/e2e/quote-to-cash.spec.ts new file mode 100644 index 0000000..6d01012 --- /dev/null +++ b/e2e/quote-to-cash.spec.ts @@ -0,0 +1,337 @@ +import { expect, test } from "@playwright/test"; + +const COMMERCE_BASE_PATH = "/work/commerce-operations"; +const COMMERCE_API_ORIGIN = "http://localhost:3003"; + +test.describe("quote-to-cash workflow", () => { + test("moves a quotation through payment clearance and fulfillment across zones", async ({ + page, + request, + }) => { + test.setTimeout(90_000); + + await page.goto(`${COMMERCE_BASE_PATH}/quotations`); + + await expect( + page.getByRole("heading", { + name: "Quotations", + }), + ).toBeVisible(); + + await page + .getByRole("link", { + name: /Create quotation/i, + }) + .click(); + + await expect( + page.getByRole("heading", { + name: "Create quotation", + }), + ).toBeVisible(); + + await page.getByLabel("Customer account").selectOption("CUST-0192"); + await page.getByLabel("SKU").fill("E2E-MONITOR-01"); + await page.getByLabel("Item name").fill("E2E Test Monitor"); + await page.getByLabel("Quantity").fill("2"); + await page.getByLabel("Unit price").fill("5000000"); + await page.getByLabel("Discount").fill("1000000"); + + await page + .getByRole("button", { + name: "Create draft quotation", + }) + .click(); + + await expect(page).toHaveURL( + /\/work\/commerce-operations\/quotations\/QT-\d{4}-\d{4}$/, + ); + + const quotationId = page.url().split("/").pop(); + + expect(quotationId).toBeTruthy(); + + if (!quotationId) { + throw new Error( + "Quotation ID was not found after creating the quotation.", + ); + } + + await expect( + page.getByRole("button", { + name: "Send quotation", + }), + ).toBeVisible(); + + await page + .getByRole("button", { + name: "Send quotation", + }) + .click(); + + await expect( + page.getByRole("button", { + name: "Accept quotation", + }), + ).toBeVisible(); + + await page + .getByRole("button", { + name: "Accept quotation", + }) + .click(); + + const convertButton = page.getByRole("button", { + name: /Convert to order/i, + }); + + await expect(convertButton).toBeVisible(); + + await Promise.all([ + page.waitForURL(/\/work\/commerce-operations\/orders\/ORD-\d{4}-\d{4}$/, { + timeout: 20_000, + waitUntil: "commit", + }), + + convertButton.click(), + ]); + + const orderId = page.url().split("/").pop(); + + expect(orderId).toBeTruthy(); + + if (!orderId) { + throw new Error("Order ID was not found after converting the quotation."); + } + + expect(orderId).toMatch(/^ORD-\d{4}-\d{4}$/); + + await expect( + page.getByRole("heading", { + name: orderId, + }), + ).toBeVisible(); + + await expect( + page + .getByText(quotationId, { + exact: true, + }) + .first(), + ).toBeVisible(); + + // A converted order starts as PENDING and must not have a payment yet. + await expect( + page.getByText("NOT CREATED", { + exact: true, + }), + ).toBeVisible(); + + const confirmButton = page.getByRole("button", { + name: "Confirm order", + }); + + await Promise.all([ + page.waitForURL( + /\/work\/commerce-operations\/payments\/PAY-\d{4}-\d{4}$/, + { + timeout: 20_000, + waitUntil: "commit", + }, + ), + + confirmButton.click(), + ]); + + const paymentId = page.url().split("/").pop(); + + expect(paymentId).toBeTruthy(); + + if (!paymentId) { + throw new Error("Payment ID was not found after confirming the order."); + } + + expect(paymentId).toMatch(/^PAY-\d{4}-\d{4}$/); + + await expect( + page.getByRole("heading", { + name: paymentId, + }), + ).toBeVisible(); + + await expect( + page + .getByText("PENDING", { + exact: true, + }) + .first(), + ).toBeVisible(); + + // Backend must reject fulfillment while payment is still pending. + const blockedProcessingResponse = await request.post( + `${COMMERCE_API_ORIGIN}/api/orders/${orderId}/process`, + ); + + expect(blockedProcessingResponse.status()).toBe(409); + + const blockedProcessingBody = (await blockedProcessingResponse.json()) as { + error?: { + message?: string; + }; + }; + + expect(blockedProcessingBody.error?.message).toContain( + "cannot start processing until payment is PAID", + ); + + // Return to the order before paying to verify the UI gate. + await Promise.all([ + page.waitForURL( + new RegExp(`/work/commerce-operations/orders/${orderId}$`), + { + timeout: 20_000, + waitUntil: "commit", + }, + ), + + page + .getByRole("link", { + name: /View order/i, + }) + .click(), + ]); + + await expect( + page.getByText(paymentId, { + exact: true, + }), + ).toBeVisible(); + + await expect( + page + .getByText("PENDING", { + exact: true, + }) + .first(), + ).toBeVisible(); + + await expect( + page.getByRole("button", { + name: "Start processing", + }), + ).toBeDisabled(); + + // Go back to the payment and clear it. + await Promise.all([ + page.waitForURL( + new RegExp(`/work/commerce-operations/payments/${paymentId}$`), + { + timeout: 20_000, + waitUntil: "commit", + }, + ), + + page + .getByRole("link", { + name: /View payment/i, + }) + .click(), + ]); + + const markPaidButton = page.getByRole("button", { + name: "Mark as paid", + }); + + await expect(markPaidButton).toBeVisible(); + + await Promise.all([ + page.waitForURL( + new RegExp(`/work/commerce-operations/orders/${orderId}$`), + { + timeout: 20_000, + waitUntil: "commit", + }, + ), + + markPaidButton.click(), + ]); + + await expect( + page.getByText(paymentId, { + exact: true, + }), + ).toBeVisible(); + + await expect( + page + .getByText("PAID", { + exact: true, + }) + .first(), + ).toBeVisible(); + + const startProcessingButton = page.getByRole("button", { + name: "Start processing", + }); + + await expect(startProcessingButton).toBeEnabled(); + + // A PAID order cannot be cancelled before its payment is refunded. + await expect( + page.getByRole("button", { + name: "Cancel order", + }), + ).toBeDisabled(); + + const blockedCancellationResponse = await request.post( + `${COMMERCE_API_ORIGIN}/api/orders/${orderId}/cancel`, + ); + + expect(blockedCancellationResponse.status()).toBe(409); + + const blockedCancellationBody = + (await blockedCancellationResponse.json()) as { + error?: { + message?: string; + }; + }; + + expect(blockedCancellationBody.error?.message).toContain( + "must be refunded before order", + ); + + await startProcessingButton.click(); + + await expect( + page.getByRole("button", { + name: "Mark shipped", + }), + ).toBeVisible(); + + await page + .getByRole("button", { + name: "Mark shipped", + }) + .click(); + + await expect( + page.getByRole("button", { + name: "Complete order", + }), + ).toBeVisible(); + + await page + .getByRole("button", { + name: "Complete order", + }) + .click(); + + await expect( + page + .getByText("COMPLETED", { + exact: true, + }) + .first(), + ).toBeVisible(); + }); +}); diff --git a/e2e/quote-to-order.spec.ts b/e2e/quote-to-order.spec.ts deleted file mode 100644 index 5e37b77..0000000 --- a/e2e/quote-to-order.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const COMMERCE_BASE_PATH = "/work/commerce-operations"; - -test.describe("quote-to-order workflow", () => { - test("creates, approves, converts, and fulfills an order across microfrontends", async ({ - page, - }) => { - test.setTimeout(60_000); - - await page.goto(`${COMMERCE_BASE_PATH}/quotations`); - - await expect( - page.getByRole("heading", { - name: "Quotations", - }), - ).toBeVisible(); - - await page - .getByRole("link", { - name: /Create quotation/i, - }) - .click(); - - await expect( - page.getByRole("heading", { - name: "Create quotation", - }), - ).toBeVisible(); - - await page.getByLabel("Customer account").selectOption("CUST-0192"); - - await page.getByLabel("SKU").fill("E2E-MONITOR-01"); - - await page.getByLabel("Item name").fill("E2E Test Monitor"); - - await page.getByLabel("Quantity").fill("2"); - - await page.getByLabel("Unit price").fill("5000000"); - - await page.getByLabel("Discount").fill("1000000"); - - await page - .getByRole("button", { - name: "Create draft quotation", - }) - .click(); - - await expect(page).toHaveURL( - /\/work\/commerce-operations\/quotations\/QT-\d{4}-\d{4}$/, - ); - - const quotationId = page.url().split("/").pop(); - - expect(quotationId).toBeTruthy(); - - if (!quotationId) { - throw new Error( - "Quotation ID was not found after creating the quotation.", - ); - } - - await expect( - page.getByRole("button", { - name: "Send quotation", - }), - ).toBeVisible(); - - await page - .getByRole("button", { - name: "Send quotation", - }) - .click(); - - await expect( - page.getByRole("button", { - name: "Accept quotation", - }), - ).toBeVisible(); - - await page - .getByRole("button", { - name: "Accept quotation", - }) - .click(); - - const convertButton = page.getByRole("button", { - name: /Convert to order/i, - }); - - await expect(convertButton).toBeVisible(); - - await Promise.all([ - page.waitForURL(/\/work\/commerce-operations\/orders\/ORD-\d{4}-\d{4}$/, { - timeout: 20_000, - waitUntil: "commit", - }), - - convertButton.click(), - ]); - - const orderId = page.url().split("/").pop(); - - expect(orderId).toBeTruthy(); - - if (!orderId) { - throw new Error("Order ID was not found after converting the quotation."); - } - - expect(orderId).toMatch(/^ORD-\d{4}-\d{4}$/); - - await expect( - page.getByRole("heading", { - name: orderId, - }), - ).toBeVisible(); - - await expect( - page - .getByText(quotationId, { - exact: true, - }) - .first(), - ).toBeVisible(); - - await page - .getByRole("button", { - name: "Confirm order", - }) - .click(); - - await expect( - page.getByRole("button", { - name: "Start processing", - }), - ).toBeVisible(); - - await page - .getByRole("button", { - name: "Start processing", - }) - .click(); - - await expect( - page.getByRole("button", { - name: "Mark shipped", - }), - ).toBeVisible(); - - await page - .getByRole("button", { - name: "Mark shipped", - }) - .click(); - - await expect( - page.getByRole("button", { - name: "Complete order", - }), - ).toBeVisible(); - - await page - .getByRole("button", { - name: "Complete order", - }) - .click(); - - await expect( - page - .getByText("COMPLETED", { - exact: true, - }) - .first(), - ).toBeVisible(); - }); -}); diff --git a/package-lock.json b/package-lock.json index df4e117..6e10a08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10143,12 +10143,15 @@ "version": "0.1.0", "devDependencies": { "@types/react": "^19", + "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.3.2", + "react-dom": "19.2.8", "typescript": "^5" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" } } } diff --git a/packages/platform-ui/src/index.ts b/packages/platform-ui/src/index.ts index 99b5b1d..8e6862e 100644 --- a/packages/platform-ui/src/index.ts +++ b/packages/platform-ui/src/index.ts @@ -4,3 +4,10 @@ export { createCommerceHref, type CommerceSection, } from "./CommerceShell"; + +export { + COMMERCE_FAVICON_URL, + COMMERCE_METADATA_DESCRIPTION, + COMMERCE_METADATA_TITLE, + commerceMetadata, +} from "./metadata"; diff --git a/packages/platform-ui/src/metadata.ts b/packages/platform-ui/src/metadata.ts new file mode 100644 index 0000000..6be33c7 --- /dev/null +++ b/packages/platform-ui/src/metadata.ts @@ -0,0 +1,16 @@ +export const COMMERCE_METADATA_TITLE = "Commerce Operations | Seiba Shonia"; + +export const COMMERCE_METADATA_DESCRIPTION = + "A B2B quote-to-cash operations platform covering quotations, orders, payments, customers, and fulfillment."; + +export const COMMERCE_FAVICON_URL = "https://seibashonia.dev/favicon.ico"; + +export const commerceMetadata = { + title: COMMERCE_METADATA_TITLE, + description: COMMERCE_METADATA_DESCRIPTION, + + icons: { + icon: COMMERCE_FAVICON_URL, + shortcut: COMMERCE_FAVICON_URL, + }, +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index c861714..a09115d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,12 +12,15 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { "@types/react": "^19", + "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.3.2", + "react-dom": "19.2.8", "typescript": "^5" } } diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx index 6e7dfe5..6a3b4b6 100644 --- a/packages/ui/src/Button.tsx +++ b/packages/ui/src/Button.tsx @@ -15,7 +15,7 @@ export function buttonClassName( className = "", ) { const base = - "inline-flex items-center justify-center rounded-lg font-semibold transition focus:outline-none focus:ring-2 focus:ring-indigo-500/30 disabled:pointer-events-none disabled:opacity-50"; + "cursor-pointer inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition focus:outline-none focus:ring-2 focus:ring-indigo-500/30 disabled:pointer-events-none disabled:opacity-50"; const variants: Record = { primary: diff --git a/packages/ui/src/SubmitButton.tsx b/packages/ui/src/SubmitButton.tsx new file mode 100644 index 0000000..416d386 --- /dev/null +++ b/packages/ui/src/SubmitButton.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { useFormStatus } from "react-dom"; + +import { buttonClassName, type ButtonSize, type ButtonVariant } from "./Button"; + +type SubmitButtonProps = Omit< + ButtonHTMLAttributes, + "children" | "className" +> & { + children: ReactNode; + pendingText: string; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +}; + +export function SubmitButton({ + children, + pendingText, + variant = "primary", + size = "md", + className = "", + disabled = false, + ...props +}: SubmitButtonProps) { + const { pending } = useFormStatus(); + + const isDisabled = disabled || pending; + + return ( + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d8e2cad..f8c5383 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -18,3 +18,5 @@ export { } from "./ProgressSteps"; export { StatCard } from "./StatCard"; + +export { SubmitButton } from "./SubmitButton";