Skip to main content
Version: RK Auto

DealerPay Payment Gateway Integration

Author(s)

  • Sanket
  • Ashik

Last Updated Date

[2026-05-25]


SRS References


Version History

VersionDateChangesAuthor
1.02026-05-25Initial draft — full implementation plan for invoice payment via DealerPay Connect v2Ashik

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/Processing payments
  • 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:

  1. Customer can initiate payment for an invoice using a new card via DealerPay-hosted iframe.
  2. Customer can initiate payment using a previously saved card token (no iframe required).
  3. Frontend stores the asyncProcessingId received from DealerPay iframe postMessage via the backend.
  4. Customer polls payment status; backend internally calls DealerPay and finalizes the DB when complete.
  5. DealerPay delivers payment completion via webhook; backend verifies and finalizes idempotently.
  6. Reconciliation scheduler processes stuck Initiated or Processing payments every 5 minutes.
  7. Expired iframe URLs (5-min DealerPay limit) can be refreshed via a dedicated endpoint.
  8. Customer can list their saved card tokens (last4, cardBrand, expiry, isDefault).
  9. Customer can delete a saved card token (soft delete).
  10. Customer and admin can retrieve invoice details and look up invoice by service request.
  11. Duplicate payment attempts for the same invoice are prevented via idempotency checks.
  12. All DealerPay API calls are audit-logged in dealerpayapilog.

Non-Functional:

  1. Payment initiation latency < 2 seconds (excluding DealerPay external round-trip).
  2. Webhook handler always returns HTTP 200 — prevents DealerPay retry storms.
  3. Optimistic locking (rowversion) ensures no duplicate DB finalization under concurrent webhook + polling.
  4. API keys are never exposed in logs, responses, or source control.
  5. Raw card data (PAN, CVV) never touches our backend — PCI-DSS out-of-scope via DealerPay iframe.
  6. JWT with customer/admin roles enforced on every payment and invoice endpoint.
  7. 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:

  1. ServiceRequestService auto-creates an invoice when a service request transitions to Completed.
  2. Customer calls POST /payments/initiate → backend validates invoice, calls DealerPay CNP API → returns iframe URL.
  3. Customer fills card in DealerPay-hosted iframe → DealerPay fires postMessage({ asyncProcessingId }) to parent window.
  4. Frontend calls PUT /payments/{id}/async-id to store the asyncId → begins polling GET /payments/{id}/status.
  5. Backend polls DealerPay processingStatus → when complete = true, calls Transaction API → finalizes DB atomically.
  6. DealerPay webhook arrives in parallel → same idempotent FinalizePayment() method, protected by rowversion.
  7. Reconciliation scheduler handles any payments stuck in Initiated or Processing state.
┌────────────┐     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 by servicerequests.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:

StatusValueDescription
Initiated1Payment row created, CNP iframe URL generated. Waiting for customer to submit card.
Processing2asyncProcessingId stored. DealerPay is processing the card asynchronously.
Success3Payment confirmed via DealerPay Transaction API. Invoice marked Paid.
Failed4Card declined or DealerPay returned success=false. Customer may retry.
Expired5Iframe URL expired before card submission, or processing timed out (set by scheduler).
Cancelled6Explicitly cancelled by customer or admin. (Post-POC)
Refunded7Full refund issued via DealerPay. (Post-POC)
PartiallyRefunded8Partial 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 of requestedservices.serviceprice WHERE servicestatus = 'Completed'
  • totalamount = subtotal + taxamount − discountamount
  • dueamount = 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-KEY and raw card data from requestpayloadjson before storing.


Tables Overview

TablePurposeCreated When
invoicesAccounting record for a serviceService request → Completed
paymentsTransaction attempt recordCustomer initiates payment
payment_allocationsMaps payment amount to invoicePayment finalizes as Success
customer_payment_tokensSaved DealerPay card tokensPayment finalizes with safe=true
dealerpayapilogAudit trail of every DealerPay API callEvery 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.jsonBaseUrl: https://connect.dealer-pay.com + production keys
  • appsettings.Staging.json → sandbox URL + staging keys

