Skip to main content
Version: RK Auto

On-Site Payment Feature

Author(s)

  • Ashik

Last Updated Date

[2026-06-10]


SRS References


Version History

VersionDateChangesAuthor
1.02026-06-10Initial draft — on-site payment using existing payments table with paymentprooffilekey columnAshik
1.12026-06-10Customer payment method selection API added; Pay Later writes paymentchannel to invoices; receipt API removedAshik

Feature Overview

Objective:
Enable technicians to record physical (on-site) payments made by customers at the time of service completion. The flow supports cash, cheque, and other accepted payment methods. The customer selects a payment method via the app, which notifies the technician via SignalR to display the On-Site Payment form. After payment is recorded, the customer receives a real-time update via the existing payment_completed SignalR event. If the technician defers payment (Pay Later), the invoice is updated so that the customer is only shown the Digital Payment option going forward.

Scope:

  • Customer payment method selection API: POST /onsite-payments/select-payment-method (customer role)
  • SignalR notification to technician when customer selects On-Site Payment (payment_method_selected event)
  • On-Site Payment form on the technician app (reads invoice via existing GET /payments/service-request/{serviceRequestId})
  • Record On-Site Payment: Cash / Cheque / Others with optional reference number and proof image
  • Pay Later action: sets invoices.paymentchannel = 'Digital' so customer is directed straight to Digital Payment
  • Customer real-time payment status update via existing payment_completed SignalR event

Out of Scope (this document):

  • Digital Payment flow (DealerPay CNP iframe / Saved Card) — documented in payment-gateway-features.md
  • Partial on-site payments
  • Admin refund flows for on-site payments

Dependencies:

  • PostgreSQL — existing payments, invoices, payment_allocations tables
    • payments: + onsitepaymentmethod TEXT NULL + paymentprooffilekey TEXT NULL (new columns)
    • invoices: + paymentchannel TEXT NULL (new column)
  • ASP.NET Core 8 (existing project structure)
  • JWT authentication (customer and technician roles)
  • File management service (existing upload endpoint) — for payment proof image
  • SignalR RealTimeHub — existing BroadcastPaymentCompletedAsync + new BroadcastPaymentMethodSelectedAsync
  • Dapper (DAL layer — follows existing codebase pattern)

Requirements

Functional:

  1. After a service request is marked Completed, the customer app prompts a payment method selection screen: Digital Payment or On-Site Payment.
  2. When the customer selects a payment method, the customer app calls POST /onsite-payments/select-payment-method. This sets invoices.paymentchannel and notifies the technician.
  3. If Digital Payment is selected → paymentchannel = 'Digital' → existing DealerPay flow continues; no technician notification needed.
  4. If On-Site Payment is selected → paymentchannel = 'OnSite' → SignalR payment_method_selected event is sent to the technician → technician app displays the On-Site Payment form.
  5. Technician reads the read-only Total Amount via the existing GET /payments/service-request/{serviceRequestId} endpoint.
  6. Technician can Record On-Site Payment: select method (Cash / Cheque / Others), optionally enter Reference Number (required for Cheque), optionally upload proof image (required for Cheque), and submit via POST /onsite-payments/record.
  7. On submission: system inserts a payment row into payments, marks invoice Paid, and broadcasts payment_completed via SignalR.
  8. Technician can click Pay Later → calls POST /onsite-payments/pay-later → backend sets invoices.paymentchannel = 'Digital', no payment row is created.
  9. After Pay Later, when the customer opens the payment screen, the app checks paymentchannel = 'Digital' and skips the selection screen — customer goes directly to Digital Payment.
  10. On digital payment success, existing FinalizePayment() fires BroadcastPaymentCompletedAsync to notify the customer.

Non-Functional:

  1. On-site payment submission completes within 2 seconds (excluding file upload).
  2. File upload uses the existing FileManagementController upload endpoint.
  3. SignalR broadcasts are fire-and-forget — must NOT block the API response.
  4. JWT with technician/customer roles enforced on all new endpoints.
  5. Accepted file types: JPEG / PNG, max 10 MB (enforced by existing upload endpoint).

Feature Flow Summary

1. Technician marks service request as Completed
→ SignalR: "service_completed" → Request-{id} + AllAdmins
→ Customer app receives "service_completed"

2. Customer opens app → sees payment method selection screen
(screen is shown because invoice.paymentchannel is NULL)

○ Digital Payment selected:
POST /onsite-payments/select-payment-method { paymentMethod: "Digital" }
→ invoices.paymentchannel = 'Digital'
→ existing DealerPay flow (no technician notification)

○ On-Site Payment selected:
POST /onsite-payments/select-payment-method { paymentMethod: "OnSite" }
→ invoices.paymentchannel = 'OnSite'
→ SignalR: "payment_method_selected" → Request-{id}
→ Customer app shows: Payment Status = Pending

3. Technician app receives "payment_method_selected" event (paymentMethod = "OnSite")
→ Technician app automatically shows the On-Site Payment form

4. Technician fetches invoice (read-only):
GET /payments/service-request/{serviceRequestId} ← existing endpoint

5. Technician action:

A. Record On-Site Payment
- Select method: Cash | Cheque | Others
- Reference Number (required for Cheque)
- Upload Proof via existing file-management upload (required for Cheque)
- POST /onsite-payments/record
→ INSERT payments (OnSite, paymentstatus = Success)
→ UPDATE invoices (paymentstatus = Paid, paymentchannel stays 'OnSite')
→ INSERT payment_allocations
→ SignalR: "payment_completed" → Request-{id} + AllAdmins
→ Customer app receives "payment_completed" → shows Paid receipt

B. Pay Later
- POST /onsite-payments/pay-later
→ UPDATE invoices SET paymentchannel = 'Digital' ← DB write
→ No payment row created
→ Technician released for next job
→ Customer app: on next open, paymentchannel = 'Digital'
→ skip selection screen → go straight to Digital Payment

Design Specifications

Architecture Overview

── On-Site Payment Selected path ─────────────────────────────────────────────────────

