On-Site Payment Feature
Author(s)
- Ashik
Last Updated Date
[2026-06-10]
SRS References
Version History
| Version | Date | Changes | Author |
|---|---|---|---|
| 1.0 | 2026-06-10 | Initial draft — on-site payment using existing payments table with paymentprooffilekey column | Ashik |
| 1.1 | 2026-06-10 | Customer payment method selection API added; Pay Later writes paymentchannel to invoices; receipt API removed | Ashik |
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_selectedevent) - 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_completedSignalR 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_allocationstablespayments: +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— existingBroadcastPaymentCompletedAsync+ newBroadcastPaymentMethodSelectedAsync - Dapper (DAL layer — follows existing codebase pattern)
Requirements
Functional:
- After a service request is marked
Completed, the customer app prompts a payment method selection screen: Digital Payment or On-Site Payment. - When the customer selects a payment method, the customer app calls
POST /onsite-payments/select-payment-method. This setsinvoices.paymentchanneland notifies the technician. - If Digital Payment is selected →
paymentchannel = 'Digital'→ existing DealerPay flow continues; no technician notification needed. - If On-Site Payment is selected →
paymentchannel = 'OnSite'→ SignalRpayment_method_selectedevent is sent to the technician → technician app displays the On-Site Payment form. - Technician reads the read-only Total Amount via the existing
GET /payments/service-request/{serviceRequestId}endpoint. - 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. - On submission: system inserts a payment row into
payments, marks invoicePaid, and broadcastspayment_completedvia SignalR. - Technician can click Pay Later → calls
POST /onsite-payments/pay-later→ backend setsinvoices.paymentchannel = 'Digital', no payment row is created. - 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. - On digital payment success, existing
FinalizePayment()firesBroadcastPaymentCompletedAsyncto notify the customer.
Non-Functional:
- On-site payment submission completes within 2 seconds (excluding file upload).
- File upload uses the existing
FileManagementControllerupload endpoint. - SignalR broadcasts are fire-and-forget — must NOT block the API response.
- JWT with technician/customer roles enforced on all new endpoints.
- 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
| Value | Int | Description |
|---|---|---|
NewCard | 1 | DealerPay CNP iframe flow (existing) |
SavedCard | 2 | DealerPay saved token flow (existing) |
OnSite | 3 | NEW — Physical payment recorded by technician |
PaymentChannel Enum (new — stored on invoices)
| Value | Int | Stored when |
|---|---|---|
Digital | 1 | Customer selects Digital Payment or technician clicks Pay Later |
OnSite | 2 | Customer selects On-Site Payment |
| (null) | — | Invoice created but customer has not yet selected a payment method |
OnsitePaymentMethod Enum
| Value | Int | Description | ReferenceNumber | PaymentProof |
|---|---|---|---|---|
| Cash | 1 | Physical cash to technician | Optional | Optional |
| Cheque | 2 | Cheque — number and proof image required | Required | Required |
| Others | 3 | Other accepted method | Optional | Optional |
⚠️ The
OnsitePaymentMethodis stored inpayments.onsitepaymentmethod(nullable — null for digital payment rows).
⚠️ The
PaymentChannelis stored ininvoices.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:
| Column | On-Site Payment value |
|---|---|
paymentmethod | 'OnSite' (new PaymentMethod enum value = 3) |
onsitepaymentmethod | 'Cash' | 'Cheque' | 'Others' (NEW column) |
paymentprooffilekey | S3 file key if proof uploaded, else NULL (NEW column) |
paymentstatus | 'Success' — recorded immediately, no async gateway |
referencenumber | Cheque number or other ref (existing column reused) |
totalamount | Invoice total amount |
completedat | Timestamp of payment recording (NOW()) |
customerid | Invoice customer ID |
invoiceid | The linked invoice |
traceid | NULL |
asyncid | NULL |
checkouturl | NULL |
expireat | NULL |
cardbrand | NULL |
last4 | NULL |
authcode | NULL |
savetokenrequested | FALSE |
gatewayrequest | NULL |
gatewayresponse | NULL |
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:
| Value | Set when | Customer app behaviour |
|---|---|---|
NULL | Invoice just created (after service Completed) | Show payment method selection screen |
'OnSite' | Customer calls select-payment-method with OnSite | Show "Payment Pending — Technician collecting" |
'Digital' | Customer calls select-payment-method with Digital or Technician calls Pay Later | Skip 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
| Table | Changed? | What changes |
|---|---|---|
payments | ✅ Modified | Add onsitepaymentmethod TEXT NULL + paymentprooffilekey TEXT NULL |
invoices | ✅ Modified | Add paymentchannel TEXT NULL ('OnSite' / 'Digital' / NULL) |
payment_allocations | ✗ No change | Existing insert pattern reused |
Migration file: DatabaseService/Scripts/000039-AddOnsitePaymentColumns.sql
API Endpoints
| Endpoint | Method | Purpose | Auth | Request Model | Response Model | Status Codes |
|---|---|---|---|---|---|---|
/payments/service-request/{serviceRequestId} | GET | Get invoice details — used by technician to load On-Site Payment form (existing) | JWT (customer OR technician OR admin) | – | InvoiceResponse | 200, 403, 404, 500 |
/onsite-payments/select-payment-method | POST | Customer selects payment method (OnSite / Digital); notifies technician via SignalR if OnSite | JWT (customer) | SelectPaymentMethodRequest | CommonResponse | 200, 400, 403, 404, 500 |
/onsite-payments/record | POST | Technician records on-site payment (Cash / Cheque / Others) | JWT (technician) | RecordOnsitePaymentRequest | CommonResponse | 200, 400, 403, 404, 500 |
/onsite-payments/pay-later | POST | Technician defers payment — sets paymentchannel = 'Digital' on invoice | JWT (technician) | PayLaterRequest | CommonResponse | 200, 400, 403, 404, 500 |
Note: The existing
GET /payments/service-request/{serviceRequestId}endpoint now also returnspaymentChannelfrom 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.csasGetInvoiceByServiceRequest. Only the response model needs the newpaymentChannelfield 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 }
]
}
}
paymentChannelvalues: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
Completedstatus - The invoice must be in
Pendingstatus paymentchannelmust currently beNULL(selection can only be made once — returns 400 if already set)- If
PaymentChannel = Digital→ no SignalR broadcast; customer proceeds toPOST /payments/initiate - If
PaymentChannel = OnSite→ broadcastpayment_method_selectedtoRequest-{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
Completedstatus - Invoice must have
paymentchannel = 'OnSite'(customer must have selected On-Site first) - Invoice must be in
Pendingstatus (not alreadyPaid) PaymentMethod = Cheque→ReferenceNumberis required;PaymentProofFileKeyis requiredPaymentMethod = CashorOthers→ both are optional- Idempotency: if a
Successpayment 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
Pendingstatus (not alreadyPaid) paymentchannelis forced to'Digital'regardless of its current value (NULL or 'OnSite')- No payment row is created, no payment_allocations insert
- A
pay_laterSignalR event is broadcast toRequest-{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
| Event | Constant | Fired By | Target Groups | Payload | When |
|---|---|---|---|---|---|
service_completed | Events.ServiceCompleted | BroadcastServiceCompletedAsync() | Request-{id} + AllAdmins | ServiceCompletedBroadcastModel | Technician marks service request Completed |
payment_method_selected | Events.PaymentMethodSelected (NEW) | BroadcastPaymentMethodSelectedAsync() (NEW) | Request-{id} | { serviceRequestId, paymentChannel } | Customer selects On-Site Payment |
pay_later | Events.PayLater (NEW) | BroadcastPayLaterAsync() (NEW) | Request-{id} | { serviceRequestId, message } | Technician clicks Pay Later |
payment_completed | Events.PaymentCompleted | BroadcastPaymentCompletedAsync() | Request-{id} + AllAdmins | ServiceRequestDetailedResponse | On-site payment recorded OR digital payment finalized |
Group membership (established on SignalR OnConnectedAsync):
- Customer → joins
Request-{requestId}viaJoinCustomerRequestGroupsAsync()on connect - Technician → joins
Request-{requestId}viaJoinTechnicianRequestGroupsAsync()on connect - Admin → joins
AllAdminsgroup 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:
| File | Change |
|---|---|
Models/Common/Enum.cs | Add OnSite = 3 to PaymentMethod; add new PaymentChannel enum; add new OnsitePaymentMethod enum |
Hubs/Events.cs | Add PaymentMethodSelected = "payment_method_selected" + PayLater = "pay_later" constants |
Hubs/IRealTimeService.cs | Add BroadcastPaymentMethodSelectedAsync + BroadcastPayLaterAsync method signatures |
Hubs/RealTimeService.cs | Implement both new broadcast methods (Request-{id} group only — no AllAdmins) |
Models/Payment/Responses/InvoiceResponse.cs | Add PaymentChannel field (nullable string) to the response |
DAL/Payment/PaymentDal.cs | Update GetInvoiceByRequestId query to also SELECT paymentchannel from invoices |
Program.cs | Register IOnsitePaymentDal, OnsitePaymentDal, IOnsitePaymentService, OnsitePaymentService |
DatabaseService/Scripts/ | Add 000039-AddOnsitePaymentColumns.sql |
Business Rules
-
Customer Ownership (select-payment-method): Verify
serviceRequest.customerId == JWT customerId→ 403 if mismatch. -
One-Time Selection Guard:
invoices.paymentchannelmust beNULLbefore the customer can set it. Return 400 if already set (prevents duplicate selection). -
Technician Ownership (record, pay-later): Verify
serviceRequest.assignedTechnicianId == JWT technicianId→ 403 if mismatch. -
Invoice
paymentchannelGuard (record): Before recording an on-site payment, verifypaymentchannel = 'OnSite'. This ensures the customer explicitly chose On-Site Payment before the technician can record it. -
Invoice Status Guard: Always verify
invoice.paymentstatus == 'Pending'before any write. Return 400 if alreadyPaid. -
Idempotency: Check
paymentsfor existingSuccessrow withpaymentmethod = 'OnSite'on the sameinvoiceid→ 400 if found. -
Cheque Validation:
OnsitePaymentMethod = Chequerequires bothReferenceNumberandPaymentProofFileKey→ 400 if either is missing. -
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. -
Pay Later — DB Write Required:
POST /onsite-payments/pay-laterMUST updateinvoices.paymentchannel = 'Digital'. This is what forces the customer app to skip the selection screen and go directly to Digital Payment. -
Pay Later —
pay_laterBroadcast 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. -
SignalR Broadcasts are Fire-and-Forget: Never
awaitbroadcasts in the critical path of an HTTP endpoint. Wrap in_ = Task.Run(...)to avoid blocking the response. -
Payment Proof URL: Raw
paymentprooffilekeyS3 key is NEVER returned in API responses. Always resolve to CDN/pre-signed URL before responding. -
Mutual Exclusivity with Digital Payment:
- If a digital payment is already
InitiatedorProcessing→ on-site record returns 400. - If on-site is already recorded (
paymentstatus = 'Paid') → digital payment initiation returns 400 (existing guard unchanged).
- If a digital payment is already
Development Tasks & Estimates
| No | Task Name | Estimate (Hours) | Dependencies | Notes |
|---|---|---|---|---|
| 1 | DB migration: onsitepaymentmethod + paymentprooffilekey on payments; paymentchannel on invoices | 1.5 | None | 000039-AddOnsitePaymentColumns.sql |
| 2 | Add enums: OnSite to PaymentMethod, new PaymentChannel, new OnsitePaymentMethod | 1 | None | Models/Common/Enum.cs |
| 3 | Add PaymentMethodSelected constant to Events.cs | 0.5 | None | |
| 4 | Add BroadcastPaymentMethodSelectedAsync to IRealTimeService + RealTimeService | 2 | 3 | Fires to Request-{id} group only (no AllAdmins) |
| 5 | Add paymentChannel field to InvoiceResponse model + update DAL query | 1 | 1 | PaymentDal.GetInvoiceByRequestId — add column to SELECT |
| 6 | Request/Response model classes | 1 | None | SelectPaymentMethodRequest, RecordOnsitePaymentRequest, etc. |
| 7 | IOnsitePaymentDal + OnsitePaymentDal | 3 | 1, 2, 6 | UpdateInvoicePaymentChannelAsync, idempotency check, atomic TX |
| 8 | IOnsitePaymentService + OnsitePaymentService | 4 | 4, 7 | All validation + Cheque rules + fire-and-forget broadcasts |
| 9 | POST /onsite-payments/select-payment-method controller | 1.5 | 7, 8 | Customer endpoint |
| 10 | POST /onsite-payments/record controller | 1.5 | 7, 8 | Technician endpoint |
| 11 | POST /onsite-payments/pay-later controller | 1 | 7, 8 | Technician endpoint |
| 12 | Program.cs — register new DAL + Service | 0.5 | 7, 8 | |
| 13 | Unit Tests | 5 | 9–11 | Channel guard, Cheque rules, idempotency, Pay Later DB write |
| 14 | Integration Tests | 4 | 9–11 | End-to-end: Cheque, Cash, Pay Later → Digital Payment |
| 15 | Documentation & examples | 2 | All | This document |
| Total | Development Time | 29.5 hours | ~3–4 days with 1 developer |
Testing & Quality Assurance
Unit Tests
SelectPaymentMethodAsync—paymentchannelalready set → 400.SelectPaymentMethodAsync— customer does not own request → 403.SelectPaymentMethodAsyncwithOnSite→BroadcastPaymentMethodSelectedAsyncis called.SelectPaymentMethodAsyncwithDigital→ no SignalR broadcast.RecordOnsitePaymentAsync—paymentchannel != 'OnSite'→ 400.RecordOnsitePaymentAsync—PaymentMethod = ChequemissingReferenceNumber→ 400.RecordOnsitePaymentAsync— invoice alreadyPaid→ 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.PayLaterAsync—invoices.paymentchannelset to'Digital'after call.PayLaterAsync— no payment row created after call.PayLaterAsync—BroadcastPayLaterAsyncis called (fire-and-forget) after the DB write.BroadcastPayLaterAsyncfailure does NOT cause HTTP 500 (fire-and-forget).BroadcastPaymentCompletedAsyncfailure 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 receivespayment_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-methodcall → 400. - Duplicate
POST /onsite-payments/record→ 400. POST /onsite-payments/recordwithout priorselect-payment-method→ 400 (paymentchannel guard).
Acceptance Criteria
- On-site payment submission completes within 2 seconds (excluding file upload).
- All 3 DB writes in
recordare atomic — fully roll back on any failure. - No duplicate payment rows under any concurrency scenario.
paymentprooffilekeyraw S3 key never appears in any API response.- Cheque payments without required fields rejected at API layer.
- Technician app receives
payment_method_selectedSignalR event when customer selects On-Site. - Customer app receives
payment_completedSignalR event after technician records payment. - After Pay Later,
invoices.paymentchannel = 'Digital'— customer app goes directly to Digital Payment.
Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
Customer double-taps and calls select-payment-method twice | Medium | Medium | One-time selection guard (paymentchannel IS NULL check) → 400 on second call |
| Technician records payment before customer selects On-Site | High | Low | paymentchannel = 'OnSite' guard in RecordOnsitePaymentAsync → 400 if not set |
| Technician records payment while digital payment is in progress | High | Low | Check payments for Initiated/Processing digital row before INSERT |
| Atomic TX failure leaves invoice inconsistent | High | Low | All 3 writes in single IDbTransaction; rollback on any failure |
Pay Later forgets to write paymentchannel | High | Low | DAL UpdateInvoicePaymentChannelAsync call is mandatory in PayLater service logic |
| SignalR broadcast failure blocks API response | Medium | Low | Broadcasts are fire-and-forget — never awaited in critical path |
Customer misses payment_completed (offline) | Low | Medium | Customer 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_completedevent: callGET /payments/service-request/{id}to readpaymentChannel.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-methodimmediately. - Listen for
payment_completed→ update UI to Paid receipt.
- On
-
Frontend Developers (Technician App):
- Listen for
payment_method_selected→ ifpaymentChannel = "OnSite", automatically show the On-Site Payment form for thatserviceRequestId. - Load invoice via existing
GET /payments/service-request/{serviceRequestId}. - If Cheque: upload proof image first, then include
paymentProofFileKeyinPOST /onsite-payments/record. - Pay Later button →
POST /onsite-payments/pay-later.
- Listen for
-
Backend Developers:
OnsitePaymentService.RecordOnsitePaymentAsync— validatepaymentchannel = 'OnSite'BEFORE starting the DB transaction.OnsitePaymentService.PayLaterAsync— theinvoices.paymentchannel = 'Digital'update is the critical side effect; do not skip it.BroadcastPaymentMethodSelectedAsyncmust only send toRequest-{requestId}group (NOTAllAdmins).paymentprooffilekeystored inpayments— 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)
paymentchannelguard on record, (4) duplicate selection guard, (5) customer apppaymentChannelfield driving UI.
- Priority test scenarios: (1) full Cheque flow with SignalR events end-to-end, (2) Pay Later → Digital Payment end-to-end, (3)
-
DevOps:
- Migration
000039must be deployed before the application update. - No new environment variables required.
- Migration