DealerPay Payment Gateway Integration
Author(s)
- Sanket
- Ashik
Last Updated Date
[2026-05-25]
SRS References
Version History
| Version | Date | Changes | Author |
|---|---|---|---|
| 1.0 | 2026-05-25 | Initial draft — full implementation plan for invoice payment via DealerPay Connect v2 | Ashik |
Feature Overview
Objective:
Enable customers to pay their service request invoices through a secure, reliable payment gateway powered by DealerPay Connect API v2.0. The system supports new card entry via a DealerPay-hosted iframe and saved card token charging, with webhook-based and polling-based confirmation, reconciliation scheduling for stuck payments, and full audit logging of all DealerPay API interactions.
Scope:
- Invoice auto-generation when a service request transitions to
Completed - New card payment via DealerPay CNP hosted iframe (Flow A)
- Saved card token payment without iframe (Flow B)
- Webhook-based and polling-based payment finalization
- Reconciliation background service for stuck
Initiated/Processingpayments - Saved card management — list, set default, delete
- Invoice retrieval for customer and admin
Dependencies:
- DealerPay Connect API v2.0 (external payment processor —
https://connect.dealer-pay.com) - PostgreSQL (persistent storage of payments, invoices, tokens, audit logs)
- ASP.NET Core 8 BackgroundService (reconciliation scheduler)
- JWT authentication (customer and admin roles)
- Polly (HTTP retry policy for DealerPay API calls)
- Dapper (DAL layer — follows existing codebase pattern)
Requirements
Functional:
- Customer can initiate payment for an invoice using a new card via DealerPay-hosted iframe.
- Customer can initiate payment using a previously saved card token (no iframe required).
- Frontend stores the
asyncProcessingIdreceived from DealerPay iframepostMessagevia the backend. - Customer polls payment status; backend internally calls DealerPay and finalizes the DB when complete.
- DealerPay delivers payment completion via webhook; backend verifies and finalizes idempotently.
- Reconciliation scheduler processes stuck
InitiatedorProcessingpayments every 5 minutes. - Expired iframe URLs (5-min DealerPay limit) can be refreshed via a dedicated endpoint.
- Customer can list their saved card tokens (last4, cardBrand, expiry, isDefault).
- Customer can delete a saved card token (soft delete).
- Customer and admin can retrieve invoice details and look up invoice by service request.
- Duplicate payment attempts for the same invoice are prevented via idempotency checks.
- All DealerPay API calls are audit-logged in
dealerpayapilog.
Non-Functional:
- Payment initiation latency < 2 seconds (excluding DealerPay external round-trip).
- Webhook handler always returns HTTP 200 — prevents DealerPay retry storms.
- Optimistic locking (
rowversion) ensures no duplicate DB finalization under concurrent webhook + polling. - API keys are never exposed in logs, responses, or source control.
- Raw card data (PAN, CVV) never touches our backend — PCI-DSS out-of-scope via DealerPay iframe.
- JWT with customer/admin roles enforced on every payment and invoice endpoint.
- Reconciliation batch limited to 50 payments per cycle to avoid DB lock contention.
Design Specifications
Architecture Overview
The payment gateway feature integrates with the existing service request system:
- ServiceRequestService auto-creates an invoice when a service request transitions to
Completed. - Customer calls
POST /payments/initiate→ backend validates invoice, calls DealerPay CNP API → returns iframe URL. - Customer fills card in DealerPay-hosted iframe → DealerPay fires
postMessage({ asyncProcessingId })to parent window. - Frontend calls
PUT /payments/{id}/async-idto store the asyncId → begins pollingGET /payments/{id}/status. - Backend polls DealerPay processingStatus → when
complete = true, calls Transaction API → finalizes DB atomically. - DealerPay webhook arrives in parallel → same idempotent
FinalizePayment()method, protected byrowversion. - Reconciliation scheduler handles any payments stuck in
InitiatedorProcessingstate.
┌────────────┐ POST /payments/initiate ┌─────────────────┐
│ Frontend │ ──────────────────────────────▶ │ Our Backend │
│ │ │ │──▶ DealerPay CNP API
│ │ ◀── { paymentId, checkoutUrl } ── │ │◀── { url, traceId, expires }
│ │ └─────────────────┘
│ [IFRAME] │◀── Load DealerPay secure form URL
│ │
│ DealerPay postMessage ──▶ Frontend receives { asyncProcessingId }
│ │
│ │ PUT /payments/{id}/async-id ┌─────────────────┐
│ │ ──────────────────────────────▶ │ Our Backend │
│ │ │ stores asyncId │
│ │ GET /payments/{id}/status └─────────────────┘
│ [polling] │ ──────────────────────────────▶ polls DealerPay ──▶ processingStatus + Transaction API
│ │ ◀── { status: "Success" } ────── finalizes DB atomically
└────────────┘
DealerPay Webhook ──▶ POST /webhooks/dealerpay/{paymentId}
│
Idempotent FinalizePayment()
│ (parallel)
Reconciliation Scheduler (every 5 min)
Data Models
Below are the main models used in API requests, responses, and internal logic.
// ── Request Models ────────────────────────────────────────────────────────────
/// <summary>
/// Request body for POST /payments/initiate (new card, CNP iframe flow).
/// Maps to: InitiatePaymentRequest.cs
/// </summary>
public record InitiatePaymentRequest
{
/// <summary>
/// The invoice to pay. Must belong to the authenticated customer and not be in Paid status.
/// </summary>
public Guid InvoiceId { get; init; }
/// <summary>
/// If true, instructs DealerPay to save the card token (safe=true on CNP request).
/// The returned safeToken is stored in customer_payment_tokens after a successful payment.
/// Default: false.
/// </summary>
public bool SaveCardInfo { get; init; } = false;
}
/// <summary>
/// Request body for POST /payments/initiate-saved-card (saved token flow).
/// Maps to: InitiateSavedCardPaymentRequest.cs
/// </summary>
public record InitiateSavedCardPaymentRequest
{
/// <summary>
/// The invoice to pay. Must belong to the authenticated customer and not be in Paid status.
/// </summary>
public Guid InvoiceId { get; init; }
/// <summary>
/// Our internal token UUID from customer_payment_tokens.tokenid.
/// Must belong to the authenticated customer and isactive = true.
/// </summary>
public Guid TokenId { get; init; }
}
/// <summary>
/// Request body for PUT /payments/{paymentId}/async-id.
/// Sent by the frontend after receiving asyncProcessingId from DealerPay iframe postMessage.
/// Maps to: StoreAsyncIdRequest.cs
/// </summary>
public record StoreAsyncIdRequest
{
/// <summary>
/// The asyncProcessingId value received from DealerPay via window.parent.postMessage().
/// Required. Must be non-empty.
/// </summary>
public string AsyncProcessingId { get; init; } = string.Empty;
}
// ── Response Models ───────────────────────────────────────────────────────────
public record InitiatePaymentResponse
{
public Guid PaymentId { get; init; }
public string? CheckoutUrl { get; init; } // Null for saved-card flow
public DateTime? ExpiresAt { get; init; } // Null for saved-card flow
public bool Processing { get; init; } = false; // True for saved-card flow
public string? Message { get; init; }
}
public record PaymentStatusResponse
{
public string PaymentStatus { get; init; } = string.Empty; // Processing | Success | Failed | Expired
public string? Message { get; init; }
public string? TransactionId { get; init; }
public string? AuthCode { get; init; }
public string? CardBrand { get; init; }
public string? Last4 { get; init; }
public decimal? AmountPaid { get; init; }
public bool? CanRetry { get; init; }
}
public record SavedCardResponse
{
public Guid TokenId { get; init; }
public string? CardBrand { get; init; }
public string? Last4 { get; init; }
public int? ExpiryMonth { get; init; }
public int? ExpiryYear { get; init; }
public bool IsDefault { get; init; }
}
public record InvoiceResponse
{
public Guid InvoiceId { get; init; }
public string InvoiceNumber { get; init; } = string.Empty;
public Guid ServiceRequestId { get; init; }
public decimal TotalAmount { get; init; }
public decimal PaidAmount { get; init; }
public decimal DueAmount { get; init; }
public string PaymentStatus { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public List<InvoiceLineItemResponse> Services { get; init; } = new();
}
public record InvoiceLineItemResponse
{
public string ServiceName { get; init; } = string.Empty;
public decimal ServicePrice { get; init; }
}
public record CommonResponse
{
public int Status { get; init; }
public string? Message { get; init; }
}
// ── Entity Models (DB mapping) ────────────────────────────────────────────────
public record InvoiceEntity
{
public Guid InvoiceId { get; init; }
public string InvoiceNumber { get; init; } = string.Empty;
public Guid ServiceRequestId { get; init; }
public Guid CustomerId { get; init; }
public decimal SubTotal { get; init; }
public decimal TaxAmount { get; init; }
public decimal DiscountAmount { get; init; }
public decimal TotalAmount { get; init; }
public decimal PaidAmount { get; init; }
public decimal DueAmount { get; init; }
public string PaymentStatus { get; init; } = "Pending";
public string? Notes { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
}
public record PaymentEntity
{
public Guid PaymentId { get; init; }
public Guid CustomerId { get; init; }
public Guid InvoiceId { get; init; }
public string ReferenceNumber { get; init; } = string.Empty;
public string PaymentMethod { get; init; } = string.Empty; // NewCard | SavedCard
public decimal TotalAmount { get; init; }
public string PaymentStatus { get; init; } = "Initiated";
public string? TraceId { get; init; }
public string? AsyncId { get; init; }
public string? TransactionId { get; init; }
public string? CheckoutUrl { get; init; }
public DateTime? ExpireAt { get; init; }
public DateTime? InitiatedAt { get; init; }
public DateTime? CompletedAt { get; init; }
public DateTime? FailedAt { get; init; }
public string? Message { get; init; }
public string? AuthCode { get; init; }
public string? CardBrand { get; init; }
public string? Last4 { get; init; }
public bool SaveTokenRequested { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
public long RowVersion { get; init; }
}
public record CustomerPaymentTokenEntity
{
public Guid TokenId { get; init; }
public Guid CustomerId { get; init; }
public string DealerPayToken { get; init; } = string.Empty; // Never expose in API responses
public string? CardBrand { get; init; }
public string? Last4 { get; init; }
public int? ExpiryMonth { get; init; }
public int? ExpiryYear { get; init; }
public string? CardHolderName { get; init; }
public bool IsDefault { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
}
// ── DealerPay API Models ──────────────────────────────────────────────────────
public record DealerPayCustomer
{
public string CustomerNumber { get; init; } = string.Empty;
public string? FirstName { get; init; }
public string? LastName { get; init; }
public string? CompanyName { get; init; }
public string? Address1 { get; init; }
public string? Address2 { get; init; }
public string? City { get; init; }
public string? State { get; init; }
public string? Zip { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; }
}
public record DealerPayCnpRequest
{
public string DepartmentId { get; init; } = string.Empty;
public string? User { get; init; }
public decimal SaleAmount { get; init; }
public string Reference { get; init; } = string.Empty; // Must equal our paymentId
public DealerPayCustomer Customer { get; init; } = new();
public bool Safe { get; init; } = false;
public bool AuthOnly { get; init; } = false;
public string? NotificationUrl { get; init; }
public bool AutoSendReceipt { get; init; } = false;
}
public record DealerPayCnpResponse
{
public bool Success { get; init; }
public string? Message { get; init; }
public string? TraceId { get; init; }
public DealerPayCnpData? Data { get; init; }
}
public record DealerPayCnpData
{
public string Url { get; init; } = string.Empty;
public DateTime Expires { get; init; }
}
public record DealerPaySafeRequest
{
public string DepartmentId { get; init; } = string.Empty;
public string SafeToken { get; init; } = string.Empty;
public decimal SaleAmount { get; init; }
public string Reference { get; init; } = string.Empty;
public string? User { get; init; }
public string? NotificationUrl { get; init; }
public bool AutoSendReceipt { get; init; } = false;
}
public record DealerPaySafeResponse
{
public bool Success { get; init; }
public string? Message { get; init; }
public string? TraceId { get; init; }
public DealerPaySafeData? Data { get; init; }
}
public record DealerPaySafeData
{
public string AsyncProcessingId { get; init; } = string.Empty;
}
public record DealerPayProcessingStatusResponse
{
public bool Success { get; init; }
public DealerPayProcessingStatusData? Data { get; init; }
}
public record DealerPayProcessingStatusData
{
public bool Complete { get; init; }
public string? TransactionId { get; init; }
}
public record DealerPayTransactionResponse
{
public bool Success { get; init; }
public DealerPayTransactionData? Data { get; init; }
}
public record DealerPayTransactionData
{
public string TransactionId { get; init; } = string.Empty;
public bool Success { get; init; }
public decimal Amount { get; init; }
public string? AuthCode { get; init; }
public string? CardBrand { get; init; }
public string? Last4 { get; init; }
public string? CardHolderName { get; init; }
public string? SafeToken { get; init; } // Only present when CNP initiated with safe=true
public string? Reference { get; init; }
public string? Message { get; init; }
}
// ── Enums ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Status of a payment transaction attempt. Belongs to the payments table.
/// Add to VanTrackerService/Models/Common/Enum.cs
/// </summary>
public enum TransactionStatus
{
Initiated = 1, // Payment row created, CNP URL generated, waiting for card submission
Processing = 2, // asyncProcessingId received, DealerPay is processing the card
Success = 3, // Payment confirmed via Transaction API
Failed = 4, // Card declined or DealerPay returned success=false
Expired = 5, // Iframe URL expired OR processing timed out
Cancelled = 6, // Customer explicitly cancelled
Refunded = 7, // Full refund issued (post-POC)
PartiallyRefunded = 8 // Partial refund issued (post-POC)
}
/// <summary>
/// Accounting status of an invoice. Belongs to the invoices table.
/// Add to VanTrackerService/Models/Common/Enum.cs
/// </summary>
public enum InvoicePaymentStatus
{
Pending = 1, // Invoice created, no payment received yet
PartiallyPaid = 2, // Some amount paid, balance still due (post-POC)
Paid = 3, // Fully paid
Refunded = 4, // Full refund issued
PartiallyRefunded = 5, // Partial refund
Failed = 6 // Payment repeatedly failed
}
/// <summary>
/// Payment method used for a payment. Belongs to the payments table.
/// Add to VanTrackerService/Models/Common/Enum.cs
/// </summary>
public enum PaymentMethod
{
NewCard = 1, // CNP iframe flow
SavedCard = 2 // Saved token flow
}
⚠️ Important: The existing
PaymentStatus { Pending=1, Paid=2 }enum is used byservicerequests.paymentstatus. Do not remove it. The new enums above are separate and apply only to the payment/invoice feature.
TransactionStatus Enum
The TransactionStatus enum represents the lifecycle state of a payment row:
| Status | Value | Description |
|---|---|---|
| Initiated | 1 | Payment row created, CNP iframe URL generated. Waiting for customer to submit card. |
| Processing | 2 | asyncProcessingId stored. DealerPay is processing the card asynchronously. |
| Success | 3 | Payment confirmed via DealerPay Transaction API. Invoice marked Paid. |
| Failed | 4 | Card declined or DealerPay returned success=false. Customer may retry. |
| Expired | 5 | Iframe URL expired before card submission, or processing timed out (set by scheduler). |
| Cancelled | 6 | Explicitly cancelled by customer or admin. (Post-POC) |
| Refunded | 7 | Full refund issued via DealerPay. (Post-POC) |
| PartiallyRefunded | 8 | Partial refund issued. (Post-POC) |
In the context of the payment flow:
- Payment is created with status Initiated (CNP) or Processing (Saved Card — skips Initiated)
- Customer submits card in iframe → status becomes Processing
- DealerPay confirms payment → status becomes Success or Failed
- Scheduler marks timed-out payments as Expired
Payment Flow Diagrams
Flow A — New Card (CNP Iframe)
Used when the customer has no saved card or chooses to pay with a new card.
Step 1 Frontend calls POST /payments/initiate (invoiceId, saveCardInfo)
Step 2 Backend validates invoice → calls DealerPay CNP API → stores payment row (Initiated)
→ returns { paymentId, checkoutUrl, expiresAt }
Step 3 Frontend renders <iframe src=checkoutUrl>
Step 4 Customer enters card in DealerPay-hosted iframe and submits
DealerPay fires: window.parent.postMessage({ asyncProcessingId }, "*")
Step 5 Frontend calls PUT /payments/{paymentId}/async-id { asyncProcessingId }
Backend stores asyncId → payment status → Processing
Step 6 Frontend polls GET /payments/{paymentId}/status every 3 seconds
Step 7 Backend polling: calls DealerPay processingStatus/{asyncId}
→ complete=false → return { status: "Processing" }
→ complete=true → call DealerPay Transaction/{transactionId}
→ FinalizePayment() atomically
→ return { status: "Success" | "Failed" }
Step 8 (Parallel) DealerPay webhook → POST /webhooks/dealerpay/{paymentId}
→ idempotent FinalizePayment() (rowversion protects against double-write)
Step 9 (Fallback) Reconciliation scheduler handles any stuck payments
sequenceDiagram
participant FE as Frontend
participant BE as Our Backend
participant DP as DealerPay API
participant DB as PostgreSQL
FE->>BE: POST /payments/initiate {invoiceId, saveCardInfo}
BE->>DB: Validate invoice (exists, not Paid, no active payment)
BE->>DP: POST /Payment/CardNotPresent {saleAmount, reference=paymentId, notificationUrl}
DP-->>BE: { url, expires, traceId }
BE->>DB: INSERT payments (status=Initiated) + UPDATE invoices (Pending)
BE-->>FE: { paymentId, checkoutUrl, expiresAt }
FE->>FE: Render <iframe src=checkoutUrl>
Note over FE: Customer fills card details
DP->>FE: postMessage({ asyncProcessingId })
FE->>BE: PUT /payments/{paymentId}/async-id
BE->>DB: UPDATE payments SET asyncid, status=Processing
BE-->>FE: 200 OK
loop Every 3 seconds
FE->>BE: GET /payments/{paymentId}/status
BE->>DP: GET /Payment/processingStatus/{asyncId}
alt complete = false
BE-->>FE: { status: "Processing" }
else complete = true
BE->>DP: GET /Transaction/{transactionId}
BE->>DB: FinalizePayment() — atomic transaction
BE-->>FE: { status: "Success" or "Failed" }
end
end
DP->>BE: POST /webhooks/dealerpay/{paymentId}
Note over BE: Idempotent — no-op if already Success
Flow B — Saved Card (Token)
Used when the customer has a previously saved card token.
Step 1 Frontend calls GET /payments/saved-cards → displays list (last4, brand, expiry)
Step 2 Customer selects a saved card
Step 3 Frontend calls POST /payments/initiate-saved-card (invoiceId, tokenId)
Step 4 Backend validates invoice + token ownership → calls DealerPay Safe API
→ creates payment row (Processing) → stores asyncId
→ returns { paymentId, processing: true } — NO iframe step
Step 5 Frontend polls GET /payments/{paymentId}/status
Step 6 Same polling → FinalizePayment() logic as Flow A
Step 7 (Parallel) Webhook + (Fallback) Scheduler — same as Flow A
sequenceDiagram
participant FE as Frontend
participant BE as Our Backend
participant DP as DealerPay API
participant DB as PostgreSQL
FE->>BE: GET /payments/saved-cards
BE->>DB: SELECT customer_payment_tokens WHERE customerid=? AND isactive=true
BE-->>FE: [{ tokenId, last4, cardBrand, expiryMonth, expiryYear }]
FE->>BE: POST /payments/initiate-saved-card {invoiceId, tokenId}
BE->>DB: Validate invoice + token ownership + no active payment
BE->>DP: POST /Payment/Safe {safeToken, saleAmount, reference=paymentId}
DP-->>BE: { asyncProcessingId, traceId }
BE->>DB: INSERT payments (status=Processing, asyncid) + UPDATE invoices
BE-->>FE: { paymentId, processing: true }
loop Every 3 seconds
FE->>BE: GET /payments/{paymentId}/status
BE->>DP: GET /Payment/processingStatus/{asyncId}
alt complete = false
BE-->>FE: { status: "Processing" }
else complete = true
BE->>DP: GET /Transaction/{transactionId}
BE->>DB: FinalizePayment() — atomic transaction
BE-->>FE: { status: "Success" or "Failed" }
end
end
Database Schema
invoices
CREATE TABLE invoices (
invoiceid UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
invoicenumber TEXT NOT NULL,
servicerequestid UUID NOT NULL,
customerid UUID NOT NULL,
subtotal NUMERIC(10, 2) NOT NULL DEFAULT 0,
taxamount NUMERIC(10, 2) NOT NULL DEFAULT 0,
discountamount NUMERIC(10, 2) NOT NULL DEFAULT 0,
totalamount NUMERIC(10, 2) NOT NULL DEFAULT 0,
paidamount NUMERIC(10, 2) NOT NULL DEFAULT 0,
dueamount NUMERIC(10, 2) NOT NULL DEFAULT 0,
paymentstatus TEXT NOT NULL DEFAULT 'Pending',
notes TEXT NULL,
createdat TIMESTAMP NOT NULL DEFAULT NOW(),
updatedat TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_invoices_invoicenumber UNIQUE (invoicenumber),
CONSTRAINT uq_invoices_servicerequestid UNIQUE (servicerequestid),
CONSTRAINT fk_invoices_servicerequestid FOREIGN KEY (servicerequestid) REFERENCES servicerequests (requestid)
);
CREATE INDEX idx_invoices_customerid ON invoices (customerid);
CREATE INDEX idx_invoices_paymentstatus ON invoices (paymentstatus);
Invoice Number Format: INV-{YYYY}-{6-digit-zero-padded-sequence} e.g., INV-2026-000001
paymentstatus valid values: Pending | PartiallyPaid | Paid | Refunded | PartiallyRefunded | Failed
Auto-created inside ServiceRequestService when service request transitions to Completed:
subtotal= SUM ofrequestedservices.servicepriceWHEREservicestatus = 'Completed'totalamount= subtotal + taxamount − discountamountdueamount= totalamount,paymentstatus=Pending
payments
CREATE TABLE payments (
paymentid UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
customerid UUID NOT NULL,
invoiceid UUID NOT NULL,
referencenumber TEXT NOT NULL, -- equals paymentId; sent as DealerPay reference
paymentmethod TEXT NOT NULL, -- 'NewCard' | 'SavedCard'
totalamount NUMERIC(10, 2) NOT NULL,
paymentstatus TEXT NOT NULL DEFAULT 'Initiated',
traceid TEXT NULL, -- DealerPay traceId from initiation
asyncid TEXT NULL, -- DealerPay asyncProcessingId
transactionid TEXT NULL, -- DealerPay transactionId (post-completion)
checkouturl TEXT NULL, -- Iframe URL (CNP only; needed for refresh)
expireat TIMESTAMP NULL, -- Iframe URL expiry (CNP only)
initiatedat TIMESTAMP NULL,
completedat TIMESTAMP NULL,
failedat TIMESTAMP NULL,
message TEXT NULL, -- Decline reason or error
authcode TEXT NULL,
cardbrand TEXT NULL, -- Visa / Mastercard / Amex etc.
last4 TEXT NULL,
savetokenrequested BOOLEAN NOT NULL DEFAULT FALSE,
gatewayrequest JSONB NULL, -- Sanitized DealerPay request JSON
gatewayresponse JSONB NULL, -- DealerPay response JSON
webhookpayload JSONB NULL, -- Raw inbound webhook body
createdat TIMESTAMP NOT NULL DEFAULT NOW(),
updatedat TIMESTAMP NOT NULL DEFAULT NOW(),
rowversion BIGINT NOT NULL DEFAULT 0, -- Optimistic concurrency control
CONSTRAINT uq_payments_referencenumber UNIQUE (referencenumber),
CONSTRAINT fk_payments_invoiceid FOREIGN KEY (invoiceid) REFERENCES invoices (invoiceid)
);
CREATE INDEX idx_payments_invoiceid ON payments (invoiceid);
CREATE INDEX idx_payments_customerid ON payments (customerid);
CREATE INDEX idx_payments_paymentstatus ON payments (paymentstatus);
CREATE INDEX idx_payments_processing ON payments (paymentstatus, updatedat)
WHERE paymentstatus IN ('Initiated', 'Processing'); -- Partial index for scheduler
payment_allocations
CREATE TABLE payment_allocations (
allocationid UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
paymentid UUID NOT NULL,
invoiceid UUID NOT NULL,
allocatedamount NUMERIC(10, 2) NOT NULL,
createdat TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT fk_allocations_paymentid FOREIGN KEY (paymentid) REFERENCES payments (paymentid),
CONSTRAINT fk_allocations_invoiceid FOREIGN KEY (invoiceid) REFERENCES invoices (invoiceid)
);
CREATE INDEX idx_allocations_paymentid ON payment_allocations (paymentid);
CREATE INDEX idx_allocations_invoiceid ON payment_allocations (invoiceid);
For POC, each payment fully pays one invoice. Table exists for future partial payment support.
customer_payment_tokens
CREATE TABLE customer_payment_tokens (
tokenid UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
customerid UUID NOT NULL,
dealerpaytoken TEXT NOT NULL, -- DealerPay safeToken — NEVER return in API responses
cardbrand TEXT NULL,
last4 TEXT NULL,
expirymonth INT NULL,
expiryyear INT NULL,
cardholdername TEXT NULL,
isdefault BOOLEAN NOT NULL DEFAULT FALSE,
isactive BOOLEAN NOT NULL DEFAULT TRUE,
createdat TIMESTAMP NOT NULL DEFAULT NOW(),
updatedat TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tokens_customerid ON customer_payment_tokens (customerid)
WHERE isactive = TRUE;
dealerpayapilog
CREATE TABLE dealerpayapilog (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
userid UUID NULL,
invoiceid UUID NULL,
paymentid UUID NULL,
apiendpoint TEXT NOT NULL,
methodtype TEXT NOT NULL, -- 'POST' | 'GET'
requestpayloadjson JSONB NULL, -- Sanitized — no API keys or card data
responsepayloadjson JSONB NULL,
requestedat TIMESTAMP NOT NULL DEFAULT NOW(),
respondedat TIMESTAMP NULL,
issuccess BOOLEAN NOT NULL DEFAULT FALSE,
errormessage TEXT NULL,
durationms INT NULL
);
CREATE INDEX idx_apilog_paymentid ON dealerpayapilog (paymentid);
CREATE INDEX idx_apilog_requestedat ON dealerpayapilog (requestedat);
⚠️ Always strip
X-API-KEYand raw card data fromrequestpayloadjsonbefore storing.
Tables Overview
| Table | Purpose | Created When |
|---|---|---|
invoices | Accounting record for a service | Service request → Completed |
payments | Transaction attempt record | Customer initiates payment |
payment_allocations | Maps payment amount to invoice | Payment finalizes as Success |
customer_payment_tokens | Saved DealerPay card tokens | Payment finalizes with safe=true |
dealerpayapilog | Audit trail of every DealerPay API call | Every DealerPay API call |
Migration file: DatabaseService/Scripts/000038-AddPaymentAndInvoiceTables.sql
Create tables in order: invoices → payments → payment_allocations → customer_payment_tokens → dealerpayapilog
Configuration
appsettings.json (Development)
{
"DealerPay": {
"BaseUrl": "https://connect-sb.dealer-pay.com",
"ApiKey": "YOUR_SANDBOX_API_KEY",
"DealerId": "YOUR_DEALER_ID",
"DepartmentId": "be65f7cc-acec-41f5-911a-c8037fa34844",
"WebhookBaseUrl": "https://your-dev-tunnel-or-ngrok.com",
"AutoSendReceipt": false,
"ReconciliationIntervalMinutes": 5,
"StalePaymentThresholdMinutes": 15,
"ExpiredPaymentThresholdMinutes": 30
}
}
appsettings.Production.json→BaseUrl: https://connect.dealer-pay.com+ production keysappsettings.Staging.json→ sandbox URL + staging keys
⚠️
ApiKeymust come from environment variableDealerPay__ApiKeyin production — never committed to source control.
Options Class
// File: VanTrackerService/Models/Payment/DealerPayOptions.cs
public class DealerPayOptions
{
public string BaseUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public string DealerId { get; set; } = string.Empty;
public string DepartmentId { get; set; } = string.Empty;
public string WebhookBaseUrl { get; set; } = string.Empty;
public bool AutoSendReceipt { get; set; } = false;
public int ReconciliationIntervalMinutes { get; set; } = 5;
public int StalePaymentThresholdMinutes { get; set; } = 15;
public int ExpiredPaymentThresholdMinutes { get; set; } = 30;
}
Payment Gateway Endpoints
All payment endpoints require JWT authentication with customer role unless otherwise noted.
| Endpoint | Method | Purpose | Parameters | Request Model | Response Model | Status Codes |
|---|---|---|---|---|---|---|
/payments/initiate | POST | Initiate new card payment — returns DealerPay iframe URL | JWT (customer), body | InitiatePaymentRequest | InitiatePaymentResponse | 200, 400, 403, 404, 500 |
/payments/initiate-saved-card | POST | Charge a saved card token — no iframe | JWT (customer), body | InitiateSavedCardPaymentRequest | InitiatePaymentResponse | 200, 400, 403, 404, 500 |
/payments/{paymentId}/async-id | PUT | Store asyncProcessingId received from DealerPay iframe postMessage | JWT (customer), paymentId path, body | StoreAsyncIdRequest | CommonResponse | 200, 400, 404, 500 |
/payments/{paymentId}/status | GET | Poll payment status — backend calls DealerPay and finalizes if done | JWT (customer), paymentId path | – | PaymentStatusResponse | 200, 404, 500 |
/payments/{paymentId}/refresh-url | POST | Refresh expired 5-min iframe URL | JWT (customer), paymentId path | – | InitiatePaymentResponse | 200, 400, 404, 500 |
/payments/saved-cards | GET | List customer's active saved cards | JWT (customer) | – | List<SavedCardResponse> | 200, 401, 500 |
/payments/saved-cards/{tokenId} | DELETE | Soft-delete a saved card | JWT (customer), tokenId path | – | CommonResponse | 200, 404, 500 |
/invoices/{invoiceId} | GET | Get invoice details | JWT (customer or admin), invoiceId path | – | InvoiceResponse | 200, 403, 404, 500 |
/invoices/by-service-request/{serviceRequestId} | GET | Get invoice by service request ID | JWT (customer or admin), serviceRequestId path | – | InvoiceResponse | 200, 403, 404, 500 |
/webhooks/dealerpay/{paymentId} | POST | Inbound DealerPay payment completion webhook | Anonymous, paymentId path, raw body | – | HTTP 200 (always) | 200 |
API Interfaces
1. POST /payments/initiate — Initiate New Card Payment
Purpose:
Creates a payment record and returns a DealerPay CNP iframe URL for the customer to enter their card details.
Authentication: JWT token in Authorization header (customer role)
Path Parameters: None
Request Model: InitiatePaymentRequest
Response Model: InitiatePaymentResponse
Status Codes: 200 (OK), 400 (Bad Request), 403 (Forbidden), 404 (Not Found), 500 (Server Error)
Business Rules:
- If invoice is already
Paid→ return 400 - If customer does not own the invoice → return 403
- If an
InitiatedorProcessingpayment already exists for this invoice → return 200 with existing record (idempotent — do NOT create a duplicate) checkoutUrlis stored in thepaymentstable for idempotency return and URL refresh
Internal Logic:
1. Get customerId from JWT
2. Validate invoice: exists, belongs to customer, not Paid
3. Idempotency: check for active Initiated/Processing payment on same invoice
4. Call DealerPay CNP API (§DealerPay 2.1)
notificationUrl = "{config.WebhookBaseUrl}/webhooks/dealerpay/{paymentId}"
reference = paymentId.ToString(), safe = saveCardInfo
Log to dealerpayapilog
5. INSERT payments (status=Initiated, traceid, expireat, checkouturl, gatewayrequest/response)
6. UPDATE invoices SET paymentstatus = 'Pending'
7. Return { paymentId, checkoutUrl, expiresAt }
2. POST /payments/initiate-saved-card — Initiate Saved Card Payment
Purpose:
Charges a previously saved card token via DealerPay Safe API. No iframe is involved.
Authentication: JWT token in Authorization header (customer role)
Request Model: InitiateSavedCardPaymentRequest
Response Model: InitiatePaymentResponse
Status Codes: 200 (OK), 400 (Bad Request), 403 (Forbidden), 404 (Not Found), 500 (Server Error)
Business Rules:
- Token must belong to the authenticated customer and
isactive = true - Payment row created directly in
Processingstate (noInitiatedstep — no iframe) - Same idempotency check as §1
Internal Logic:
1. Validate invoice (same as §1)
2. Fetch token: customerid matches + isactive = true
3. Idempotency check
4. Call DealerPay Safe API (§DealerPay 2.3)
safeToken = token.dealerpaytoken, reference = paymentId.ToString()
Log to dealerpayapilog
5. INSERT payments (status=Processing, asyncid from response)
6. UPDATE invoices SET paymentstatus = 'Pending'
7. Return { paymentId, processing: true }
3. PUT /payments/{paymentId}/async-id — Store Async Processing ID
Purpose:
Stores the asyncProcessingId received from DealerPay via iframe postMessage. Transitions payment from Initiated → Processing.
Authentication: JWT token in Authorization header (customer role)
Path Parameters:
paymentId(GUID): The payment to update
Request Model: StoreAsyncIdRequest
Response Model: CommonResponse
Status Codes: 200 (OK), 400 (Bad Request), 404 (Not Found), 500 (Server Error)
Business Rules:
- Payment must be in
Initiatedstatus asyncProcessingIdmust be non-empty
Frontend Note:
window.addEventListener('message', (event) => {
if (event.data && event.data.asyncProcessingId) {
// Call PUT /payments/{paymentId}/async-id
}
});
Confirm exact
event.datastructure with DealerPay before implementation.
4. GET /payments/{paymentId}/status — Poll Payment Status
Purpose:
Frontend polls this endpoint every 3 seconds. Backend internally calls DealerPay and finalizes the DB when processing is complete. Frontend must never call DealerPay directly.
Authentication: JWT token in Authorization header (customer role)
Path Parameters:
paymentId(GUID): The payment to check
Request Model: None
Response Model: PaymentStatusResponse
Status Codes: 200 (OK), 404 (Not Found), 500 (Server Error)
Business Rules:
- Terminal states (
Success,Failed,Expired,Cancelled) return immediately — no DealerPay call - If
asyncidis null → returnProcessing(card not yet submitted in iframe) FinalizePayment()is called whencomplete = true— shared with webhook handler and scheduler
Internal Logic:
1. Fetch payment, verify ownership
2. If terminal status → return immediately
3. If asyncid IS NULL → return { paymentStatus: "Processing" }
4. Call DealerPay processingStatus/{asyncid}
5. If complete=false → return { paymentStatus: "Processing" }
6. If complete=true → Call Transaction API → FinalizePayment()
FinalizePayment() (atomic, idempotent, shared with webhook + scheduler):
BEGIN TRANSACTION
UPDATE payments SET status=Success, completedat, authcode, cardbrand, last4, rowversion+1
WHERE paymentid=? AND rowversion=? ← optimistic lock
→ 0 rows updated → ROLLBACK → return Processing (another thread beat us)
UPDATE invoices SET paidamount+=, dueamount-=, paymentstatus='Paid'/'PartiallyPaid'
INSERT payment_allocations
IF savetokenrequested AND safeToken → INSERT customer_payment_tokens (dedup check first)
COMMIT
5. POST /payments/{paymentId}/refresh-url — Refresh Expired Iframe URL
Purpose:
Re-calls DealerPay CNP API to get a new iframe URL when the previous 5-minute URL has expired.
Authentication: JWT token in Authorization header (customer role)
Path Parameters:
paymentId(GUID): The payment to refresh
Request Model: None
Response Model: InitiatePaymentResponse
Status Codes: 200 (OK), 400 (Bad Request), 404 (Not Found), 500 (Server Error)
Business Rules:
- Payment must be in
Initiatedstatus (card not yet submitted) - If status is
Processing,Success, orFailed→ return 400 (cannot refresh)
6. GET /payments/saved-cards — List Saved Cards
Purpose:
Returns the authenticated customer's active saved card tokens for display on the payment screen.
Authentication: JWT token in Authorization header (customer role)
Request Model: None
Response Model: List<SavedCardResponse>
Status Codes: 200 (OK), 401 (Unauthorized), 500 (Server Error)
Business Rules:
dealerpaytokenmust never be returned in this response — backend-only value- Ordered by
isdefault DESC, createdat DESC
7. DELETE /payments/saved-cards/{tokenId} — Remove Saved Card
Purpose:
Soft-deletes a saved card by setting isactive = FALSE. Promotes the next most recent card as default if the deleted card was the default.
Authentication: JWT token in Authorization header (customer role)
Path Parameters:
tokenId(GUID): The token to remove
Business Rules:
- Token must belong to the authenticated customer
- Soft delete only —
dealerpaytokenis retained for audit
8. GET /invoices/{invoiceId} — Get Invoice Details
Purpose:
Customer or admin retrieves invoice details including line items. Customers can only access their own invoices.
Authentication: JWT token (customer verifies ownership; admin skips ownership check)
Response Model: InvoiceResponse
Status Codes: 200 (OK), 403 (Forbidden), 404 (Not Found), 500 (Server Error)
9. GET /invoices/by-service-request/{serviceRequestId} — Get Invoice by Service Request
Purpose:
Convenience endpoint — look up invoice via service request ID (1:1 relationship). Same response format as §8.
10. POST /webhooks/dealerpay/{paymentId} — DealerPay Webhook
Purpose:
Receives payment completion notifications from DealerPay. Always returns HTTP 200 regardless of outcome. Verification is done via Transaction API before any DB change.
Authentication: [AllowAnonymous] — called directly by DealerPay
Business Rules:
- Always return HTTP 200 — any non-200 will cause DealerPay to retry → duplicate processing risk
- Never trust the raw webhook payload — always verify via Transaction API (§DealerPay 2.4)
- Idempotent: if payment already
Success→ return 200 immediately, no DB write
Internal Logic:
1. Read raw body — store immediately (do not parse yet)
2. Fetch payment by paymentId; if not found → return 200
3. UPDATE payments SET webhookpayload = raw body
4. If status is already terminal → return 200 immediately
5. Best-effort extract transactionId from body
6. Verify via Transaction API (§DealerPay 2.4)
7. Call FinalizePayment() — same shared logic as polling endpoint
8. Always return HTTP 200
Reconciliation Background Service
File: VanTrackerService/BackgroundServices/PaymentReconciliationService.cs
Purpose: Recovers payments stuck in Initiated or Processing due to browser close, network drop, webhook miss, or server restart.
Pattern: Follows existing ScheduledNotificationBackgroundService — uses IServiceScopeFactory for scoped resolution.
public PaymentReconciliationService(
ILogger<PaymentReconciliationService> logger,
IServiceScopeFactory serviceScopeFactory,
IOptions<DealerPayOptions> dealerPayOptions)
Logic:
Run every {ReconciliationIntervalMinutes} minutes (default: 5)
QUERY: SELECT * FROM payments
WHERE paymentstatus IN ('Initiated', 'Processing')
AND updatedat < NOW() - INTERVAL '{StalePaymentThresholdMinutes} minutes'
ORDER BY updatedat ASC
LIMIT 50
FOR EACH stale payment:
IF Initiated:
expireat < NOW() AND asyncid IS NULL
→ UPDATE paymentstatus = 'Expired'
Otherwise → skip (still within window)
IF Processing:
Call DealerPay processingStatus/{asyncid}
→ complete=true → Call Transaction API → FinalizePayment()
→ complete=false → if initiatedat + ExpiredThreshold < NOW() → Expired
else → leave for next cycle
Log: paymentId, old status, new status, reason
DealerPay API Reference (External)
Docs: https://docs.dealer-pay.com
Sandbox:https://connect-sb.dealer-pay.com
Production:https://connect.dealer-pay.com
Authentication Headers (all requests)
X-API-KEY: {your_api_key}
x-dealer-id: {your_dealer_id}
Content-Type: application/json
Accept: application/json
DealerPay Endpoint Summary
| Purpose | Method | Endpoint | Confirmed |
|---|---|---|---|
| Initiate CNP (iframe) | POST | /api/v2/Payment/CardNotPresent | ✅ Confirmed |
| Check async processing | GET | /api/v2/Payment/processingStatus/{asyncId} | ✅ Confirmed |
| Charge saved token | POST | /api/v2/Payment/Safe | ⚠️ Inferred |
| Get transaction details | GET | /api/v2/Transaction/{transactionId} | ✅ Confirmed |
| Webhook (inbound) | POST | (our server receives this) | ⚠️ Payload unconfirmed |
⚠️ = Verify exact request/response field names in DealerPay live docs before implementation.
2.1 Card Not Present (CNP) Payment Initiation
Endpoint: POST /api/v2/Payment/CardNotPresent
Request Body:
{
"departmentId": "be65f7cc-acec-41f5-911a-c8037fa34844",
"user": "Bob Smith",
"saleAmount": 150.75,
"reference": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customer": {
"customerNumber": "CUST-0001",
"firstName": "Bob",
"lastName": "Smith",
"companyName": null,
"address1": "124 Cherry Drive",
"address2": null,
"city": "St. Louis",
"state": "MO",
"zip": "12345",
"email": "bob.smith@example.com",
"phoneNumber": "3125551212"
},
"safe": true,
"authOnly": false,
"notificationUrl": "https://api.yourapp.com/webhooks/dealerpay/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"autoSendReceipt": false
}
| Field | Type | Required | Notes |
|---|---|---|---|
departmentId | UUID | ✅ | From DealerPay:DepartmentId config |
user | string | ❌ | Customer full name for DealerPay reporting |
saleAmount | decimal | ✅ | Amount to charge |
reference | string | ✅ | Must equal our paymentId — echoed back in webhook |
customer | object | ✅ | firstName+lastName OR companyName |
safe | boolean | ❌ | true = save card token; token returned in Transaction API |
authOnly | boolean | ❌ | Use false — we want immediate capture |
notificationUrl | string | ❌ | Our webhook URL with paymentId embedded |
autoSendReceipt | boolean | ❌ | false — we send our own branded receipts |
Response (200 OK):
{
"success": true,
"message": null,
"traceId": "abc123-trace-id",
"data": {
"url": "https://connect.dealer-pay.com/securecardentry/be65f7cc-acec-41f5-911a-c8037fa34844",
"expires": "2026-05-23T14:15:22Z"
}
}
⚠️ The iframe URL expires after 5 minutes. Customer must request a refresh via
POST /payments/{paymentId}/refresh-urlif they exceed this limit.
2.2 Processing Status Check
Endpoint: GET /api/v2/Payment/processingStatus/{asyncProcessingId}
Response — Still Processing:
{ "success": true, "data": { "complete": false, "transactionId": null } }
Response — Completed:
{ "success": true, "data": { "complete": true, "transactionId": "txn-uuid-here" } }
When
complete = true, always call the Transaction API (§2.4) immediately. Do NOT treatcompletealone as payment confirmation.
2.3 Safe (Saved Token) Payment
Endpoint: POST /api/v2/Payment/Safe
Request Body:
{
"departmentId": "be65f7cc-acec-41f5-911a-c8037fa34844",
"safeToken": "tok_abc123_safe_token",
"saleAmount": 150.75,
"reference": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"user": "Bob Smith",
"notificationUrl": "https://api.yourapp.com/webhooks/dealerpay/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"autoSendReceipt": false
}
Response (200 OK):
{
"success": true,
"traceId": "def456-trace-id",
"data": { "asyncProcessingId": "async-uuid-here" }
}
Safe API returns
asyncProcessingIddirectly — no iframe step. Backend immediately enters the polling loop.
2.4 Transaction Details
Endpoint: GET /api/v2/Transaction/{transactionId}
Response (200 OK):
{
"success": true,
"data": {
"transactionId": "txn-uuid-here",
"success": true,
"amount": 150.75,
"authCode": "AUTH123456",
"cardBrand": "Visa",
"last4": "4242",
"cardHolderName": "Bob Smith",
"safeToken": "tok_abc123_safe_token",
"reference": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"message": null
}
}
data.safeTokenis only present when CNP was initiated withsafe: true. Store incustomer_payment_tokens.
data.success = falsemeans card declined — still call this endpoint and mark payment asFailed.
File & Folder Structure
VanTrackerService/
├── Controllers/
│ ├── PaymentController.cs ← Payment APIs (initiate, async-id, status, saved-cards)
│ └── WebhookController.cs ← POST /webhooks/dealerpay/{paymentId}
│
├── Models/
│ └── Payment/
│ ├── DealerPayOptions.cs ← Config binding class
│ ├── Entities/
│ │ ├── InvoiceEntity.cs
│ │ ├── PaymentEntity.cs
│ │ ├── PaymentAllocationEntity.cs
│ │ └── CustomerPaymentTokenEntity.cs
│ ├── Requests/
│ │ ├── InitiatePaymentRequest.cs
│ │ ├── InitiateSavedCardPaymentRequest.cs
│ │ └── StoreAsyncIdRequest.cs
│ ├── Responses/
│ │ ├── InitiatePaymentResponse.cs
│ │ ├── PaymentStatusResponse.cs
│ │ ├── InvoiceResponse.cs
│ │ ├── InvoiceLineItemResponse.cs
│ │ └── SavedCardResponse.cs
│ └── DealerPay/
│ ├── DealerPayCnpRequest.cs
│ ├── DealerPayCnpResponse.cs
│ ├── DealerPaySafeRequest.cs
│ ├── DealerPaySafeResponse.cs
│ ├── DealerPayProcessingStatusResponse.cs
│ ├── DealerPayTransactionResponse.cs
│ └── DealerPayCustomer.cs
│
├── DAL/
│ └── Payment/
│ ├── IPaymentDal.cs
│ └── PaymentDal.cs
│
├── Services/
│ └── Payment/
│ ├── IDealerPayClient.cs
│ ├── DealerPayClient.cs
│ ├── IPaymentService.cs
│ └── PaymentService.cs
│
└── BackgroundServices/
└── PaymentReconciliationService.cs
Existing files to modify:
| File | Change |
|---|---|
Models/Common/Enum.cs | Add TransactionStatus, InvoicePaymentStatus, PaymentMethod enums |
Models/Common/StatusCode.cs | Add PAYMENT_INITIATION_FAILED, INVALID_PAYMENT_STATE if needed |
Services/ServiceRequest/ServiceRequestService.cs | Add invoice auto-creation on Completed transition |
DAL/ServiceRequest/ServiceRequestDal.cs | Add CreateInvoiceAsync method (or put in PaymentDal) |
Program.cs | Register services, DAL, HttpClient, background service |
appsettings.json (all environments) | Add DealerPay config section |
DatabaseService/Scripts/ | Add 000038-AddPaymentAndInvoiceTables.sql |
Codebase Patterns Reference
Service Interface Pattern
Follow the existing IXtimeAppointmentService pattern:
public interface IPaymentService
{
Task<(int Status, InitiatePaymentResponse Response)> InitiatePaymentAsync(
InitiatePaymentRequest request, Guid customerId);
Task<(int Status, string Message)> StoreAsyncProcessingIdAsync(
Guid paymentId, string asyncProcessingId, Guid customerId);
Task<(int Status, PaymentStatusResponse Response)> GetPaymentStatusAsync(
Guid paymentId, Guid customerId);
Task<(int Status, InitiatePaymentResponse Response)> RefreshPaymentUrlAsync(
Guid paymentId, Guid customerId);
Task<(int Status, InitiatePaymentResponse Response)> InitiateSavedCardPaymentAsync(
InitiateSavedCardPaymentRequest request, Guid customerId);
Task<(int Status, List<SavedCardResponse> Cards)> GetSavedCardsAsync(Guid customerId);
Task<(int Status, string Message)> RemoveSavedCardAsync(Guid tokenId, Guid customerId);
Task<(int Status, InvoiceResponse Response)> GetInvoiceAsync(
Guid invoiceId, Guid requestingUserId, bool isAdmin);
}
Controller Pattern
Follow the existing XtimeController pattern:
[ApiController]
[Produces("application/json")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerTag("Payment APIs")]
public class PaymentController : ControllerBase
{
private readonly ILogger<PaymentController> _logger;
private readonly IPaymentService _paymentService;
private Guid GetCustomerId() =>
Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
[HttpPost("payments/initiate")]
[CustomRequiredScope(ScopeOperator.And, Right.customer_user)]
public async Task<IActionResult> InitiatePayment([FromBody] InitiatePaymentRequest request)
{
var (status, response) = await _paymentService.InitiatePaymentAsync(request, GetCustomerId());
return status switch
{
RkStatusCode.OK => Ok(new ResponseWithData<InitiatePaymentResponse> { Status = status, Data = response }),
RkStatusCode.RECORD_NOT_FOUND => NotFound(new CommonResponse { Status = status, Message = response.Message }),
RkStatusCode.FORBIDDEN => StatusCode(403, new CommonResponse { Status = status, Message = response.Message }),
RkStatusCode.INVALID_REQUEST => BadRequest(new CommonResponse { Status = status, Message = response.Message }),
_ => StatusCode(500, new CommonResponse { Status = status, Message = response.Message })
};
}
}
DAL Pattern
Follow the existing ServiceRequestDal pattern — IDbConnection injected, raw Dapper:
public class PaymentDal : IPaymentDal
{
private readonly IDbConnection _dbConnection;
public async Task FinalizePaymentTransactionalAsync(FinalizePaymentData data)
{
if (_dbConnection.State != ConnectionState.Open) _dbConnection.Open();
using var transaction = _dbConnection.BeginTransaction();
try
{
var updated = await _dbConnection.ExecuteAsync(@"
UPDATE payments SET paymentstatus=@Status, completedat=NOW(),
transactionid=@TransactionId, authcode=@AuthCode,
cardbrand=@CardBrand, last4=@Last4,
rowversion=rowversion+1, updatedat=NOW()
WHERE paymentid=@PaymentId AND rowversion=@ExpectedRowVersion",
data, transaction);
if (updated == 0) { transaction.Rollback(); throw new ConcurrencyException(); }
await _dbConnection.ExecuteAsync(@"
UPDATE invoices SET
paidamount=paidamount+@Amount, dueamount=dueamount-@Amount,
paymentstatus=CASE WHEN dueamount-@Amount<=0 THEN 'Paid' ELSE 'PartiallyPaid' END,
updatedat=NOW()
WHERE invoiceid=@InvoiceId", data, transaction);
await _dbConnection.ExecuteAsync(@"
INSERT INTO payment_allocations (allocationid,paymentid,invoiceid,allocatedamount,createdat)
VALUES (gen_random_uuid(),@PaymentId,@InvoiceId,@Amount,NOW())", data, transaction);
transaction.Commit();
}
catch { transaction.Rollback(); throw; }
}
}
DealerPay HTTP Client Pattern
public class DealerPayClient : IDealerPayClient
{
private readonly HttpClient _httpClient;
private readonly IPaymentDal _paymentDal;
public async Task<DealerPayCnpResponse> InitiateCardNotPresentAsync(
DealerPayCnpRequest request, Guid? paymentId = null)
{
var start = DateTime.UtcNow;
var endpoint = "/api/v2/Payment/CardNotPresent";
var response = await _httpClient.PostAsJsonAsync(endpoint, request);
var content = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<DealerPayCnpResponse>(content);
await _paymentDal.LogDealerPayApiCallAsync(new DealerPayApiLogEntity
{
ApiEndpoint = endpoint, MethodType = "POST", PaymentId = paymentId,
RequestPayloadJson = JsonSerializer.Serialize(SanitizeForLogging(request)),
ResponsePayloadJson = content,
RequestedAt = start, RespondedAt = DateTime.UtcNow,
IsSuccess = response.IsSuccessStatusCode,
DurationMs = (int)(DateTime.UtcNow - start).TotalMilliseconds
});
return result!;
}
}
Program.cs Registration
builder.Services.Configure<DealerPayOptions>(builder.Configuration.GetSection("DealerPay"));
builder.Services.AddHttpClient("DealerPay", (sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<DealerPayOptions>>().Value;
client.BaseAddress = new Uri(opts.BaseUrl);
client.DefaultRequestHeaders.Add("X-API-KEY", opts.ApiKey);
client.DefaultRequestHeaders.Add("x-dealer-id", opts.DealerId);
client.Timeout = TimeSpan.FromSeconds(30);
});
// TODO: .AddPolicyHandler(GetRetryPolicy()) — Polly 3 retries, exponential backoff
builder.Services.AddTransient<IPaymentDal, PaymentDal>();
builder.Services.AddTransient<IDealerPayClient, DealerPayClient>();
builder.Services.AddTransient<IPaymentService, PaymentService>();
builder.Services.AddHostedService<PaymentReconciliationService>();
API Examples
1. POST /payments/initiate — New Card Payment
Request:
POST /payments/initiate
Authorization: Bearer {JWT_TOKEN}
Content-Type: application/json
{
"invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"saveCardInfo": true
}
Response (200 OK):
{
"status": 0,
"message": "Payment initiated successfully",
"data": {
"paymentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"checkoutUrl": "https://connect.dealer-pay.com/securecardentry/be65f7cc-acec-41f5-911a-c8037fa34844",
"expiresAt": "2026-05-23T14:15:22Z"
}
}
Error (400 — Invoice Already Paid):
{ "status": 400, "message": "Invoice is already paid" }
Error (403 — Wrong Customer):
{ "status": 403, "message": "You do not have access to this invoice" }
Error (404 — Invoice Not Found):
{ "status": 404, "message": "Invoice not found" }
2. PUT /payments/{paymentId}/async-id — Store asyncProcessingId
Request:
PUT /payments/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/async-id
Authorization: Bearer {JWT_TOKEN}
Content-Type: application/json
{
"asyncProcessingId": "async-uuid-from-dealerpay-postmessage"
}
Response (200 OK):
{ "status": 0, "message": "Async ID stored successfully" }
Error (400 — Wrong Status):
{ "status": 400, "message": "Payment is not in Initiated state" }
3. GET /payments/{paymentId}/status — Poll Status
Request:
GET /payments/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/status
Authorization: Bearer {JWT_TOKEN}
Response — Processing:
{
"status": 0,
"data": {
"paymentStatus": "Processing",
"message": "Your payment is being processed. Please wait."
}
}
Response — Success:
{
"status": 0,
"data": {
"paymentStatus": "Success",
"message": "Payment completed successfully",
"transactionId": "txn-uuid-here",
"authCode": "AUTH123456",
"cardBrand": "Visa",
"last4": "4242",
"amountPaid": 150.75
}
}
Response — Failed:
{
"status": 0,
"data": {
"paymentStatus": "Failed",
"message": "Your card was declined. Please try a different card.",
"canRetry": true
}
}
4. POST /payments/initiate-saved-card — Saved Card Payment
Request:
POST /payments/initiate-saved-card
Authorization: Bearer {JWT_TOKEN}
Content-Type: application/json
{
"invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tokenId": "cccccccc-dddd-eeee-ffff-000000000000"
}
Response (200 OK):
{
"status": 0,
"message": "Payment processing",
"data": {
"paymentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"processing": true,
"message": "Your saved card is being charged. Please wait."
}
}
Error (404 — Token Not Found):
{ "status": 404, "message": "Saved card not found" }
5. GET /payments/saved-cards — List Saved Cards
Request:
GET /payments/saved-cards
Authorization: Bearer {JWT_TOKEN}
Response (200 OK):
{
"status": 0,
"data": [
{
"tokenId": "cccccccc-dddd-eeee-ffff-000000000000",
"cardBrand": "Visa",
"last4": "4242",
"expiryMonth": 12,
"expiryYear": 2028,
"isDefault": true
},
{
"tokenId": "dddddddd-eeee-ffff-0000-111111111111",
"cardBrand": "Mastercard",
"last4": "8888",
"expiryMonth": 6,
"expiryYear": 2027,
"isDefault": false
}
]
}
6. GET /invoices/{invoiceId} — Invoice Details
Request:
GET /invoices/3fa85f64-5717-4562-b3fc-2c963f66afa6
Authorization: Bearer {JWT_TOKEN}
Response (200 OK):
{
"status": 0,
"data": {
"invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"invoiceNumber": "INV-2026-000001",
"serviceRequestId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"totalAmount": 150.75,
"paidAmount": 0.00,
"dueAmount": 150.75,
"paymentStatus": "Pending",
"createdAt": "2026-05-23T10:00:00Z",
"services": [
{ "serviceName": "Oil Change", "servicePrice": 75.50 },
{ "serviceName": "Brake Inspection", "servicePrice": 75.25 }
]
}
}
7. POST /webhooks/dealerpay/{paymentId} — Inbound Webhook
DealerPay calls our server (inbound):
POST /webhooks/dealerpay/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Content-Type: application/json
{
"reference": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"transactionId": "txn-uuid-here",
"success": true,
"amount": 150.75
}
Our server always returns:
HTTP 200 OK
Workflows
Workflow 1: Customer Pays with New Card (Flow A)
- Service request completes →
ServiceRequestServiceauto-creates invoice (paymentstatus = Pending). - Customer opens invoice via
GET /invoices/{invoiceId}and reviews the amount due. - Customer initiates payment → frontend calls
POST /payments/initiatewithinvoiceIdandsaveCardInfo. - Backend validates invoice, calls DealerPay CNP API, inserts payment row (
Initiated), returnscheckoutUrl. - Frontend renders iframe at
checkoutUrl. Customer enters card details in DealerPay-hosted form. - DealerPay processes card asynchronously, fires
postMessage({ asyncProcessingId })to parent window. - Frontend calls
PUT /payments/{paymentId}/async-id. Payment status →Processing. - Frontend polls
GET /payments/{paymentId}/statusevery 3 seconds. - Backend polls DealerPay → when
complete = true, calls Transaction API →FinalizePayment()atomically. - Frontend receives
{ paymentStatus: "Success" }→ shows confirmation and receipt. - (Parallel) DealerPay webhook arrives → same idempotent
FinalizePayment()viarowversion. - (Fallback) Reconciliation scheduler handles any payments that got stuck.
Workflow 2: Customer Pays with Saved Card (Flow B)
- Customer opens invoice and views amount due.
- Frontend fetches saved cards via
GET /payments/saved-cards→ displays list. - Customer selects a card and confirms.
- Frontend calls
POST /payments/initiate-saved-card→ backend validates invoice + token, calls DealerPay Safe API, inserts payment row (Processing), returns{ paymentId, processing: true }— no iframe. - Frontend polls
GET /payments/{paymentId}/statusuntil terminal state. - Same finalization, webhook, and scheduler handling as Workflow 1 (Steps 9–12).
Workflow 3: Iframe URL Expires
- Payment is in
Initiatedstate; customer exceeded the 5-minute DealerPay URL limit. - Frontend detects expiry via
expiresAtfield or DealerPay iframe shows an error. - Frontend calls
POST /payments/{paymentId}/refresh-url→ backend re-calls DealerPay CNP API → returns newcheckoutUrl. - Frontend refreshes the iframe with the new URL; flow continues from Step 5 of Workflow 1.
Business Rules
-
Idempotency:
- Before creating a new payment, always check for active
InitiatedorProcessingpayments for the same invoice. - Return the existing record rather than creating a duplicate. Frontend resumes from the existing
checkoutUrl.
- Before creating a new payment, always check for active
-
Atomic Finalization:
- All DB writes on payment success (payments, invoices, payment_allocations, customer_payment_tokens) happen inside a single
IDbTransaction. - Protected by
rowversionoptimistic locking — the losing concurrent thread gets 0 rows updated and no-ops gracefully.
- All DB writes on payment success (payments, invoices, payment_allocations, customer_payment_tokens) happen inside a single
-
Webhook Verification:
- Never finalize a payment based on the raw webhook payload alone.
- Always verify via Transaction API (§DealerPay 2.4) before writing to the database.
-
Invoice Ownership:
- Every request must verify
invoice.customerid == JWT customerIdfor customer-role callers. - Admin callers skip ownership check.
- Every request must verify
-
Card Token Deduplication:
- Before inserting a new token row, check if
dealerpaytokenalready exists for the customer. - Only one token per customer should have
isdefault = TRUE— enforced in application logic.
- Before inserting a new token row, check if
-
Saved Card Soft Delete:
- Deleting a card sets
isactive = FALSE. - If the deleted card was
isdefault, promote the next most recent active token as default.
- Deleting a card sets
-
URL Expiry:
- DealerPay CNP iframe URLs expire after 5 minutes.
- Customer must call
POST /payments/{paymentId}/refresh-urlif they exceed this limit.
-
Scheduler Expiry:
Initiatedpayment withexpireat < NOW()and noasyncid→ markExpired.Processingpayment beyondExpiredPaymentThresholdMinutes→ markExpired.
Development Tasks & Estimates
| No | Task Name | Estimate (Hours) | Dependencies | Notes |
|---|---|---|---|---|
| 1 | Database migration (5 tables) | 2 | None | invoices, payments, allocations, tokens, apilog |
| 2 | Invoice auto-creation in ServiceRequestService | 3 | 1 | Triggered on Completed transition |
| 3 | DealerPay HTTP client (CNP + Safe + Status + Transaction) | 5 | None | Polly retry, audit logging to dealerpayapilog |
| 4 | POST /payments/initiate API | 4 | 1, 3 | Validation, idempotency, DB insert |
| 5 | PUT /payments/{id}/async-id API | 2 | 1 | Simple state transition |
| 6 | GET /payments/{id}/status + FinalizePayment() | 6 | 1, 3 | Polling, optimistic locking, atomic DB transaction |
| 7 | POST /payments/initiate-saved-card API | 3 | 1, 3 | Token lookup + Safe API call |
| 8 | POST /payments/{id}/refresh-url API | 2 | 1, 3 | Re-call CNP, update payment row |
| 9 | Saved cards CRUD (list, delete) | 2 | 1 | GET + DELETE endpoints |
| 10 | Invoice GET endpoints (by id + by service req) | 2 | 1 | With line items join |
| 11 | Webhook handler | 4 | 3, 6 | Idempotent, verify via Transaction API |
| 12 | Reconciliation BackgroundService | 4 | 3, 6 | Follows ScheduledNotificationBackgroundService |
| 13 | Unit Tests | 5 | 4–12 | FinalizePayment(), concurrency, edge cases |
| 14 | Integration Tests (sandbox) | 5 | 4–12 | End-to-end flows, webhook, scheduler |
| 15 | Documentation & examples | 2 | All | API docs, sequence diagrams |
| Total | Development Time | 51 hours | ~1.5 weeks with 1 developer |
Testing & Quality Assurance
Unit Tests
FinalizePayment()with correctrowversion— succeeds and commits all 3 DB writes.FinalizePayment()with stalerowversion— throwsConcurrencyException, no DB changes.- Idempotency check — second
POST /payments/initiatefor same invoice returns existing record. - Webhook handler — already-
Successpayment returns 200 immediately, no DB write. - Reconciliation logic —
Initiatedbeyond expiry →Expired;Processingbeyond threshold →Expired.
Integration Tests
- End-to-end Flow A: initiate → async-id → poll → Success (against DealerPay sandbox).
- End-to-end Flow B: initiate-saved-card → poll → Success.
- Duplicate webhook delivery — second webhook is a no-op (200, DB unchanged).
- Concurrent webhook + polling race — exactly one finalization, no duplicate
payment_allocations. - Scheduler: stuck
Processingpayment reconciled without intervention. - Invoice
paidamount/dueamount/paymentstatuscorrect after successful payment.
Acceptance Criteria
- Payment initiation returns
checkoutUrlwithin 2 seconds (excluding DealerPay round-trip). FinalizePayment()is always atomic: either fully succeeds or fully rolls back.- No duplicate
payment_allocationsrows under any concurrency scenario. dealerpaytokennever appears in any API response ordealerpayapilogentry.- API keys never present in any log or stored payload.
- Reconciliation scheduler processes stuck payments within 5 minutes.
- All DealerPay API calls logged in
dealerpayapilogwith duration and success flag.
Testing Tools
- xUnit (unit testing).
- Moq (mocking DealerPay HTTP client and DAL).
- Testcontainers (PostgreSQL).
- DealerPay sandbox environment + test card numbers.
Deployment Considerations
-
Configuration (all environments):
DealerPay:BaseUrl— sandbox for dev/staging, production URL for prod.DealerPay:ApiKey— from environment variableDealerPay__ApiKey(never in appsettings files).DealerPay:DepartmentId— from DealerPay account settings.DealerPay:WebhookBaseUrl— ngrok/tunnel for local dev; public HTTPS URL for staging/prod.ReconciliationIntervalMinutesdefault: 5.StalePaymentThresholdMinutesdefault: 15.ExpiredPaymentThresholdMinutesdefault: 30.
-
Rollout:
- Deploy migration script first (
000038-AddPaymentAndInvoiceTables.sql). - Deploy to staging and run end-to-end tests against DealerPay sandbox.
- Enable feature toggle for invoice payment UI on staging, test with QA team.
- Monitor
dealerpayapilogfor error rates before production rollout.
- Deploy migration script first (
-
Monitoring:
- Track payment initiation success rate via
dealerpayapilog.issuccess. - Alert on
paymentstatus = 'Initiated'payments older than 30 minutes (reconciliation missed). - Alert on DealerPay API error rates > 5% in any 5-minute window.
- Monitor webhook delivery rate — if zero webhooks in 30 minutes, alert on-call.
- Track payment initiation success rate via
Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| Concurrent webhook + polling double-write | High | Medium | rowversion optimistic locking — losing thread gets 0 rows updated and no-ops gracefully |
| DealerPay API downtime at payment initiation | High | Low | Polly 3-retry with exponential backoff; no payment row created on failure |
| Webhook never arrives | Medium | Low | Polling as primary fallback; reconciliation scheduler as final safety net |
| Fake/tampered webhook payload | High | Low | Always verify via Transaction API before any DB write — webhook body is never trusted |
| Iframe URL expires before card submission | Medium | Medium | /refresh-url endpoint; frontend detects expiry via expiresAt and prompts refresh |
| API key leaked in logs | High | Low | SanitizeForLogging() strips key from all gatewayrequest and dealerpayapilog entries |
| Duplicate payment rows on rapid re-submit | Medium | Medium | Idempotency check on Initiated/Processing payments before INSERT |
Review & Approval
-
Reviewer(s):
- Sanket
- Ribhu
-
Approval Date: [To be completed after reviews]
Notes
- Frontend Developers: Start with API Examples and Workflows sections for integration. Pay special attention to the
postMessagehandler forasyncProcessingIdand the 5-minute URL expiry handling. - Backend Developers: Use Design Specifications, API Interfaces, and Codebase Patterns Reference for implementation. The
FinalizePayment()method is the most critical — it is shared by polling, webhook, and scheduler. - QA: Use Testing & Quality Assurance for test cases. Priority: concurrency tests for
FinalizePayment()and end-to-end sandbox flows. - DevOps: Never commit
DealerPay:ApiKey— inject viaDealerPay__ApiKeyenvironment variable in all environments.