Skip to main content
Version: RK Auto

Stripe Payment Integration — Technical Design Document

Project: ServiceVanTracker Backend
Author: Amarnath Garai
Status: Pre-Implementation
Last Updated: 2026-08-03


1. Overview​

ServiceVanTracker currently processes all card payments through DealerPay — a hosted iframe (Card-Not-Present) flow. This document covers the design and implementation of a parallel, independent Stripe payment stack that allows customers to pay using Stripe's Payment Element.

Design Philosophy​

The core principle is zero changes to the existing DealerPay stack. Stripe gets its own dedicated:

  • Controller — StripePaymentController under /stripe/payments
  • Service — StripePaymentService implementing IStripePaymentService
  • DAL — StripePaymentDal implementing IStripePaymentDal
  • Background service — StripeReconciliationService

Both stacks share the same PostgreSQL database tables (payments, invoices, customer_payment_tokens, customermaster) but access them through their own independent queries. The gateway_name column on the payments table ('DealerPay' vs 'Stripe') separates them at runtime.

Why not a strategy pattern?
DealerPay uses an iframe + async polling. Stripe uses a frontend Payment Element that handles card entry directly. Forcing both into a shared interface creates unnecessary adapter glue. A dedicated stack gives each gateway clean, purpose-built code with zero risk to live DealerPay traffic.


2. Architecture Overview​

DealerPay Stack (unchanged)

  • PaymentController (/payments) → PaymentService → PaymentDal
  • PaymentService → DealerPayClient (external HTTP)
  • PaymentReconciliationService → PaymentService

Stripe Stack (new)

  • StripePaymentController (/stripe/payments) → StripePaymentService → StripePaymentDal
  • StripePaymentService → Stripe .NET SDK (external HTTP)
  • StripeReconciliationService → StripePaymentService

Both DALs write to the same PostgreSQL database. gateway_name = 'Stripe' on every payment row distinguishes Stripe records from DealerPay records.


3. Database Schema Changes​

New Migrations Required​

000063-AddStripePaymentColumns.sql

-- Migration: 000063-AddStripePaymentColumns.sql
-- Purpose: Add columns required for Stripe integration alongside DealerPay

-- 1. payments table: differentiate gateway
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS gateway_name TEXT NOT NULL DEFAULT 'DealerPay';

-- 2. customermaster table: store Stripe Customer ID
ALTER TABLE customermaster
ADD COLUMN IF NOT EXISTS stripecustomerid TEXT;

-- 3. customerpaymenttokens table: store Stripe PaymentMethod ID
ALTER TABLE customerpaymenttokens
ADD COLUMN IF NOT EXISTS stripepaymentmethodid TEXT;

How Existing payments Columns Map to Stripe​

ColumnDealerPay UsageStripe Usage
gateway_name'DealerPay''Stripe'
trace_idDealerPay traceIdStripe PaymentIntent.Id (pi_xxx)
async_idDealerPay asyncProcessingIdNot used (null)
checkout_urlDealerPay iframe URLNot used (null)
transaction_idDealerPay transaction IDStripe PaymentIntent.Id (same as trace_id)
reference_numberpaymentId.ToString()paymentId.ToString() (same pattern)

4. API Endpoints​


4.1 POST /stripe/payments/initiate​

Purpose: Create a new Stripe PaymentIntent and return the clientSecret that the frontend passes to the Stripe Payment Element.

Auth: JWT Bearer (customer_user scope required)

Request Body​