⚠️ ApiKey must come from environment variable DealerPay__ApiKey in 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.

EndpointMethodPurposeParametersRequest ModelResponse ModelStatus Codes
/payments/initiatePOSTInitiate new card payment — returns DealerPay iframe URLJWT (customer), bodyInitiatePaymentRequestInitiatePaymentResponse200, 400, 403, 404, 500
/payments/initiate-saved-cardPOSTCharge a saved card token — no iframeJWT (customer), bodyInitiateSavedCardPaymentRequestInitiatePaymentResponse200, 400, 403, 404, 500
/payments/{paymentId}/async-idPUTStore asyncProcessingId received from DealerPay iframe postMessageJWT (customer), paymentId path, bodyStoreAsyncIdRequestCommonResponse200, 400, 404, 500
/payments/{paymentId}/statusGETPoll payment status — backend calls DealerPay and finalizes if doneJWT (customer), paymentId pathPaymentStatusResponse200, 404, 500
/payments/{paymentId}/refresh-urlPOSTRefresh expired 5-min iframe URLJWT (customer), paymentId pathInitiatePaymentResponse200, 400, 404, 500
/payments/saved-cardsGETList customer's active saved cardsJWT (customer)List<SavedCardResponse>200, 401, 500
/payments/saved-cards/{tokenId}DELETESoft-delete a saved cardJWT (customer), tokenId pathCommonResponse200, 404, 500
/invoices/{invoiceId}GETGet invoice detailsJWT (customer or admin), invoiceId pathInvoiceResponse200, 403, 404, 500
/invoices/by-service-request/{serviceRequestId}GETGet invoice by service request IDJWT (customer or admin), serviceRequestId pathInvoiceResponse200, 403, 404, 500
/webhooks/dealerpay/{paymentId}POSTInbound DealerPay payment completion webhookAnonymous, paymentId path, raw bodyHTTP 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 Initiated or Processing payment already exists for this invoice → return 200 with existing record (idempotent — do NOT create a duplicate)
  • checkoutUrl is stored in the payments table 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 Processing state (no Initiated step — 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 InitiatedProcessing.

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 Initiated status
  • asyncProcessingId must be non-empty

Frontend Note:

window.addEventListener('message', (event) => {
if (event.data && event.data.asyncProcessingId) {
// Call PUT /payments/{paymentId}/async-id
}
});

Confirm exact event.data structure 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 asyncid is null → return Processing (card not yet submitted in iframe)
  • FinalizePayment() is called when complete = 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 Initiated status (card not yet submitted)
  • If status is Processing, Success, or Failed → 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:

  • dealerpaytoken must 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 — dealerpaytoken is 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

PurposeMethodEndpointConfirmed
Initiate CNP (iframe)POST/api/v2/Payment/CardNotPresent✅ Confirmed
Check async processingGET/api/v2/Payment/processingStatus/{asyncId}✅ Confirmed
Charge saved tokenPOST/api/v2/Payment/Safe⚠️ Inferred
Get transaction detailsGET/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
}
FieldTypeRequiredNotes
departmentIdUUIDFrom DealerPay:DepartmentId config
userstringCustomer full name for DealerPay reporting
saleAmountdecimalAmount to charge
referencestringMust equal our paymentId — echoed back in webhook
customerobjectfirstName+lastName OR companyName
safebooleantrue = save card token; token returned in Transaction API
authOnlybooleanUse false — we want immediate capture
notificationUrlstringOur webhook URL with paymentId embedded
autoSendReceiptbooleanfalse — 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-url if 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 treat complete alone 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 asyncProcessingId directly — 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.safeToken is only present when CNP was initiated with safe: true. Store in customer_payment_tokens.
data.success = false means card declined — still call this endpoint and mark payment as Failed.


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:

