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 —
StripePaymentControllerunder/stripe/payments - Service —
StripePaymentServiceimplementingIStripePaymentService - DAL —
StripePaymentDalimplementingIStripePaymentDal - 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→PaymentDalPaymentService→DealerPayClient(external HTTP)PaymentReconciliationService→PaymentService
Stripe Stack (new)
StripePaymentController (/stripe/payments)→StripePaymentService→StripePaymentDalStripePaymentService→ 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
| Column | DealerPay Usage | Stripe Usage |
|---|---|---|
gateway_name | 'DealerPay' | 'Stripe' |
trace_id | DealerPay traceId | Stripe PaymentIntent.Id (pi_xxx) |
async_id | DealerPay asyncProcessingId | Not used (null) |
checkout_url | DealerPay iframe URL | Not used (null) |
transaction_id | DealerPay transaction ID | Stripe PaymentIntent.Id (same as trace_id) |
reference_number | paymentId.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"
}
| Field | Type | Required | Description |
|---|---|---|---|
invoiceId | Guid | ✅ | 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"
}
| Field | Type | Description |
|---|---|---|
paymentId | Guid | Internal payment ID. Pass to status polling and embed in webhook URL |
clientSecret | string | Pass to stripe.confirmPayment() on the frontend |
amount | decimal | Invoice amount in dollars |
Error Responses
| HTTP | Scenario |
|---|---|
400 | Invoice already paid |
403 | Invoice belongs to a different customer |
404 | Invoice not found |
500 | Stripe API error |
Workflow
- Extract
userIdfrom JWTsubclaim. - Fetch invoice by
invoiceIdusingGetInvoiceByIdAsync(invoiceId). Return404if not found. - Verify
invoice.CustomerId == userId. Return403if mismatch. - Check invoice is not already Paid. Return
400if it is. - Fetch
stripecustomeridfor this customer fromcustomermasterusingGetStripeCustomerIdAsync(customerId).- If not set: call Stripe
CustomerService.CreateAsync(email, name)and store the resultingcus_xxxback tocustomermasterusingSaveStripeCustomerIdAsync(customerId, stripeCustomerId). Card details are NOT fetched here.
- If not set: call Stripe
- Call Stripe
PaymentIntentService.CreateAsyncwith:amountin cents (dollars × 100)currency = "usd"customer = cus_xxxidempotency_key = invoiceId.ToString() + "_Stripe"(prevents duplicate PaymentIntents for the same invoice)metadata = { invoiceId, customerId }
- Stripe returns the PaymentIntent object (
pi_xxx). - Check DB
paymentstable for an existing row withtraceid = pi_xxxusingGetPaymentByTraceIdAsync(intent.Id).- If found: return its
paymentIdandclientSecret(same intent, idempotency). - If not found: Call
InsertPaymentAsync(newPayment)to insert a newpaymentsrow. We generate an internalPaymentId(GUID) and store the Stripepi_xxxinTraceId. Most importantly, we store the frontend'sinvoiceIdin theInvoiceIdcolumn so it is firmly linked to thisPaymentId.
- If found: return its
- Return
{ paymentId, clientSecret, amount }to the frontend. - 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
| Param | Type | Description |
|---|---|---|
paymentId | Guid | The 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
}
| Field | Type | Description |
|---|---|---|
paymentStatus | string | Processing | Success | Failed |
transactionId | string? | Stripe PaymentIntent.Id. Present on Success |
transactionNo | string? | Internal sequential transaction number. Present on Success |
authCode | string? | Card auth code. Present on Success |
cardBrand | string? | e.g. "visa", "mastercard". Present on Success |
last4 | string? | Last 4 digits. Present on Success |
amountPaid | decimal? | Present on Success |
Status Values
paymentStatus | Meaning | Frontend Action |
|---|---|---|
Processing | Stripe is still processing | Keep polling (every 2–3 seconds) |
Success | Payment confirmed and finalized | Show success screen, stop polling |
Failed | Payment declined or failed | Show error message, allow retry |
Error Responses
| HTTP | Scenario |
|---|---|
403 | Payment belongs to a different customer |
404 | Payment not found |
500 | Stripe API error |
Workflow
- Extract
userIdfrom JWT. - Fetch payment by
paymentIdusingGetPaymentByIdAsync(paymentId). Return404if not found. Note thispaymentobject contains theInvoiceId. - Verify
payment.CustomerId == userId. Return403if mismatch. - If payment is already
SuccessorFailedin the database (e.g. webhook already finalized it): return the cached terminal response immediately — no Stripe API call needed. - Call Stripe
PaymentIntentService.GetAsync(payment.TraceId)to get the live status. - Map the Stripe status:
succeeded→ run finalization (step 7), then returnSuccess.processingorrequires_confirmation→ returnProcessing.requires_payment_methodorpayment_failed→ callUpdatePaymentAsyncto mark payment asFailedin DB, returnFailed.
- Finalization (when
succeeded):- Update Payment: Call
UpdatePaymentAsync(payment.PaymentId, updateReq). This updatestransactionId,authCode,cardBrand, etc. It usesRowVersionfor optimistic concurrency (handles race with webhook). - Save Token: If
paymentIntent.SetupFutureUsage == "off_session"andPaymentMethodIdis present, checkCustomerStripeTokenExistsAsync(customerId, pm_xxx). If false, store it viaInsertCustomerStripeTokenAsync(...). - Update Invoice: Call
UpdateInvoiceOnPaymentSuccessAsync(payment.InvoiceId, payment.TotalAmount, txnNo). Note: we pullInvoiceIddirectly from thepaymentrecord we fetched in step 2. This sets the invoice toPaid. - Allocate Funds: Call
InsertPaymentAllocationAsync(payment.PaymentId, payment.InvoiceId, payment.TotalAmount).
- Update Payment: Call
- 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
| Param | Type | Description |
|---|---|---|
paymentId | Guid | Embedded in the webhook URL configured in the Stripe dashboard: https://api.example.com/stripe/payments/{paymentId}/webhook |
Required Header
| Header | Description |
|---|---|
Stripe-Signature | HMAC-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
- Read the raw request body as a string (must be the raw bytes — do not deserialize first, or signature verification breaks).
- Call
EventUtility.ConstructEvent(rawBody, stripeSignatureHeader, webhookSecret).- If signature is invalid: return
400. Stripe will retry.
- If signature is invalid: return
- Fetch payment by Stripe's TraceId using
GetPaymentByTraceIdAsync(intent.Id). This directly searches for the row wheretrace_id == intent.Id. The returnedpaymentrow already contains theInvoiceId. Return400if not found. - If event is
payment_intent.succeeded:- Extract
paymentMethodId,card.brand,last4,exp_month,exp_year,authorization_codefrom 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(...)usingrowversionfor optimistic concurrency. - Save Token: Call
CustomerStripeTokenExistsAsync(...)andInsertCustomerStripeTokenAsync(...). - Update Invoice: Call
UpdateInvoiceOnPaymentSuccessAsync(payment.InvoiceId, ...). - Allocate Funds: Call
InsertPaymentAllocationAsync(...).
- Update Payment: Call
- Extract
- If event is
payment_intent.payment_failed:- Call
UpdatePaymentAsync(...)to mark payment asFailedin DB with the error message. - Return
200.
- Call
- Any other event type: return
200without 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-cardsendpoint. 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
}
]
}
| Field | Type | Description |
|---|---|---|
hasSavedCards | bool | True if the savedCards list has at least one item |
savedCards | array | List of the user's saved cards |
savedCards[].tokenId | Guid | Internal ID. Pass to /initiate-saved. Never the raw Stripe pm_xxx |
savedCards[].cardBrand | string? | "visa", "mastercard", "amex", etc. |
savedCards[].last4 | string? | Last 4 digits |
savedCards[].expiryMonth | int? | 1–12 |
savedCards[].expiryYear | int? | 4-digit year |
savedCards[].isDefault | bool | Whether this is the customer's default card |
Returns { "hasSavedCards": false, "savedCards": [] } if no saved Stripe cards exist.
Workflow
- Extract
userIdfrom JWT, resolve tocustomerId. - Query
customer_payment_tokenswherecustomer_id = customerIdANDstripepaymentmethodid IS NOT NULLANDis_active = true. - Map each row to
SavedStripeCardResponse— do not includestripepaymentmethodidin the response. - 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
GetActiveStripePaymentForInvoiceAsyncfor an existingInitiatedorProcessingStripe payment on the same invoice. - If one is found: return the existing
paymentIdandclientSecret— 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
paymentstable has arowversion(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 asCONFLICT. - 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
StripeExceptionwith acode(e.g.card_declined,expired_card). - The service catches it, inserts the
paymentsrow with statusFailed, 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.CustomerIdis compared to the authenticateduserIdfrom the JWTsubclaim. - 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
StripeExceptionis thrown on invalid signature → return HTTP 400. - Stripe will retry delivery. If rejections continue, Stripe alerts the dashboard.
STRIPE_WEBHOOK_SECRETis 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 bystripepaymentmethodid. - If a row already exists for that
pm_xxxand 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:
StripeReconciliationServiceruns everySTRIPE_RECONCILIATION_INTERVAL_MINUTES(default: 5 minutes).- It queries for all
InitiatedorProcessingStripe payments whereupdated_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).canceledorpayment_failed→ mark asFailed.requires_payment_method(never submitted) → mark asExpired.- 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— registerStripeOptions,IStripePaymentDal,IStripePaymentService,StripeReconciliationService
7. Security Rules
| Rule | Detail |
|---|---|
stripepaymentmethodid | Never returned in any API response |
SecretKey (sk_live_xxx) | Never logged, never returned in any response |
| Webhook endpoint | No JWT auth — protected by Stripe signature verification only |
StripeCustomerId | Stored in customermaster, never included in client-accessible payment responses |
| Token lookup | Saved-card endpoints return only tokenId (internal GUID). Client never sees the raw Stripe pm_xxx |
8. Environment Variables
| Variable | Purpose | Example |
|---|---|---|
STRIPE_SECRET_KEY | Stripe API secret key (server-side only) | sk_live_xxx |
STRIPE_PUBLISHABLE_KEY | Returned to frontend for Payment Element initialization | pk_live_xxx |
STRIPE_WEBHOOK_SECRET | Validates incoming Stripe webhook signatures | whsec_xxx |
STRIPE_RECONCILIATION_INTERVAL_MINUTES | How often the background reconciler runs | 5 |
STRIPE_STALE_PAYMENT_THRESHOLD_MINUTES | Age at which an incomplete payment is treated as stale | 10 |
9. Endpoint Summary
| Method | Route | Auth | Description |
|---|---|---|---|
POST | /stripe/payments/initiate | JWT | Create PaymentIntent, return clientSecret |
GET | /stripe/payments/{id}/status | JWT | Poll PaymentIntent status |
POST | /stripe/payments/{id}/webhook | Stripe Signature | Receive Stripe payment events |
GET | /stripe/payments/saved-cards | JWT | List customer's saved Stripe cards |