Skip to main content

Feature 02 — Return / Exchange Workflow

Phase: 1 (Foundation) Priority: 🔴 Critical Status: ⏳ Pending

Context

The customer_return table and customer-returns NestJS module already exist with full CRUD. What is missing:

  1. A PWA form to create and manage returns
  2. An exchange workflow: a return linked atomically to a new replacement sale
  3. Store credit issuance as a refund method (depends on Feature 05)
  4. PDF generation for return documents

Existing foundation:

  • customer_return table: id, sale_id FK, return_type (RETURN/REPLACEMENT/REPAIR), status, detail JSON, total_amount
  • apps/backend/src/customer-returns/ — full CRUD service + controller
  • CustomerReturnStatus: ACTIVE, INACTIVE, PENDING, APPROVED, REJECTED, PROCESSED, DELIVERED
  • ReturnType: RETURN, REPLACEMENT, REPAIR

Relevant existing files:

  • apps/backend/src/customer-returns/application/customer-returns.service.ts
  • apps/backend/src/customer-returns/interfaces/customer-returns.controller.ts
  • apps/backend/src/customer-returns/interfaces/dtos/create-customer-return.dto.ts
  • apps/frontend-pwa/src/components/forms/sale/SaleForm.tsx — reference for sale item pattern
  • apps/frontend-pwa/src/services/salesService.ts — reference pattern

Task Checklist

Database

  • Create migration: packages/backend/database/src/migrations/2026-03-XX-customer-return-exchange.mjs
  • Alter customer_return: add exchange_sale_id, store_credit_id, refund_method columns
  • Add new enum values to customer_return_status: EXCHANGE_PENDING, EXCHANGE_COMPLETED
  • Run pnpm run migration:local:push
  • Run pnpm run generate:types

Backend

  • Add CreateExchangeDTO to interfaces/dtos/
  • Add createExchange() method to customer-returns.service.ts
  • Add POST /customer-returns/:id/exchange endpoint to controller
  • Add PDF endpoints: GET /customer-returns/:id/pdf, GET /customer-returns/:id/print
  • Integrate PDF generation using existing PdfModule (follow sales.service.ts pattern)
  • Update packages/global/enums/customer-return.enums.ts — add new status values

PWA Frontend

  • Create apps/frontend-pwa/src/types/customerReturn.ts
  • Create apps/frontend-pwa/src/services/customerReturnService.ts
  • Create apps/frontend-pwa/src/components/forms/customer-return/CustomerReturnPage.tsx
  • Create apps/frontend-pwa/src/components/forms/customer-return/CustomerReturnForm.tsx
  • Create apps/frontend-pwa/src/components/forms/customer-return/CustomerReturnList.tsx
  • Register form in apps/frontend-pwa/src/pages/MainPage.tsx

Verification

  • Migration applies cleanly
  • Types regenerated
  • Backend builds
  • POST /customer-returns creates a return linked to a sale
  • POST /customer-returns/:id/exchange creates return + new sale atomically
  • PWA form renders, sale lookup works, items are selectable
  • Return PDF generates correctly

Database Changes

// packages/backend/database/src/migrations/2026-03-XX-customer-return-exchange.mjs
import { Kysely, sql } from "kysely";

export async function up(db) {
await db.schema.alterTable("customer_return")
.addColumn("exchange_sale_id", "uuid", (col) =>
col.references("sale.id").onDelete("set null"))
.addColumn("store_credit_id", "uuid") // FK added after store_credit migration
.addColumn("refund_method", "varchar") // 'cash' | 'store_credit' | 'original_payment'
.execute();
}

export async function down(db) {
await db.schema.alterTable("customer_return")
.dropColumn("refund_method")
.dropColumn("store_credit_id")
.dropColumn("exchange_sale_id")
.execute();
}

Update global enums (packages/global/enums/customer-return.enums.ts):

export enum CustomerReturnStatus {
ACTIVE = "ACTIVE",
INACTIVE = "INACTIVE",
PENDING = "PENDING",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
PROCESSED = "PROCESSED",
DELIVERED = "DELIVERED",
EXCHANGE_PENDING = "EXCHANGE_PENDING", // NEW
EXCHANGE_COMPLETED = "EXCHANGE_COMPLETED", // NEW
}

export enum RefundMethod {
CASH = "cash",
STORE_CREDIT = "store_credit",
ORIGINAL_PAYMENT = "original_payment",
}

Backend Implementation

New DTO: create-exchange.dto.ts

export class CreateExchangeDTO {
@IsUUID() updatedBy: string;
@IsUUID() locationId: string;
@IsUUID() currencyId: string;
@IsNumber() exchangeRate: number;
@IsArray() @ValidateNested({ each: true }) @Type(() => ExchangeItemDTO)
exchangeItems: ExchangeItemDTO[];
}

export class ExchangeItemDTO {
@IsUUID() productId: string;
@IsOptional() @IsUUID() variantId?: string;
@IsNumber() @IsPositive() quantity: number;
@IsNumber() unitPrice: number;
}

Exchange Method in customer-returns.service.ts