┌──────────────────┐ POST /onsite-payments/select-payment-method ┌────────────────┐
│ Customer App │ { paymentMethod: "OnSite" } ─────────────────▶│ Backend │
│ │ ◀── { success } ───────────────────────────── │ UPDATE │
│ [Shows Pending] │ │ invoices │
└──────────────────┘ │ paymentchannel│
│ = 'OnSite' │
└───────┬────────┘
│ SignalR
BroadcastPaymentMethodSelectedAsync()

┌──────────────▼──────┐
│ "payment_method_ │
│ selected" event │
│ → Request-{id} │
└──────────────┬──────┘

┌──────────────▼──────┐
│ Technician App │
│ receives event │
│ → shows OnSite │
│ Payment Form │
└─────────────────────┘

┌──────────────────┐ GET /payments/service-request/{id} ┌─────────────────┐
│ Technician App │ ──────────────────────────────────── ▶│ Backend │
│ │ ◀── { totalAmount, Pending } ────── │ (existing) │
│ [Fills Form] │ └─────────────────┘
│ │ POST /onsite-payments/record ┌─────────────────┐
│ │ ──────────────────────────────────── ▶│ Backend │
│ │ ◀── { paymentId, Paid, ... } ────── │ INSERT payments│
└──────────────────┘ │ UPDATE invoices│──▶ SignalR
│ INSERT allocs │ "payment_completed"
└─────────────────┘ → Request-{id}
→ AllAdmins
── Pay Later path ─────────────────────────────────────────────────────────────────────

┌──────────────────┐ POST /onsite-payments/pay-later ┌─────────────────┐
│ Technician App │ ──────────────────────────────────── ▶│ Backend │
│ │ ◀── { success } ──────────────────── │ UPDATE invoices│
└──────────────────┘ │ paymentchannel │
│ = 'Digital' │
└─────────────────┘
Customer app (next open):
paymentchannel = 'Digital' → skip selection
→ goes straight to Digital Payment flow

Data Models

// ── Request Models ────────────────────────────────────────────────────────────

/// <summary>
/// Request body for POST /onsite-payments/select-payment-method.
/// Submitted by the customer to declare their chosen payment method.
/// Backend persists the choice to invoices.paymentchannel and notifies the technician
/// via SignalR if OnSite is chosen.
/// Maps to: SelectPaymentMethodRequest.cs
/// </summary>
public record SelectPaymentMethodRequest
{
public Guid ServiceRequestId { get; init; }

/// <summary>
/// 'OnSite' or 'Digital'.
/// Stored in invoices.paymentchannel.
/// </summary>
public PaymentChannel PaymentChannel { get; init; }
}

/// <summary>
/// Request body for POST /onsite-payments/record.
/// Submitted by the technician to record a physical (on-site) payment.
/// Maps to: RecordOnsitePaymentRequest.cs
/// </summary>
public record RecordOnsitePaymentRequest
{
/// <summary>
/// The service request ID this payment is for.
/// The technician must be the assigned technician for this service request.
/// </summary>
public Guid ServiceRequestId { get; init; }

/// <summary>
/// Payment method chosen on-site.
/// Allowed: Cash | Cheque | Others
/// </summary>
public OnsitePaymentMethod PaymentMethod { get; init; }

/// <summary>
/// Cheque number or other reference. Required when PaymentMethod = Cheque.
/// </summary>
public string? ReferenceNumber { get; init; }

/// <summary>
/// S3 file key of the uploaded payment proof image.
/// Required when PaymentMethod = Cheque.
/// Must be uploaded separately via the existing file-management upload endpoint first.
/// </summary>
public string? PaymentProofFileKey { get; init; }

/// <summary>Optional technician notes about the payment.</summary>
public string? Notes { get; init; }
}

/// <summary>
/// Request body for POST /onsite-payments/pay-later.
/// Submitted by the technician to defer payment.
/// Maps to: PayLaterRequest.cs
/// </summary>
public record PayLaterRequest
{
public Guid ServiceRequestId { get; init; }
}

// ── Extended Entity note (existing payments table) ───────────────────────────

/// <summary>
/// The existing PaymentEntity already covers all digital-payment fields.
/// For on-site payments, we reuse the same entity and table.
/// New fields added to payments table: onsitepaymentmethod, paymentprooffilekey
/// New field added to invoices table: paymentchannel
/// PaymentMethod = OnSite (new enum value)
/// PaymentStatus = Success (payment recorded immediately — no async gateway)
/// All DealerPay-specific fields (traceid, asyncid, checkouturl, etc.) remain NULL for on-site rows.
/// </summary>

Enums

/// <summary>
/// EXISTING enum — add new value OnSite = 3.
/// Lives in VanTrackerService/Models/Common/Enum.cs
/// </summary>
public enum PaymentMethod
{
NewCard = 1, // DealerPay CNP iframe flow
SavedCard = 2, // DealerPay saved token flow
OnSite = 3 // NEW — cash/cheque/other physical payment recorded by technician
}

/// <summary>
/// NEW enum — payment channel chosen by the customer after service completion.
/// Stored in invoices.paymentchannel column.
/// NULL = customer has not yet selected a method (show selection screen).
/// Add to VanTrackerService/Models/Common/Enum.cs
/// </summary>
public enum PaymentChannel
{
Digital = 1, // Customer chose digital (DealerPay); or Pay Later was selected by technician
OnSite = 2 // Customer chose on-site; technician will collect payment physically
}

/// <summary>
/// NEW enum — on-site payment sub-method selected by the technician.
/// Stored in payments.onsitepaymentmethod column.
/// Add to VanTrackerService/Models/Common/Enum.cs
/// </summary>
public enum OnsitePaymentMethod
{
Cash = 1, // Physical cash
Cheque = 2, // Cheque — requires ReferenceNumber + PaymentProof
Others = 3 // Other accepted methods (e.g., bank transfer)
}

PaymentMethod Enum — Updated Table

ValueIntDescription
NewCard1DealerPay CNP iframe flow (existing)
SavedCard2DealerPay saved token flow (existing)
OnSite3NEW — Physical payment recorded by technician