{
"invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
FieldTypeRequiredDescription
invoiceIdGuid✅The invoice the customer intends to pay

Success Response — 200 OK​

{
"paymentId": "a1b2c3d4-...",
"clientSecret": "pi_3OxXxXxX_secret_yYyYyYyY",
"amount": 149.99,
"message": "Payment initiated successfully"
}
FieldTypeDescription
paymentIdGuidInternal payment ID. Pass to status polling and embed in webhook URL
clientSecretstringPass to stripe.confirmPayment() on the frontend
amountdecimalInvoice amount in dollars

Error Responses​

HTTPScenario
400Invoice already paid
403Invoice belongs to a different customer
404Invoice not found
500Stripe API error

Workflow​

  1. Extract userId from JWT sub claim.
  2. Fetch invoice by invoiceId using GetInvoiceByIdAsync(invoiceId). Return 404 if not found.
  3. Verify invoice.CustomerId == userId. Return 403 if mismatch.
  4. Check invoice is not already Paid. Return 400 if it is.
  5. Fetch stripecustomerid for this customer from customermaster using GetStripeCustomerIdAsync(customerId).
    • If not set: call Stripe CustomerService.CreateAsync(email, name) and store the resulting cus_xxx back to customermaster using SaveStripeCustomerIdAsync(customerId, stripeCustomerId). Card details are NOT fetched here.
  6. Call Stripe PaymentIntentService.CreateAsync with:
    • amount in cents (dollars × 100)
    • currency = "usd"
    • customer = cus_xxx
    • idempotency_key = invoiceId.ToString() + "_Stripe" (prevents duplicate PaymentIntents for the same invoice)
    • metadata = { invoiceId, customerId }
  7. Stripe returns the PaymentIntent object (pi_xxx).
  8. Check DB payments table for an existing row with traceid = pi_xxx using GetPaymentByTraceIdAsync(intent.Id).
    • If found: return its paymentId and clientSecret (same intent, idempotency).
    • If not found: Call InsertPaymentAsync(newPayment) to insert a new payments row. We generate an internal PaymentId (GUID) and store the Stripe pi_xxx in TraceId. Most importantly, we store the frontend's invoiceId in the InvoiceId column so it is firmly linked to this PaymentId.
  9. Return { paymentId, clientSecret, amount } to the frontend.
  10. Frontend: renders the Stripe Payment Element using clientSecret, customer enters card and submits.

4.2 GET /stripe/payments/{paymentId}/status​

Purpose: Polling API endpoint. The frontend will call this API repeatedly after stripe.confirmPayment() resolves. The backend queries Stripe to fetch the live status and returns it to the frontend.

Auth: JWT Bearer (customer_user scope required)

Path Parameters​

ParamTypeDescription
paymentIdGuidThe paymentId returned from /initiate

Success Response — 200 OK​

{
"paymentId": "a1b2c3d4-...",
"paymentStatus": "Success",
"message": "Payment completed successfully",
"transactionId": "pi_3OxXxXxX",
"transactionNo": "TXN-2026-0001",
"authCode": "123456",
"cardBrand": "visa",
"last4": "4242",
"amountPaid": 149.99
}
FieldTypeDescription
paymentStatusstringProcessing | Success | Failed
transactionIdstring?Stripe PaymentIntent.Id. Present on Success
transactionNostring?Internal sequential transaction number. Present on Success
authCodestring?Card auth code. Present on Success
cardBrandstring?e.g. "visa", "mastercard". Present on Success
last4string?Last 4 digits. Present on Success
amountPaiddecimal?Present on Success

Status Values​

paymentStatusMeaningFrontend Action
ProcessingStripe is still processingKeep polling (every 2–3 seconds)
SuccessPayment confirmed and finalizedShow success screen, stop polling
FailedPayment declined or failedShow error message, allow retry

Error Responses​

HTTPScenario
403Payment belongs to a different customer
404Payment not found
500Stripe API error

Workflow​

  1. Extract userId from JWT.
  2. Fetch payment by paymentId using GetPaymentByIdAsync(paymentId). Return 404 if not found. Note this payment object contains the InvoiceId.
  3. Verify payment.CustomerId == userId. Return 403 if mismatch.
  4. If payment is already Success or Failed in the database (e.g. webhook already finalized it): return the cached terminal response immediately — no Stripe API call needed.
  5. Call Stripe PaymentIntentService.GetAsync(payment.TraceId) to get the live status.
  6. Map the Stripe status:
    • succeeded → run finalization (step 7), then return Success.
    • processing or requires_confirmation → return Processing.
    • requires_payment_method or payment_failed → call UpdatePaymentAsync to mark payment as Failed in DB, return Failed.
  7. Finalization (when succeeded):
    • Update Payment: Call UpdatePaymentAsync(payment.PaymentId, updateReq). This updates transactionId, authCode, cardBrand, etc. It uses RowVersion for optimistic concurrency (handles race with webhook).
    • Save Token: If paymentIntent.SetupFutureUsage == "off_session" and PaymentMethodId is present, check CustomerStripeTokenExistsAsync(customerId, pm_xxx). If false, store it via InsertCustomerStripeTokenAsync(...).
    • Update Invoice: Call UpdateInvoiceOnPaymentSuccessAsync(payment.InvoiceId, payment.TotalAmount, txnNo). Note: we pull InvoiceId directly from the payment record we fetched in step 2. This sets the invoice to Paid.
    • Allocate Funds: Call InsertPaymentAllocationAsync(payment.PaymentId, payment.InvoiceId, payment.TotalAmount).
  8. Return { paymentStatus: "Success", transactionId, authCode, last4, ... }.

4.3 POST /stripe/payments/{paymentId}/webhook​

Purpose: Receive asynchronous payment events from Stripe's infrastructure. Not called by the customer app — called by Stripe servers.

Auth: None (no JWT). Protected exclusively by Stripe webhook signature verification.

Path Parameters​

ParamTypeDescription
paymentIdGuidEmbedded in the webhook URL configured in the Stripe dashboard: https://api.example.com/stripe/payments/{paymentId}/webhook

Required Header​

HeaderDescription
Stripe-SignatureHMAC-SHA256 signature set by Stripe. Must be verified using STRIPE_WEBHOOK_SECRET

Request Body (Stripe Event Payload)​

payment_intent.succeeded

{
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_3OxXxXxX",
"amount": 14999,
"currency": "usd",
"payment_method": "pm_1OxXxXxX",
"charges": {
"data": [{
"payment_method_details": {
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2027
}
},
"authorization_code": "AUTH123"
}]
}
}
}
}

