Skip to main content

Overview

The payment system handles disbursement of winnings to ticket holders. It supports full payments, partial payments, payment reversal, and finalization of partial payments.

Payment Workflow

Payment States

Ticket StatusDescriptionCan Pay?Can Reverse?
EVALUATED (unpaid)Winning ticket, no payment✅ Yes❌ No
EVALUATED (partial)Some amount paid, balance remains✅ Yes✅ Last payment
PAIDFully paid❌ No✅ Last payment
FINALIZEDPartial payment accepted as final❌ No✅ Last payment

Registering Payments

Full Payment

Pay the complete prize amount in one transaction.
curl -X POST https://api.example.com/api/v1/tickets/:id/pay \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 15000,
    "paymentMethod": "CASH",
    "notes": "Paid in full at main office"
  }'
From src/api/v1/controllers/ticket.controller.ts:173-183:
async registerPayment(req: AuthenticatedRequest, res: Response) {
  const userId = req.user!.id;
  const ticketId = req.params.id;
  const result = await TicketService.registerPayment(
    ticketId,
    req.body,
    userId,
    req.requestId
  );
  return success(res, result);
}

Partial Payment

Pay a portion of the prize, with remaining balance tracked.
{
  "amount": 5000,
  "paymentMethod": "CASH",
  "notes": "Partial payment 1 of 3"
}
Example scenario:
  • Total payout: ₡15,000
  • Payment 1: ₡5,000 → remainingAmount = 10,000
  • Payment 2: ₡5,000 → remainingAmount = 5,000
  • Payment 3: ₡5,000 → remainingAmount = 0, status = PAID

Payment Methods

type PaymentMethod = 
  | 'CASH'           // Cash payment
  | 'BANK_TRANSFER'  // Bank transfer
  | 'MOBILE_PAYMENT' // Mobile money (Sinpe Móvil)
  | 'CHECK'          // Check payment
  | 'OTHER';         // Other method

Payment Tracking

Ticket Payment Fields

{
  totalPayout: number;      // Total prize amount
  totalPaid: number;        // Sum of all payments
  remainingAmount: number;  // Unpaid balance
  status: 'EVALUATED' | 'PAID' | 'FINALIZED';
  lastPaymentAt: Date | null;
}

Get Payment History

GET /api/v1/tickets/:ticketId/payment-history
From src/api/v1/controllers/ticketPayment.controller.ts:170-181:
async getPaymentHistory(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  const { ticketId } = req.params;
  
  const result = await TicketPaymentService.getPaymentHistory(ticketId, {
    id: req.user.id,
    role: req.user.role,
    ventanaId: req.user.ventanaId,
  });
  
  return success(res, result);
}
Response:
{
  "payments": [
    {
      "id": "uuid-payment-1",
      "amount": 5000,
      "paymentMethod": "CASH",
      "notes": "Partial payment 1",
      "createdAt": "2025-01-20T10:00:00.000Z",
      "createdBy": {
        "id": "uuid-user",
        "name": "Juan Pérez"
      }
    },
    {
      "id": "uuid-payment-2",
      "amount": 5000,
      "paymentMethod": "CASH",
      "notes": "Partial payment 2",
      "createdAt": "2025-01-20T14:00:00.000Z",
      "createdBy": {...}
    }
  ],
  "summary": {
    "totalPayout": 15000,
    "totalPaid": 10000,
    "remainingAmount": 5000
  }
}

Reversing Payments

Revert the most recent payment (undo last transaction).
POST /api/v1/tickets/:id/reverse-payment
{
  "reason": "Payment error - wrong amount entered"
}
From src/api/v1/controllers/ticket.controller.ts:189-200:
async reversePayment(req: AuthenticatedRequest, res: Response) {
  const userId = req.user!.id;
  const ticketId = req.params.id;
  const { reason } = req.body;
  const result = await TicketService.reversePayment(
    ticketId,
    userId,
    reason,
    req.requestId
  );
  return success(res, result);
}
Reversal rules:
  • Only the last payment can be reversed
  • Cannot reverse if new payments were made after
  • Ticket status reverts to previous state
  • totalPaid and remainingAmount are recalculated
  • All reversals are logged in ActivityLog

Finalizing Partial Payments

Mark a partial payment as final, accepting remaining debt.
POST /api/v1/tickets/:id/finalize-payment
{
  "notes": "Customer agreed to accept ₡10,000 instead of full ₡15,000 due to operational limits"
}
From src/api/v1/controllers/ticket.controller.ts:206-217:
async finalizePayment(req: AuthenticatedRequest, res: Response) {
  const userId = req.user!.id;
  const ticketId = req.params.id;
  const { notes } = req.body;
  const result = await TicketService.finalizePayment(
    ticketId,
    userId,
    notes,
    req.requestId
  );
  return success(res, result);
}
Effects:
  • Ticket status changes to FINALIZED
  • remainingAmount stays as-is (not zeroed)
  • No further payments can be registered
  • Finance team can track unpaid balance
Use finalization when:
  • Customer accepts partial payment as settlement
  • Operational cash limits prevent full payout
  • Agreement reached for installment completion later

TicketPayment Entity

Dedicated payment records in TicketPayment model.

Create Payment (Standalone API)

