diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index ca30ade..43e7d72 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -34,9 +34,10 @@ Local `main` in the main clone is at `5693f7b`, an older Production commit that | #17 Production release: free-only MVP | `main` ← `launch-mvp` | `83a30a7` | `lint, unit, e2e` ✅ (push + PR) · Vercel ✅ · Vercel Preview Comments ✅ | [41564501659](https://github.com/Jakuraa/StrangersMeetup/actions/runs/41564501659) (push) success | | #30 M4.3 UPI Security Remediation | `launch-mvp` ← `feature/m4-3-upi-security-remediation` | `e4babe6` | `lint, unit, e2e` ✅ | PR Review Baseline for M4.3.1 | | #31 M4.3.1 Review Remediation | `feature/m4-3-upi-security-remediation` ← `feature/m4-3-1-review-remediation` | `03bf518` | `lint, unit, e2e` ✅ | Independent Review Corrections for PR #30 | -| (Remediation) M4.3.2 Financial Integrity Remediation | `feature/m4-3-1-review-remediation` ← `feature/m4-3-2-financial-integrity` | (work branch) | `lint, unit, e2e` ✅ | Fail-Closed Merchant Identity, Secure Manual Reconciliation & State Transition Verification | +| #32 M4.3.2 Financial Integrity Remediation | `feature/m4-3-1-review-remediation` ← `feature/m4-3-2-financial-integrity` | `62cb0a3` | `lint, unit, e2e` ✅ · Vercel ✅ | Fail-Closed Merchant Identity & Secure Manual Refund Reconciliation | +| (Review) M4.3.3 Final Provider Verification Integrity | `feature/m4-3-2-financial-integrity` ← `feature/m4-3-3-provider-verification` | (work branch) | `lint, unit, e2e` ✅ | Removal of Currency Defaults, Elimination of Merchant Fallback, Authentic S2S Polling & Attestation | -**Owner gate:** PR #17 must not be merged without the owner's separate approval. `main` has no GitHub Actions runs on record. PR #30, PR #31, and PR #17 must not be merged automatically without owner review. Keep `ENABLE_LIVE_PAYMENTS=false` everywhere. +**Owner gate:** PR #17 must not be merged without the owner's separate approval. `main` has no GitHub Actions runs on record. PR #32, PR #31, PR #30, and PR #17 must not be merged without separate owner approval. Keep `ENABLE_LIVE_PAYMENTS=false` everywhere. PR #23 is **merged** at `7c5079e` (P3 Location Intelligence + M2 Remediation). PR #25 is **merged** at `83a30a7` (Owner Addendum Ops: dynamic DB block list, branch archiving, Phase 5.6/5.7 reports). @@ -46,6 +47,7 @@ PR #28 is **merged** at `c3b4e83` (M4.1 UPI-First Checkout, Dynamic QR & Verifie PR #29 is **merged** at `c71ec8d` (M4.2 Independent UPI Payment Security & Production Readiness Audit). PR #30 is **open / preserved** (`feature/m4-3-upi-security-remediation`). PR #31 is **open / preserved** (`feature/m4-3-1-review-remediation`). +PR #32 is **open / preserved** (`feature/m4-3-2-financial-integrity`). ## 4. Migrations per environment diff --git a/docs/UPI_PROVIDER_READINESS.md b/docs/UPI_PROVIDER_READINESS.md index 9442f4f..b318120 100644 --- a/docs/UPI_PROVIDER_READINESS.md +++ b/docs/UPI_PROVIDER_READINESS.md @@ -97,11 +97,14 @@ flowchart TD ### Readiness Status: -#### A. Code & Architectural Prerequisites (Engineering — Completed in M4.3, M4.3.1 & M4.3.2) +#### A. Code & Architectural Prerequisites (Engineering — Completed in M4.3, M4.3.1, M4.3.2 & M4.3.3) - [x] **Fix Webhook Signature Calculation**: Updated `verifyWebhookSignature` in `lib/upi/adapter.js` to compute `SHA256(response + saltKey) + "###" + saltIndex` on the decoded payload object. Verified with PhonePe S2S test. - [x] **Strict Webhook Amount Verification (REV-04)**: Implemented in `applySuccessfulUpiPayment` and `processUpiWebhook`; requires safe positive integer exact paise match; currency and merchantId match; underpayment or malformed amounts are marked `disputed` with 0 bookings created. -- [x] **Fail-Closed Merchant Identity & Currency Invariance (M4.3.2)**: Replaced permissive merchant logic with strict fail-closed verification across verified, stored, and server-configured merchant IDs. Rejects missing, null, empty string, or mismatched merchant IDs across PhonePe PG and mock adapters. Enforces non-empty `'INR'` currency matching. -- [x] **Secure Manual Refund Reconciliation (M4.3.2)**: Prohibited generated or placeholder `MANUAL-...` references. Implemented `verifyFinanceAuthorization` checking active database roles (`super_admin`, `finance`), `isValidExternalRefundRef` requiring verifiable external references (bank UTR, PG refund ARN), and `isValidEvidenceRef` requiring documented evidence references recorded in the immutable audit ledger. Documented operational CBS limitation. +- [x] **Fail-Closed Merchant Identity & Currency Invariance (M4.3.2 & M4.3.3)**: Replaced permissive merchant logic with strict fail-closed verification across verified, stored, and server-configured merchant IDs. Rejects missing, null, empty string, or mismatched merchant IDs across PhonePe PG and mock adapters. Enforces non-empty `'INR'` currency matching. +- [x] **Elimination of Currency Defaults & Synthetic Fallbacks (M4.3.3)**: Removed `verified.currency || 'INR'` in webhook processing and `gatewayStatus.currency || 'INR'` in polling. Missing currency remains missing (`null`) across all adapters. Never manufactures INR as proof of provider verification. +- [x] **Elimination of Configured Merchant Fallback in Status Check (M4.3.3)**: Removed `data.merchantId || this.merchantId` fallback in `PhonePeAdapter.getPaymentStatus`. Provider status responses missing `merchantId` remain `null`, preventing synthetic identity substitution. Three-way comparison in `applySuccessfulUpiPayment` fails closed with `missing_verified_merchant_id` or `merchant_mismatch`. +- [x] **Provider API Limitations Disclosed (M4.3.3)**: Documented that PhonePe domestic PG typically omits an explicit `currency` field in standard v1 responses; automated confirmation strictly requires explicit provider currency and merchant identity. +- [x] **Secure Manual Refund Reconciliation & Operator Attestation (M4.3.2 & M4.3.3)**: Prohibited generated or placeholder `MANUAL-...` references. Implemented `verifyFinanceAuthorization` checking active database roles (`super_admin`, `finance`), `isValidExternalRefundRef` requiring verifiable external references (bank UTR, PG refund ARN), and `isValidEvidenceRef` requiring documented evidence references recorded in the immutable audit ledger. Documented operational CBS limitation and labeled attestation as `operator_attested: true`, `bankVerified: false`. - [x] **Enforce Authentication on `/checkout/:slug/upi`**: Gated unauthenticated users with redirect to `/signin`; guest checkout assigns guestToken and requires authenticated claim via `/api/checkout/upi/claim`. - [x] **Duplicate Booking Protection (REV-02)**: Prevented duplicate active bookings during intent creation (409 `already_booked`), confirmation locked transaction (queued for refund in `refund_pending`), guest claims (409), and database constraint races (SQLSTATE `23505` savepoint recovery). - [x] **Refund Operational Recovery & Reconciliation (REV-03, M4.3.2)**: Permitted refunding payments in both `succeeded` and `refund_pending` states; isolated external gateway calls outside holding transactions; caught provider failures without losing audit state; added authorized manual finance reconciliation mechanism; ensured booking revocation and chat removals fire only upon confirmed refund. diff --git a/docs/UPI_SECURITY_AUDIT.md b/docs/UPI_SECURITY_AUDIT.md index 0f16f87..45bc4d0 100644 --- a/docs/UPI_SECURITY_AUDIT.md +++ b/docs/UPI_SECURITY_AUDIT.md @@ -349,3 +349,53 @@ Following independent owner review of PR #31 (`feature/m4-3-1-review-remediation - **Migration Suite**: 8/8 passed (`test/migrations.test.js`). - **Linter**: `140/140 files OK` (`npm run lint`). - **Absolute Safety Boundary**: `ENABLE_LIVE_PAYMENTS=false` preserved across all files and environments. Real money transactions remain 100% disabled. Merges to PR #31, PR #30, and PR #17 strictly prohibited. + +--- + +## 8. Milestone M4.3.3 Final Provider Verification Integrity Review + +Milestone M4.3.3 implements a final zero-trust review and remediation of provider verification paths, eliminating implicit defaults, silent identity fallbacks, and unverified confirmations across PhonePe PG and mock adapters on branch `feature/m4-3-3-provider-verification`. + +### 1. Removal of Currency Defaults Across Verification Paths +- **Eliminated Permissive Defaults**: Removed all instances of fallback currency defaults (`verified.currency || 'INR'` in `processUpiWebhook` and `gatewayStatus.currency || 'INR'` in `getUpiPaymentStatus`). +- **Faithful Provider Currency Representation**: Updated `PhonePeAdapter.getPaymentStatus`, `PhonePeAdapter.verifyWebhook`, `MockUpiAdapter.getPaymentStatus`, and `MockUpiAdapter.verifyWebhook` so that missing or omitted currency from the provider response strictly remains missing (`null`), and empty string remains empty string. Never manufactures `'INR'` as proof of provider verification. +- **Strict Currency Invariance in Confirmation**: `applySuccessfulUpiPayment` fail-closed currency verification requires that: + - Incoming provider currency is a non-empty string. + - Stored database payment currency is `'INR'`. + - Both values strictly equal `'INR'` (`safeEqual` / constant-time match). + - Missing or null currency triggers dispute reason `missing_currency`. + - Non-INR, lowercase, or mismatched currency triggers dispute reason `currency_mismatch`. + - In either failure case, the payment is moved to `status = 'disputed'`, failure reason records `Payment verification failed: Currency mismatch ()`, an immutable entry is logged in `payment_audits`, and 0 attendee bookings or Telegram invite links are created. + +### 2. Removal of Merchant Identity Fallback in Provider Status Verification +- **Eliminated Fallback in `PhonePeAdapter.getPaymentStatus`**: Replaced `const statusMerchantId = (data.merchantId || this.merchantId || '').trim();` with strict extraction `data.merchantId !== undefined && data.merchantId !== null ? (typeof data.merchantId === 'string' ? data.merchantId.trim() : data.merchantId) : null;`. If the gateway response omits `merchantId`, it remains `null` and is never substituted with server configuration. +- **Fail-Closed Three-Way Merchant Verification**: When polling gateway status (`checkGateway: true`), the returned status is passed to `applySuccessfulUpiPayment`, which enforces constant-time equality across: + 1. `verifiedMerchantId`: Merchant ID returned in the provider status response. + 2. `storedMerchantId`: Merchant ID recorded during intent creation in `payments.merchant_id`. + 3. `configuredMerchantId`: Merchant ID loaded from server adapter configuration (`adapter.merchantId`). + - Missing or empty provider merchant ID records `missing_verified_merchant_id`. + - Tampered or missing stored merchant ID records `missing_stored_merchant_id`. + - Mismatched provider merchant ID records `merchant_mismatch`. + - Any failure immediately disputes the payment with 0 confirmed bookings. +- **Provider API Limitation Disclosure**: Official PhonePe v1 Check Status API (`/pg/v1/status/{merchantId}/{orderId}`) and standard S2S webhook payloads are designed for domestic INR processing and typically omit an explicit `currency` field. If PhonePe omits `currency` or `merchantId` in standard callbacks, fail-closed enforcement prevents automatic confirmation. Authoritative verification relies on explicit provider fields; synthetic or manufactured fields are strictly disallowed. + +### 3. Review of Provider Status Authenticity & Safety Invariants +- **Server-to-Server Authenticity**: Payment status polling executes strictly through authenticated server-to-server requests using PhonePe's HMAC/SHA256 `X-VERIFY` header (`SHA256(endpoint + saltKey)###saltIndex`) and `X-MERCHANT-ID`. +- **Cryptographic Signature Verification**: Incoming webhooks are verified against `X-VERIFY` (`SHA256(base64Response + saltKey)###saltIndex`) before reading payload contents. +- **Atomic Concurrency Guard**: Amount (positive safe integer paise match), order ID, merchant identity (three-way match), and currency (INR match) are checked atomically inside a locked PostgreSQL transaction (`SELECT ... FOR UPDATE`). +- **Client Return URL & Browser Callback Isolation**: Browser navigations to `/checkout/:slug/upi` or client polling endpoints (`/api/checkout/upi/status/:id`) never establish payment success. Spoofed query parameters (e.g. `?status=succeeded`) are completely ignored. Polling without `check=1` only reads existing database status. +- **Idempotency**: Repeated webhook deliveries or status poll queries on an already `succeeded` order return duplicate status without modifying database rows or creating duplicate bookings. +- **Booking & Invite Gating**: Pending, failed, and disputed payments never create confirmed bookings or Telegram invites. + +### 4. Manual Refund Operational Limitation & Attestation +- **CBS Limitation Documented**: String validation in `isValidExternalRefundRef` enforces syntactical correctness (6–120 chars, alphanumeric syntax, anti-placeholder rules), but does **not** interface with Core Banking System (CBS) host-to-host settlement rails. +- **Operator-Attested Labeling**: All manual refund reconciliations are explicitly tagged with `attestation: 'operator_attested'` and `bankVerified: false` in `payments.raw_ref.reconciliation`, `payment_audits.details`, and audit logs. +- **Server-Side Authorization**: Re-verified against active `super_admin` or `finance` roles in `admin_users` table; caller strings are never trusted. Public users and support roles are rejected (401/403). +- **Anti-Fraud Rules**: Fake, placeholder, single-character repetition (`000000`, `XXXXXX`), and trivial sequences (`123456`, `abcdef`) are strictly rejected (400). + +### M4.3.3 Verification Summary +- **Total Test Suite**: 472 tests total, 470 passed, 2 skipped (unconfigured Stars sandbox), 0 failed (`duration: ~51s`). +- **UPI Checkout Suite**: 37/37 passed (`test/upi-checkout.test.js`). +- **Migration Suite**: 8/8 passed (`test/migrations.test.js`). +- **Linter**: `140/140 files OK` (`npm run lint`). +- **Absolute Safety Boundary**: `ENABLE_LIVE_PAYMENTS=false` preserved across all files and environments. No real payments or refunds initiated. Merges to PR #32, PR #31, PR #30, and PR #17 strictly prohibited. Stopped for independent owner review. diff --git a/lib/services/upi-payments.js b/lib/services/upi-payments.js index 7e6d89d..3b5c1a5 100644 --- a/lib/services/upi-payments.js +++ b/lib/services/upi-payments.js @@ -365,7 +365,8 @@ export async function getUpiPaymentStatus(orderId, { user = null, guestToken = n if (!user) { throw new HttpError(401, 'unauthorized', 'Authentication required to view payment status'); } - if (payment.user_id !== user.id && !isStaff) { + const currentUserId = user.id || user.userId; + if (payment.user_id !== currentUserId && !isStaff) { throw new HttpError(403, 'forbidden', 'Access denied to this payment order'); } } else { @@ -402,7 +403,7 @@ export async function getUpiPaymentStatus(orderId, { user = null, guestToken = n orderId, providerPaymentId: gatewayStatus.providerPaymentId, verifiedAmountPaise: gatewayStatus.amountPaise, - currency: gatewayStatus.currency || 'INR', + currency: gatewayStatus.currency, merchantId: gatewayStatus.merchantId, actor: 'poll:gateway' }); @@ -508,13 +509,18 @@ export async function applySuccessfulUpiPayment({ disputeReason }); + let failureLabel = 'Verification mismatch'; + if (disputeReason.includes('amount')) failureLabel = 'Amount mismatch'; + else if (disputeReason.includes('currency')) failureLabel = 'Currency mismatch'; + else if (disputeReason.includes('merchant')) failureLabel = 'Merchant mismatch'; + await c.query( `UPDATE payments SET status = 'disputed', - failure_reason = 'Payment verification failed: Amount mismatch (' || $2 || ')', + failure_reason = 'Payment verification failed: ' || $3 || ' (' || $2 || ')', webhook_verified_at = now() WHERE id = $1`, - [payment.id, disputeReason] + [payment.id, disputeReason, failureLabel] ); await c.query( @@ -955,7 +961,7 @@ export async function processUpiWebhook({ headers, rawBody, clientIp }) { providerPaymentId: verified.providerPaymentId, verifiedAmountPaise: verified.amountPaise, merchantId: verified.merchantId, - currency: verified.currency || 'INR', + currency: verified.currency, actor: `webhook:${verified.provider}` }); if (res.status === 'disputed') { @@ -1149,6 +1155,8 @@ export async function refundUpiOrder(orderId, { effectiveRef, JSON.stringify({ manualReconciliation: true, + attestation: 'operator_attested', + bankVerified: false, externalRefundRef: effectiveRef, evidenceRef, reason, @@ -1168,6 +1176,8 @@ export async function refundUpiOrder(orderId, { verifiedActor, JSON.stringify({ reconciliationType: 'manual_finance_reconciliation', + attestation: 'operator_attested', + bankVerified: false, externalRefundRef: effectiveRef, evidenceRef, reason, @@ -1208,7 +1218,9 @@ export async function refundUpiOrder(orderId, { reason, externalRefundRef: effectiveRef, evidenceRef, - manualReconciliation: true + manualReconciliation: true, + attestation: 'operator_attested', + bankVerified: false }, c ); @@ -1217,7 +1229,9 @@ export async function refundUpiOrder(orderId, { orderId, status: 'refunded', refundId: effectiveRef, - manualReconciliation: true + manualReconciliation: true, + attestation: 'operator_attested', + bankVerified: false }; }); } diff --git a/lib/upi/adapter.js b/lib/upi/adapter.js index 05d48dc..c05c6b9 100644 --- a/lib/upi/adapter.js +++ b/lib/upi/adapter.js @@ -199,13 +199,12 @@ export class PhonePeAdapter { } const data = json.data || {}; - const statusMerchantId = (data.merchantId || this.merchantId || '').trim(); - if (data.merchantId && this.merchantId && data.merchantId.trim() !== this.merchantId.trim()) { - throw new PaymentAdapterError( - `PhonePe status check merchantId mismatch: expected '${this.merchantId}', received '${data.merchantId}'`, - 'MERCHANT_MISMATCH' - ); - } + const statusMerchantId = (data.merchantId !== undefined && data.merchantId !== null) + ? (typeof data.merchantId === 'string' ? data.merchantId.trim() : data.merchantId) + : null; + const statusCurrency = (data.currency !== undefined && data.currency !== null) + ? (typeof data.currency === 'string' ? data.currency.trim() : data.currency) + : null; return { provider: 'phonepe', @@ -214,8 +213,8 @@ export class PhonePeAdapter { providerTxnId: data.transactionId || providerTxnId || `TXN-${orderId}`, providerPaymentId: data.paymentInstrument?.utr || null, status: normalizedStatus, - amountPaise: data.amount || null, - currency: 'INR', + amountPaise: data.amount !== undefined ? data.amount : null, + currency: statusCurrency, rawStatus: code, message: json.message || '' }; @@ -283,6 +282,10 @@ export class PhonePeAdapter { normalizedStatus = 'cancelled'; } + const statusCurrency = (data.currency !== undefined && data.currency !== null) + ? (typeof data.currency === 'string' ? data.currency.trim() : data.currency) + : null; + return { provider: 'phonepe', merchantId: data.merchantId.trim(), @@ -290,7 +293,7 @@ export class PhonePeAdapter { providerTxnId: data.transactionId, providerPaymentId: data.paymentInstrument?.utr || null, amountPaise: data.amount, - currency: 'INR', + currency: statusCurrency, status: normalizedStatus, rawStatus: code, raw: decoded @@ -403,17 +406,21 @@ export class MockUpiAdapter { async getPaymentStatus(orderId, providerTxnId = null) { const sim = this.simulatedResults.get(orderId) || 'pending'; + const status = typeof sim === 'string' ? sim : (sim?.status || 'pending'); + const merchantId = typeof sim === 'object' && 'merchantId' in sim ? sim.merchantId : this.merchantId; + const currency = typeof sim === 'object' && 'currency' in sim ? sim.currency : 'INR'; + const amountPaise = typeof sim === 'object' && 'amountPaise' in sim ? sim.amountPaise : 49900; return { provider: 'upi_mock', - merchantId: this.merchantId, + merchantId, orderId, providerTxnId: providerTxnId || `MOCK-${orderId}`, - providerPaymentId: sim === 'succeeded' ? `UTR${Date.now()}` : null, - status: sim, - amountPaise: 49900, - currency: 'INR', - rawStatus: sim.toUpperCase(), - message: `Simulated mock payment ${sim}` + providerPaymentId: status === 'succeeded' ? `UTR${Date.now()}` : null, + status, + amountPaise, + currency, + rawStatus: status.toUpperCase(), + message: `Simulated mock payment ${status}` }; } @@ -438,6 +445,10 @@ export class MockUpiAdapter { ); } + const statusCurrency = (body.currency !== undefined && body.currency !== null) + ? (typeof body.currency === 'string' ? body.currency.trim() : body.currency) + : null; + return { provider: 'upi_mock', merchantId: body.merchantId.trim(), @@ -445,7 +456,7 @@ export class MockUpiAdapter { providerTxnId: body.providerTxnId || `MOCK-${body.orderId}`, providerPaymentId: body.utr || `UTR${Date.now()}`, amountPaise: body.amountPaise, - currency: 'INR', + currency: statusCurrency, status: body.status || 'succeeded', rawStatus: (body.status || 'succeeded').toUpperCase(), raw: body diff --git a/test/upi-checkout.test.js b/test/upi-checkout.test.js index 73352f2..43b455d 100644 --- a/test/upi-checkout.test.js +++ b/test/upi-checkout.test.js @@ -21,6 +21,7 @@ import { import { PhonePeAdapter, MockUpiAdapter, + mockInstance, buildAppIntents } from '../lib/upi/adapter.js'; import { @@ -266,6 +267,7 @@ test('webhook confirmation: valid webhook confirms booking, updates payment, and merchantTransactionId: intent.orderId, transactionId: `TXN-${intent.orderId}`, amount: 50000, + currency: 'INR', state: 'COMPLETED', paymentInstrument: { type: 'UPI', @@ -499,6 +501,7 @@ test('http routes: POST /api/payments/webhook/phonepe processes valid webhook pa merchantTransactionId: orderId, transactionId: `TXN-HTTP-${orderId}`, amount: 10000, + currency: 'INR', state: 'COMPLETED', paymentInstrument: { type: 'UPI', @@ -588,6 +591,7 @@ test('FIN-01: Webhook underpayment attack rejected and marked disputed with 0 bo merchantTransactionId: intent.orderId, transactionId: `TXN-UNDERPAY-${intent.orderId}`, amount: 100, // Only 100 paise (₹1) + currency: 'INR', state: 'COMPLETED', paymentInstrument: { type: 'UPI', utr: 'UTR-ATTACK-001' } } @@ -642,6 +646,7 @@ test('DATA-01: Guest checkout flow without user_id succeeds on webhook without 5 merchantTransactionId: intent.orderId, transactionId: `TXN-GUEST-${intent.orderId}`, amount: 15000, + currency: 'INR', state: 'COMPLETED', paymentInstrument: { type: 'UPI', utr: 'UTR-GUEST-001' } } @@ -1013,6 +1018,7 @@ test('REV-02: Duplicate booking during webhook confirmation moves payment to ref merchantTransactionId: intent.orderId, transactionId: `TXN-DUP-${intent.orderId}`, amount: 25000, + currency: 'INR', state: 'COMPLETED' } }; @@ -1816,3 +1822,792 @@ test('M4.3.2: Secure manual refund reconciliation authorization, evidence refere assert.equal(finalHttpPay.status, 'refunded'); assert.equal(finalHttpPay.refund_ref, 'BANK-API-UTR-20261104'); }); + +test('M4.3.3: Strict currency invariant verification across webhook and status polling paths', async () => { + const saltKey = '099eb0cd-0252-4e2a-8aca-eb3c6bd470f1'; + const adapter = new PhonePeAdapter({ merchantId: 'PGTESTPAYUAT', saltKey, saltIndex: '1' }); + const mockAdapter = new MockUpiAdapter({ merchantId: 'PGTESTPAYUAT' }); + + // 1. Adapter level: missing currency remains missing, never manufactured as 'INR' + // PhonePeAdapter.verifyWebhook + for (const testCurrency of [undefined, null, '', ' ', 'EUR', 'USD', 'INR']) { + const payload = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: 'ORD-CURR-TEST', + transactionId: 'TXN-CURR-1', + amount: 25000, + ...(testCurrency !== undefined ? { currency: testCurrency } : {}), + state: 'COMPLETED' + } + }; + const b64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + const sig = crypto.createHash('sha256').update(`${b64}${saltKey}`).digest('hex') + '###1'; + const verified = adapter.verifyWebhook({ 'x-verify': sig }, JSON.stringify({ response: b64 })); + + if (testCurrency === undefined || testCurrency === null) { + assert.equal(verified.currency, null, `Currency must remain null when ${testCurrency}`); + } else if (testCurrency === '' || testCurrency === ' ') { + assert.equal(verified.currency, '', 'Empty currency must remain empty string'); + } else { + assert.equal(verified.currency, testCurrency.trim(), `Currency must match provider payload: ${testCurrency}`); + } + } + + // MockUpiAdapter.verifyWebhook + for (const testCurrency of [undefined, null, '', ' ', 'GBP', 'INR']) { + const mockBody = { + merchantId: 'PGTESTPAYUAT', + orderId: 'ORD-MOCK-CURR', + amountPaise: 25000, + ...(testCurrency !== undefined ? { currency: testCurrency } : {}), + status: 'succeeded' + }; + const verified = mockAdapter.verifyWebhook( + { 'x-webhook-secret': 'test-upi-webhook-secret' }, + JSON.stringify(mockBody) + ); + if (testCurrency === undefined || testCurrency === null) { + assert.equal(verified.currency, null, `Mock currency must remain null when ${testCurrency}`); + } else if (testCurrency === '' || testCurrency === ' ') { + assert.equal(verified.currency, '', 'Mock empty currency must remain empty string'); + } else { + assert.equal(verified.currency, testCurrency.trim(), `Mock currency must match: ${testCurrency}`); + } + } + + // 2. Webhook processing path: processUpiWebhook + const { event, tier } = await createPaidEvent({ price: 35000 }); + + // 2a. Missing currency (omitted from payload) + const itMissing = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + const pMissing = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itMissing.orderId, + transactionId: `TXN-MISSING-${itMissing.orderId}`, + amount: 35000, + state: 'COMPLETED' + } + }; + const b64Missing = Buffer.from(JSON.stringify(pMissing)).toString('base64'); + const resMissing = await processUpiWebhook({ + headers: { 'x-verify': crypto.createHash('sha256').update(`${b64Missing}${saltKey}`).digest('hex') + '###1' }, + rawBody: JSON.stringify({ response: b64Missing }), + clientIp: '127.0.0.1' + }); + assert.equal(resMissing.ok, false); + assert.equal(resMissing.error, 'missing_currency'); + + const payMissing = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [itMissing.orderId]); + assert.equal(payMissing.status, 'disputed'); + assert.ok(payMissing.failure_reason.includes('Currency mismatch (missing_currency)')); + + const auditMissing = await db.one('SELECT details FROM payment_audits WHERE order_id = $1 AND to_status = $2', [itMissing.orderId, 'disputed']); + assert.equal(auditMissing.details.reason, 'missing_currency'); + assert.equal(auditMissing.details.expectedCurrency, 'INR'); + + // 2b. Null currency + const itNull = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + const pNull = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itNull.orderId, + transactionId: `TXN-NULL-${itNull.orderId}`, + amount: 35000, + currency: null, + state: 'COMPLETED' + } + }; + const b64Null = Buffer.from(JSON.stringify(pNull)).toString('base64'); + const resNull = await processUpiWebhook({ + headers: { 'x-verify': crypto.createHash('sha256').update(`${b64Null}${saltKey}`).digest('hex') + '###1' }, + rawBody: JSON.stringify({ response: b64Null }), + clientIp: '127.0.0.1' + }); + assert.equal(resNull.ok, false); + assert.equal(resNull.error, 'missing_currency'); + + // 2c. Empty currency string + const itEmpty = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + const pEmpty = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itEmpty.orderId, + transactionId: `TXN-EMPTY-${itEmpty.orderId}`, + amount: 35000, + currency: ' ', + state: 'COMPLETED' + } + }; + const b64Empty = Buffer.from(JSON.stringify(pEmpty)).toString('base64'); + const resEmpty = await processUpiWebhook({ + headers: { 'x-verify': crypto.createHash('sha256').update(`${b64Empty}${saltKey}`).digest('hex') + '###1' }, + rawBody: JSON.stringify({ response: b64Empty }), + clientIp: '127.0.0.1' + }); + assert.equal(resEmpty.ok, false); + assert.equal(resEmpty.error, 'missing_currency'); + + // 2d. Mismatched currency (USD / EUR) + const itMismatch = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + const pMismatch = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itMismatch.orderId, + transactionId: `TXN-USD-${itMismatch.orderId}`, + amount: 35000, + currency: 'USD', + state: 'COMPLETED' + } + }; + const b64Mismatch = Buffer.from(JSON.stringify(pMismatch)).toString('base64'); + const resMismatch = await processUpiWebhook({ + headers: { 'x-verify': crypto.createHash('sha256').update(`${b64Mismatch}${saltKey}`).digest('hex') + '###1' }, + rawBody: JSON.stringify({ response: b64Mismatch }), + clientIp: '127.0.0.1' + }); + assert.equal(resMismatch.ok, false); + assert.equal(resMismatch.error, 'currency_mismatch'); + + const payMismatch = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [itMismatch.orderId]); + assert.equal(payMismatch.status, 'disputed'); + assert.ok(payMismatch.failure_reason.includes('Currency mismatch (currency_mismatch)')); + + // 2e. Valid INR currency succeeds + const itValid = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + const pValid = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itValid.orderId, + transactionId: `TXN-VALID-${itValid.orderId}`, + amount: 35000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-CURR-OK-1' } + } + }; + const b64Valid = Buffer.from(JSON.stringify(pValid)).toString('base64'); + const resValid = await processUpiWebhook({ + headers: { 'x-verify': crypto.createHash('sha256').update(`${b64Valid}${saltKey}`).digest('hex') + '###1' }, + rawBody: JSON.stringify({ response: b64Valid }), + clientIp: '127.0.0.1' + }); + assert.equal(resValid.ok, true); + assert.equal(resValid.result, 'confirmed'); + + const payValid = await db.one('SELECT status, booking_id FROM payments WHERE order_id = $1', [itValid.orderId]); + assert.equal(payValid.status, 'succeeded'); + assert.ok(payValid.booking_id); + + // 3. Status polling path: getUpiPaymentStatus({ checkGateway: true }) + // 3a. Polling with missing currency from provider status check + const itPollMissing = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itPollMissing.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itPollMissing.orderId, + transactionId: `TXN-POLL-${itPollMissing.orderId}`, + amount: 35000, + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-POLL-1' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollResMissing = await getUpiPaymentStatus(itPollMissing.orderId, { user: guest2, checkGateway: true }); + assert.equal(pollResMissing.status, 'disputed'); + assert.equal(pollResMissing.disputeReason, 'missing_currency'); + + const payPollMissing = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [itPollMissing.orderId]); + assert.equal(payPollMissing.status, 'disputed'); + assert.ok(payPollMissing.failure_reason.includes('Currency mismatch (missing_currency)')); + + // 3b. Polling with null currency from provider status check + const itPollNull = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itPollNull.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itPollNull.orderId, + transactionId: `TXN-POLL-${itPollNull.orderId}`, + amount: 35000, + currency: null, + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-POLL-2' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollResNull = await getUpiPaymentStatus(itPollNull.orderId, { user: guest2, checkGateway: true }); + assert.equal(pollResNull.status, 'disputed'); + assert.equal(pollResNull.disputeReason, 'missing_currency'); + + // 3c. Polling with empty currency string from provider status check + const itPollEmpty = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itPollEmpty.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itPollEmpty.orderId, + transactionId: `TXN-POLL-${itPollEmpty.orderId}`, + amount: 35000, + currency: '', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-POLL-3' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollResEmpty = await getUpiPaymentStatus(itPollEmpty.orderId, { user: guest2, checkGateway: true }); + assert.equal(pollResEmpty.status, 'disputed'); + assert.equal(pollResEmpty.disputeReason, 'missing_currency'); + + // 3d. Polling with mismatched currency (USD) from provider status check + const itPollUSD = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itPollUSD.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itPollUSD.orderId, + transactionId: `TXN-POLL-${itPollUSD.orderId}`, + amount: 35000, + currency: 'USD', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-POLL-4' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollResUSD = await getUpiPaymentStatus(itPollUSD.orderId, { user: guest2, checkGateway: true }); + assert.equal(pollResUSD.status, 'disputed'); + assert.equal(pollResUSD.disputeReason, 'currency_mismatch'); + + // 3e. Polling with valid verified INR currency confirms successfully + const itPollOK = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itPollOK.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itPollOK.orderId, + transactionId: `TXN-POLL-${itPollOK.orderId}`, + amount: 35000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-POLL-5' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollResOK = await getUpiPaymentStatus(itPollOK.orderId, { user: guest2, checkGateway: true }); + assert.equal(pollResOK.status, 'succeeded'); + assert.ok(pollResOK.bookingId); + + const payPollOK = await db.one('SELECT status, booking_id FROM payments WHERE order_id = $1', [itPollOK.orderId]); + assert.equal(payPollOK.status, 'succeeded'); + assert.equal(payPollOK.booking_id, pollResOK.bookingId); + + // 3f. Mock adapter status polling: missing currency and mismatched currency fail closed + process.env.UPI_ADAPTER = 'mock'; + let itMockCurFail; + let itMockCurMis; + try { + itMockCurFail = await createUpiPaymentIntent({ slug: event.slug, user: guest3, ticketTypeId: tier.id }); + mockInstance.setSimulatedStatus(itMockCurFail.orderId, { status: 'succeeded', amountPaise: 35000, currency: null, merchantId: 'PGTESTPAYUAT' }); + const pollMockFail = await getUpiPaymentStatus(itMockCurFail.orderId, { user: guest3, checkGateway: true }); + assert.equal(pollMockFail.status, 'disputed'); + assert.equal(pollMockFail.disputeReason, 'missing_currency'); + + itMockCurMis = await createUpiPaymentIntent({ slug: event.slug, user: guest3, ticketTypeId: tier.id }); + mockInstance.setSimulatedStatus(itMockCurMis.orderId, { status: 'succeeded', amountPaise: 35000, currency: 'EUR', merchantId: 'PGTESTPAYUAT' }); + const pollMockMis = await getUpiPaymentStatus(itMockCurMis.orderId, { user: guest3, checkGateway: true }); + assert.equal(pollMockMis.status, 'disputed'); + assert.equal(pollMockMis.disputeReason, 'currency_mismatch'); + } finally { + delete process.env.UPI_ADAPTER; + } + + // Restore stub + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes('/pg/v1/refund')) { + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS', data: { transactionId: 'TXN-REF-1' } }) }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + // Verify that all disputed intents created zero bookings + for (const it of [itMissing, itNull, itEmpty, itMismatch, itPollMissing, itPollNull, itPollEmpty, itPollUSD, itMockCurFail, itMockCurMis]) { + const bk = await db.one('SELECT count(*)::int AS n FROM bookings WHERE payment_id = (SELECT id FROM payments WHERE order_id = $1)', [it.orderId]); + assert.equal(bk.n, 0, `Disputed intent ${it.orderId} must have 0 bookings`); + } +}); + +test('M4.3.3: Fail-closed provider status merchant identity verification without silent fallback', async () => { + const saltKey = '099eb0cd-0252-4e2a-8aca-eb3c6bd470f1'; + const adapter = new PhonePeAdapter({ merchantId: 'PGTESTPAYUAT', saltKey, saltIndex: '1' }); + + // 1. PhonePeAdapter.getPaymentStatus unit tests: missing merchantId must NOT fall back to configured merchantId + for (const tc of [ + { label: 'missing', input: undefined, expected: null }, + { label: 'null', input: null, expected: null }, + { label: 'empty', input: '', expected: '' }, + { label: 'whitespace', input: ' ', expected: '' }, + { label: 'matching', input: 'PGTESTPAYUAT', expected: 'PGTESTPAYUAT' }, + { label: 'mismatched', input: 'WRONG_MERCHANT', expected: 'WRONG_MERCHANT' } + ]) { + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes('/pg/v1/status/')) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + ...(tc.input !== undefined ? { merchantId: tc.input } : {}), + merchantTransactionId: 'ORD-TEST-MCHK', + transactionId: 'TXN-MCHK-1', + amount: 20000, + currency: 'INR', + state: 'COMPLETED' + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const res = await adapter.getPaymentStatus('ORD-TEST-MCHK'); + assert.equal( + res.merchantId, + tc.expected, + `PhonePeAdapter.getPaymentStatus must return '${tc.expected}' for ${tc.label} merchantId, never fallback` + ); + } + + // 2. Status polling path (getUpiPaymentStatus with checkGateway: true) + const { event, tier } = await createPaidEvent({ price: 28000 }); + + // 2a. Provider returns status with missing merchantId + const itMissing = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itMissing.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + // merchantId omitted! + merchantTransactionId: itMissing.orderId, + transactionId: `TXN-MISSING-${itMissing.orderId}`, + amount: 28000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-M1' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollMissing = await getUpiPaymentStatus(itMissing.orderId, { user: guest1, checkGateway: true }); + assert.equal(pollMissing.status, 'disputed'); + assert.equal(pollMissing.disputeReason, 'missing_verified_merchant_id'); + + const payMissing = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [itMissing.orderId]); + assert.equal(payMissing.status, 'disputed'); + assert.ok(payMissing.failure_reason.includes('Merchant mismatch (missing_verified_merchant_id)')); + + // 2b. Provider returns status with null merchantId + const itNull = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itNull.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: null, + merchantTransactionId: itNull.orderId, + transactionId: `TXN-NULL-${itNull.orderId}`, + amount: 28000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-M2' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollNull = await getUpiPaymentStatus(itNull.orderId, { user: guest1, checkGateway: true }); + assert.equal(pollNull.status, 'disputed'); + assert.equal(pollNull.disputeReason, 'missing_verified_merchant_id'); + + // 2c. Provider returns status with empty merchantId string + const itEmpty = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itEmpty.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: '', + merchantTransactionId: itEmpty.orderId, + transactionId: `TXN-EMPTY-${itEmpty.orderId}`, + amount: 28000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-M3' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollEmpty = await getUpiPaymentStatus(itEmpty.orderId, { user: guest1, checkGateway: true }); + assert.equal(pollEmpty.status, 'disputed'); + assert.equal(pollEmpty.disputeReason, 'missing_verified_merchant_id'); + + // 2d. Provider returns status with mismatched merchantId + const itMismatch = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itMismatch.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'ATTACKER_MERCHANT', + merchantTransactionId: itMismatch.orderId, + transactionId: `TXN-MISMATCH-${itMismatch.orderId}`, + amount: 28000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-M4' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollMismatch = await getUpiPaymentStatus(itMismatch.orderId, { user: guest1, checkGateway: true }); + assert.equal(pollMismatch.status, 'disputed'); + assert.equal(pollMismatch.disputeReason, 'merchant_mismatch'); + + const payMismatch = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [itMismatch.orderId]); + assert.equal(payMismatch.status, 'disputed'); + assert.ok(payMismatch.failure_reason.includes('Merchant mismatch (merchant_mismatch)')); + + // 2e. Provider returns matching merchantId confirms successfully + const itMatching = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itMatching.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itMatching.orderId, + transactionId: `TXN-MATCHING-${itMatching.orderId}`, + amount: 28000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-M5' } + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollMatching = await getUpiPaymentStatus(itMatching.orderId, { user: guest1, checkGateway: true }); + assert.equal(pollMatching.status, 'succeeded'); + assert.ok(pollMatching.bookingId); + + // 3. Stored payment row merchant_id tampered to NULL fails closed + const itStoredNull = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + await db.query(`UPDATE payments SET merchant_id = NULL WHERE order_id = $1`, [itStoredNull.orderId]); + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes(`/pg/v1/status/PGTESTPAYUAT/${itStoredNull.orderId}`)) { + return { + status: 200, + body: JSON.stringify({ + success: true, + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: itStoredNull.orderId, + transactionId: `TXN-STOREDNULL-${itStoredNull.orderId}`, + amount: 28000, + currency: 'INR', + state: 'COMPLETED' + } + }) + }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + const pollStoredNull = await getUpiPaymentStatus(itStoredNull.orderId, { user: guest2, checkGateway: true }); + assert.equal(pollStoredNull.status, 'disputed'); + assert.equal(pollStoredNull.disputeReason, 'missing_stored_merchant_id'); + + // 4. Mock adapter polling path: missing and mismatched merchantId fail closed + process.env.UPI_ADAPTER = 'mock'; + let itMockM1; + let itMockM2; + try { + itMockM1 = await createUpiPaymentIntent({ slug: event.slug, user: guest3, ticketTypeId: tier.id }); + mockInstance.setSimulatedStatus(itMockM1.orderId, { status: 'succeeded', amountPaise: 28000, merchantId: null, currency: 'INR' }); + const pollMockM1 = await getUpiPaymentStatus(itMockM1.orderId, { user: guest3, checkGateway: true }); + assert.equal(pollMockM1.status, 'disputed'); + assert.equal(pollMockM1.disputeReason, 'missing_verified_merchant_id'); + + itMockM2 = await createUpiPaymentIntent({ slug: event.slug, user: guest3, ticketTypeId: tier.id }); + mockInstance.setSimulatedStatus(itMockM2.orderId, { status: 'succeeded', amountPaise: 28000, merchantId: 'ATTACKER_MERCHANT', currency: 'INR' }); + const pollMockM2 = await getUpiPaymentStatus(itMockM2.orderId, { user: guest3, checkGateway: true }); + assert.equal(pollMockM2.status, 'disputed'); + assert.equal(pollMockM2.disputeReason, 'merchant_mismatch'); + } finally { + delete process.env.UPI_ADAPTER; + } + + // Restore stub + stubUrl('https://api-preprod.phonepe.com', (url) => { + if (url.includes('/pg/v1/refund')) { + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS', data: { transactionId: 'TXN-REF-1' } }) }; + } + return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) }; + }); + + // Zero confirmed bookings for all disputed orders + for (const it of [itMissing, itNull, itEmpty, itMismatch, itStoredNull, itMockM1, itMockM2]) { + const bk = await db.one('SELECT count(*)::int AS n FROM bookings WHERE payment_id = (SELECT id FROM payments WHERE order_id = $1)', [it.orderId]); + assert.equal(bk.n, 0, `Disputed merchant check ${it.orderId} must have 0 bookings`); + } +}); + +test('M4.3.3: Manual refund operational limitation, operator attestation and idempotency', async () => { + const { event, tier } = await createPaidEvent({ price: 16000 }); + const intent = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + + await applySuccessfulUpiPayment({ + orderId: intent.orderId, + verifiedAmountPaise: 16000, + currency: 'INR', + merchantId: 'PGTESTPAYUAT' + }); + + const financeAdminUser = await db.one( + `INSERT INTO admin_users (telegram_user_id, role, active) + VALUES (980000999, 'finance', true) + ON CONFLICT (telegram_user_id) DO UPDATE SET active = true, role = 'finance' + RETURNING *` + ); + + // Manual reconciliation includes explicit attestation labeling + const refundRes = await refundUpiOrder(intent.orderId, { + actor: `admin:${financeAdminUser.id}:finance`, + reason: 'Bank transfer manual reversal operator verified', + manualReconciliation: true, + externalRefundRef: 'BANK-M433-UTR-99999', + evidenceRef: 'AUDIT-TICKET-M433-99999' + }); + + assert.equal(refundRes.status, 'refunded'); + assert.equal(refundRes.manualReconciliation, true); + assert.equal(refundRes.attestation, 'operator_attested'); + assert.equal(refundRes.bankVerified, false); + + // DB payments table raw_ref.reconciliation check + const pay = await db.one('SELECT raw_ref FROM payments WHERE order_id = $1', [intent.orderId]); + assert.equal(pay.raw_ref.reconciliation.attestation, 'operator_attested'); + assert.equal(pay.raw_ref.reconciliation.bankVerified, false); + + // payment_audits table check + const auditRow = await db.one( + `SELECT details FROM payment_audits WHERE order_id = $1 AND to_status = 'refunded' AND actor LIKE 'admin:%'`, + [intent.orderId] + ); + assert.equal(auditRow.details.attestation, 'operator_attested'); + assert.equal(auditRow.details.bankVerified, false); + + // Idempotent retry returns duplicate + const retryRes = await refundUpiOrder(intent.orderId, { + actor: `admin:${financeAdminUser.id}:finance`, + manualReconciliation: true, + externalRefundRef: 'BANK-M433-UTR-99999', + evidenceRef: 'AUDIT-TICKET-M433-99999' + }); + assert.equal(retryRes.status, 'refunded'); + assert.equal(retryRes.duplicate, true); +}); + +test('M4.3.3: Provider status authenticity, client return URL isolation and idempotent event safety', async () => { + const saltKey = '099eb0cd-0252-4e2a-8aca-eb3c6bd470f1'; + const { event, tier } = await createPaidEvent({ price: 12000 }); + + // 1. Client return URL and browser callback never establish payment success + const intent = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id }); + + // Browser loads checkout with spoofed return query params + const spoofRes = await guest1.get(`/checkout/${event.slug}/upi?status=succeeded&orderId=${intent.orderId}&payment=paid`); + assert.equal(spoofRes.status, 200); + + // Intent in DB remains strictly pending with 0 bookings + const payStillPending = await db.one('SELECT status, booking_id FROM payments WHERE order_id = $1', [intent.orderId]); + assert.equal(payStillPending.status, 'pending'); + assert.equal(payStillPending.booking_id, null); + + // Client polling endpoint without check=1 does not confirm payment + const pollClient = await guest1.get(`/api/checkout/upi/status/${intent.orderId}`); + assert.equal(pollClient.status, 200); + assert.equal(pollClient.json.status, 'pending'); + assert.equal(pollClient.json.bookingId, null); + + // 2. Cryptographic webhook verification and idempotency + const payload = { + code: 'PAYMENT_SUCCESS', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: intent.orderId, + transactionId: `TXN-AUTH-${intent.orderId}`, + amount: 12000, + currency: 'INR', + state: 'COMPLETED', + paymentInstrument: { type: 'UPI', utr: 'UTR-AUTH-1' } + } + }; + const b64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + const validSig = crypto.createHash('sha256').update(`${b64}${saltKey}`).digest('hex') + '###1'; + + // Tampered signature is rejected + await assert.rejects( + () => processUpiWebhook({ + headers: { 'x-verify': 'TAMPERED_HASH###1' }, + rawBody: JSON.stringify({ response: b64 }), + clientIp: '127.0.0.1' + }), + /signature/i + ); + + // Valid webhook confirms payment and creates exactly 1 booking + const okHook = await processUpiWebhook({ + headers: { 'x-verify': validSig }, + rawBody: JSON.stringify({ response: b64 }), + clientIp: '127.0.0.1' + }); + assert.equal(okHook.ok, true); + assert.equal(okHook.result, 'confirmed'); + + const countAfterFirst = await db.one('SELECT count(*)::int AS n FROM bookings WHERE event_id = $1', [event.id]); + assert.equal(countAfterFirst.n, 1); + + // Idempotent duplicate delivery: replaying webhook returns duplicate and creates 0 new bookings + const dupHook = await processUpiWebhook({ + headers: { 'x-verify': validSig }, + rawBody: JSON.stringify({ response: b64 }), + clientIp: '127.0.0.1' + }); + assert.equal(dupHook.ok, true); + assert.equal(dupHook.result, 'duplicate'); + + const countAfterDup = await db.one('SELECT count(*)::int AS n FROM bookings WHERE event_id = $1', [event.id]); + assert.equal(countAfterDup.n, 1); + + // Status polling on already succeeded payment remains idempotent + const pollAfterSucceeded = await getUpiPaymentStatus(intent.orderId, { user: guest1, checkGateway: true }); + assert.equal(pollAfterSucceeded.status, 'succeeded'); + + const countAfterPoll = await db.one('SELECT count(*)::int AS n FROM bookings WHERE event_id = $1', [event.id]); + assert.equal(countAfterPoll.n, 1); + + // 3. Pending, failed and disputed payments NEVER create confirmed bookings or Telegram invites + const failedIntent = await createUpiPaymentIntent({ slug: event.slug, user: guest2, ticketTypeId: tier.id }); + const failedPayload = { + code: 'PAYMENT_ERROR', + data: { + merchantId: 'PGTESTPAYUAT', + merchantTransactionId: failedIntent.orderId, + transactionId: `TXN-FAIL-${failedIntent.orderId}`, + amount: 12000, + currency: 'INR', + state: 'FAILED' + } + }; + const b64Fail = Buffer.from(JSON.stringify(failedPayload)).toString('base64'); + const failSig = crypto.createHash('sha256').update(`${b64Fail}${saltKey}`).digest('hex') + '###1'; + + await processUpiWebhook({ + headers: { 'x-verify': failSig }, + rawBody: JSON.stringify({ response: b64Fail }), + clientIp: '127.0.0.1' + }); + + const payFail = await db.one('SELECT status FROM payments WHERE order_id = $1', [failedIntent.orderId]); + assert.equal(payFail.status, 'failed'); + + const failBookings = await db.one('SELECT count(*)::int AS n FROM bookings WHERE payment_id = (SELECT id FROM payments WHERE order_id = $1)', [failedIntent.orderId]); + assert.equal(failBookings.n, 0); + + const failInvites = await db.one('SELECT count(*)::int AS n FROM invite_links WHERE user_id = $1', [guest2.userId]); + assert.equal(failInvites.n, 0); +});