PaymentChannel Enum (new — stored on invoices)

ValueIntStored when
Digital1Customer selects Digital Payment or technician clicks Pay Later
OnSite2Customer selects On-Site Payment
(null)Invoice created but customer has not yet selected a payment method

OnsitePaymentMethod Enum

ValueIntDescriptionReferenceNumberPaymentProof
Cash1Physical cash to technicianOptionalOptional
Cheque2Cheque — number and proof image requiredRequiredRequired
Others3Other accepted methodOptionalOptional

⚠️ The OnsitePaymentMethod is stored in payments.onsitepaymentmethod (nullable — null for digital payment rows).

⚠️ The PaymentChannel is stored in invoices.paymentchannel (nullable — null until customer makes a selection). The customer app uses this field to decide whether to show the selection screen.


Sample Workflow

Workflow A: Technician Records On-Site Payment (Cheque)

Step 1  Technician marks service request as Completed via existing API.
ServiceRequestService auto-creates an invoice (paymentstatus = Pending,
paymentchannel = NULL).

── SignalR Broadcast ──────────────────────────────────────────────
Server calls: BroadcastServiceCompletedAsync(requestId, model)
Event fired: "service_completed"
Target group: Request-{requestId} + AllAdmins
Payload: ServiceCompletedBroadcastModel
──────────────────────────────────────────────────────────────────

Step 2 Customer app receives "service_completed" SignalR event.
Customer app checks invoice.paymentchannel:
→ paymentchannel is NULL → show payment method selection screen:
○ Digital Payment
○ On-Site Payment

Customer selects "On-Site Payment".
Customer app calls:

POST /onsite-payments/select-payment-method
Authorization: Bearer {CUSTOMER_JWT_TOKEN}
Content-Type: application/json