FileChange
Models/Common/Enum.csAdd TransactionStatus, InvoicePaymentStatus, PaymentMethod enums
Models/Common/StatusCode.csAdd PAYMENT_INITIATION_FAILED, INVALID_PAYMENT_STATE if needed
Services/ServiceRequest/ServiceRequestService.csAdd invoice auto-creation on Completed transition
DAL/ServiceRequest/ServiceRequestDal.csAdd CreateInvoiceAsync method (or put in PaymentDal)
Program.csRegister 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)

  1. Service request completesServiceRequestService auto-creates invoice (paymentstatus = Pending).
  2. Customer opens invoice via GET /invoices/{invoiceId} and reviews the amount due.
  3. Customer initiates payment → frontend calls POST /payments/initiate with invoiceId and saveCardInfo.
  4. Backend validates invoice, calls DealerPay CNP API, inserts payment row (Initiated), returns checkoutUrl.
  5. Frontend renders iframe at checkoutUrl. Customer enters card details in DealerPay-hosted form.
  6. DealerPay processes card asynchronously, fires postMessage({ asyncProcessingId }) to parent window.
  7. Frontend calls PUT /payments/{paymentId}/async-id. Payment status → Processing.
  8. Frontend polls GET /payments/{paymentId}/status every 3 seconds.
  9. Backend polls DealerPay → when complete = true, calls Transaction API → FinalizePayment() atomically.
  10. Frontend receives { paymentStatus: "Success" } → shows confirmation and receipt.
  11. (Parallel) DealerPay webhook arrives → same idempotent FinalizePayment() via rowversion.
  12. (Fallback) Reconciliation scheduler handles any payments that got stuck.

Workflow 2: Customer Pays with Saved Card (Flow B)

  1. Customer opens invoice and views amount due.
  2. Frontend fetches saved cards via GET /payments/saved-cards → displays list.
  3. Customer selects a card and confirms.
  4. 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.
  5. Frontend polls GET /payments/{paymentId}/status until terminal state.
  6. Same finalization, webhook, and scheduler handling as Workflow 1 (Steps 9–12).

Workflow 3: Iframe URL Expires

  1. Payment is in Initiated state; customer exceeded the 5-minute DealerPay URL limit.
  2. Frontend detects expiry via expiresAt field or DealerPay iframe shows an error.
  3. Frontend calls POST /payments/{paymentId}/refresh-url → backend re-calls DealerPay CNP API → returns new checkoutUrl.
  4. Frontend refreshes the iframe with the new URL; flow continues from Step 5 of Workflow 1.

Business Rules

  1. Idempotency:

    • Before creating a new payment, always check for active Initiated or Processing payments for the same invoice.
    • Return the existing record rather than creating a duplicate. Frontend resumes from the existing checkoutUrl.
  2. Atomic Finalization:

    • All DB writes on payment success (payments, invoices, payment_allocations, customer_payment_tokens) happen inside a single IDbTransaction.
    • Protected by rowversion optimistic locking — the losing concurrent thread gets 0 rows updated and no-ops gracefully.
  3. 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.
  4. Invoice Ownership:

    • Every request must verify invoice.customerid == JWT customerId for customer-role callers.
    • Admin callers skip ownership check.
  5. Card Token Deduplication:

    • Before inserting a new token row, check if dealerpaytoken already exists for the customer.
    • Only one token per customer should have isdefault = TRUE — enforced in application logic.
  6. 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.
  7. URL Expiry:

    • DealerPay CNP iframe URLs expire after 5 minutes.
    • Customer must call POST /payments/{paymentId}/refresh-url if they exceed this limit.
  8. Scheduler Expiry:

    • Initiated payment with expireat < NOW() and no asyncid → mark Expired.
    • Processing payment beyond ExpiredPaymentThresholdMinutes → mark Expired.

Development Tasks & Estimates

