Skip to main content

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)

MethodPathHandlerDescription
GET/stores/:store_id/transactionsTransactionController.listList transactions (paginated)
GET/stores/:store_id/transactions/:idTransactionController.getGet transaction with items + payments
GET/stores/:store_id/transactions/summaryTransactionController.getSummaryAggregate stats (total sales, count, avg)
GET/stores/:store_id/transactions/daily-salesTransactionController.getDailySalesDaily sales breakdown
GET/stores/:store_id/discountsDiscountController.listList discounts (paginated)
GET/stores/:store_id/discounts/:idDiscountController.getGet single discount
GET/stores/:store_id/discounts/activeDiscountController.getActiveList currently active discounts
GET/stores/:store_id/returnsReturnController.listList returns (paginated)
GET/stores/:store_id/returns/:idReturnController.getGet return with details

Store-Scoped — Store Manager+ (Write)

MethodPathHandlerDescription
POST/stores/:store_id/transactions/:id/voidTransactionController.voidTxVoid a transaction
POST/stores/:store_id/returnsReturnController.createCreate a return

Store-Scoped — Store Admin Only (Write)

MethodPathHandlerDescription
POST/stores/:store_id/discountsDiscountController.createCreate discount
PUT/stores/:store_id/discounts/:idDiscountController.updateUpdate discount
DELETE/stores/:store_id/discounts/:idDiscountController.deleteDelete discount
POST/stores/:store_id/discounts/:id/toggleDiscountController.toggleActiveToggle active status

SQLDelight Tables & Queries

transactions table

Key queries:

  • findByStoreId(store_id, limit, offset) - Paginated list
  • findById(transaction_id) - Single lookup
  • countByStoreId(store_id) - Pagination count
  • getSummary(store_id, start_date, end_date) - Aggregate stats
  • getDailySales(store_id, start_date, end_date) - Daily breakdown
  • insert(...) - Create transaction
  • updateStatus(status, transaction_id) - Void

transaction_items table

Key queries:

  • findByTransactionId(transaction_id) - Items for a transaction
  • insert(...) - Create item

transaction_payments table

Key queries:

  • findByTransactionId(transaction_id) - Payments for a transaction
  • insert(...) - Create payment

returns table

Key queries:

  • findByStoreId(store_id, limit, offset) - Paginated list
  • findById(return_id) - Single lookup
  • findByIdWithDetails(return_id) - With user/product/customer names (JOIN)
  • countByStoreId(store_id) - Pagination count
  • insert(...) - Create return

discounts table

Key queries:

  • findByStoreId(store_id, limit, offset) - Paginated list
  • findById(discount_id) - Single lookup
  • findActiveByStoreId(store_id) - Active discounts (within date range)
  • countByStoreId(store_id) - Pagination count
  • insert(...) - Create
  • update(...) - Update
  • delete(discount_id) - Hard delete or soft delete
  • toggleActive(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

  1. 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.
  2. Voiding a transaction sets status = "voided" — does NOT delete the record
  3. Voiding can optionally reverse inventory (add back items to stock)
  4. Returns create an inventory ledger entry if restocked = true
  5. Returns update serial number status if the product has serial tracking
  6. Discounts have a date range — getActive filters by current date
  7. Discount types: percent (percentage off) or fixed (flat amount off)
  8. max_discount caps the total discount amount for percentage discounts
  9. min_purchase sets a minimum order value for the discount to apply
  10. Summary/daily-sales accept ?start_date= and ?end_date= query params

Acceptance Criteria

  1. Transaction list/get works with items and payments joined
  2. Transaction summary returns correct aggregates for date range
  3. Daily sales returns per-day breakdown
  4. Voiding a transaction updates status and optionally reverses inventory
  5. Return creation validates product/transaction and handles restocking
  6. Discount CRUD works with active filtering by date range
  7. Discount toggle flips active status
  8. All list endpoints support pagination
  9. Bazel build passes
  10. Unit tests for TransactionService, ReturnService, DiscountService