{ "serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"paymentChannel": "OnSite" }

Backend:
→ UPDATE invoices SET paymentchannel = 'OnSite', updatedat = NOW()
→ Returns { status: 0 }

── SignalR Broadcast ──────────────────────────────────────────────
Server calls: BroadcastPaymentMethodSelectedAsync(requestId, "OnSite")
Event fired: "payment_method_selected"
Target group: Request-{requestId}
Payload: { serviceRequestId, paymentChannel: "OnSite" }
──────────────────────────────────────────────────────────────────

Customer app shows: Payment Status = Pending.

Step 3 Technician app receives "payment_method_selected" SignalR event
with paymentChannel = "OnSite".
Technician app automatically navigates to / displays the On-Site Payment form.

Step 4 Technician app loads the invoice (read-only total amount):

GET /payments/service-request/{serviceRequestId}
Authorization: Bearer {TECHNICIAN_JWT_TOKEN}

Response: { invoiceId, invoiceNumber, totalAmount: 150.75,
paymentStatus: "Pending", services: [...] }
Technician sees totalAmount as a READ-ONLY field.

Step 5 Technician fills the form:
- Payment Method: Cheque
- Reference Number: "CHQ-00123" ← required for Cheque
- Uploads the cheque photo via the EXISTING file-management upload endpoint:
POST /file-management/upload
→ returns { fileKey: "onsite-proof/2026/06/abc123.jpg" }

Step 6 Technician submits the payment:

POST /onsite-payments/record
Authorization: Bearer {TECHNICIAN_JWT_TOKEN}
Content-Type: application/json

{
"serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"paymentMethod": "Cheque",
"referenceNumber": "CHQ-00123",
"paymentProofFileKey": "onsite-proof/2026/06/abc123.jpg"
}

Step 7 Backend processes the request:
a. Validates technician is the assigned technician
b. Validates invoice.paymentchannel = 'OnSite' (customer confirmed on-site)
c. Validates invoice.paymentstatus = 'Pending'
d. Validates: Cheque requires ReferenceNumber + PaymentProofFileKey
e. BEGIN TRANSACTION:
INSERT payments (paymentmethod = 'OnSite', onsitepaymentmethod = 'Cheque',
paymentstatus = 'Success', referencenumber, paymentprooffilekey,
totalamount, completedat = NOW(), ...)
UPDATE invoices SET paidamount = totalamount, dueamount = 0,
paymentstatus = 'Paid', updatedat = NOW()
INSERT payment_allocations (paymentid, invoiceid, allocatedamount)
COMMIT
f. Returns { paymentId, paymentStatus: "Paid", totalAmount,
paymentMethod: "Cheque", referenceNumber, paidAt, paymentProofUrl }

── SignalR Broadcast ──────────────────────────────────────────────
Server calls: BroadcastPaymentCompletedAsync(requestId)
Event fired: "payment_completed"
Target group: Request-{requestId} + AllAdmins
Payload: ServiceRequestDetailedResponse (fetched fresh from DB)
──────────────────────────────────────────────────────────────────

Step 8 Customer app receives "payment_completed" SignalR event.
Customer app displays:
✓ Payment Status: Paid
✓ Total Amount: $150.75
✓ Payment Method: Cheque
✓ Reference Number: CHQ-00123
✓ Payment Date: 2026-06-10 15:30:00
✓ Payment Proof: [thumbnail image]

Workflow B: Technician Selects Pay Later

Step 1  Technician marks service request as Completed.
Invoice auto-created (paymentstatus = Pending, paymentchannel = NULL).

── SignalR Broadcast ──────────────────────────────────────────────
Event: "service_completed" → Request-{requestId} + AllAdmins
──────────────────────────────────────────────────────────────────

Step 2 Customer app receives "service_completed".
paymentchannel = NULL → customer sees payment method selection.
Customer not selects any method:

Step 3 Technician clicks "Pay Later":

POST /onsite-payments/pay-later
Authorization: Bearer {TECHNICIAN_JWT_TOKEN}
Content-Type: application/json

{ "serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" }

Step 4 Backend:
a. Validates technician is assigned + invoice is Pending
b. UPDATE invoices SET paymentchannel = 'Digital', updatedat = NOW()
(no payment row created, no payment_allocations insert)
c. Returns { status: 0, message: "Payment deferred." }
Technician is now free for the next service request.

── SignalR Broadcast ──────────────────────────────────────────────
Server calls: BroadcastPayLaterAsync(requestId)
Event fired: "pay_later"
Target group: Request-{requestId}
Payload: { serviceRequestId, message: "Technician has deferred payment. Please complete payment via Digital Payment." }
──────────────────────────────────────────────────────────────────

Step 5 Customer app receives "pay_later" SignalR event.
Customer app navigates back to the home screen and shows a message:
"Technician has deferred payment. You can complete your payment via Digital Payment."

Step 6 When customer taps to pay (from home screen or notification):
Customer calls:

GET /payments/service-request/{serviceRequestId}

Response: { paymentStatus: "Pending", paymentChannel: "Digital" }

paymentChannel = 'Digital' → customer app skips payment method
selection and goes directly to Digital Payment (DealerPay flow).

Step 6 Customer completes Digital Payment:
POST /payments/initiate → ... → FinalizePayment()

── SignalR Broadcast (existing FinalizePayment logic) ──────────────
Server calls: BroadcastPaymentCompletedAsync(requestId)
Event fired: "payment_completed"
Target group: Request-{requestId} + AllAdmins
Payload: ServiceRequestDetailedResponse
────────────────────────────────────────────────────────────────────

Step 7 Customer app receives "payment_completed" → shows Paid receipt.

Workflow C: On-Site Cash Payment (no reference, no proof)

Step 1–4  Same as Workflow A Steps 1–4.

Step 5 Technician fills the form: Payment Method = Cash.
(Reference Number and Proof are optional for Cash — left empty.)

Step 6 Technician submits:

POST /onsite-payments/record
Authorization: Bearer {TECHNICIAN_JWT_TOKEN}

{ "serviceRequestId": "...", "paymentMethod": "Cash" }

Step 7 Backend records payment, marks invoice Paid.

── SignalR Broadcast ──────────────────────────────────────────────
Event: "payment_completed" → Request-{requestId} + AllAdmins
──────────────────────────────────────────────────────────────────

Step 8 Customer app receives "payment_completed":
✓ Payment Status: Paid
✓ Method: Cash
✓ Reference: (none)
✓ Proof: (none)

Database Schema

payments (EXISTING TABLE — two new columns)

-- Migration: 000039-AddOnsitePaymentColumns.sql

-- 1. On-site sub-method (Cash / Cheque / Others) — NULL for digital payment rows
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS onsitepaymentmethod TEXT NULL;
-- Allowed values: 'Cash' | 'Cheque' | 'Others' | NULL

-- 2. Payment proof image file key — NULL for digital or cash-without-proof rows
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS paymentprooffilekey TEXT NULL;
-- S3 file key — resolve to CDN/pre-signed URL before exposing in API responses

CREATE INDEX IF NOT EXISTS idx_payments_onsitepaymentmethod
ON payments (onsitepaymentmethod)
WHERE onsitepaymentmethod IS NOT NULL;

How existing payments columns map to on-site payment rows:

ColumnOn-Site Payment value
paymentmethod'OnSite' (new PaymentMethod enum value = 3)
onsitepaymentmethod'Cash' | 'Cheque' | 'Others' (NEW column)
paymentprooffilekeyS3 file key if proof uploaded, else NULL (NEW column)
paymentstatus'Success' — recorded immediately, no async gateway
referencenumberCheque number or other ref (existing column reused)
totalamountInvoice total amount
completedatTimestamp of payment recording (NOW())
customeridInvoice customer ID
invoiceidThe linked invoice
traceidNULL
asyncidNULL
checkouturlNULL
expireatNULL
cardbrandNULL
last4NULL
authcodeNULL
savetokenrequestedFALSE
gatewayrequestNULL
gatewayresponseNULL

invoices (EXISTING TABLE — one new column)

-- 3. Payment channel chosen by the customer — NULL until selection is made
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS paymentchannel TEXT NULL;
-- NULL = customer has not yet selected a method (show selection screen)
-- 'OnSite' = customer selected On-Site Payment
-- 'Digital' = customer selected Digital Payment OR technician chose Pay Later

CREATE INDEX IF NOT EXISTS idx_invoices_paymentchannel
ON invoices (paymentchannel)
WHERE paymentchannel IS NOT NULL;

paymentchannel lifecycle:

ValueSet whenCustomer app behaviour
NULLInvoice just created (after service Completed)Show payment method selection screen
'OnSite'Customer calls select-payment-method with OnSiteShow "Payment Pending — Technician collecting"
'Digital'Customer calls select-payment-method with Digital or Technician calls Pay LaterSkip selection — go directly to Digital Payment

payment_allocations (EXISTING — no schema changes)

An allocation row is inserted as normal when on-site payment is recorded, linking payments.paymentid to invoices.invoiceid.


Tables Overview

TableChanged?What changes
payments✅ ModifiedAdd onsitepaymentmethod TEXT NULL + paymentprooffilekey TEXT NULL
invoices✅ ModifiedAdd paymentchannel TEXT NULL ('OnSite' / 'Digital' / NULL)
payment_allocations✗ No changeExisting insert pattern reused

Migration file: DatabaseService/Scripts/000039-AddOnsitePaymentColumns.sql


API Endpoints

EndpointMethodPurposeAuthRequest ModelResponse ModelStatus Codes
/payments/service-request/{serviceRequestId}GETGet invoice details — used by technician to load On-Site Payment form (existing)JWT (customer OR technician OR admin)InvoiceResponse200, 403, 404, 500
/onsite-payments/select-payment-methodPOSTCustomer selects payment method (OnSite / Digital); notifies technician via SignalR if OnSiteJWT (customer)SelectPaymentMethodRequestCommonResponse200, 400, 403, 404, 500
/onsite-payments/recordPOSTTechnician records on-site payment (Cash / Cheque / Others)JWT (technician)RecordOnsitePaymentRequestCommonResponse200, 400, 403, 404, 500
/onsite-payments/pay-laterPOSTTechnician defers payment — sets paymentchannel = 'Digital' on invoiceJWT (technician)PayLaterRequestCommonResponse200, 400, 403, 404, 500

Note: The existing GET /payments/service-request/{serviceRequestId} endpoint now also returns paymentChannel from the invoice. This is the field the customer app reads to decide whether to show the selection screen.


API Interfaces

1. GET /payments/service-request/{serviceRequestId} — Get Invoice (Existing, minor response update)

Purpose:
Used by the technician to load the read-only invoice summary on the On-Site Payment form. Also used by the customer app on refresh to read paymentChannel and determine UI state.

Authentication: JWT (customer OR technician OR admin)

Existing Response Model: InvoiceResponse — add paymentChannel field to the response

Existing endpoint in PaymentController.cs as GetInvoiceByServiceRequest. Only the response model needs the new paymentChannel field added.

Updated Response (200 OK):

{
"status": 0,
"data": {
"invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"invoiceNumber": "INV-2026-000042",
"serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"totalAmount": 150.75,
"paidAmount": 0.00,
"dueAmount": 150.75,
"paymentStatus": "Pending",
"paymentChannel": null,
"createdAt": "2026-06-10T10:00:00Z",
"services": [
{ "serviceName": "Oil Change", "servicePrice": 75.50 },
{ "serviceName": "Brake Inspection", "servicePrice": 75.25 }
]
}
}

paymentChannel values: null (not selected yet) | "OnSite" | "Digital"


2. POST /onsite-payments/select-payment-method — Customer Selects Payment Method (NEW)

Purpose:
Called by the customer immediately after tapping Digital Payment or On-Site Payment on the selection screen. Persists the choice to invoices.paymentchannel. If OnSite is chosen, broadcasts payment_method_selected via SignalR to notify the technician to display the On-Site Payment form.

Authentication: JWT token (customer role)

Request Model: SelectPaymentMethodRequest

Response Model: CommonResponse

Status Codes: 200 (OK), 400 (Bad Request), 403 (Forbidden), 404 (Not Found), 500 (Server Error)

Business Rules:

  • The calling customer must own the service request
  • The service request must be in Completed status
  • The invoice must be in Pending status
  • paymentchannel must currently be NULL (selection can only be made once — returns 400 if already set)
  • If PaymentChannel = Digital → no SignalR broadcast; customer proceeds to POST /payments/initiate
  • If PaymentChannel = OnSite → broadcast payment_method_selected to Request-{requestId} group

Internal Logic:

1. Extract customerId from JWT
2. Fetch service request → verify requestStatus = Completed, customerId == jwt customerId → else 403
3. Fetch invoice by servicerequestid → verify paymentstatus = 'Pending' → else 400
4. Verify invoice.paymentchannel IS NULL → else 400 ("Payment method already selected")
5. UPDATE invoices SET paymentchannel = @SelectedChannel, updatedat = NOW()
6. If PaymentChannel = 'OnSite':
[Fire-and-forget] BroadcastPaymentMethodSelectedAsync(requestId, "OnSite")
→ fires "payment_method_selected" to Group: Request-{requestId}
7. Return CommonResponse { status: 0 }

Sample Request (customer selects On-Site):

POST /onsite-payments/select-payment-method
Authorization: Bearer {CUSTOMER_JWT_TOKEN}
Content-Type: application/json

{
"serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"paymentChannel": "OnSite"
}

Sample Response (200 OK):

{ "status": 0, "message": "Payment method selected. Technician has been notified." }

Sample Request (customer selects Digital):

{
"serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"paymentChannel": "Digital"
}

Sample Response (200 OK):

{ "status": 0, "message": "Proceed with Digital Payment." }

Error (400 — already selected):

{ "status": -20009, "message": "Payment method has already been selected for this invoice" }

Error (403 — not the customer's request):

{ "status": -20010, "message": "You do not have access to this service request" }

3. POST /onsite-payments/record — Record On-Site Payment (NEW)

Purpose:
Called by the technician to record a physical on-site payment. Inserts into the existing payments table, marks invoice Paid, fires BroadcastPaymentCompletedAsync.

Authentication: JWT token (technician role)

Request Model: RecordOnsitePaymentRequest

Response Model: RecordOnsitePaymentResponse

Status Codes: 200 (OK), 400 (Bad Request), 403 (Forbidden), 404 (Not Found), 500 (Server Error)

Business Rules:

  • Calling technician must be the assigned technician for the service request
  • Service request must be in Completed status
  • Invoice must have paymentchannel = 'OnSite' (customer must have selected On-Site first)
  • Invoice must be in Pending status (not already Paid)
  • PaymentMethod = ChequeReferenceNumber is required; PaymentProofFileKey is required
  • PaymentMethod = Cash or Others → both are optional
  • Idempotency: if a Success payment row already exists for this invoice → return 400

Internal Logic:

1. Extract technicianId from JWT
2. Fetch service request → verify status = Completed, assignedTechnicianId == technicianId → else 403
3. Fetch invoice by servicerequestid
→ verify paymentchannel = 'OnSite' → else 400 ("Customer has not selected On-Site Payment")
→ verify paymentstatus = 'Pending' → else 400 ("Invoice is already paid")
4. Idempotency: check payments for existing Success + OnSite row on same invoiceid → 400 if found
5. Validate: PaymentMethod = Cheque AND (ReferenceNumber null OR PaymentProofFileKey null) → 400
6. BEGIN TRANSACTION
a. INSERT payments (paymentmethod='OnSite', onsitepaymentmethod, paymentstatus='Success',
referencenumber, paymentprooffilekey, totalamount,
completedat=NOW(), customerid, invoiceid, DealerPay fields=NULL)
b. UPDATE invoices SET paidamount=totalamount, dueamount=0,
paymentstatus='Paid', updatedat=NOW()
c. INSERT payment_allocations (paymentid, invoiceid, allocatedamount=totalamount)
COMMIT
7. [Fire-and-forget] _realTimeService.BroadcastPaymentCompletedAsync(serviceRequestId)
→ fires "payment_completed" to Group: Request-{serviceRequestId} + Group: AllAdmins
8. Return CommonResponse { status: 0, message: "On-site payment recorded successfully" }

Sample Request:

POST /onsite-payments/record
Authorization: Bearer {TECHNICIAN_JWT_TOKEN}
Content-Type: application/json

{
"serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"paymentMethod": "Cheque",
"referenceNumber": "CHQ-00123",
"paymentProofFileKey": "onsite-proof/2026/06/abc123.jpg",
"notes": "Cheque received at customer location"
}

Sample Response (200 OK):

{
"status": 0,
"message": "On-site payment recorded successfully"
}

Error (400 — Customer has not selected On-Site):

{ "status": -20009, "message": "Customer has not selected On-Site Payment for this invoice" }

Error (400 — Cheque missing reference number):

{ "status": -20009, "message": "Reference number is required for Cheque payments" }

Error (400 — Invoice already paid):

{ "status": -20009, "message": "Invoice is already paid" }

Error (403 — Not assigned technician):

{ "status": -20010, "message": "You are not assigned to this service request" }

4. POST /onsite-payments/pay-later — Defer Payment (NEW)

Purpose:
Called by the technician to release themselves for the next job. Sets invoices.paymentchannel = 'Digital' so that when the customer next opens the payment screen, they are taken directly to Digital Payment without seeing the On-Site Payment option. No payment row is created. Broadcasts a pay_later SignalR event to the customer app so the customer is immediately navigated back to the home screen with a descriptive message.

Authentication: JWT token (technician role)

Request Model: PayLaterRequest

Response Model: CommonResponse

Status Codes: 200 (OK), 400 (Bad Request), 403 (Forbidden), 404 (Not Found), 500 (Server Error)

Business Rules:

  • Calling technician must be the assigned technician
  • Invoice must be in Pending status (not already Paid)
  • paymentchannel is forced to 'Digital' regardless of its current value (NULL or 'OnSite')
  • No payment row is created, no payment_allocations insert
  • A pay_later SignalR event is broadcast to Request-{requestId} (customer app only) after the DB write

Internal Logic:

1. Extract technicianId from JWT
2. Fetch service request → verify status = Completed, assignedTechnicianId == technicianId → else 403
3. Fetch invoice by servicerequestid → verify paymentstatus = 'Pending' → else 400
4. UPDATE invoices SET paymentchannel = 'Digital', updatedat = NOW()
5. Return CommonResponse { status: 0, message: "Payment deferred." }
(NO payment row, NO payment_allocations)
6. [Fire-and-forget] _realTimeService.BroadcastPayLaterAsync(serviceRequestId)
→ fires "pay_later" to Group: Request-{serviceRequestId}
→ Payload: { serviceRequestId, message: "Technician has deferred payment. Please complete payment via Digital Payment." }

Sample Request:

POST /onsite-payments/pay-later
Authorization: Bearer {TECHNICIAN_JWT_TOKEN}
Content-Type: application/json

{ "serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" }

Sample Response (200 OK):

{
"status": 0,
"message": "Payment deferred. Customer will be directed to Digital Payment."
}

Error (400 — Invoice already paid):

{ "status": -20009, "message": "Invoice is already paid. No action required." }

Error (403 — Not assigned technician):

{ "status": -20010, "message": "You are not assigned to this service request" }

SignalR Real-Time Events Summary

EventConstantFired ByTarget GroupsPayloadWhen
service_completedEvents.ServiceCompletedBroadcastServiceCompletedAsync()Request-{id} + AllAdminsServiceCompletedBroadcastModelTechnician marks service request Completed
payment_method_selectedEvents.PaymentMethodSelected (NEW)BroadcastPaymentMethodSelectedAsync() (NEW)Request-{id}{ serviceRequestId, paymentChannel }Customer selects On-Site Payment
pay_laterEvents.PayLater (NEW)BroadcastPayLaterAsync() (NEW)Request-{id}{ serviceRequestId, message }Technician clicks Pay Later
payment_completedEvents.PaymentCompletedBroadcastPaymentCompletedAsync()Request-{id} + AllAdminsServiceRequestDetailedResponseOn-site payment recorded OR digital payment finalized

Group membership (established on SignalR OnConnectedAsync):

  • Customer → joins Request-{requestId} via JoinCustomerRequestGroupsAsync() on connect
  • Technician → joins Request-{requestId} via JoinTechnicianRequestGroupsAsync() on connect
  • Admin → joins AllAdmins group on connect

New constants to add in Events.cs:

/// <summary>
/// Server → Client (Technician): Customer has selected On-Site Payment.
/// Technician app should display the On-Site Payment form.
/// Payload: { ServiceRequestId, PaymentChannel }
/// </summary>
public const string PaymentMethodSelected = "payment_method_selected";

/// <summary>
/// Server → Client (Customer): Technician has deferred payment via Pay Later.
/// Customer app should navigate to the home screen and display a message.
/// Payload: { ServiceRequestId, Message }
/// </summary>
public const string PayLater = "pay_later";

New methods to add to IRealTimeService.cs and implement in RealTimeService.cs:

/// <summary>
/// Broadcasts to the request group that the customer has selected a payment method.
/// Only fires when PaymentChannel = OnSite.
/// </summary>
Task BroadcastPaymentMethodSelectedAsync(Guid requestId, string paymentChannel);

/// <summary>
/// Broadcasts to the request group (customer only) that the technician has deferred payment.
/// Customer app should navigate to home screen and show a descriptive message.
/// Does NOT broadcast to AllAdmins.
/// </summary>
Task BroadcastPayLaterAsync(Guid requestId);

Client-side SignalR listeners:

// ── Customer App ──────────────────────────────────────────────────────────────

connection.on("service_completed", (payload) => {
// ServiceCompletedBroadcastModel
// Fetch GET /payments/service-request/{id} to read paymentChannel
// paymentChannel = null → show payment method selection screen
});

connection.on("pay_later", (payload) => {
// payload = { serviceRequestId, message }
// Navigate customer back to the home screen
// Show a toast / banner: payload.message
// e.g. "Technician has deferred payment. Please complete payment via Digital Payment."
// On next open: GET /payments/service-request/{id} → paymentChannel = 'Digital'
// → skip selection screen → go directly to Digital Payment
});

connection.on("payment_completed", (payload) => {
// ServiceRequestDetailedResponse — payment is now Paid
// Update UI to show Paid receipt (amount, method, reference, date)
});

// ── Technician App ────────────────────────────────────────────────────────────

connection.on("payment_method_selected", (payload) => {
// payload = { serviceRequestId, paymentChannel: "OnSite" }
// Navigate to / display the On-Site Payment form for this serviceRequestId
});

File & Folder Structure

New files to create:

VanTrackerService/
├── Controllers/
│ └── OnsitePaymentController.cs ← [NEW] All 3 on-site payment endpoints

├── Models/
│ └── OnsitePayment/
│ ├── Requests/
│ │ ├── SelectPaymentMethodRequest.cs ← [NEW]
│ │ ├── RecordOnsitePaymentRequest.cs ← [NEW]
│ │ └── PayLaterRequest.cs ← [NEW]
│ └── Responses/
│ (no custom response model — all endpoints return CommonResponse)

├── DAL/
│ └── OnsitePayment/
│ ├── IOnsitePaymentDal.cs ← [NEW]
│ └── OnsitePaymentDal.cs ← [NEW]

└── Services/
└── OnsitePayment/
├── IOnsitePaymentService.cs ← [NEW]
└── OnsitePaymentService.cs ← [NEW]

Existing files to modify:

FileChange
Models/Common/Enum.csAdd OnSite = 3 to PaymentMethod; add new PaymentChannel enum; add new OnsitePaymentMethod enum
Hubs/Events.csAdd PaymentMethodSelected = "payment_method_selected" + PayLater = "pay_later" constants
Hubs/IRealTimeService.csAdd BroadcastPaymentMethodSelectedAsync + BroadcastPayLaterAsync method signatures
Hubs/RealTimeService.csImplement both new broadcast methods (Request-{id} group only — no AllAdmins)
Models/Payment/Responses/InvoiceResponse.csAdd PaymentChannel field (nullable string) to the response
DAL/Payment/PaymentDal.csUpdate GetInvoiceByRequestId query to also SELECT paymentchannel from invoices
Program.csRegister IOnsitePaymentDal, OnsitePaymentDal, IOnsitePaymentService, OnsitePaymentService
DatabaseService/Scripts/Add 000039-AddOnsitePaymentColumns.sql

Business Rules

  1. Customer Ownership (select-payment-method): Verify serviceRequest.customerId == JWT customerId → 403 if mismatch.

  2. One-Time Selection Guard: invoices.paymentchannel must be NULL before the customer can set it. Return 400 if already set (prevents duplicate selection).

  3. Technician Ownership (record, pay-later): Verify serviceRequest.assignedTechnicianId == JWT technicianId → 403 if mismatch.

  4. Invoice paymentchannel Guard (record): Before recording an on-site payment, verify paymentchannel = 'OnSite'. This ensures the customer explicitly chose On-Site Payment before the technician can record it.

  5. Invoice Status Guard: Always verify invoice.paymentstatus == 'Pending' before any write. Return 400 if already Paid.

  6. Idempotency: Check payments for existing Success row with paymentmethod = 'OnSite' on the same invoiceid → 400 if found.

  7. Cheque Validation: OnsitePaymentMethod = Cheque requires both ReferenceNumber and PaymentProofFileKey → 400 if either is missing.

  8. Atomic DB Writes (record): All three DB operations (INSERT payments, UPDATE invoices, INSERT payment_allocations) run inside a single IDbTransaction → rolled back fully on any failure.

  9. Pay Later — DB Write Required: POST /onsite-payments/pay-later MUST update invoices.paymentchannel = 'Digital'. This is what forces the customer app to skip the selection screen and go directly to Digital Payment.

  10. Pay Later — pay_later Broadcast Required: After the DB write, BroadcastPayLaterAsync(requestId) must be called (fire-and-forget) to notify the customer app immediately. The customer app navigates to the home screen and displays the deferred-payment message. Without this event, the customer would remain stuck on the payment selection screen.

  11. SignalR Broadcasts are Fire-and-Forget: Never await broadcasts in the critical path of an HTTP endpoint. Wrap in _ = Task.Run(...) to avoid blocking the response.

  12. Payment Proof URL: Raw paymentprooffilekey S3 key is NEVER returned in API responses. Always resolve to CDN/pre-signed URL before responding.

  13. Mutual Exclusivity with Digital Payment:

    • If a digital payment is already Initiated or Processing → on-site record returns 400.
    • If on-site is already recorded (paymentstatus = 'Paid') → digital payment initiation returns 400 (existing guard unchanged).

Development Tasks & Estimates

NoTask NameEstimate (Hours)DependenciesNotes
1DB migration: onsitepaymentmethod + paymentprooffilekey on payments; paymentchannel on invoices1.5None000039-AddOnsitePaymentColumns.sql
2Add enums: OnSite to PaymentMethod, new PaymentChannel, new OnsitePaymentMethod1NoneModels/Common/Enum.cs
3Add PaymentMethodSelected constant to Events.cs0.5None
4Add BroadcastPaymentMethodSelectedAsync to IRealTimeService + RealTimeService23Fires to Request-{id} group only (no AllAdmins)
5Add paymentChannel field to InvoiceResponse model + update DAL query11PaymentDal.GetInvoiceByRequestId — add column to SELECT
6Request/Response model classes1NoneSelectPaymentMethodRequest, RecordOnsitePaymentRequest, etc.
7IOnsitePaymentDal + OnsitePaymentDal31, 2, 6UpdateInvoicePaymentChannelAsync, idempotency check, atomic TX
8IOnsitePaymentService + OnsitePaymentService44, 7All validation + Cheque rules + fire-and-forget broadcasts
9POST /onsite-payments/select-payment-method controller1.57, 8Customer endpoint
10POST /onsite-payments/record controller1.57, 8Technician endpoint
11POST /onsite-payments/pay-later controller17, 8Technician endpoint
12Program.cs — register new DAL + Service0.57, 8
13Unit Tests59–11Channel guard, Cheque rules, idempotency, Pay Later DB write
14Integration Tests49–11End-to-end: Cheque, Cash, Pay Later → Digital Payment
15Documentation & examples2AllThis document
TotalDevelopment Time29.5 hours~3–4 days with 1 developer

Testing & Quality Assurance

Unit Tests

  • SelectPaymentMethodAsyncpaymentchannel already set → 400.
  • SelectPaymentMethodAsync — customer does not own request → 403.
  • SelectPaymentMethodAsync with OnSiteBroadcastPaymentMethodSelectedAsync is called.
  • SelectPaymentMethodAsync with Digital → no SignalR broadcast.
  • RecordOnsitePaymentAsyncpaymentchannel != 'OnSite' → 400.
  • RecordOnsitePaymentAsyncPaymentMethod = Cheque missing ReferenceNumber → 400.
  • RecordOnsitePaymentAsync — invoice already Paid → 400.
  • RecordOnsitePaymentAsync — on-site payment already recorded → 400 (idempotency).
  • RecordOnsitePaymentAsync — technician not assigned → 403.
  • RecordOnsitePaymentTransactionalAsync — all 3 writes commit atomically.
  • RecordOnsitePaymentTransactionalAsync — UPDATE invoices failure rolls back INSERT payments.
  • PayLaterAsyncinvoices.paymentchannel set to 'Digital' after call.
  • PayLaterAsync — no payment row created after call.
  • PayLaterAsyncBroadcastPayLaterAsync is called (fire-and-forget) after the DB write.
  • BroadcastPayLaterAsync failure does NOT cause HTTP 500 (fire-and-forget).
  • BroadcastPaymentCompletedAsync failure does NOT cause HTTP 500 (fire-and-forget).

Integration Tests

  • End-to-end Cheque: Customer selects OnSite → technician receives payment_method_selected → technician records Cheque → invoice Paid → customer receives payment_completed.
  • End-to-end Cash: Same flow without reference/proof.
  • Pay Later end-to-end: Customer selects OnSite → technician clicks Pay Later → paymentchannel = 'Digital' → customer fetches invoice → paymentchannel = Digital → customer completes Digital Payment → invoice Paid.
  • Duplicate select-payment-method call → 400.
  • Duplicate POST /onsite-payments/record → 400.
  • POST /onsite-payments/record without prior select-payment-method → 400 (paymentchannel guard).

Acceptance Criteria

  • On-site payment submission completes within 2 seconds (excluding file upload).
  • All 3 DB writes in record are atomic — fully roll back on any failure.
  • No duplicate payment rows under any concurrency scenario.
  • paymentprooffilekey raw S3 key never appears in any API response.
  • Cheque payments without required fields rejected at API layer.
  • Technician app receives payment_method_selected SignalR event when customer selects On-Site.
  • Customer app receives payment_completed SignalR event after technician records payment.
  • After Pay Later, invoices.paymentchannel = 'Digital' — customer app goes directly to Digital Payment.

Risks & Mitigations

RiskImpactLikelihoodMitigation
Customer double-taps and calls select-payment-method twiceMediumMediumOne-time selection guard (paymentchannel IS NULL check) → 400 on second call
Technician records payment before customer selects On-SiteHighLowpaymentchannel = 'OnSite' guard in RecordOnsitePaymentAsync → 400 if not set
Technician records payment while digital payment is in progressHighLowCheck payments for Initiated/Processing digital row before INSERT
Atomic TX failure leaves invoice inconsistentHighLowAll 3 writes in single IDbTransaction; rollback on any failure
Pay Later forgets to write paymentchannelHighLowDAL UpdateInvoicePaymentChannelAsync call is mandatory in PayLater service logic
SignalR broadcast failure blocks API responseMediumLowBroadcasts are fire-and-forget — never awaited in critical path
Customer misses payment_completed (offline)LowMediumCustomer app calls GET /payments/service-request/{id} on app open to check status

Review & Approval

  • Reviewer(s):

    • Sanket
    • Ribhu
  • Approval Date: [To be completed after reviews]


Notes

  • Frontend Developers (Customer App):

    • On service_completed event: call GET /payments/service-request/{id} to read paymentChannel.
      • null → show selection screen (Digital / On-Site)
      • 'Digital' → skip selection, go straight to Digital Payment
      • 'OnSite' → show "Payment Pending — Technician collecting"
    • After user taps a method: call POST /onsite-payments/select-payment-method immediately.
    • Listen for payment_completed → update UI to Paid receipt.
  • Frontend Developers (Technician App):

    • Listen for payment_method_selected → if paymentChannel = "OnSite", automatically show the On-Site Payment form for that serviceRequestId.
    • Load invoice via existing GET /payments/service-request/{serviceRequestId}.
    • If Cheque: upload proof image first, then include paymentProofFileKey in POST /onsite-payments/record.
    • Pay Later button → POST /onsite-payments/pay-later.
  • Backend Developers:

    • OnsitePaymentService.RecordOnsitePaymentAsync — validate paymentchannel = 'OnSite' BEFORE starting the DB transaction.
    • OnsitePaymentService.PayLaterAsync — the invoices.paymentchannel = 'Digital' update is the critical side effect; do not skip it.
    • BroadcastPaymentMethodSelectedAsync must only send to Request-{requestId} group (NOT AllAdmins).
    • paymentprooffilekey stored in payments — resolve to CDN/pre-signed URL before returning.
  • QA:

    • Priority test scenarios: (1) full Cheque flow with SignalR events end-to-end, (2) Pay Later → Digital Payment end-to-end, (3) paymentchannel guard on record, (4) duplicate selection guard, (5) customer app paymentChannel field driving UI.
  • DevOps:

    • Migration 000039 must be deployed before the application update.
    • No new environment variables required.