async createExchange(returnId: string, dto: CreateExchangeDTO) {
// Fetch the customer return
const customerReturn = await this.repository.findById(returnId);
if (!customerReturn) throw new NotFoundException('Return not found');

// Use a DB transaction
return this.database.transaction().execute(async (trx) => {
// 1. Update customer_return status → EXCHANGE_PENDING
await this.repository.update(returnId, {
status: CustomerReturnStatus.EXCHANGE_PENDING,
updatedBy: dto.updatedBy,
}, trx);

// 2. Create a new DRAFT sale with the exchange items
const newSale = await this.salesService.createSale({
businessId: customerReturn.businessId,
locationId: dto.locationId,
currencyId: dto.currencyId,
exchangeRate: dto.exchangeRate,
status: SaleStatus.DRAFT,
createdBy: dto.updatedBy,
customerId: customerReturn.customerId,
saleDate: new Date().toISOString(),
saleDetail: { items: dto.exchangeItems },
paymentDetail: { items: [] },
// ... required fields
}, trx);

// 3. Link exchange_sale_id on the return
await this.repository.update(returnId, {
exchangeSaleId: newSale.id,
status: CustomerReturnStatus.EXCHANGE_COMPLETED,
}, trx);

return { customerReturn: updated, exchangeSale: newSale };
});
}

New Endpoints in customer-returns.controller.ts

@Post(":id/exchange")
async createExchange(
@Param("id") id: string,
@Body() dto: CreateExchangeDTO,
) { ... }

@Get(":id/pdf")
async getPdf(@Param("id") id: string, @Query("locationId") locationId: string) { ... }

@Get(":id/print")
async getPrintView(@Param("id") id: string) { ... }

PWA Frontend

Types: types/customerReturn.ts

import { CustomerReturnStatus, ReturnType, RefundMethod } from "@flowpos-workspace/global/enums/customer-return.enums";

export interface CustomerReturnItem {
productId: string;
productName?: string;
variantId?: string;
variantName?: string;
quantity: number;
unitPrice: number;
amount: number;
reason?: string;
}

export interface CustomerReturn {
id?: string;
businessId: string;
locationId: string;
locationName?: string;
status: CustomerReturnStatus;
returnDate: string;
customerId?: string;
saleId: string;
documentNumber?: string;
returnType: ReturnType;
refundMethod?: RefundMethod;
reason?: string;
detail: { items: CustomerReturnItem[] };
totalAmount: number;
totalBaseAmount: number;
currencyId: string;
exchangeRate: number;
exchangeSaleId?: string;
storeCreditId?: string;
}

export interface CustomerReturnFormData {
id?: string;
saleId: string;
returnType: ReturnType;
refundMethod: RefundMethod;
reason?: string;
items: CustomerReturnItem[];
currencyId: string;
exchangeRate: number;
// For exchange flow:
exchangeItems?: ExchangeItem[];
}

Service: customerReturnService.ts

const BASE_URL = `${API_URL}/customer-returns`;

export async function getCustomerReturns(
token: string,
businessId: string,
page = 1,
size = 10,
status?: string,
): Promise<IOffsetPagination<CustomerReturn>>

export async function getCustomerReturnById(token: string, id: string): Promise<CustomerReturn>

export async function createCustomerReturn(
token: string,
data: Partial<CustomerReturn>,
): Promise<CustomerReturn>

export async function updateCustomerReturn(
token: string,
id: string,
data: Partial<CustomerReturn>,
): Promise<CustomerReturn>

export async function createExchange(
token: string,
returnId: string,
data: CreateExchangeData,
): Promise<{ customerReturn: CustomerReturn; exchangeSale: Sale }>

export async function generateCustomerReturnPDF(token: string, id: string): Promise<Blob>
export async function downloadCustomerReturnPDF(token: string, id: string): Promise<void>

Page: CustomerReturnPage.tsx

export function CustomerReturnPage() {
const [mode, setMode] = useState<"list" | "new-return" | "new-exchange">("list");
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { token } = useAuth();
const { currentBusiness } = useCurrentBusiness();
const { currentLocation } = useCurrentLocation();
const { t } = useTranslation();

// ... handleSubmit, handleExchange, handleNewDocument
}

Form: CustomerReturnForm.tsx — Key Sections

1. SALE LOOKUP
┌─────────────────────────────────────┐
│ Search sale by document number... │
│ [Found: SALE-2026-001 | $450.00] │
└─────────────────────────────────────┘

2. ITEMS TO RETURN (from original sale)
┌──────────────────────────────────────────┐
│ ☑ Blue Shirt / M qty: [1] of 2 $45 │
│ ☑ Red Pants / L qty: [1] of 1 $65 │
└──────────────────────────────────────────┘

3. RETURN DETAILS
Return type: (●) Return ( ) Exchange ( ) Repair
Refund method: (●) Cash ( ) Store Credit ( ) Original Payment
Reason: [Defective / Wrong size / Changed mind / Other ▼]

4. EXCHANGE ITEMS (shown when type = Exchange)
[+ Add replacement item]
┌──────────────────────────────────────────┐
│ [Product search...] qty: [1] $45 │
└──────────────────────────────────────────┘

5. SUMMARY
Items returned: 2 | Refund total: $110.00
[Cancel] [Submit Return]