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:
- A PWA form to create and manage returns
- An exchange workflow: a return linked atomically to a new replacement sale
- Store credit issuance as a refund method (depends on Feature 05)
- PDF generation for return documents
Existing foundation:
customer_returntable:id,sale_idFK,return_type(RETURN/REPLACEMENT/REPAIR),status,detailJSON,total_amountapps/backend/src/customer-returns/— full CRUD service + controllerCustomerReturnStatus: ACTIVE, INACTIVE, PENDING, APPROVED, REJECTED, PROCESSED, DELIVEREDReturnType: RETURN, REPLACEMENT, REPAIR
Relevant existing files:
apps/backend/src/customer-returns/application/customer-returns.service.tsapps/backend/src/customer-returns/interfaces/customer-returns.controller.tsapps/backend/src/customer-returns/interfaces/dtos/create-customer-return.dto.tsapps/frontend-pwa/src/components/forms/sale/SaleForm.tsx— reference for sale item patternapps/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: addexchange_sale_id,store_credit_id,refund_methodcolumns - 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
CreateExchangeDTOtointerfaces/dtos/ - Add
createExchange()method tocustomer-returns.service.ts - Add
POST /customer-returns/:id/exchangeendpoint to controller - Add PDF endpoints:
GET /customer-returns/:id/pdf,GET /customer-returns/:id/print - Integrate PDF generation using existing
PdfModule(followsales.service.tspattern) - 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-returnscreates a return linked to a sale -
POST /customer-returns/:id/exchangecreates 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]