Phase 5: Transactions & Returns
Depends on: Phase 4 (Inventory & Serials) New Endpoints: 15 New Files: 22
Goal
Implement the transaction lifecycle: creating transactions (from POS terminal), listing/viewing transactions, voiding transactions, generating summary/daily-sales reports, processing returns, and managing store discounts. After this phase, the core POS flow is functional — products can be sold, refunded, and discounted.
Endpoints
Store-Scoped — All Store Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /stores/:store_id/transactions | TransactionController.list | List transactions (paginated) |
| GET | /stores/:store_id/transactions/:id | TransactionController.get | Get transaction with items + payments |
| GET | /stores/:store_id/transactions/summary | TransactionController.getSummary | Aggregate stats (total sales, count, avg) |
| GET | /stores/:store_id/transactions/daily-sales | TransactionController.getDailySales | Daily sales breakdown |
| GET | /stores/:store_id/discounts | DiscountController.list | List discounts (paginated) |
| GET | /stores/:store_id/discounts/:id | DiscountController.get | Get single discount |
| GET | /stores/:store_id/discounts/active | DiscountController.getActive | List currently active discounts |
| GET | /stores/:store_id/returns | ReturnController.list | List returns (paginated) |
| GET | /stores/:store_id/returns/:id | ReturnController.get | Get return with details |
Store-Scoped — Store Manager+ (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /stores/:store_id/transactions/:id/void | TransactionController.voidTx | Void a transaction |
| POST | /stores/:store_id/returns | ReturnController.create | Create a return |
Store-Scoped — Store Admin Only (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /stores/:store_id/discounts | DiscountController.create | Create discount |
| PUT | /stores/:store_id/discounts/:id | DiscountController.update | Update discount |
| DELETE | /stores/:store_id/discounts/:id | DiscountController.delete | Delete discount |
| POST | /stores/:store_id/discounts/:id/toggle | DiscountController.toggleActive | Toggle active status |
SQLDelight Tables & Queries
transactions table
Key queries:
findByStoreId(store_id, limit, offset)- Paginated listfindById(transaction_id)- Single lookupcountByStoreId(store_id)- Pagination countgetSummary(store_id, start_date, end_date)- Aggregate statsgetDailySales(store_id, start_date, end_date)- Daily breakdowninsert(...)- Create transactionupdateStatus(status, transaction_id)- Void
transaction_items table
Key queries:
findByTransactionId(transaction_id)- Items for a transactioninsert(...)- Create item
transaction_payments table
Key queries:
findByTransactionId(transaction_id)- Payments for a transactioninsert(...)- Create payment
returns table
Key queries:
findByStoreId(store_id, limit, offset)- Paginated listfindById(return_id)- Single lookupfindByIdWithDetails(return_id)- With user/product/customer names (JOIN)countByStoreId(store_id)- Pagination countinsert(...)- Create return
discounts table
Key queries:
findByStoreId(store_id, limit, offset)- Paginated listfindById(discount_id)- Single lookupfindActiveByStoreId(store_id)- Active discounts (within date range)countByStoreId(store_id)- Pagination countinsert(...)- Createupdate(...)- Updatedelete(discount_id)- Hard delete or soft deletetoggleActive(active, discount_id)- Toggle
Files to Create
Controllers (3)
controller/store/TransactionController.java
@RequestMapping("/stores/{storeId}/transactions")
GET / -> transactionService.list(storeId, page, size)
GET /{id} -> transactionService.get(id)
GET /summary -> transactionService.getSummary(storeId, startDate, endDate)
GET /daily-sales -> transactionService.getDailySales(storeId, startDate, endDate)
POST /{id}/void -> transactionService.voidTransaction(id)
controller/store/ReturnController.java
@RequestMapping("/stores/{storeId}/returns")
GET / -> returnService.list(storeId, page, size)
GET /{id} -> returnService.get(id)
POST / -> returnService.create(storeId, request, authenticatedUser)
controller/store/DiscountController.java
@RequestMapping("/stores/{storeId}/discounts")
GET / -> discountService.list(storeId, page, size)
GET /{id} -> discountService.get(id)
GET /active -> discountService.getActive(storeId)
POST / -> discountService.create(storeId, request)
PUT /{id} -> discountService.update(id, request)
DELETE /{id} -> discountService.delete(id)
POST /{id}/toggle -> discountService.toggleActive(id)
Services (3)
service/TransactionService.java
- list, get (with items + payments), getSummary, getDailySales, voidTransaction
- get() joins transaction_items and transaction_payments
- voidTransaction: sets status to "voided", optionally reverses inventory
- Summary: aggregates total_spent, count, average for date range
- Daily sales: groups by date within range
service/ReturnService.java
- list, get, create
- create: validates product exists, validates transaction if provided
- If restocked=true, creates inventory_ledger entry to add back quantity
- Updates serial number status if applicable
service/DiscountService.java
- list, get, getActive, create, update, delete, toggleActive
- getActive: filters by date range (start_date <= now <= end_date) AND active=true
- Validates: discount belongs to store
Repositories (3)
repository/TransactionRepository.java
repository/ReturnRepository.java
repository/DiscountRepository.java
Request DTOs (3)
dto/request/CreateReturnRequest.java
{ customerId: UUID, productId: @NotNull UUID, transactionId: UUID,
quantity: @NotNull @Min(1) int, refundAmount: @NotNull BigDecimal,
reason: @NotBlank String, reasonNotes: String, restocked: boolean }
dto/request/CreateDiscountRequest.java
{ name: @NotBlank String, description: String,
startDate: @NotNull Instant, endDate: @NotNull Instant,
productId: UUID, productType: String,
discountType: @NotBlank @Pattern("percent|fixed") String,
amount: @NotNull BigDecimal, minPurchase: BigDecimal,
maxDiscount: BigDecimal, active: boolean }
dto/request/UpdateDiscountRequest.java
(same fields as CreateDiscountRequest)
Response DTOs (7)
dto/response/TransactionResponse.java
{ transactionId: UUID, transactionRef: int, storeId: UUID, orgId: UUID,
userId: UUID, customerId: UUID, terminalId: UUID,
datetime: Instant, subtotal: BigDecimal, taxApplied: BigDecimal,
discountTotal: BigDecimal, totalSpent: BigDecimal,
paymentMethod: String, status: String,
createdAt: Instant, updatedAt: Instant }
dto/response/TransactionDetailResponse.java
extends TransactionResponse +
{ userName: String, customerName: String,
items: List<TransactionItemDto>, payments: List<TransactionPaymentDto> }
(inner DTOs for items and payments)
dto/response/TransactionSummaryResponse.java
{ totalSales: BigDecimal, transactionCount: int, averageTransaction: BigDecimal,
totalTax: BigDecimal, totalDiscounts: BigDecimal, startDate: Instant, endDate: Instant }
dto/response/DailySalesResponse.java
{ date: LocalDate, totalSales: BigDecimal, transactionCount: int }
dto/response/ReturnResponse.java
{ returnId: UUID, storeId: UUID, userId: UUID, customerId: UUID,
productId: UUID, transactionId: UUID, quantity: int,
refundAmount: BigDecimal, dateReturned: Instant,
reason: String, reasonNotes: String, restocked: boolean, createdAt: Instant }
dto/response/ReturnWithDetailsResponse.java
extends ReturnResponse + { userName: String, productName: String, customerName: String }
dto/response/DiscountResponse.java
{ discountId: UUID, orgId: UUID, storeId: UUID, productId: UUID,
name: String, description: String, discountType: String,
amount: BigDecimal, minPurchase: BigDecimal, maxDiscount: BigDecimal,
startDate: Instant, endDate: Instant, productType: String,
active: boolean, createdAt: Instant, updatedAt: Instant }
Mappers (3)
mapper/TransactionMapper.java
- toResponse(Transaction) -> TransactionResponse
- toDetailResponse(Transaction, List<TransactionItem>, List<TransactionPayment>) -> TransactionDetailResponse
- toSummaryResponse(aggregate row) -> TransactionSummaryResponse
- toDailySalesResponse(aggregate row) -> DailySalesResponse
mapper/ReturnMapper.java
- toResponse(Return) -> ReturnResponse
- toDetailResponse(ReturnWithDetails) -> ReturnWithDetailsResponse
mapper/DiscountMapper.java
- toResponse(Discount) -> DiscountResponse
Business Rules
- Transactions are created by the Android POS terminal, not directly via REST (the transaction endpoints are read-only + void; creation happens via terminal-api). Merchant-api provides list/view/void/summary.
- Voiding a transaction sets
status = "voided"— does NOT delete the record - Voiding can optionally reverse inventory (add back items to stock)
- Returns create an inventory ledger entry if
restocked = true - Returns update serial number status if the product has serial tracking
- Discounts have a date range —
getActivefilters by current date - Discount types:
percent(percentage off) orfixed(flat amount off) max_discountcaps the total discount amount for percentage discountsmin_purchasesets a minimum order value for the discount to apply- Summary/daily-sales accept
?start_date=and?end_date=query params
Acceptance Criteria
- Transaction list/get works with items and payments joined
- Transaction summary returns correct aggregates for date range
- Daily sales returns per-day breakdown
- Voiding a transaction updates status and optionally reverses inventory
- Return creation validates product/transaction and handles restocking
- Discount CRUD works with active filtering by date range
- Discount toggle flips active status
- All list endpoints support pagination
- Bazel build passes
- Unit tests for TransactionService, ReturnService, DiscountService