NoTask NameEstimate (Hours)DependenciesNotes
1Database migration (5 tables)2Noneinvoices, payments, allocations, tokens, apilog
2Invoice auto-creation in ServiceRequestService31Triggered on Completed transition
3DealerPay HTTP client (CNP + Safe + Status + Transaction)5NonePolly retry, audit logging to dealerpayapilog
4POST /payments/initiate API41, 3Validation, idempotency, DB insert
5PUT /payments/{id}/async-id API21Simple state transition
6GET /payments/{id}/status + FinalizePayment()61, 3Polling, optimistic locking, atomic DB transaction
7POST /payments/initiate-saved-card API31, 3Token lookup + Safe API call
8POST /payments/{id}/refresh-url API21, 3Re-call CNP, update payment row
9Saved cards CRUD (list, delete)21GET + DELETE endpoints
10Invoice GET endpoints (by id + by service req)21With line items join
11Webhook handler43, 6Idempotent, verify via Transaction API
12Reconciliation BackgroundService43, 6Follows ScheduledNotificationBackgroundService
13Unit Tests54–12FinalizePayment(), concurrency, edge cases
14Integration Tests (sandbox)54–12End-to-end flows, webhook, scheduler
15Documentation & examples2AllAPI docs, sequence diagrams
TotalDevelopment Time51 hours~1.5 weeks with 1 developer

Testing & Quality Assurance

Unit Tests

  • FinalizePayment() with correct rowversion — succeeds and commits all 3 DB writes.
  • FinalizePayment() with stale rowversion — throws ConcurrencyException, no DB changes.
  • Idempotency check — second POST /payments/initiate for same invoice returns existing record.
  • Webhook handler — already-Success payment returns 200 immediately, no DB write.
  • Reconciliation logic — Initiated beyond expiry → Expired; Processing beyond 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 Processing payment reconciled without intervention.
  • Invoice paidamount / dueamount / paymentstatus correct after successful payment.

Acceptance Criteria

  • Payment initiation returns checkoutUrl within 2 seconds (excluding DealerPay round-trip).
  • FinalizePayment() is always atomic: either fully succeeds or fully rolls back.
  • No duplicate payment_allocations rows under any concurrency scenario.
  • dealerpaytoken never appears in any API response or dealerpayapilog entry.
  • API keys never present in any log or stored payload.
  • Reconciliation scheduler processes stuck payments within 5 minutes.
  • All DealerPay API calls logged in dealerpayapilog with 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 variable DealerPay__ApiKey (never in appsettings files).
    • DealerPay:DepartmentId — from DealerPay account settings.
    • DealerPay:WebhookBaseUrl — ngrok/tunnel for local dev; public HTTPS URL for staging/prod.
    • ReconciliationIntervalMinutes default: 5.
    • StalePaymentThresholdMinutes default: 15.
    • ExpiredPaymentThresholdMinutes default: 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 dealerpayapilog for error rates before production rollout.
  • 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.

Risks & Mitigations

RiskImpactLikelihoodMitigation
Concurrent webhook + polling double-writeHighMediumrowversion optimistic locking — losing thread gets 0 rows updated and no-ops gracefully
DealerPay API downtime at payment initiationHighLowPolly 3-retry with exponential backoff; no payment row created on failure
Webhook never arrivesMediumLowPolling as primary fallback; reconciliation scheduler as final safety net
Fake/tampered webhook payloadHighLowAlways verify via Transaction API before any DB write — webhook body is never trusted
Iframe URL expires before card submissionMediumMedium/refresh-url endpoint; frontend detects expiry via expiresAt and prompts refresh
API key leaked in logsHighLowSanitizeForLogging() strips key from all gatewayrequest and dealerpayapilog entries
Duplicate payment rows on rapid re-submitMediumMediumIdempotency 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 postMessage handler for asyncProcessingId and 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 via DealerPay__ApiKey environment variable in all environments.