payment_intent.payment_failed

{
"type": "payment_intent.payment_failed",
"data": {
"object": {
"id": "pi_3OxXxXxX",
"last_payment_error": {
"message": "Your card was declined."
}
}
}
}

Response — 200 OK​

Always return 200 OK to Stripe (including for ignored event types). Returning non-200 causes Stripe to retry the delivery.

{ "message": "Webhook processed" }

Workflow​

  1. Read the raw request body as a string (must be the raw bytes — do not deserialize first, or signature verification breaks).
  2. Call EventUtility.ConstructEvent(rawBody, stripeSignatureHeader, webhookSecret).
    • If signature is invalid: return 400. Stripe will retry.
  3. Fetch payment by Stripe's TraceId using GetPaymentByTraceIdAsync(intent.Id). This directly searches for the row where trace_id == intent.Id. The returned payment row already contains the InvoiceId. Return 400 if not found.
  4. If event is payment_intent.succeeded:
    • Extract paymentMethodId, card.brand, last4, exp_month, exp_year, authorization_code from the event object.
    • If payment is already in a terminal state (polling beat the webhook): return 200 "Already finalized" — no duplicate work.
    • Otherwise: run finalization.
    • Finalization Steps:
      • Update Payment: Call UpdatePaymentAsync(...) using rowversion for optimistic concurrency.
      • Save Token: Call CustomerStripeTokenExistsAsync(...) and InsertCustomerStripeTokenAsync(...).
      • Update Invoice: Call UpdateInvoiceOnPaymentSuccessAsync(payment.InvoiceId, ...).
      • Allocate Funds: Call InsertPaymentAllocationAsync(...).
  5. If event is payment_intent.payment_failed:
    • Call UpdatePaymentAsync(...) to mark payment as Failed in DB with the error message.
    • Return 200.
  6. Any other event type: return 200 without any DB changes (no-op).

4.4 GET /stripe/payments/saved-cards​

Purpose: List all active Stripe saved cards for the authenticated customer.

Auth: JWT Bearer (customer_user scope required)