POST /api/v1/ticket-payments
From src/api/v1/controllers/ticketPayment.controller.ts:19-55:
async create(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  const validatedData = CreatePaymentSchema.parse(req.body);
  
  const result = await TicketPaymentService.create(validatedData, {
    id: req.user.id,
    role: req.user.role,
    ventanaId: req.user.ventanaId,
  });
  
  // Check if cached response (idempotency)
  const isCached = (result as any).cached === true;
  const statusCode = isCached ? 200 : 201;
  
  return res.status(statusCode).json({
    success: true,
    data: result,
    ...(isCached ? { meta: { cached: true } } : {})
  });
}
Request body:
{
  "ticketId": "uuid-ticket",
  "amount": 5000,
  "paymentMethod": "CASH",
  "notes": "Payment note",
  "idempotencyKey": "unique-key-123"  // Optional, prevents duplicates
}

Idempotency Support

Include idempotencyKey to prevent duplicate payments on network retry:
  • Same key returns cached payment with 200 OK and meta.cached: true
  • Different key creates new payment with 201 Created

List All Payments

GET /api/v1/ticket-payments?ventanaId=uuid&status=PAID&page=1&pageSize=20
Query parameters:
  • ventanaId: Filter by ventana
  • vendedorId: Filter by vendedor
  • ticketId: Filter by specific ticket
  • status: Filter by status
  • fromDate, toDate: Date range (YYYY-MM-DD)
  • sortBy: createdAt | amount | ticketNumber
  • sortOrder: asc | desc
  • page, pageSize: Pagination
From src/api/v1/controllers/ticketPayment.controller.ts:61-96:
async list(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  const validatedQuery = ListPaymentsQuerySchema.parse(req.query);
  
  const result = await TicketPaymentService.list(
    validatedQuery.page,
    validatedQuery.pageSize,
    filters,
    {
      id: req.user.id,
      role: req.user.role,
      ventanaId: req.user.ventanaId,
    }
  );
  
  return success(res, result);
}

Get Payment Details

GET /api/v1/ticket-payments/:id
From src/api/v1/controllers/ticketPayment.controller.ts:102-113:
async getById(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  const { id } = req.params;
  
  const result = await TicketPaymentService.getById(id, {
    id: req.user.id,
    role: req.user.role,
    ventanaId: req.user.ventanaId,
  });
  
  return success(res, result);
}

Update Payment

PATCH /api/v1/ticket-payments/:id
{
  "notes": "Updated note - payment confirmed by supervisor"
}
From src/api/v1/controllers/ticketPayment.controller.ts:119-138:
async update(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  const { id } = req.params;
  
  const validatedData = UpdatePaymentSchema.parse(req.body);
  
  const result = await TicketPaymentService.update(
    id,
    validatedData,
    req.user.id,
    {
      id: req.user.id,
      role: req.user.role,
      ventanaId: req.user.ventanaId,
    }
  );
  
  return success(res, result);
}

Reverse Payment (Standalone)

POST /api/v1/ticket-payments/:id/reverse
From src/api/v1/controllers/ticketPayment.controller.ts:144-164:
async reverse(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  const { id } = req.params;
  
  const result = await TicketPaymentService.reverse(id, req.user.id, {
    id: req.user.id,
    role: req.user.role,
    ventanaId: req.user.ventanaId,
  });
  
  await ActivityService.log({
    userId: req.user.id,
    action: "TICKET_PAYMENT_REVERSE" as any,
    targetType: "TICKET_PAYMENT",
    targetId: id,
    details: { reversed: true },
    layer: "controller",
  });
  
  return success(res, result);
}

Payment Analytics

Summary Metrics

Payment metrics are included in sales summary endpoints.
GET /api/v1/ventas/summary?date=today
Response includes:
{
  "totalPaid": 125000,           // Total paid to winners
  "remainingAmount": 35000,      // Prizes still unpaid
  "paidTicketsCount": 45,        // Fully paid tickets
  "unpaidTicketsCount": 12       // Tickets with pending payment
}

Dashboard Payment Tracking

GET /api/v1/admin/dashboard?date=today
From src/api/v1/controllers/dashboard.controller.ts:44-74:
async getMainDashboard(req: AuthenticatedRequest, res: Response) {
  // ... date and RBAC logic
  
  const result = await DashboardService.getFullDashboard({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    loteriaId: query.loteriaId,
    betType: query.betType,
    interval: query.interval,
    scope: query.scope || 'all',
    dimension: query.dimension,
  }, req.user!.role);
  
  return success(res, result);
}
Includes payment KPIs:
{
  "payments": {
    "totalPaid": 125000,
    "remainingAmount": 35000,
    "paidCount": 45,
    "unpaidCount": 12
  }
}

Best Practices

Include idempotencyKey in payment requests to prevent duplicates on network retry or user double-click.
Include notes field with payment context: location, payment method details, supervisor approval, etc.
Always fetch ticket details first to check:
  • status === 'EVALUATED' (not already paid)
  • remainingAmount > 0
  • totalPayout matches expectation
For large prizes:
  • Show remainingAmount prominently
  • Allow multiple small payments
  • Track payment history in UI
  • Use finalization when appropriate
Require supervisor approval for payment reversals. Log detailed reasons.

Common Workflows

Full Payment Flow

1

Verify ticket is winner

GET /api/v1/tickets/:id
Check: status === 'EVALUATED' and totalPayout > 0
2

Register payment

POST /api/v1/tickets/:id/pay
Amount = totalPayout
3

Verify completion

Response should show:
  • totalPaid === totalPayout
  • remainingAmount === 0
  • status === 'PAID'

Partial Payment Flow

1

First partial payment

POST /api/v1/tickets/:id/pay
Amount < totalPayout
2

Track remaining balance

Response shows updated remainingAmount
3

Subsequent payments

Repeat until remainingAmount === 0 or finalize
4

Optional: Finalize

If stopping before full payment:
POST /api/v1/tickets/:id/finalize-payment

Build docs developers (and LLMs) love