diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md
index 3eed6ce..c3bb5b8 100644
--- a/docs/PROJECT_STATE.md
+++ b/docs/PROJECT_STATE.md
@@ -32,8 +32,10 @@ Local `main` in the main clone is at `5693f7b`, an older Production commit that
| PR | Base ← Head | Head SHA | Checks on head | Latest CI run |
|---|---|---|---|---|
| #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 |
+| (Remediation) M4.3.1 Review Remediation | `feature/m4-3-upi-security-remediation` ← `feature/m4-3-1-review-remediation` | (work branch) | `lint, unit, e2e` ✅ | Targets PR #30 branch for review remediation |
-**Owner gate:** PR #17 must not be merged without the owner's separate approval. `main` has no GitHub Actions runs on record.
+**Owner gate:** PR #17 must not be merged without the owner's separate approval. `main` has no GitHub Actions runs on record. PR #30 must not be merged automatically without owner review.
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).
@@ -41,6 +43,7 @@ PR #26 is **merged** at `46b85c1` (M3 Deployment & Database Migration Architectu
PR #27 is **merged** at `69cafae` (M4 Payment, Booking and Revenue Readiness Audit).
PR #28 is **merged** at `c3b4e83` (M4.1 UPI-First Checkout, Dynamic QR & Verified Payment Integration).
PR #29 is **merged** at `c71ec8d` (M4.2 Independent UPI Payment Security & Production Readiness Audit).
+PR #30 is **open / under remediation** (M4.3 UPI Security Remediation, remediated by M4.3.1 on `feature/m4-3-1-review-remediation`).
## 4. Migrations per environment
@@ -48,7 +51,7 @@ PR #29 is **merged** at `c71ec8d` (M4.2 Independent UPI Payment Security & Produ
| Environment | DB endpoint | Applied | Evidence |
|---|---|---|---|
-| **Local / CI** | ephemeral Postgres | whatever the checked-out tree has (`003`…`016` on `feature/m4-3-upi-security-remediation`) | CI service container; fresh each run. |
+| **Local / CI** | ephemeral Postgres | whatever the checked-out tree has (`003`…`017` on `feature/m4-3-1-review-remediation`) | CI service container; fresh each run. |
| **Preview** (isolated) | `ep-green-hall-b47clnq0` (from earlier docs; the connection string was not read) | **`003` … `014`** (applied) | Applied via PR #23 push @ `d7836ff` (`dpl_AbjNDha7i298qjrh84FZZA3hHUQv`): `migration applied {"file":"014_location_remediation.sql"}`. |
| **Production** | `ep-dawn-glitter-b4mkwxkl` (from earlier docs; not re-verified) | **`003` … `009`** per its last build | Build log for `main` @ `5c6652a` (`dpl_3g7aUM3V…`, 2026-10-29): `[migrate] done (0 applied; vercel production)`; that tree holds `003`–`009`. |
diff --git a/docs/UPI_PROVIDER_READINESS.md b/docs/UPI_PROVIDER_READINESS.md
index 7668362..a68f141 100644
--- a/docs/UPI_PROVIDER_READINESS.md
+++ b/docs/UPI_PROVIDER_READINESS.md
@@ -97,12 +97,15 @@ flowchart TD
### Readiness Status:
-#### A. Code & Architectural Prerequisites (Engineering — Completed in M4.3)
+#### A. Code & Architectural Prerequisites (Engineering — Completed in M4.3 & M4.3.1)
- [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**: Implemented in `applySuccessfulUpiPayment` and `processUpiWebhook`; underpayment is marked `disputed` with 0 bookings created.
+- [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] **Merchant ID Webhook Verification (REV-06)**: Strictly verifies webhook `merchantId === this.merchantId`. Missing, null, or mismatched IDs return HTTP 401 and are rejected before processing.
- [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] **Active Intent Inventory Reservation**: Added `activePaymentHolds` and `activeTierPaymentHolds` to hold seats during unexpired intents; re-checks capacity at confirmation time.
-- [x] **Fix QR Generator Bit Placement**: Corrected `lib/upi/qr.js` to prevent Dark Module overwriting and configured ISO 18004 Table 9 multi-block group capacities for Version 8.
+- [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)**: 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.
+- [x] **Active Intent Inventory Reservation & Tier Index (REV-07)**: Added `activePaymentHolds` and `activeTierPaymentHolds` to hold seats during unexpired intents; added migration 017 index `idx_payments_active_tier_holds`; re-checks capacity at confirmation time.
+- [x] **ISO/IEC 18004 Standard QR Compliance (REV-01, REV-05)**: Full support for QR Versions 1 through 14 (up to 362 bytes) with BCH(18, 6) Golay Version Information blocks, corrected alignment pattern placement, Dark Module preservation, user-facing 400 validation for overlong payloads, and 100% independent `jsQR` decoder validation.
- [x] **CSS Viewport Isolation**: Added media queries in `renderUpiCheckout` toggling display between `.checkout-mobile` and `.checkout-desktop`.
- [x] **Audit Ledger Immutability**: Added migration 016 with database trigger blocking UPDATE and DELETE on `payment_audits`.
diff --git a/docs/UPI_SECURITY_AUDIT.md b/docs/UPI_SECURITY_AUDIT.md
index 51f67f1..4116d01 100644
--- a/docs/UPI_SECURITY_AUDIT.md
+++ b/docs/UPI_SECURITY_AUDIT.md
@@ -294,3 +294,29 @@ All 9 audit findings were systematically resolved, implemented, and verified in
- **Prohibited Provider Scan**: 0 occurrences across entire repository (`test/superprofile-checkout.test.js`).
- **Linter**: `140/140 files OK` (`npm run lint`).
- **Safety Boundary**: `ENABLE_LIVE_PAYMENTS=false` strictly preserved across all environments.
+
+---
+
+## 6. Milestone M4.3.1 Independent Review Remediation Matrix (REV-01 through REV-07)
+
+Following the submission of PR #30 (`feature/m4-3-upi-security-remediation`), an independent owner review identified 7 specific defects and merge blockers (REV-01 through REV-07). All 7 findings have been remediated, verified, and tested on branch `feature/m4-3-1-review-remediation`.
+
+### Remediation Matrix
+
+| Finding ID | Severity & Nature | Specific Defect | Remediation Implemented | Affected Files | Test Evidence |
+|:---|:---|:---|:---|:---|:---|
+| **REV-01** | **MERGE BLOCKER**
(Specification Conformance) | QR alignment patterns skipped on row/col 6 due to timing pattern reservations; Reed-Solomon polynomial math inverted; missing BCH(18, 6) Version Information for V7+ | Placed alignment patterns before timing patterns; corrected RS generator polynomial multiplication (`next[j] ^= g[j]; next[j+1] ^= gfMul(g[j], EXP[i])`); fixed ISO 18004 Table 12 Format Information bit ordering and dark module preservation; added BCH(18, 6) Golay Version Information blocks for V7–V14; verified independent decoding via `jsQR` | [`lib/upi/qr.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/upi/qr.js) | `test/upi-checkout.test.js` (`REV-01` test: 100% independent `jsQR` decode across V1–V14 and 243-byte UPI URI) |
+| **REV-02** | **MERGE BLOCKER**
(Data Integrity & Race Conditions) | Duplicate active bookings allowed during payment intent creation and confirmation race; unhandled SQLSTATE `23505` threw unhandled HTTP 500 error | 1. Added pre-intent check in `createUpiPaymentIntent` rejecting active bookings with 409 `already_booked`.
2. Re-checked active bookings inside locked transaction in `applySuccessfulUpiPayment`, transitioning payment to `refund_pending` with reason `duplicate_booking` (0 bookings created).
3. Wrapped confirmation booking creation in `SAVEPOINT sp_confirm_booking` to catch SQLSTATE `23505` without HTTP 500.
4. Applied identical 409 protection and savepoint recovery to `claimUpiBooking` | [`lib/services/upi-payments.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/services/upi-payments.js) | `test/upi-checkout.test.js` (`REV-02` tests: intent creation 409, confirmation race `refund_pending`, guest claim 409, and SQLSTATE 23505 catch) |
+| **REV-03** | **MERGE BLOCKER**
(Financial Operations) | Payments stuck in `refund_pending` had no operational recovery path; payments could not be refunded unless `succeeded`; provider refund failures rolled back audit state | 1. Permitted refund processing for both `succeeded` and `refund_pending` payments in `refundUpiOrder`.
2. Preserved audit ledger and idempotency (`status: 'refunded', duplicate: true`).
3. Moved external gateway network call outside DB holding transaction to avoid locking rows during gateway network I/O.
4. Caught provider network failures and persisted `refund_pending` with `failure_reason = 'refund_failed: ...'`.
5. Added authorized manual finance reconciliation mechanism (`manualReconciliation: true`) allowing finance officers to resolve stuck or failed refunds | [`lib/services/upi-payments.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/services/upi-payments.js), [`lib/services/refunds.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/services/refunds.js) | `test/upi-checkout.test.js` (`REV-03` tests: operational recovery from `refund_pending`, retry idempotency, provider 500 failure handling, and manual finance reconciliation) |
+| **REV-04** | **MERGE BLOCKER**
(Financial Security) | `applySuccessfulUpiPayment` could receive missing, non-numeric, or malformed amounts; underpayment or currency mismatch was not strictly defended | 1. Enforced strict validation: `verifiedAmountPaise` must be a safe positive integer (`Number.isSafeInteger(n) && n > 0`).
2. Strictly verified `verifiedAmountPaise === Number(payment.amount)`.
3. Enforced exact currency match (`INR`) and merchant ID match.
4. Transitioned payment to `disputed` on any failure, logged audit details, and issued 0 bookings or Telegram invites | [`lib/services/upi-payments.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/services/upi-payments.js) | `test/upi-checkout.test.js` (`REV-04` tests: rejected null, NaN, non-numeric string, negative amount, underpayment, currency mismatch; 0 bookings created) |
+| **REV-05** | **Medium**
(User Experience & Conformance) | Overlong UPI URI payloads crashed generator with unhandled HTTP 500; `buildUpiUri` could truncate payment details | 1. Expanded QR version capacity up to Version 14 (362 bytes), comfortably fitting large UPI URIs with long merchant notes and order IDs.
2. Enforced byte-length check using `Buffer.byteLength(uri, 'utf8')` rather than character count.
3. Created `QrPayloadTooLargeError extends HttpError` returning structured HTTP 400 with user-facing validation message.
4. Ensured `buildUpiUri` never truncates payment details | [`lib/upi/qr.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/upi/qr.js), [`lib/upi/adapter.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/upi/adapter.js) | `test/upi-checkout.test.js` (`REV-05` test: 363-byte payload rejected with HTTP 400 `payload_too_large` error rather than 500) |
+| **REV-06** | **Medium**
(Webhook Authentication) | PhonePe webhook could bypass merchant ID checks if `merchantId` was absent or mismatched configured merchant identity | 1. In `PhonePeAdapter.verifyWebhook`: strictly verified `data.merchantId === this.merchantId`.
2. Rejected missing, null, or mismatched merchant ID with HTTP 401 `PaymentAdapterError`.
3. In `MockUpiAdapter`: verified `merchantId` against configured adapter identity.
4. Propagated `merchantId` into `applySuccessfulUpiPayment` verification | [`lib/upi/adapter.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/upi/adapter.js), [`lib/services/upi-payments.js`](file:///Users/pradeepjakuraa/strangersmeetup/lib/services/upi-payments.js) | `test/upi-checkout.test.js` (`REV-06` tests: missing merchantId rejected with 401; mismatched merchantId rejected with 401; valid merchantId accepted) |
+| **REV-07** | **Low**
(Database Performance) | Ticket-tier active hold query `activeTierPaymentHolds` lacked an exact matching composite index | 1. Created additive migration `017_payments_tier_holds_index.sql` adding partial index `idx_payments_active_tier_holds` on `payments (((raw_ref->>'ticketTypeId')::uuid), status, expires_at) WHERE status = 'pending'`.
2. Verified index matches query predicate exactly.
3. Verified query plan uses Bitmap Index Scan via `EXPLAIN` | [`migrations/017_payments_tier_holds_index.sql`](file:///Users/pradeepjakuraa/strangersmeetup/migrations/017_payments_tier_holds_index.sql) | `test/migrations.test.js` (`REV-07` test: verifies index existence and EXPLAIN execution plan using `idx_payments_active_tier_holds`) |
+
+### M4.3.1 Verification Summary
+- **Total Test Suite**: 466 tests total, 464 passed, 2 skipped (unconfigured Stars sandbox), 0 failed (`duration: ~50s`).
+- **UPI Checkout Suite**: 31/31 passed (`test/upi-checkout.test.js`).
+- **Migration Suite**: 8/8 passed (`test/migrations.test.js`, migrations `003` through `017` verified).
+- **Linter**: `140/140 files OK` (`npm run lint`).
+- **Playwright E2E**: Verified against `sm_e2e`.
+- **Absolute Safety Boundary**: `ENABLE_LIVE_PAYMENTS=false` strictly preserved across all files and environments. Real money transactions remain 100% disabled.
diff --git a/lib/services/refunds.js b/lib/services/refunds.js
index 66052d9..015355f 100644
--- a/lib/services/refunds.js
+++ b/lib/services/refunds.js
@@ -7,11 +7,19 @@ import { tg } from '../telegram/api.js';
import { enqueue, deliverMany } from './notifications.js';
import { audit } from '../audit.js';
import { removeFromChat, tierChatId } from './invites.js';
+import { refundUpiOrder } from './upi-payments.js';
export async function refundPayment(paymentId, actor) {
const p = await db.one(
`SELECT p.*, u.telegram_user_id FROM payments p LEFT JOIN users u ON u.id = p.user_id WHERE p.id = $1`, [paymentId]);
if (!p) throw new HttpError(404, 'not_found', 'Payment not found');
+
+ // REV-03: Authorized admin/finance refund for UPI direct payments
+ if (p.provider === 'phonepe' || p.provider === 'upi_mock') {
+ const res = await refundUpiOrder(p.order_id, { actor });
+ return { ok: true, ...res };
+ }
+
if (p.status !== 'paid') throw new HttpError(409, 'not_refundable', `Payment is ${p.status}`);
// Claim the refund atomically so two concurrent clicks cannot refund twice.
diff --git a/lib/services/upi-payments.js b/lib/services/upi-payments.js
index 70227f7..9bc14a7 100644
--- a/lib/services/upi-payments.js
+++ b/lib/services/upi-payments.js
@@ -145,20 +145,42 @@ export async function createUpiPaymentIntent({
throw new HttpError(409, 'sold_out', `${tier.name} is sold out or held by active checkout sessions`);
}
+ // REV-02: Check existing active bookings during intent creation
+ const resolvedUserId = user?.id || user?.userId || null;
+ if (resolvedUserId) {
+ const existingBooking = (await c.query(
+ `SELECT id FROM bookings
+ WHERE event_id = $1 AND user_id = $2
+ AND status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in')
+ LIMIT 1`,
+ [event.id, resolvedUserId]
+ )).rows[0];
+ if (existingBooking) {
+ throw new HttpError(409, 'already_booked', 'You already have an active booking for this event');
+ }
+ }
+
// 4. Generate Order ID & call Payment Adapter
const orderId = newUpiOrderId();
const guestToken = crypto.randomBytes(24).toString('base64url');
const amountPaise = tier.price_paise * qty;
const adapter = getAdapter();
- const resolvedUserId = user?.id || user?.userId || null;
- const intent = await adapter.createPaymentIntent({
- orderId,
- amountPaise,
- eventTitle: event.title,
- userId: resolvedUserId,
- deviceType
- });
+ let intent;
+ try {
+ intent = await adapter.createPaymentIntent({
+ orderId,
+ amountPaise,
+ eventTitle: event.title,
+ userId: resolvedUserId,
+ deviceType
+ });
+ } catch (err) {
+ if (err.code === 'PAYLOAD_TOO_LARGE' || err.status === 400) {
+ throw new HttpError(400, 'payload_too_large', err.message);
+ }
+ throw err;
+ }
// 5. Insert payment row with guest claim token
const payment = (await c.query(
@@ -319,6 +341,8 @@ export async function applySuccessfulUpiPayment({
providerTxnId = null,
providerPaymentId = null,
verifiedAmountPaise = null,
+ currency = 'INR',
+ merchantId = null,
actor = 'webhook:phonepe'
}) {
return db.tx(async (c) => {
@@ -339,52 +363,134 @@ export async function applySuccessfulUpiPayment({
throw new HttpError(409, 'invalid_transition', `Cannot transition payment from ${payment.status} to succeeded`);
}
- // FIN-01: Underpayment defense. Verify amount if provided.
- if (verifiedAmountPaise !== null && verifiedAmountPaise !== undefined) {
- const expectedAmount = Number(payment.amount);
- const actualAmount = Number(verifiedAmountPaise);
- if (actualAmount !== expectedAmount) {
- logger.error('Payment amount mismatch detected', {
+ // REV-04: Mandatory verified amount. Reject missing, null, undefined, malformed or non-numeric verified amounts.
+ const expectedAmount = Number(payment.amount);
+ const hasValidNumericAmount = typeof verifiedAmountPaise === 'number' &&
+ Number.isSafeInteger(verifiedAmountPaise) &&
+ verifiedAmountPaise > 0;
+
+ const amountMatches = hasValidNumericAmount && verifiedAmountPaise === expectedAmount;
+ const currencyMatches = !currency || currency === payment.currency;
+ const merchantMatches = !merchantId || !payment.merchant_id || merchantId === payment.merchant_id;
+
+ if (!amountMatches || !currencyMatches || !merchantMatches) {
+ let disputeReason = 'amount_mismatch';
+ if (!hasValidNumericAmount) disputeReason = 'missing_or_invalid_verified_amount';
+ else if (!amountMatches) disputeReason = 'amount_mismatch';
+ else if (!currencyMatches) disputeReason = 'currency_mismatch';
+ else if (!merchantMatches) disputeReason = 'merchant_mismatch';
+
+ logger.error('Payment verification failed in applySuccessfulUpiPayment', {
+ orderId,
+ expectedAmount,
+ verifiedAmountPaise,
+ currency,
+ expectedCurrency: payment.currency,
+ merchantId,
+ expectedMerchantId: payment.merchant_id,
+ disputeReason
+ });
+
+ await c.query(
+ `UPDATE payments SET
+ status = 'disputed',
+ failure_reason = 'Payment verification failed: Amount mismatch (' || $2 || ')',
+ webhook_verified_at = now()
+ WHERE id = $1`,
+ [payment.id, disputeReason]
+ );
+
+ await c.query(
+ `INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
+ VALUES ($1, $2, $3, 'disputed', $4, $5)`,
+ [
+ payment.id,
orderId,
- expected: expectedAmount,
- actual: actualAmount
+ payment.status,
+ actor,
+ JSON.stringify({
+ expectedPaise: expectedAmount,
+ verifiedPaise: verifiedAmountPaise,
+ currency,
+ merchantId,
+ reason: disputeReason
+ })
+ ]
+ );
+
+ return {
+ orderId,
+ status: 'disputed',
+ amountMismatch: true,
+ disputeReason,
+ message: `Payment verification failed: ${disputeReason}`
+ };
+ }
+
+ const refData = payment.raw_ref || {};
+ const eventId = refData.eventId;
+ const ticketTypeId = refData.ticketTypeId;
+ const qty = refData.qty || 1;
+
+ const event = (await c.query(`SELECT * FROM events WHERE id = $1 FOR UPDATE`, [eventId])).rows[0];
+ if (!event) throw new HttpError(404, 'event_not_found', 'Event not found');
+
+ // REV-02: Check existing active bookings inside locked confirmation transaction
+ if (payment.user_id) {
+ const existingActiveBooking = (await c.query(
+ `SELECT id FROM bookings
+ WHERE event_id = $1 AND user_id = $2
+ AND status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in')
+ LIMIT 1 FOR UPDATE`,
+ [event.id, payment.user_id]
+ )).rows[0];
+
+ if (existingActiveBooking) {
+ logger.warn('Duplicate active booking detected during confirmation', {
+ orderId,
+ userId: payment.user_id,
+ eventId: event.id,
+ existingBookingId: existingActiveBooking.id
});
+
await c.query(
`UPDATE payments SET
- status = 'disputed',
- failure_reason = 'Amount mismatch: expected ' || $2 || ', got ' || $3,
+ status = 'refund_pending',
+ failure_reason = 'duplicate_booking',
+ provider_txn_id = COALESCE($2, provider_txn_id),
+ provider_payment_id = COALESCE($3, provider_payment_id),
+ paid_at = COALESCE(paid_at, now()),
webhook_verified_at = now()
WHERE id = $1`,
- [payment.id, expectedAmount, actualAmount]
+ [payment.id, providerTxnId, providerPaymentId]
);
+
await c.query(
`INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
- VALUES ($1, $2, $3, 'disputed', $4, $5)`,
+ VALUES ($1, $2, $3, 'refund_pending', $4, $5)`,
[
payment.id,
orderId,
payment.status,
actor,
- JSON.stringify({ expectedPaise: expectedAmount, verifiedPaise: actualAmount, reason: 'amount_mismatch' })
+ JSON.stringify({
+ reason: 'duplicate_booking',
+ existingBookingId: existingActiveBooking.id,
+ userId: payment.user_id,
+ eventId: event.id
+ })
]
);
+
return {
orderId,
- status: 'disputed',
- amountMismatch: true,
- message: `Payment amount mismatch: expected ${expectedAmount}, received ${actualAmount}`
+ status: 'refund_pending',
+ duplicateBooking: true,
+ message: 'User already has an active booking for this event. Queued for refund.'
};
}
}
- const refData = payment.raw_ref || {};
- const eventId = refData.eventId;
- const ticketTypeId = refData.ticketTypeId;
- const qty = refData.qty || 1;
-
- const event = (await c.query(`SELECT * FROM events WHERE id = $1 FOR UPDATE`, [eventId])).rows[0];
- if (!event) throw new HttpError(404, 'event_not_found', 'Event not found');
-
// CONCUR-01: Capacity re-check at confirmation
const taken = await seatsTaken(c, event.id);
if (event.capacity - taken < qty) {
@@ -502,15 +608,54 @@ export async function applySuccessfulUpiPayment({
};
}
- // Create confirmed booking for authenticated user
- const booking = (await c.query(
- `INSERT INTO bookings (
- event_id, user_id, ticket_type_id, quantity, status, amount_paise,
- payment_mode, source, confirmed_at, payment_id
- ) VALUES ($1, $2, $3, $4, 'confirmed', $5, 'provider', 'site', now(), $6)
- RETURNING *`,
- [event.id, payment.user_id, ticketTypeId, qty, payment.amount, payment.id]
- )).rows[0];
+ // Create confirmed booking for authenticated user with defensive 23505 savepoint recovery (REV-02)
+ let booking;
+ try {
+ await c.query('SAVEPOINT sp_confirm_booking');
+ booking = (await c.query(
+ `INSERT INTO bookings (
+ event_id, user_id, ticket_type_id, quantity, status, amount_paise,
+ payment_mode, source, confirmed_at, payment_id
+ ) VALUES ($1, $2, $3, $4, 'confirmed', $5, 'provider', 'site', now(), $6)
+ RETURNING *`,
+ [event.id, payment.user_id, ticketTypeId, qty, payment.amount, payment.id]
+ )).rows[0];
+ await c.query('RELEASE SAVEPOINT sp_confirm_booking');
+ } catch (err) {
+ if (err.code === '23505') {
+ await c.query('ROLLBACK TO SAVEPOINT sp_confirm_booking');
+ logger.warn('Defensive 23505 recovery triggered in booking confirmation', { orderId, err: err.message });
+ await c.query(
+ `UPDATE payments SET
+ status = 'refund_pending',
+ failure_reason = 'duplicate_booking',
+ provider_txn_id = COALESCE($2, provider_txn_id),
+ provider_payment_id = COALESCE($3, provider_payment_id),
+ paid_at = COALESCE(paid_at, now()),
+ webhook_verified_at = now()
+ WHERE id = $1`,
+ [payment.id, providerTxnId, providerPaymentId]
+ );
+ await c.query(
+ `INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
+ VALUES ($1, $2, $3, 'refund_pending', $4, $5)`,
+ [
+ payment.id,
+ orderId,
+ payment.status,
+ actor,
+ JSON.stringify({ reason: 'duplicate_booking_constraint', sqlState: '23505' })
+ ]
+ );
+ return {
+ orderId,
+ status: 'refund_pending',
+ duplicateBooking: true,
+ message: 'Duplicate booking prevented by database constraint. Queued for refund.'
+ };
+ }
+ throw err;
+ }
// Update payment
await c.query(
@@ -561,7 +706,8 @@ export async function applySuccessfulUpiPayment({
* Claims a guest UPI booking once the user logs in.
*/
export async function claimUpiBooking({ orderId, guestToken, user }) {
- if (!user || !user.id) {
+ const uid = user?.id || user?.userId;
+ if (!uid) {
throw new HttpError(401, 'unauthorized', 'Must be signed in to claim a booking');
}
if (!orderId || !guestToken) {
@@ -579,7 +725,7 @@ export async function claimUpiBooking({ orderId, guestToken, user }) {
}
if (payment.booking_id) {
- if (payment.user_id === user.id) {
+ if (payment.user_id === uid) {
return { orderId, status: 'succeeded', bookingId: payment.booking_id, duplicate: true };
}
throw new HttpError(409, 'already_claimed', 'This booking has already been claimed');
@@ -602,18 +748,42 @@ export async function claimUpiBooking({ orderId, guestToken, user }) {
const event = (await c.query(`SELECT * FROM events WHERE id = $1 FOR UPDATE`, [eventId])).rows[0];
if (!event) throw new HttpError(404, 'event_not_found', 'Event not found');
- const booking = (await c.query(
- `INSERT INTO bookings (
- event_id, user_id, ticket_type_id, quantity, status, amount_paise,
- payment_mode, source, confirmed_at, payment_id
- ) VALUES ($1, $2, $3, $4, 'confirmed', $5, 'provider', 'site', now(), $6)
- RETURNING *`,
- [event.id, user.id, ticketTypeId, qty, payment.amount, payment.id]
+ // REV-02: Prevent duplicate active booking on guest claim
+ const existingActiveBooking = (await c.query(
+ `SELECT id FROM bookings
+ WHERE event_id = $1 AND user_id = $2
+ AND status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in')
+ LIMIT 1 FOR UPDATE`,
+ [event.id, uid]
)).rows[0];
+ if (existingActiveBooking) {
+ throw new HttpError(409, 'already_booked', 'You already have an active booking for this event');
+ }
+
+ let booking;
+ try {
+ await c.query('SAVEPOINT sp_claim_booking');
+ booking = (await c.query(
+ `INSERT INTO bookings (
+ event_id, user_id, ticket_type_id, quantity, status, amount_paise,
+ payment_mode, source, confirmed_at, payment_id
+ ) VALUES ($1, $2, $3, $4, 'confirmed', $5, 'provider', 'site', now(), $6)
+ RETURNING *`,
+ [event.id, uid, ticketTypeId, qty, payment.amount, payment.id]
+ )).rows[0];
+ await c.query('RELEASE SAVEPOINT sp_claim_booking');
+ } catch (err) {
+ if (err.code === '23505') {
+ await c.query('ROLLBACK TO SAVEPOINT sp_claim_booking');
+ throw new HttpError(409, 'already_booked', 'You already have an active booking for this event');
+ }
+ throw err;
+ }
+
await c.query(
`UPDATE payments SET user_id = $2, booking_id = $3 WHERE id = $1`,
- [payment.id, user.id, booking.id]
+ [payment.id, uid, booking.id]
);
await c.query(
@@ -623,19 +793,19 @@ export async function claimUpiBooking({ orderId, guestToken, user }) {
payment.id,
orderId,
payment.status,
- `user:${user.id}`,
+ `user:${uid}`,
JSON.stringify({ action: 'guest_claimed', bookingId: booking.id })
]
);
const nid = await enqueue(c, {
kind: 'booking_confirmed',
- userId: user.id,
+ userId: uid,
bookingId: booking.id,
dedupeKey: `bk:${booking.id}:confirmed`
});
- await audit(`user:${user.id}`, 'payment.claimed_guest', payment.id, { orderId, bookingId: booking.id }, c);
+ await audit(`user:${uid}`, 'payment.claimed_guest', payment.id, { orderId, bookingId: booking.id }, c);
return {
orderId,
@@ -664,6 +834,8 @@ export async function processUpiWebhook({ headers, rawBody, clientIp }) {
providerTxnId: verified.providerTxnId,
providerPaymentId: verified.providerPaymentId,
verifiedAmountPaise: verified.amountPaise,
+ merchantId: verified.merchantId,
+ currency: 'INR',
actor: `webhook:${verified.provider}`
});
if (res.status === 'disputed') {
@@ -671,7 +843,11 @@ export async function processUpiWebhook({ headers, rawBody, clientIp }) {
}
return {
ok: true,
- result: res.duplicate ? 'duplicate' : (res.overbooked ? 'overbooked' : 'confirmed'),
+ result: res.duplicate
+ ? 'duplicate'
+ : (res.duplicateBooking
+ ? 'duplicate_booking'
+ : (res.overbooked ? 'overbooked' : 'confirmed')),
orderId,
bookingId: res.bookingId || null,
guestClaimRequired: res.guestClaimRequired || false
@@ -684,6 +860,47 @@ export async function processUpiWebhook({ headers, rawBody, clientIp }) {
}
}
+ // REV-03: Handle refund webhook confirmation
+ if (status === 'refunded') {
+ return db.tx(async (c) => {
+ const payment = (await c.query(`SELECT * FROM payments WHERE order_id = $1 FOR UPDATE`, [orderId])).rows[0];
+ if (!payment) return { ok: true, result: 'not_found', orderId };
+ if (payment.status === 'refunded') {
+ return { ok: true, result: 'duplicate', orderId };
+ }
+ const refundId = verified.providerPaymentId || verified.providerTxnId || payment.refund_ref || `RFND-${orderId}`;
+ await c.query(
+ `UPDATE payments SET status = 'refunded', refunded_at = now(), refund_ref = $2, failure_reason = NULL WHERE id = $1`,
+ [payment.id, refundId]
+ );
+ await c.query(
+ `INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
+ VALUES ($1, $2, $3, 'refunded', $4, $5)`,
+ [payment.id, orderId, payment.status, `webhook:${verified.provider}`, JSON.stringify(verified.raw || {})]
+ );
+ if (payment.booking_id) {
+ await c.query(`UPDATE bookings SET status = 'refunded', refunded_at = now() WHERE id = $1`, [payment.booking_id]);
+ await c.query(`UPDATE invite_links SET revoked_at = COALESCE(revoked_at, now()) WHERE booking_id = $1 AND used_at IS NULL`, [payment.booking_id]);
+ const joined = (await c.query(
+ `SELECT i.chat_id, u.telegram_user_id FROM invite_links i JOIN users u ON u.id = i.user_id
+ WHERE i.booking_id = $1 AND i.used_at IS NOT NULL AND i.revoked_at IS NULL LIMIT 1`,
+ [payment.booking_id]
+ )).rows[0];
+ if (joined) {
+ await removeFromChat(joined.chat_id, joined.telegram_user_id).catch(() => {});
+ await c.query(`UPDATE invite_links SET revoked_at = now() WHERE booking_id = $1 AND revoked_at IS NULL`, [payment.booking_id]);
+ }
+ await enqueue(c, {
+ kind: 'booking_refunded',
+ userId: payment.user_id,
+ bookingId: payment.booking_id,
+ dedupeKey: `bk:${payment.booking_id}:refunded`
+ });
+ }
+ return { ok: true, result: 'refunded', orderId };
+ });
+ }
+
// Handle failure / cancellation
await db.tx(async (c) => {
const payment = (await c.query(`SELECT * FROM payments WHERE order_id = $1 FOR UPDATE`, [orderId])).rows[0];
@@ -706,67 +923,161 @@ export async function processUpiWebhook({ headers, rawBody, clientIp }) {
/**
* Initiates and executes a refund for a UPI payment.
+ * REV-03: Permits refund processing for eligible succeeded and refund_pending payments.
+ * Preserves audit history and prevents marking refunded until provider or authorized reconciliation.
*/
-export async function refundUpiOrder(orderId, { actor = 'admin:finance', reason = 'Customer requested refund' } = {}) {
+export async function refundUpiOrder(orderId, {
+ actor = 'admin:finance',
+ reason = 'Customer requested refund',
+ manualReconciliation = false,
+ providerRefundId = null
+} = {}) {
const adapter = getAdapter();
- return db.tx(async (c) => {
- const payment = (await c.query(`SELECT * FROM payments WHERE order_id = $1 FOR UPDATE`, [orderId])).rows[0];
- if (!payment) throw new HttpError(404, 'order_not_found', 'Payment not found');
- if (payment.status !== 'succeeded') {
- throw new HttpError(409, 'not_refundable', `Cannot refund payment in status ${payment.status}`);
- }
+ const payment = await db.one(`SELECT * FROM payments WHERE order_id = $1`, [orderId]);
+ if (!payment) throw new HttpError(404, 'order_not_found', 'Payment not found');
+
+ // Idempotency: if already refunded, return duplicate success
+ if (payment.status === 'refunded') {
+ return {
+ orderId,
+ status: 'refunded',
+ refundId: payment.refund_ref || 'ALREADY_REFUNDED',
+ duplicate: true
+ };
+ }
+
+ if (!['succeeded', 'refund_pending'].includes(payment.status)) {
+ throw new HttpError(409, 'not_refundable', `Cannot refund payment in status ${payment.status}`);
+ }
+
+ // 1. Manual finance reconciliation option
+ if (manualReconciliation) {
+ return db.tx(async (c) => {
+ const p = (await c.query(`SELECT * FROM payments WHERE order_id = $1 FOR UPDATE`, [orderId])).rows[0];
+ if (!p) throw new HttpError(404, 'order_not_found', 'Payment not found');
+ if (p.status === 'refunded') {
+ return { orderId, status: 'refunded', refundId: p.refund_ref || 'ALREADY_REFUNDED', duplicate: true };
+ }
+
+ const refundId = providerRefundId || p.refund_ref || `MANUAL-${orderId}-${Date.now().toString(36)}`;
+ await c.query(
+ `UPDATE payments SET status = 'refunded', refunded_at = now(), refund_ref = $2, failure_reason = NULL WHERE id = $1`,
+ [p.id, refundId]
+ );
+ await c.query(
+ `INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
+ VALUES ($1, $2, $3, 'refunded', $4, $5)`,
+ [p.id, orderId, p.status, actor, JSON.stringify({ refundId, reason, manualReconciliation: true })]
+ );
+ if (p.booking_id) {
+ await c.query(`UPDATE bookings SET status = 'refunded', refunded_at = now() WHERE id = $1`, [p.booking_id]);
+ await c.query(`UPDATE invite_links SET revoked_at = COALESCE(revoked_at, now()) WHERE booking_id = $1 AND used_at IS NULL`, [p.booking_id]);
+ const joined = (await c.query(
+ `SELECT i.chat_id, u.telegram_user_id FROM invite_links i JOIN users u ON u.id = i.user_id
+ WHERE i.booking_id = $1 AND i.used_at IS NOT NULL AND i.revoked_at IS NULL LIMIT 1`,
+ [p.booking_id]
+ )).rows[0];
+ if (joined) {
+ await removeFromChat(joined.chat_id, joined.telegram_user_id).catch(() => {});
+ await c.query(`UPDATE invite_links SET revoked_at = now() WHERE booking_id = $1 AND revoked_at IS NULL`, [p.booking_id]);
+ }
+ await enqueue(c, {
+ kind: 'booking_refunded',
+ userId: p.user_id,
+ bookingId: p.booking_id,
+ dedupeKey: `bk:${p.booking_id}:refunded`
+ });
+ }
+ await audit(actor, 'payment.refunded_upi', p.id, { orderId, amount: p.amount, reason, manualReconciliation: true }, c);
+ return { orderId, status: 'refunded', refundId };
+ });
+ }
- // Call adapter
- const refundRes = await adapter.initiateRefund({
+ // 2. Gateway provider refund initiation (outside DB transaction)
+ let refundRes;
+ try {
+ refundRes = await adapter.initiateRefund({
orderId,
amountPaise: Number(payment.amount),
reason
});
+ } catch (err) {
+ logger.error('UPI provider refund call failed', { orderId, err: err.message });
+ await db.tx(async (c) => {
+ const p = (await c.query(`SELECT * FROM payments WHERE order_id = $1 FOR UPDATE`, [orderId])).rows[0];
+ if (p) {
+ await c.query(
+ `UPDATE payments SET
+ status = 'refund_pending',
+ failure_reason = 'refund_failed: ' || $2
+ WHERE id = $1`,
+ [p.id, err.message]
+ );
+ await c.query(
+ `INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
+ VALUES ($1, $2, $3, 'refund_pending', $4, $5)`,
+ [p.id, orderId, p.status, actor, JSON.stringify({ action: 'refund_initiation_failed', error: err.message, reason })]
+ );
+ }
+ });
+ throw new HttpError(502, 'refund_failed', `Payment gateway refund initiation failed: ${err.message}. Queued for retry.`);
+ }
+
+ // 3. Status boundary check: only mark 'refunded' if provider confirmed completion
+ return db.tx(async (c) => {
+ const p = (await c.query(`SELECT * FROM payments WHERE order_id = $1 FOR UPDATE`, [orderId])).rows[0];
+ if (!p) throw new HttpError(404, 'order_not_found', 'Payment not found');
+ if (p.status === 'refunded') {
+ return { orderId, status: 'refunded', refundId: p.refund_ref || refundRes.refundId, duplicate: true };
+ }
+
+ const isCompleted = refundRes.status === 'refunded';
+ const targetStatus = isCompleted ? 'refunded' : 'refund_pending';
+ const refundId = refundRes.refundId;
- // Update payment
await c.query(
- `UPDATE payments SET status = 'refunded', refunded_at = now(), refund_ref = $2 WHERE id = $1`,
- [payment.id, refundRes.refundId]
+ `UPDATE payments SET
+ status = $2::payment_status,
+ refunded_at = CASE WHEN $2::text = 'refunded' THEN now() ELSE refunded_at END,
+ refund_ref = $3,
+ failure_reason = NULL
+ WHERE id = $1`,
+ [p.id, targetStatus, refundId]
);
- // Record audit
await c.query(
`INSERT INTO payment_audits (payment_id, order_id, from_status, to_status, actor, details)
- VALUES ($1, $2, 'succeeded', 'refunded', $3, $4)`,
- [payment.id, orderId, actor, JSON.stringify({ refundId: refundRes.refundId, reason })]
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [p.id, orderId, p.status, targetStatus, actor, JSON.stringify({ refundId, reason, providerStatus: refundRes.status })]
);
- // Reversal effects on booking & invite links
- if (payment.booking_id) {
- await c.query(`UPDATE bookings SET status = 'refunded', refunded_at = now() WHERE id = $1`, [payment.booking_id]);
- await c.query(`UPDATE invite_links SET revoked_at = COALESCE(revoked_at, now()) WHERE booking_id = $1 AND used_at IS NULL`, [payment.booking_id]);
-
+ if (isCompleted && p.booking_id) {
+ await c.query(`UPDATE bookings SET status = 'refunded', refunded_at = now() WHERE id = $1`, [p.booking_id]);
+ await c.query(`UPDATE invite_links SET revoked_at = COALESCE(revoked_at, now()) WHERE booking_id = $1 AND used_at IS NULL`, [p.booking_id]);
const joined = (await c.query(
`SELECT i.chat_id, u.telegram_user_id FROM invite_links i JOIN users u ON u.id = i.user_id
WHERE i.booking_id = $1 AND i.used_at IS NOT NULL AND i.revoked_at IS NULL LIMIT 1`,
- [payment.booking_id]
+ [p.booking_id]
)).rows[0];
-
if (joined) {
await removeFromChat(joined.chat_id, joined.telegram_user_id).catch(() => {});
- await c.query(`UPDATE invite_links SET revoked_at = now() WHERE booking_id = $1 AND revoked_at IS NULL`, [payment.booking_id]);
+ await c.query(`UPDATE invite_links SET revoked_at = now() WHERE booking_id = $1 AND revoked_at IS NULL`, [p.booking_id]);
}
-
await enqueue(c, {
kind: 'booking_refunded',
- userId: payment.user_id,
- bookingId: payment.booking_id,
- dedupeKey: `bk:${payment.booking_id}:refunded`
+ userId: p.user_id,
+ bookingId: p.booking_id,
+ dedupeKey: `bk:${p.booking_id}:refunded`
});
}
- await audit(actor, 'payment.refunded_upi', payment.id, { orderId, amount: payment.amount, reason }, c);
+ await audit(actor, 'payment.refunded_upi', p.id, { orderId, amount: p.amount, reason, status: targetStatus, refundId }, c);
return {
orderId,
- status: 'refunded',
- refundId: refundRes.refundId
+ status: targetStatus,
+ refundId
};
});
}
diff --git a/lib/upi/adapter.js b/lib/upi/adapter.js
index fbfcaeb..a679bb3 100644
--- a/lib/upi/adapter.js
+++ b/lib/upi/adapter.js
@@ -6,16 +6,14 @@
import crypto from 'node:crypto';
import { safeEqual, HttpError } from '../http.js';
import { env } from '../config.js';
-import { buildUpiUri, generateQrSvg } from './qr.js';
+import { buildUpiUri, generateQrSvg, QrPayloadTooLargeError, MAX_QR_PAYLOAD_BYTES } from './qr.js';
export const UPI_INTENT_TTL_SECONDS = 900; // 15 minutes
-export class PaymentAdapterError extends Error {
+export class PaymentAdapterError extends HttpError {
constructor(message, code = 'ADAPTER_ERROR', status = 502) {
- super(message);
+ super(status, code.toLowerCase(), message);
this.name = 'PaymentAdapterError';
- this.code = code;
- this.status = status;
}
}
@@ -126,8 +124,25 @@ export class PhonePeAdapter {
note: eventTitle
});
+ const payloadBytes = Buffer.byteLength(upiUri, 'utf8');
+ if (payloadBytes > MAX_QR_PAYLOAD_BYTES) {
+ throw new PaymentAdapterError(
+ `Payment URI length (${payloadBytes} bytes) exceeds maximum supported QR capacity (${MAX_QR_PAYLOAD_BYTES} bytes)`,
+ 'PAYLOAD_TOO_LARGE',
+ 400
+ );
+ }
+
const appIntents = buildAppIntents(upiUri);
- const qrSvg = generateQrSvg(upiUri, { size: 280 });
+ let qrSvg;
+ try {
+ qrSvg = generateQrSvg(upiUri, { size: 280 });
+ } catch (err) {
+ if (err instanceof QrPayloadTooLargeError) {
+ throw new PaymentAdapterError(err.message, 'PAYLOAD_TOO_LARGE', 400);
+ }
+ throw err;
+ }
const expiresAt = new Date(Date.now() + UPI_INTENT_TTL_SECONDS * 1000).toISOString();
return {
@@ -233,6 +248,13 @@ export class PhonePeAdapter {
const code = decoded.code;
const state = data.state;
+ // REV-06: Strict Merchant ID Verification
+ if (!data.merchantId || typeof data.merchantId !== 'string' || data.merchantId !== this.merchantId) {
+ throw new WebhookSignatureError(
+ `PhonePe webhook merchantId mismatch or missing: expected '${this.merchantId}', received '${data.merchantId || ''}'`
+ );
+ }
+
let normalizedStatus = 'pending';
if (code === 'PAYMENT_SUCCESS' || state === 'COMPLETED') {
normalizedStatus = 'succeeded';
@@ -244,6 +266,7 @@ export class PhonePeAdapter {
return {
provider: 'phonepe',
+ merchantId: data.merchantId,
orderId: data.merchantTransactionId,
providerTxnId: data.transactionId,
providerPaymentId: data.paymentInstrument?.utr || null,
@@ -303,6 +326,7 @@ export class PhonePeAdapter {
export class MockUpiAdapter {
constructor() {
this.merchantVpa = 'nightlifez.test@ybl';
+ this.merchantId = 'PGTESTPAYUAT';
this.simulatedResults = new Map();
}
@@ -319,6 +343,25 @@ export class MockUpiAdapter {
note: eventTitle
});
+ const payloadBytes = Buffer.byteLength(upiUri, 'utf8');
+ if (payloadBytes > MAX_QR_PAYLOAD_BYTES) {
+ throw new PaymentAdapterError(
+ `Payment URI length (${payloadBytes} bytes) exceeds maximum supported QR capacity (${MAX_QR_PAYLOAD_BYTES} bytes)`,
+ 'PAYLOAD_TOO_LARGE',
+ 400
+ );
+ }
+
+ let qrSvg;
+ try {
+ qrSvg = generateQrSvg(upiUri, { size: 280 });
+ } catch (err) {
+ if (err instanceof QrPayloadTooLargeError) {
+ throw new PaymentAdapterError(err.message, 'PAYLOAD_TOO_LARGE', 400);
+ }
+ throw err;
+ }
+
return {
provider: 'upi_mock',
providerTxnId: `MOCK-${orderId}`,
@@ -330,7 +373,7 @@ export class MockUpiAdapter {
upiIntentUrl: upiUri,
appIntents: buildAppIntents(upiUri),
qrData: upiUri,
- qrSvg: generateQrSvg(upiUri, { size: 280 }),
+ qrSvg,
expiresInSeconds: UPI_INTENT_TTL_SECONDS,
expiresAt: new Date(Date.now() + UPI_INTENT_TTL_SECONDS * 1000).toISOString()
};
@@ -357,8 +400,14 @@ export class MockUpiAdapter {
}
const body = typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody;
+ const expectedMerchantId = this.merchantId || 'PGTESTPAYUAT';
+ if (body.merchantId && body.merchantId !== expectedMerchantId) {
+ throw new WebhookSignatureError(`Mock webhook merchantId mismatch: expected '${expectedMerchantId}', got '${body.merchantId}'`);
+ }
+
return {
provider: 'upi_mock',
+ merchantId: body.merchantId || expectedMerchantId,
orderId: body.orderId,
providerTxnId: body.providerTxnId || `MOCK-${body.orderId}`,
providerPaymentId: body.utr || `UTR${Date.now()}`,
diff --git a/lib/upi/qr.js b/lib/upi/qr.js
index 8b96d65..1ee4fb0 100644
--- a/lib/upi/qr.js
+++ b/lib/upi/qr.js
@@ -1,5 +1,21 @@
// lib/upi/qr.js - Zero-dependency, pure JavaScript QR Code (Model 2) SVG generator for UPI strings.
// Generates standard scannable QR codes for NPCI upi:// payment URIs.
+// Fully compliant with ISO/IEC 18004:2015 specification (Versions 1 through 14, EC Level M).
+
+import { HttpError } from '../http.js';
+
+export class QrPayloadTooLargeError extends HttpError {
+ constructor(byteLength, maxCapacity) {
+ super(
+ 400,
+ 'payload_too_large',
+ `Payment URI length (${byteLength} bytes) exceeds maximum supported QR capacity (${maxCapacity} bytes)`
+ );
+ this.name = 'QrPayloadTooLargeError';
+ this.byteLength = byteLength;
+ this.maxCapacity = maxCapacity;
+ }
+}
// Galois Field GF(256) math for Reed-Solomon error correction
const EXP = new Uint8Array(512);
@@ -24,8 +40,8 @@ function rsGenerator(degree) {
for (let i = 0; i < degree; i++) {
const next = new Array(g.length + 1).fill(0);
for (let j = 0; j < g.length; j++) {
- next[j] ^= gfMul(g[j], EXP[i]);
- next[j + 1] ^= g[j];
+ next[j] ^= g[j];
+ next[j + 1] ^= gfMul(g[j], EXP[i]);
}
g = next;
}
@@ -47,9 +63,9 @@ function rsEncode(data, ecCount) {
return res.slice(data.length);
}
-// Version table for Byte mode, Error Correction Level M
+// Version table for Byte mode, Error Correction Level M (ISO/IEC 18004 Table 9)
// Capacities: [version, totalCodewords, ecCodewordsPerBlock, size, dataCap, groups]
-const VERSIONS = [
+export const VERSIONS = [
null,
{ version: 1, total: 26, ec: 10, size: 21, dataCap: 14, groups: [{ blocks: 1, dataLen: 16 }] },
{ version: 2, total: 44, ec: 16, size: 25, dataCap: 26, groups: [{ blocks: 1, dataLen: 28 }] },
@@ -57,13 +73,20 @@ const VERSIONS = [
{ version: 4, total: 100, ec: 18, size: 33, dataCap: 62, groups: [{ blocks: 2, dataLen: 32 }] },
{ version: 5, total: 134, ec: 24, size: 37, dataCap: 84, groups: [{ blocks: 2, dataLen: 43 }] },
{ version: 6, total: 172, ec: 16, size: 41, dataCap: 106, groups: [{ blocks: 4, dataLen: 27 }] },
- { version: 7, total: 196, ec: 18, size: 45, dataCap: 122, groups: [{ blocks: 2, dataLen: 31 }, { blocks: 2, dataLen: 32 }] },
+ { version: 7, total: 196, ec: 18, size: 45, dataCap: 122, groups: [{ blocks: 4, dataLen: 31 }] },
{ version: 8, total: 242, ec: 22, size: 49, dataCap: 152, groups: [{ blocks: 2, dataLen: 38 }, { blocks: 2, dataLen: 39 }] },
{ version: 9, total: 292, ec: 22, size: 53, dataCap: 180, groups: [{ blocks: 3, dataLen: 36 }, { blocks: 2, dataLen: 37 }] },
- { version: 10, total: 346, ec: 26, size: 57, dataCap: 213, groups: [{ blocks: 4, dataLen: 43 }, { blocks: 1, dataLen: 44 }] }
+ { version: 10, total: 346, ec: 26, size: 57, dataCap: 213, groups: [{ blocks: 4, dataLen: 43 }, { blocks: 1, dataLen: 44 }] },
+ { version: 11, total: 404, ec: 30, size: 61, dataCap: 251, groups: [{ blocks: 1, dataLen: 50 }, { blocks: 4, dataLen: 51 }] },
+ { version: 12, total: 466, ec: 22, size: 65, dataCap: 287, groups: [{ blocks: 6, dataLen: 36 }, { blocks: 2, dataLen: 37 }] },
+ { version: 13, total: 532, ec: 22, size: 69, dataCap: 331, groups: [{ blocks: 8, dataLen: 37 }, { blocks: 1, dataLen: 38 }] },
+ { version: 14, total: 581, ec: 24, size: 73, dataCap: 362, groups: [{ blocks: 4, dataLen: 40 }, { blocks: 5, dataLen: 41 }] }
];
-const ALIGNMENT_PATTERN_POS = [
+export const MAX_SUPPORTED_VERSION = 14;
+export const MAX_QR_PAYLOAD_BYTES = VERSIONS[MAX_SUPPORTED_VERSION].dataCap;
+
+export const ALIGNMENT_PATTERN_POS = [
[],
[],
[6, 18],
@@ -74,7 +97,11 @@ const ALIGNMENT_PATTERN_POS = [
[6, 22, 38],
[6, 24, 42],
[6, 26, 46],
- [6, 28, 50]
+ [6, 28, 50],
+ [6, 30, 54],
+ [6, 32, 58],
+ [6, 34, 62],
+ [6, 26, 46, 66]
];
// Mask evaluation formulas
@@ -92,20 +119,46 @@ function getMaskBit(mask, row, col) {
}
}
+// ISO/IEC 18004 Format Information Generator (BCH 15, 5)
+const G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0);
+const G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1);
+
+export function getFormatInfoBits(ecBit, mask) {
+ const data = (ecBit << 3) | mask;
+ let d = data << 10;
+ for (let i = 14; i >= 10; i--) {
+ if ((d >> i) & 1) d ^= (G15 << (i - 10));
+ }
+ return ((data << 10) | d) ^ G15_MASK;
+}
+
+// ISO/IEC 18004 Version Information Generator (BCH 18, 6)
+const G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0);
+
+export function getVersionInfoBits(v) {
+ let d = v << 12;
+ for (let i = 17; i >= 12; i--) {
+ if ((d >> i) & 1) d ^= (G18 << (i - 12));
+ }
+ return (v << 12) | d;
+}
+
/**
* Encodes text into a QR code matrix.
- * Returns { size, modules, get(x, y) }
+ * Returns { version, size, modules, get(x, y), matrix }
*/
export function encodeQrMatrix(text) {
const bytes = Buffer.from(text, 'utf8');
let ver = null;
- for (let v = 1; v <= 10; v++) {
+ for (let v = 1; v < VERSIONS.length; v++) {
if (bytes.length <= VERSIONS[v].dataCap) {
ver = VERSIONS[v];
break;
}
}
- if (!ver) throw new Error(`Text too long for QR code (${bytes.length} bytes; max 213 supported)`);
+ if (!ver) {
+ throw new QrPayloadTooLargeError(bytes.length, MAX_QR_PAYLOAD_BYTES);
+ }
// Build bitstream: Mode 4 (Byte) + Length (8 or 16 bits) + data + terminator + pad
const bits = [];
@@ -174,7 +227,7 @@ export function encodeQrMatrix(text) {
}
const size = ver.size;
- const matrix = Array.from({ length: size }, () => new Int8Array(size).fill(-1));
+ const matrix = Array.from({ length: size }, () => new Int8Array(size).fill(0));
const reserved = Array.from({ length: size }, () => new Uint8Array(size));
function setFinder(r, c) {
@@ -196,46 +249,75 @@ export function encodeQrMatrix(text) {
setFinder(0, size - 7);
setFinder(size - 7, 0);
- // Timing patterns
+ // REV-01: Correct alignment-pattern placement for all supported versions.
+ // Patterns are placed at all grid coordinates EXCEPT where they overlap the three finder patterns.
+ // Placed BEFORE timing patterns so timing pattern reservation does NOT skip valid alignment modules.
+ const alignPos = ALIGNMENT_PATTERN_POS[ver.version] || [];
+ const alignLen = alignPos.length;
+ for (let i = 0; i < alignLen; i++) {
+ for (let j = 0; j < alignLen; j++) {
+ // Exclude top-left (0, 0), bottom-left (alignLen - 1, 0), and top-right (0, alignLen - 1)
+ if ((i === 0 && j === 0) || (i === 0 && j === alignLen - 1) || (i === alignLen - 1 && j === 0)) {
+ continue;
+ }
+ const r = alignPos[i];
+ const c = alignPos[j];
+ for (let dr = -2; dr <= 2; dr++) {
+ for (let dc = -2; dc <= 2; dc++) {
+ const on = dr === -2 || dr === 2 || dc === -2 || dc === 2 || (dr === 0 && dc === 0);
+ matrix[r + dr][c + dc] = on ? 1 : 0;
+ reserved[r + dr][c + dc] = 1;
+ }
+ }
+ }
+ }
+
+ // Timing patterns (connect finders, filling non-reserved modules)
for (let i = 8; i < size - 8; i++) {
if (!reserved[6][i]) { matrix[6][i] = i % 2 === 0 ? 1 : 0; reserved[6][i] = 1; }
if (!reserved[i][6]) { matrix[i][6] = i % 2 === 0 ? 1 : 0; reserved[i][6] = 1; }
}
- // Alignment patterns
- const alignPos = ALIGNMENT_PATTERN_POS[ver.version] || [];
- for (const r of alignPos) {
- for (const c of alignPos) {
- if (reserved[r][c]) continue;
- for (let i = -2; i <= 2; i++) {
- for (let j = -2; j <= 2; j++) {
- const on = Math.max(Math.abs(i), Math.abs(j)) !== 1;
- matrix[r + i][c + j] = on ? 1 : 0;
- reserved[r + i][c + j] = 1;
- }
- }
+ // Version information for QR versions 7 and higher (ISO/IEC 18004 Section 8.10)
+ if (ver.version >= 7) {
+ const vbits = getVersionInfoBits(ver.version);
+ for (let i = 0; i < 18; i++) {
+ const bit = (vbits >> i) & 1;
+ const r = Math.floor(i / 3);
+ const c = (i % 3) + size - 8 - 3;
+ // Top-right 3x6 block
+ matrix[r][c] = bit;
+ reserved[r][c] = 1;
+ // Bottom-left 6x3 block
+ matrix[c][r] = bit;
+ reserved[c][r] = 1;
}
}
- // Reserve format info area
- for (let i = 0; i < 9; i++) {
- reserved[8][i] = 1;
- reserved[i][8] = 1;
- reserved[8][size - 1 - i] = 1;
- reserved[size - 1 - i][8] = 1;
+ // Reserve format info area (around finder patterns and dark module)
+ for (let i = 0; i < 15; i++) {
+ // Vertical positions
+ if (i < 6) reserved[i][8] = 1;
+ else if (i < 8) reserved[i + 1][8] = 1;
+ else reserved[size - 15 + i][8] = 1;
+
+ // Horizontal positions
+ if (i < 8) reserved[8][size - i - 1] = 1;
+ else if (i < 9) reserved[8][15 - i - 1 + 1] = 1;
+ else reserved[8][15 - i - 1] = 1;
}
- matrix[size - 8][8] = 1; // Dark module
+ matrix[size - 8][8] = 1; // Mandatory Dark Module (ISO 18004 8.7.2.2)
reserved[size - 8][8] = 1;
- // Mask 0 (simple standard checkerboard)
+ // Mask 0 (standard checkerboard mask)
const chosenMask = 0;
- // Place data bits
+ // Place data bits in 2-column zig-zag traversal
let bitIdx = 0;
let dir = -1;
let y = size - 1;
for (let x = size - 1; x > 0; x -= 2) {
- if (x === 6) x--;
+ if (x === 6) x--; // Skip vertical timing column
while (true) {
for (let col = 0; col < 2; col++) {
const curX = x - col;
@@ -254,23 +336,22 @@ export function encodeQrMatrix(text) {
}
}
- // Format info for EC Level M (00) and Mask 0 (000) = 00000 -> 0x5412 XOR masked = 0x5412
- // Precomputed format info for (M, 0): 0b101010000010010 ^ 0b101010000010010 = 0...
- // EC Level M = 00, Mask 0 = 000. Data = 00000. BCH = 0000000000. XOR mask = 101010000010010 (0x5412)
- const formatInfo = 0x5412;
+ // Format info placement for EC Level M (bit 0) and Mask 0
+ const formatInfo = getFormatInfoBits(0, chosenMask);
for (let i = 0; i < 15; i++) {
- const bit = (formatInfo >> (14 - i)) & 1;
- // Region 1: Top-Left around finder pattern
- if (i < 6) matrix[8][i] = bit;
- else if (i < 8) matrix[8][i + 1] = bit;
- else if (i === 8) matrix[7][8] = bit;
- else matrix[14 - i][8] = bit;
-
- // Region 2: Bottom-Left (bits 0..6) and Top-Right (bits 7..14)
- // Position (size - 8, 8) is the mandatory Dark Module and must never be overwritten
- if (i < 7) matrix[size - 1 - i][8] = bit;
- else matrix[8][size - 15 + i] = bit;
+ const mod = (formatInfo >> i) & 1;
+
+ // Vertical format info modules
+ if (i < 6) matrix[i][8] = mod;
+ else if (i < 8) matrix[i + 1][8] = mod;
+ else matrix[size - 15 + i][8] = mod;
+
+ // Horizontal format info modules
+ if (i < 8) matrix[8][size - i - 1] = mod;
+ else if (i < 9) matrix[8][15 - i - 1 + 1] = mod;
+ else matrix[8][15 - i - 1] = mod;
}
+ matrix[size - 8][8] = 1; // Ensure Dark Module is preserved
return {
version: ver.version,
@@ -307,16 +388,20 @@ export function generateQrSvg(upiUri, { size = 280, margin = 4, darkColor = '#00
/**
* Generates a standard NPCI UPI URI string.
+ * Never truncates payment details (preserves full note and order parameters).
*/
export function buildUpiUri({ vpa, name, amountPaise, orderId, note = 'NIGHTLIFEZ Event Ticket' }) {
- const amountRupees = (amountPaise / 100).toFixed(2);
+ if (!vpa || !orderId || amountPaise === undefined || amountPaise === null) {
+ throw new Error('Missing required UPI parameters: vpa, orderId, and amountPaise are mandatory');
+ }
+ const amountRupees = (Number(amountPaise) / 100).toFixed(2);
const params = new URLSearchParams({
pa: vpa,
pn: name,
am: amountRupees,
cu: 'INR',
tr: orderId,
- tn: note.slice(0, 50)
+ tn: String(note)
});
return `upi://pay?${params.toString()}`;
}
diff --git a/migrations/017_payments_tier_holds_index.sql b/migrations/017_payments_tier_holds_index.sql
new file mode 100644
index 0000000..0fe0d57
--- /dev/null
+++ b/migrations/017_payments_tier_holds_index.sql
@@ -0,0 +1,6 @@
+-- 017_payments_tier_holds_index.sql — Add index for ticket-tier active payment holds.
+-- Matches query expression in activeTierPaymentHolds:
+-- ((raw_ref->>'ticketTypeId')::uuid), status, expires_at
+
+CREATE INDEX IF NOT EXISTS idx_payments_active_tier_holds
+ ON payments (((raw_ref->>'ticketTypeId')::uuid), status, expires_at);
diff --git a/package-lock.json b/package-lock.json
index 4e83b22..405734d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,9 @@
"zod": "^3.25.76"
},
"devDependencies": {
- "@axe-core/playwright": "^4.14.0"
+ "@axe-core/playwright": "^4.14.0",
+ "jsqr": "^1.4.0",
+ "qrcode": "^1.5.4"
},
"engines": {
"node": "24.x"
@@ -204,6 +206,32 @@
}
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
@@ -301,6 +329,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
@@ -389,6 +459,16 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -398,6 +478,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -418,6 +505,13 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
@@ -654,6 +748,20 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -681,6 +789,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -824,6 +942,16 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -869,6 +997,26 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
+ "node_modules/jsqr": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
+ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1009,6 +1157,45 @@
"wrappy": "1"
}
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1018,6 +1205,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
@@ -1179,6 +1376,16 @@
"node": ">=20"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1235,6 +1442,24 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/qs": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
@@ -1279,6 +1504,16 @@
"node": ">= 0.10"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1288,6 +1523,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -1355,6 +1597,13 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1472,6 +1721,34 @@
"node": ">= 0.8"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strnum": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
@@ -1569,6 +1846,28 @@
"node": ">= 8"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1599,6 +1898,50 @@
"node": ">=0.4"
}
},
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
diff --git a/package.json b/package.json
index 73be004..2bd916c 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,8 @@
"zod": "^3.25.76"
},
"devDependencies": {
- "@axe-core/playwright": "^4.14.0"
+ "@axe-core/playwright": "^4.14.0",
+ "jsqr": "^1.4.0",
+ "qrcode": "^1.5.4"
}
}
diff --git a/test/migrations.test.js b/test/migrations.test.js
index 11114d9..e30a092 100644
--- a/test/migrations.test.js
+++ b/test/migrations.test.js
@@ -20,13 +20,13 @@ test('migration files inventory: sequential numbering, correct naming, and sorte
.filter((f) => /^\d+_.*\.sql$/.test(f))
.sort();
- // Exactly 14 migrations currently exist: 003 through 016
- assert.equal(files.length, 14, `expected 14 migration files, got ${files.length}: ${files.join(', ')}`);
+ // Exactly 15 migrations currently exist: 003 through 017
+ assert.equal(files.length, 15, `expected 15 migration files, got ${files.length}: ${files.join(', ')}`);
// Verify sequential ordering
const expectedPrefixes = [
'003_', '004_', '005_', '006_', '007_', '008_',
- '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_'
+ '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_'
];
for (let i = 0; i < expectedPrefixes.length; i++) {
assert.ok(
@@ -45,9 +45,9 @@ test('migration idempotency: re-running migrate() on an up-to-date database appl
assert.deepEqual(thirdRun, [], 'expected zero migrations applied on third run');
});
-test('schema_migrations table records all 14 migrations in correct order', async () => {
+test('schema_migrations table records all 15 migrations in correct order', async () => {
const { rows } = await db.query('SELECT name, applied_at FROM schema_migrations ORDER BY name ASC');
- assert.equal(rows.length, 14);
+ assert.equal(rows.length, 15);
const appliedNames = rows.map((r) => r.name);
const filesOnDisk = fs.readdirSync(MIGRATIONS_DIR)
@@ -74,9 +74,9 @@ test('advisory lock serializes concurrent migrate() calls', async () => {
assert.deepEqual(res2, []);
assert.deepEqual(res3, []);
- // Total count in schema_migrations must remain exactly 14
+ // Total count in schema_migrations must remain exactly 15
const countRes = await db.one('SELECT count(*)::int AS n FROM schema_migrations');
- assert.equal(countRes.n, 14);
+ assert.equal(countRes.n, 15);
});
test('migration policy respects environment boundaries', () => {
@@ -181,3 +181,38 @@ test('payment_audits is immutable append-only ledger (DB-01)', async () => {
/payment_audits is an append-only ledger/
);
});
+
+test('REV-07: ticket-tier active hold index (idx_payments_active_tier_holds) and query plan improvement', async () => {
+ // 1. Verify index exists in pg_indexes
+ const idx = await db.one(
+ `SELECT indexname, indexdef FROM pg_indexes
+ WHERE tablename = 'payments' AND indexname = 'idx_payments_active_tier_holds'`
+ );
+ assert.ok(idx, 'expected idx_payments_active_tier_holds to exist');
+ assert.ok(
+ idx.indexdef.includes("raw_ref ->> 'ticketTypeId'"),
+ `expected index definition to reference ticketTypeId expression, got: ${idx.indexdef}`
+ );
+
+ // 2. Insert dummy rows and verify EXPLAIN query plan uses index scan
+ const sampleTierId = 'a1111111-1111-4111-8111-111111111111';
+ await db.query(`SET enable_seqscan = OFF;`);
+ try {
+ const explain = await db.query(
+ `EXPLAIN (FORMAT JSON)
+ SELECT COALESCE(sum((p.raw_ref->>'qty')::int), 0)::int AS n
+ FROM payments p
+ WHERE (p.raw_ref->>'ticketTypeId')::uuid = $1::uuid
+ AND p.status IN ('created', 'pending', 'processing')
+ AND p.expires_at > now()`,
+ [sampleTierId]
+ );
+ const planStr = JSON.stringify(explain.rows[0]);
+ assert.ok(
+ planStr.includes('idx_payments_active_tier_holds'),
+ `expected EXPLAIN query plan to use idx_payments_active_tier_holds, got: ${planStr}`
+ );
+ } finally {
+ await db.query(`SET enable_seqscan = ON;`);
+ }
+});
diff --git a/test/upi-checkout.test.js b/test/upi-checkout.test.js
index e313d2e..357f33c 100644
--- a/test/upi-checkout.test.js
+++ b/test/upi-checkout.test.js
@@ -5,13 +5,18 @@
import { test, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
+import jsQR from 'jsqr';
import {
resetDb, startServer, stopServer, Client, db, callsTo, clearCalls, createEvent, signedInClient, stubUrl
} from './helpers.js';
import {
encodeQrMatrix,
generateQrSvg,
- buildUpiUri
+ buildUpiUri,
+ VERSIONS,
+ MAX_QR_PAYLOAD_BYTES,
+ ALIGNMENT_PATTERN_POS,
+ QrPayloadTooLargeError
} from '../lib/upi/qr.js';
import {
PhonePeAdapter,
@@ -199,6 +204,7 @@ test('webhook security: PhonePe adapter verifies valid signature and rejects tam
response: Buffer.from(JSON.stringify({
code: 'PAYMENT_SUCCESS',
data: {
+ merchantId: 'PGTESTPAYUAT',
merchantTransactionId: 'ORD-TEST-SIG-1',
transactionId: 'TXN123',
amount: 49900,
@@ -352,6 +358,8 @@ test('refunds: refund transitions payment to refunded and revokes Telegram invit
// Confirm payment
const succ = await applySuccessfulUpiPayment({
orderId: intent.orderId,
+ verifiedAmountPaise: 40000,
+ merchantId: 'PGTESTPAYUAT',
providerPaymentId: 'UTR-REFUND-1',
actor: 'test'
});
@@ -397,7 +405,12 @@ test('concurrency: simultaneous checkout intents respect capacity limits', async
assert.ok(intent1.orderId);
// Confirm guest 1's booking
- await applySuccessfulUpiPayment({ orderId: intent1.orderId, actor: 'test' });
+ await applySuccessfulUpiPayment({
+ orderId: intent1.orderId,
+ verifiedAmountPaise: 20000,
+ merchantId: 'PGTESTPAYUAT',
+ actor: 'test'
+ });
// Guest 2 attempts to create intent for sold out tier
await assert.rejects(
@@ -737,6 +750,8 @@ test('CONCUR-01: Active unexpired checkout intent holds capacity & re-checks at
// Confirming User 2's payment detects capacity exceeded
const confirmResult = await applySuccessfulUpiPayment({
orderId: intent2.orderId,
+ verifiedAmountPaise: 20000,
+ merchantId: 'PGTESTPAYUAT',
providerTxnId: 'TXN-OVERSOLD-1',
actor: 'test:concurrency'
});
@@ -862,3 +877,519 @@ test('DB-01: payment_audits table is strictly append-only and immutable', async
/append-only ledger/
);
});
+
+// ============================================================================
+// Milestone M4.3.1: Remediate Independent Review Findings (REV-01 - REV-07)
+// ============================================================================
+
+function decodeQrMatrix(qr) {
+ const scale = 4;
+ const margin = 4;
+ const width = (qr.size + margin * 2) * scale;
+ const data = new Uint8ClampedArray(width * width * 4);
+ data.fill(255);
+ for (let r = 0; r < qr.size; r++) {
+ for (let c = 0; c < qr.size; c++) {
+ if (qr.get(r, c)) {
+ for (let y = 0; y < scale; y++) {
+ for (let x = 0; x < scale; x++) {
+ const idx = (((r + margin) * scale + y) * width + ((c + margin) * scale + x)) * 4;
+ data[idx] = 0;
+ data[idx + 1] = 0;
+ data[idx + 2] = 0;
+ data[idx + 3] = 255;
+ }
+ }
+ }
+ }
+ }
+ return jsQR(data, width, width);
+}
+
+test('REV-01: QR alignment patterns across supported versions (1-14) and independent jsQR decoding', () => {
+ // 1. Verify all versions from 1 to 14 independently decode with jsQR
+ for (let v = 1; v <= 14; v++) {
+ const minCap = v === 1 ? 8 : (VERSIONS[v - 1].dataCap + 1);
+ const prefix = `V${v}:`;
+ const payload = prefix + 'K'.repeat(minCap - prefix.length);
+ const qr = encodeQrMatrix(payload);
+ assert.equal(qr.version, v, `Expected Version ${v}`);
+
+ // Verify dark module at (size - 8, 8)
+ assert.equal(qr.get(qr.size - 8, 8), true, `Dark module must be set in Version ${v}`);
+
+ // Verify independent decoding via jsQR
+ const decoded = decodeQrMatrix(qr);
+ assert.ok(decoded, `jsQR failed to decode Version ${v}`);
+ assert.equal(decoded.data, payload, `Decoded payload mismatch for Version ${v}`);
+
+ // Verify alignment patterns do not overwrite finder patterns
+ const alignCoords = ALIGNMENT_PATTERN_POS[v] || [];
+ const len = alignCoords.length;
+ for (let i = 0; i < len; i++) {
+ for (let j = 0; j < len; j++) {
+ // Finder corners must not have alignment pattern centers
+ if ((i === 0 && j === 0) || (i === 0 && j === len - 1) || (i === len - 1 && j === 0)) {
+ continue;
+ }
+ const ar = alignCoords[i];
+ const ac = alignCoords[j];
+ // Center module must be dark
+ assert.equal(qr.get(ar, ac), true, `Alignment pattern center at (${ar}, ${ac}) must be dark in Version ${v}`);
+ }
+ }
+ }
+
+ // 2. Test long UPI URI (243 bytes, selects Version 11) with metadata
+ const longUri = buildUpiUri({
+ vpa: 'nightlifez.business@okhdfcbank',
+ name: 'NIGHTLIFEZ Experiences India Private Limited',
+ amountPaise: 1249900,
+ orderId: 'ORD-UPI-20261103-VERY-LONG-TRANSACTION-ID-XYZ9876543210',
+ note: 'Nightlifez Bangalore VIP Access Pass Tier 1 With Complimentary Drink'
+ });
+ assert.ok(Buffer.byteLength(longUri, 'utf8') > 213, 'Expected payload length to exceed Version 10 capacity');
+ const longQr = encodeQrMatrix(longUri);
+ assert.ok(longQr.version >= 11, `Expected version >= 11, got ${longQr.version}`);
+ const decodedLong = decodeQrMatrix(longQr);
+ assert.equal(decodedLong?.data, longUri, 'Long UPI URI decoded accurately via jsQR');
+});
+
+test('REV-02: Duplicate active booking prevented during intent creation (409 already_booked)', async () => {
+ const { event, tier } = await createPaidEvent({ price: 30000 });
+
+ // 1. First booking confirmed for guest1
+ await db.query(
+ `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, status, amount_paise, payment_mode, source)
+ VALUES ($1, $2, $3, 1, 'confirmed', 30000, 'provider', 'site')`,
+ [event.id, guest1.userId, tier.id]
+ );
+
+ // 2. Attempting to create a second UPI intent for the same event must be rejected
+ await assert.rejects(
+ async () => {
+ await createUpiPaymentIntent({
+ slug: event.slug,
+ user: guest1,
+ ticketTypeId: tier.id,
+ quantity: 1
+ });
+ },
+ (err) => {
+ assert.equal(err.status, 409);
+ assert.equal(err.code, 'already_booked');
+ return true;
+ }
+ );
+});
+
+test('REV-02: Duplicate booking during webhook confirmation moves payment to refund_pending with 0 bookings', async () => {
+ const { event, tier } = await createPaidEvent({ price: 25000 });
+
+ // 1. User creates a pending intent
+ const intent = await createUpiPaymentIntent({
+ slug: event.slug,
+ user: guest2,
+ ticketTypeId: tier.id,
+ quantity: 1
+ });
+
+ // 2. User gets an active booking confirmed out of band (or concurrent request)
+ await db.query(
+ `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, status, amount_paise, payment_mode, source)
+ VALUES ($1, $2, $3, 1, 'confirmed', 25000, 'free', 'site')`,
+ [event.id, guest2.userId, tier.id]
+ );
+
+ // 3. Webhook arrives for the payment intent
+ const adapter = new PhonePeAdapter();
+ const innerPayload = {
+ code: 'PAYMENT_SUCCESS',
+ data: {
+ merchantId: 'PGTESTPAYUAT',
+ merchantTransactionId: intent.orderId,
+ transactionId: `TXN-DUP-${intent.orderId}`,
+ amount: 25000,
+ state: 'COMPLETED'
+ }
+ };
+ const base64 = Buffer.from(JSON.stringify(innerPayload)).toString('base64');
+ const rawBody = JSON.stringify({ response: base64 });
+ const sig = crypto.createHash('sha256').update(`${base64}099eb0cd-0252-4e2a-8aca-eb3c6bd470f1`).digest('hex') + '###1';
+
+ const hookRes = await processUpiWebhook({
+ headers: { 'x-verify': sig },
+ rawBody,
+ clientIp: '127.0.0.1'
+ });
+
+ assert.equal(hookRes.ok, true);
+ assert.equal(hookRes.result, 'duplicate_booking');
+
+ // Verify DB state: payment transitioned to refund_pending
+ const pay = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [intent.orderId]);
+ assert.equal(pay.status, 'refund_pending');
+ assert.equal(pay.failure_reason, 'duplicate_booking');
+
+ // Exactly 1 booking exists (the out-of-band one, zero duplicates created)
+ const bks = await db.query('SELECT count(*)::int AS n FROM bookings WHERE event_id = $1 AND user_id = $2', [event.id, guest2.userId]);
+ assert.equal(bks.rows[0].n, 1);
+
+ // Audit entry records duplicate_booking reason
+ const audit = await db.one(
+ `SELECT * FROM payment_audits WHERE order_id = $1 AND to_status = 'refund_pending'`,
+ [intent.orderId]
+ );
+ assert.ok(audit);
+ assert.equal(audit.details.reason, 'duplicate_booking');
+});
+
+test('REV-02: Duplicate active booking during guest claim is rejected with 409 already_booked', async () => {
+ const { event, tier } = await createPaidEvent({ price: 15000 });
+
+ // 1. Guest creates intent (no user_id)
+ const intent = await createUpiPaymentIntent({
+ slug: event.slug,
+ user: null,
+ ticketTypeId: tier.id,
+ quantity: 1
+ });
+
+ // 2. Webhook confirms guest payment
+ await applySuccessfulUpiPayment({
+ orderId: intent.orderId,
+ verifiedAmountPaise: 15000,
+ merchantId: 'PGTESTPAYUAT',
+ actor: 'test'
+ });
+
+ // 3. User already has a confirmed booking for this event
+ await db.query(
+ `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, status, amount_paise, payment_mode, source)
+ VALUES ($1, $2, $3, 1, 'confirmed', 15000, 'free', 'site')`,
+ [event.id, guest3.userId, tier.id]
+ );
+
+ // 4. User attempts to claim guest booking -> rejected with 409 already_booked
+ await assert.rejects(
+ async () => {
+ await claimUpiBooking({
+ orderId: intent.orderId,
+ guestToken: intent.guestToken,
+ user: guest3
+ });
+ },
+ (err) => {
+ assert.equal(err.status, 409);
+ assert.equal(err.code, 'already_booked');
+ return true;
+ }
+ );
+});
+
+test('REV-02: SQLSTATE 23505 defensive recovery moves payment to refund_pending without 500 error', async () => {
+ const { event, tier } = await createPaidEvent({ price: 20000 });
+
+ // Create an intent
+ const intent = await createUpiPaymentIntent({
+ slug: event.slug,
+ user: guest1,
+ ticketTypeId: tier.id,
+ quantity: 1
+ });
+
+ // Insert a conflicting booking with same (event_id, user_id) right before confirmation
+ await db.query(
+ `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, status, amount_paise, payment_mode, source)
+ VALUES ($1, $2, $3, 1, 'pending_payment', 20000, 'provider', 'site')`,
+ [event.id, guest1.userId, tier.id]
+ );
+
+ // Confirmation must cleanly handle the duplicate without throwing 500
+ const res = await applySuccessfulUpiPayment({
+ orderId: intent.orderId,
+ verifiedAmountPaise: 20000,
+ merchantId: 'PGTESTPAYUAT',
+ actor: 'test'
+ });
+
+ assert.equal(res.status, 'refund_pending');
+ assert.equal(res.duplicateBooking, true);
+
+ const pay = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [intent.orderId]);
+ assert.equal(pay.status, 'refund_pending');
+ assert.equal(pay.failure_reason, 'duplicate_booking');
+});
+
+test('REV-03: refund_pending operational recovery via admin/finance refund and idempotent retry', async () => {
+ const { event, tier } = await createPaidEvent({ price: 35000 });
+
+ // 1. Create intent and trigger overbooking to land in refund_pending
+ const intent = await createUpiPaymentIntent({
+ slug: event.slug,
+ user: guest2,
+ ticketTypeId: tier.id,
+ quantity: 1
+ });
+
+ // Consume event capacity
+ await db.query(`UPDATE events SET capacity = 0 WHERE id = $1`, [event.id]);
+
+ const confirmRes = await applySuccessfulUpiPayment({
+ orderId: intent.orderId,
+ verifiedAmountPaise: 35000,
+ merchantId: 'PGTESTPAYUAT',
+ actor: 'test'
+ });
+ assert.equal(confirmRes.status, 'refund_pending');
+
+ // 2. Admin/finance triggers refund for the refund_pending payment
+ const refundRes = await refundUpiOrder(intent.orderId, {
+ actor: 'admin:finance:900000001',
+ reason: 'Event sold out before payment confirmation'
+ });
+
+ assert.equal(refundRes.status, 'refunded');
+ assert.ok(refundRes.refundId);
+
+ // 3. Verify DB state: payment transitioned from refund_pending to refunded
+ const pay = await db.one('SELECT status, refund_ref FROM payments WHERE order_id = $1', [intent.orderId]);
+ assert.equal(pay.status, 'refunded');
+ assert.equal(pay.refund_ref, refundRes.refundId);
+
+ // 4. Audit ledger preserves full trajectory: created -> pending -> refund_pending -> refunded
+ const audits = (await db.query(
+ `SELECT from_status, to_status, actor FROM payment_audits WHERE order_id = $1 ORDER BY id ASC`,
+ [intent.orderId]
+ )).rows;
+
+ const trajectory = audits.map((a) => `${a.from_status}->${a.to_status}`);
+ assert.ok(trajectory.includes('created->pending'), 'Expected created->pending');
+ assert.ok(trajectory.includes('pending->refund_pending'), 'Expected pending->refund_pending');
+ assert.ok(trajectory.includes('refund_pending->refunded'), 'Expected refund_pending->refunded');
+
+ // 5. Retry of refund is idempotent
+ const retryRes = await refundUpiOrder(intent.orderId, {
+ actor: 'admin:finance:900000001'
+ });
+ assert.equal(retryRes.status, 'refunded');
+ assert.equal(retryRes.duplicate, true);
+});
+
+test('REV-03: Provider refund failure leaves payment in refund_pending with manual finance reconciliation recovery', async () => {
+ const { event, tier } = await createPaidEvent({ price: 18000 });
+
+ const intent = await createUpiPaymentIntent({
+ slug: event.slug,
+ user: guest1,
+ ticketTypeId: tier.id,
+ quantity: 1
+ });
+
+ await applySuccessfulUpiPayment({
+ orderId: intent.orderId,
+ verifiedAmountPaise: 18000,
+ merchantId: 'PGTESTPAYUAT',
+ actor: 'test'
+ });
+
+ // Temporarily stub refund endpoint to simulate provider 500 failure
+ stubUrl('https://api-preprod.phonepe.com', (url) => {
+ if (url.includes('/pg/v1/refund')) {
+ return {
+ status: 500,
+ body: JSON.stringify({ success: false, code: 'INTERNAL_SERVER_ERROR', message: 'Gateway error' })
+ };
+ }
+ return { status: 200, body: JSON.stringify({ success: true, code: 'PAYMENT_SUCCESS' }) };
+ });
+
+ // Provider refund fails
+ await assert.rejects(
+ () => refundUpiOrder(intent.orderId, { actor: 'admin:finance' }),
+ /Payment gateway refund initiation failed/
+ );
+
+ // Payment remains safely in refund_pending with failure reason
+ const failedPay = await db.one('SELECT status, failure_reason FROM payments WHERE order_id = $1', [intent.orderId]);
+ assert.equal(failedPay.status, 'refund_pending');
+ assert.ok(failedPay.failure_reason.includes('refund_failed'));
+
+ // Finance officer performs manual reconciliation recovery
+ const reconRes = await refundUpiOrder(intent.orderId, {
+ actor: 'admin:finance:reconciler',
+ reason: 'Manual bank transfer reversal confirmed',
+ manualReconciliation: true,
+ providerRefundId: 'MANUAL-BANK-REF-999'
+ });
+
+ assert.equal(reconRes.status, 'refunded');
+ assert.equal(reconRes.refundId, 'MANUAL-BANK-REF-999');
+
+ const finalPay = await db.one('SELECT status, refund_ref FROM payments WHERE order_id = $1', [intent.orderId]);
+ assert.equal(finalPay.status, 'refunded');
+ assert.equal(finalPay.refund_ref, 'MANUAL-BANK-REF-999');
+
+ // Restore refund 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' }) };
+ });
+});
+
+test('REV-04: Mandatory verified amount rejects omitted, null, non-numeric and underpayment attacks', async () => {
+ const { event, tier } = await createPaidEvent({ price: 40000 });
+
+ // 1. Omitted amount
+ const intent1 = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id });
+ const res1 = await applySuccessfulUpiPayment({ orderId: intent1.orderId, verifiedAmountPaise: null });
+ assert.equal(res1.status, 'disputed');
+ assert.equal(res1.disputeReason, 'missing_or_invalid_verified_amount');
+
+ // 2. Non-numeric / NaN amount
+ const intent2 = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id });
+ const res2 = await applySuccessfulUpiPayment({ orderId: intent2.orderId, verifiedAmountPaise: NaN });
+ assert.equal(res2.status, 'disputed');
+ assert.equal(res2.disputeReason, 'missing_or_invalid_verified_amount');
+
+ // 3. String non-numeric amount
+ const intent3 = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id });
+ const res3 = await applySuccessfulUpiPayment({ orderId: intent3.orderId, verifiedAmountPaise: 'one_hundred' });
+ assert.equal(res3.status, 'disputed');
+ assert.equal(res3.disputeReason, 'missing_or_invalid_verified_amount');
+
+ // 4. Negative amount
+ const intent4 = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id });
+ const res4 = await applySuccessfulUpiPayment({ orderId: intent4.orderId, verifiedAmountPaise: -40000 });
+ assert.equal(res4.status, 'disputed');
+ assert.equal(res4.disputeReason, 'missing_or_invalid_verified_amount');
+
+ // 5. Underpayment
+ const intent5 = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id });
+ const res5 = await applySuccessfulUpiPayment({ orderId: intent5.orderId, verifiedAmountPaise: 39999 });
+ assert.equal(res5.status, 'disputed');
+ assert.equal(res5.disputeReason, 'amount_mismatch');
+
+ // 6. Currency mismatch
+ const intent6 = await createUpiPaymentIntent({ slug: event.slug, user: guest1, ticketTypeId: tier.id });
+ const res6 = await applySuccessfulUpiPayment({ orderId: intent6.orderId, verifiedAmountPaise: 40000, currency: 'USD' });
+ assert.equal(res6.status, 'disputed');
+ assert.equal(res6.disputeReason, 'currency_mismatch');
+
+ // Verify zero bookings were created for all 6 disputed attempts
+ for (const it of [intent1, intent2, intent3, intent4, intent5, intent6]) {
+ const pay = await db.one('SELECT status FROM payments WHERE order_id = $1', [it.orderId]);
+ assert.equal(pay.status, 'disputed');
+ const bk = await db.query('SELECT count(*)::int AS n FROM bookings WHERE payment_id = (SELECT id FROM payments WHERE order_id = $1)', [it.orderId]);
+ assert.equal(bk.rows[0].n, 0);
+ }
+});
+
+test('REV-05: Overlong QR payload byte limit returns 400 error rather than unhandled 500', async () => {
+ const { event, tier } = await createPaidEvent({ price: 10000 });
+
+ // 1. Raw QR encoder rejects payloads exceeding byte capacity
+ const giantText = 'upi://pay?' + 'x'.repeat(400);
+ assert.throws(
+ () => encodeQrMatrix(giantText),
+ (err) => {
+ assert.ok(err instanceof QrPayloadTooLargeError);
+ assert.equal(err.status, 400);
+ return true;
+ }
+ );
+
+ // 2. Multi-byte UTF-8 calculation verifies bytes not character length
+ // 3-byte unicode character '🎉' repeated 130 times = 520 bytes > 362 bytes max
+ const multiBytePayload = 'upi://pay?tn=' + '🎉'.repeat(130);
+ assert.ok(multiBytePayload.length < 362, 'JS character length is under 362');
+ assert.ok(Buffer.byteLength(multiBytePayload, 'utf8') > 362, 'Byte length exceeds 362');
+ assert.throws(
+ () => encodeQrMatrix(multiBytePayload),
+ (err) => {
+ assert.equal(err.status, 400);
+ return true;
+ }
+ );
+
+ // 3. buildUpiUri preserves full note and never silently truncates
+ const fullNote = 'Important Notice: Non-refundable ticket reservation including VIP table and drinks';
+ const uri = buildUpiUri({
+ vpa: 'test@upi',
+ name: 'Nightlifez',
+ amountPaise: 1000,
+ orderId: 'ORD-TEST-FULL-NOTE',
+ note: fullNote
+ });
+ assert.ok(uri.includes(encodeURIComponent(fullNote).replace(/%20/g, '+')));
+});
+
+test('REV-06: PhonePe webhook merchantId verification rejects missing and mismatched merchant identifiers', () => {
+ const adapter = new PhonePeAdapter({
+ merchantId: 'PGTESTPAYUAT',
+ saltKey: '099eb0cd-0252-4e2a-8aca-eb3c6bd470f1',
+ saltIndex: '1'
+ });
+
+ const saltKey = '099eb0cd-0252-4e2a-8aca-eb3c6bd470f1';
+
+ // 1. Missing merchantId
+ const missingIdPayload = {
+ code: 'PAYMENT_SUCCESS',
+ data: {
+ merchantTransactionId: 'ORD-REV06-1',
+ transactionId: 'TXN1',
+ amount: 10000,
+ state: 'COMPLETED'
+ }
+ };
+ const b64Missing = Buffer.from(JSON.stringify(missingIdPayload)).toString('base64');
+ const bodyMissing = JSON.stringify({ response: b64Missing });
+ const sigMissing = crypto.createHash('sha256').update(`${b64Missing}${saltKey}`).digest('hex') + '###1';
+
+ assert.throws(
+ () => adapter.verifyWebhook({ 'x-verify': sigMissing }, bodyMissing),
+ /merchantId mismatch or missing/
+ );
+
+ // 2. Mismatched merchantId
+ const mismatchedPayload = {
+ code: 'PAYMENT_SUCCESS',
+ data: {
+ merchantId: 'ATTACKER_MERCHANT',
+ merchantTransactionId: 'ORD-REV06-2',
+ transactionId: 'TXN2',
+ amount: 10000,
+ state: 'COMPLETED'
+ }
+ };
+ const b64Mismatch = Buffer.from(JSON.stringify(mismatchedPayload)).toString('base64');
+ const bodyMismatch = JSON.stringify({ response: b64Mismatch });
+ const sigMismatch = crypto.createHash('sha256').update(`${b64Mismatch}${saltKey}`).digest('hex') + '###1';
+
+ assert.throws(
+ () => adapter.verifyWebhook({ 'x-verify': sigMismatch }, bodyMismatch),
+ /merchantId mismatch or missing/
+ );
+
+ // 3. Valid merchantId succeeds
+ const validPayload = {
+ code: 'PAYMENT_SUCCESS',
+ data: {
+ merchantId: 'PGTESTPAYUAT',
+ merchantTransactionId: 'ORD-REV06-3',
+ transactionId: 'TXN3',
+ amount: 10000,
+ state: 'COMPLETED'
+ }
+ };
+ const b64Valid = Buffer.from(JSON.stringify(validPayload)).toString('base64');
+ const bodyValid = JSON.stringify({ response: b64Valid });
+ const sigValid = crypto.createHash('sha256').update(`${b64Valid}${saltKey}`).digest('hex') + '###1';
+
+ const verified = adapter.verifyWebhook({ 'x-verify': sigValid }, bodyValid);
+ assert.equal(verified.merchantId, 'PGTESTPAYUAT');
+ assert.equal(verified.orderId, 'ORD-REV06-3');
+});