Stripe saved cards are not mixed into the DealerPay GET /payments/saved-cards endpoint. Each gateway manages its own saved cards independently.

Success Response — 200 OK​

{
"hasSavedCards": true,
"savedCards": [
{
"tokenId": "b9c1d2e3-...",
"cardBrand": "visa",
"last4": "4242",
"expiryMonth": 12,
"expiryYear": 2027,
"isDefault": false
},
{
"tokenId": "f4a5b6c7-...",
"cardBrand": "mastercard",
"last4": "5678",
"expiryMonth": 3,
"expiryYear": 2026,
"isDefault": false
}
]
}
FieldTypeDescription
hasSavedCardsboolTrue if the savedCards list has at least one item
savedCardsarrayList of the user's saved cards
savedCards[].tokenIdGuidInternal ID. Pass to /initiate-saved. Never the raw Stripe pm_xxx
savedCards[].cardBrandstring?"visa", "mastercard", "amex", etc.
savedCards[].last4string?Last 4 digits
savedCards[].expiryMonthint?1–12
savedCards[].expiryYearint?4-digit year
savedCards[].isDefaultboolWhether this is the customer's default card

Returns { "hasSavedCards": false, "savedCards": [] } if no saved Stripe cards exist.

Workflow​

  1. Extract userId from JWT, resolve to customerId.
  2. Query customer_payment_tokens where customer_id = customerId AND stripepaymentmethodid IS NOT NULL AND is_active = true.
  3. Map each row to SavedStripeCardResponse — do not include stripepaymentmethodid in the response.
  4. Return an object: { hasSavedCards = list.Any(), savedCards = list }.

5. Edge Cases & Handling​

5.1 Idempotency — Duplicate Initiate Requests​

Scenario: Customer taps "Pay" twice, or the app retries on network failure.

How it's handled:

  • Before creating any PaymentIntent, the service checks GetActiveStripePaymentForInvoiceAsync for an existing Initiated or Processing Stripe payment on the same invoice.
  • If one is found: return the existing paymentId and clientSecret — the same PaymentIntent is still valid and the customer continues without a duplicate charge.
  • Stripe also de-duplicates on their side via idempotency_key = invoiceId.ToString().

5.2 Webhook vs. Polling Race Condition​

Scenario: Both the status poll and the Stripe webhook fire simultaneously after a payment succeeds. Without protection, both would try to finalize the payment and update the invoice at the same time.

How it's handled:

  • The payments table has a rowversion (optimistic concurrency) column.
  • Finalization runs UPDATE payments SET ... WHERE payment_id = @id AND rowversion = @rowversion.
  • Only one concurrent writer succeeds. The other gets 0 rows affected, which the code treats as CONFLICT.
  • On CONFLICT: re-fetch the payment from DB and return its current terminal state — the customer always sees the correct result regardless of which path won.

5.3 Saved Card Charge Failure​

Scenario: Off-session charge fails — insufficient funds, expired card, or card issuer decline.

How it's handled:

  • Stripe raises a StripeException with a code (e.g. card_declined, expired_card).
  • The service catches it, inserts the payments row with status Failed, and returns an appropriate message.
  • The customer can retry by going through the new-card flow (POST /stripe/payments/initiate) with a different card.

5.4 Invoice Already Paid​

Scenario: Customer attempts to pay an invoice that is already in Paid status.

How it's handled:

  • Invoice status is checked immediately after fetch — before any Stripe API calls.
  • Returns HTTP 400 with message "This invoice has already been paid.".

5.5 Invoice Belongs to Different Customer​

Scenario: A request attempts to pay another customer's invoice (potential IDOR attack or app bug).

How it's handled:

  • After fetching the invoice, invoice.CustomerId is compared to the authenticated userId from the JWT sub claim.
  • If they don't match: return HTTP 403. No Stripe API calls are made.

5.6 Webhook Signature Mismatch​

Scenario: The webhook endpoint receives a payload that did not originate from Stripe.

How it's handled:

  • EventUtility.ConstructEvent(rawBody, signatureHeader, webhookSecret) is called before any DB access.
  • A StripeException is thrown on invalid signature → return HTTP 400.
  • Stripe will retry delivery. If rejections continue, Stripe alerts the dashboard.
  • STRIPE_WEBHOOK_SECRET is read from an environment variable — never hardcoded.

5.7 Duplicate Token Save​

Scenario: The same card is used twice with saveCard: true. We must not create duplicate rows in customer_payment_tokens.

How it's handled:

  • Before inserting, the service calls CustomerStripeTokenExistsAsync(customerId, paymentMethodId) which checks by stripepaymentmethodid.
  • If a row already exists for that pm_xxx and customer: skip the insert silently.
  • This also handles the case where both the webhook and the reconciliation service attempt to save the token at the same time.

5.8 Abandoned / Stale Payment​

Scenario: Customer initiated a payment but never entered card details. The PaymentIntent sits in requires_payment_method state indefinitely and no webhook ever fires.

How it's handled:

  • StripeReconciliationService runs every STRIPE_RECONCILIATION_INTERVAL_MINUTES (default: 5 minutes).
  • It queries for all Initiated or Processing Stripe payments where updated_at < now() - STRIPE_STALE_PAYMENT_THRESHOLD_MINUTES (default: 10 minutes).
  • For each, it calls Stripe PaymentIntentService.GetAsync(traceId) and maps the result:
    • succeeded → run finalization (handles missed webhooks).
    • canceled or payment_failed → mark as Failed.
    • requires_payment_method (never submitted) → mark as Expired.
    • Still processing → leave unchanged, check again next cycle.

6. New Files — Implementation Checklist​

Models​

  • [ ] Models/Payment/Stripe/StripeOptions.cs
  • [ ] Models/Payment/Stripe/StripeInitiateRequest.cs
  • [ ] Models/Payment/Stripe/StripeInitiateResponse.cs
  • [ ] Models/Payment/Stripe/StripePaymentStatusResponse.cs

DAL​

  • [ ] DAL/Payment/Stripe/IStripePaymentDal.cs
  • [ ] DAL/Payment/Stripe/StripePaymentDal.cs

Service​

  • [ ] Services/Payment/Stripe/IStripePaymentService.cs
  • [ ] Services/Payment/Stripe/StripePaymentService.cs

Background Service​

  • [ ] BackgroundServices/StripeReconciliationService.cs

Controller​

  • [ ] Controllers/StripePaymentController.cs

Database Migrations​

  • [x] DatabaseService/Scripts/000063-AddStripePaymentColumns.sql

DI Registration​

  • [ ] Program.cs — register StripeOptions, IStripePaymentDal, IStripePaymentService, StripeReconciliationService

7. Security Rules​

RuleDetail
stripepaymentmethodidNever returned in any API response
SecretKey (sk_live_xxx)Never logged, never returned in any response
Webhook endpointNo JWT auth — protected by Stripe signature verification only
StripeCustomerIdStored in customermaster, never included in client-accessible payment responses
Token lookupSaved-card endpoints return only tokenId (internal GUID). Client never sees the raw Stripe pm_xxx

8. Environment Variables​

VariablePurposeExample
STRIPE_SECRET_KEYStripe API secret key (server-side only)sk_live_xxx
STRIPE_PUBLISHABLE_KEYReturned to frontend for Payment Element initializationpk_live_xxx
STRIPE_WEBHOOK_SECRETValidates incoming Stripe webhook signatureswhsec_xxx
STRIPE_RECONCILIATION_INTERVAL_MINUTESHow often the background reconciler runs5
STRIPE_STALE_PAYMENT_THRESHOLD_MINUTESAge at which an incomplete payment is treated as stale10

9. Endpoint Summary​

MethodRouteAuthDescription
POST/stripe/payments/initiateJWTCreate PaymentIntent, return clientSecret
GET/stripe/payments/{id}/statusJWTPoll PaymentIntent status
POST/stripe/payments/{id}/webhookStripe SignatureReceive Stripe payment events
GET/stripe/payments/saved-cardsJWTList customer's saved Stripe cards