@pandait.tech/payment-nuvei 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -189,7 +189,207 @@ async function verifyThreeDS(params) {
189
189
  return nuveiRequest("/v2/transaction/verify/", "POST", body);
190
190
  }
191
191
 
192
- // src/handlers/charge.ts
192
+ // src/handlers/charge/validate-order.ts
193
+ async function validateStandardOrder({
194
+ db,
195
+ getPriceDisplay,
196
+ orderData,
197
+ amount
198
+ }) {
199
+ const orderItems = orderData.items ?? [];
200
+ let verifiedSubtotal = 0;
201
+ for (const item of orderItems) {
202
+ const productDoc = await db.collection("products").doc(item.productId).get();
203
+ if (!productDoc.exists || !productDoc.data()?.isActive) {
204
+ return json(
205
+ { error: `El producto "${item.name}" ya no est\xE1 disponible` },
206
+ { status: 409 }
207
+ );
208
+ }
209
+ const productData = productDoc.data();
210
+ const isDigital = productData.isDigital === true;
211
+ const stock = productData.stock ?? 0;
212
+ if (!isDigital && stock < item.quantity) {
213
+ return json(
214
+ {
215
+ error: `Stock insuficiente para "${item.name}" (disponible: ${stock})`
216
+ },
217
+ { status: 409 }
218
+ );
219
+ }
220
+ const display = getPriceDisplay({
221
+ price: productData.price ?? 0,
222
+ autoDiscounts: productData.autoDiscounts
223
+ });
224
+ const expectedItemSubtotal = display.finalSubtotal;
225
+ if (Math.abs(expectedItemSubtotal - item.price) > 0.02) {
226
+ return json(
227
+ {
228
+ error: `El precio de "${item.name}" cambi\xF3. Recarg\xE1 la p\xE1gina para ver el precio actualizado.`
229
+ },
230
+ { status: 409 }
231
+ );
232
+ }
233
+ verifiedSubtotal += expectedItemSubtotal * item.quantity;
234
+ }
235
+ let verifiedDiscount = 0;
236
+ const orderDiscount = orderData.discount ?? 0;
237
+ const orderPromotionId = orderData.promotionId;
238
+ if (orderPromotionId && orderDiscount > 0) {
239
+ const promoDoc = await db.collection("promotions").doc(orderPromotionId).get();
240
+ if (!promoDoc.exists || !promoDoc.data()?.isActive) {
241
+ return json(
242
+ { error: "El cupon aplicado ya no es valido. Remuevelo y vuelve a intentar." },
243
+ { status: 409 }
244
+ );
245
+ }
246
+ const promo = promoDoc.data();
247
+ const now = /* @__PURE__ */ new Date();
248
+ const validFrom = promo.rules.validFrom.toDate ? promo.rules.validFrom.toDate() : new Date(promo.rules.validFrom);
249
+ const validUntil = promo.rules.validUntil.toDate ? promo.rules.validUntil.toDate() : new Date(promo.rules.validUntil);
250
+ if (now < validFrom || now > validUntil) {
251
+ return json(
252
+ { error: "El cupon aplicado ha expirado. Remuevelo y vuelve a intentar." },
253
+ { status: 409 }
254
+ );
255
+ }
256
+ if (promo.rules.maxTotalUses && promo.currentUses >= promo.rules.maxTotalUses) {
257
+ return json(
258
+ { error: "El cupon aplicado ya alcanzo su limite de usos." },
259
+ { status: 409 }
260
+ );
261
+ }
262
+ if (promo.type === "percentage") {
263
+ verifiedDiscount = verifiedSubtotal * (promo.value / 100);
264
+ if (promo.maxDiscountAmount) {
265
+ verifiedDiscount = Math.min(verifiedDiscount, promo.maxDiscountAmount);
266
+ }
267
+ } else if (promo.type === "fixed_amount") {
268
+ verifiedDiscount = Math.min(promo.value, verifiedSubtotal);
269
+ }
270
+ verifiedDiscount = Math.round(verifiedDiscount * 100) / 100;
271
+ }
272
+ const verifiedDiscountedSubtotal = Math.max(0, verifiedSubtotal - verifiedDiscount);
273
+ const verifiedVat = Math.round(verifiedDiscountedSubtotal * 0.15 * 100) / 100;
274
+ const verifiedTotal = Math.round((verifiedDiscountedSubtotal + verifiedVat) * 100) / 100;
275
+ if (verifiedTotal !== amount) {
276
+ return json(
277
+ {
278
+ error: "El monto no coincide con los precios actuales. Actualiza tu carrito."
279
+ },
280
+ { status: 409 }
281
+ );
282
+ }
283
+ return null;
284
+ }
285
+ async function finalizePaidOrder(deps, ctx) {
286
+ const { db, logger } = deps;
287
+ const {
288
+ orderId,
289
+ transactionId,
290
+ rawAuthCode,
291
+ debitResponse,
292
+ auditResponseLabel,
293
+ installments,
294
+ installmentsType,
295
+ userId,
296
+ userEmail,
297
+ vat,
298
+ amount,
299
+ token,
300
+ deleteCardAfterPayment,
301
+ extraOrderFields,
302
+ preReadOrder,
303
+ includePostPurchaseNote
304
+ } = ctx;
305
+ const hasValidAuthCode = typeof rawAuthCode === "string" && rawAuthCode.trim().length > 0 && rawAuthCode !== "null";
306
+ const batch = db.batch();
307
+ const orderRef = db.collection("orders").doc(orderId);
308
+ if (hasValidAuthCode) {
309
+ batch.update(orderRef, {
310
+ status: "paid",
311
+ paymentTransactionId: transactionId,
312
+ authorizationCode: rawAuthCode,
313
+ ...installments ? { installments, installmentsType: installmentsType ?? 0 } : {},
314
+ chargeResponseAt: /* @__PURE__ */ new Date(),
315
+ updatedAt: /* @__PURE__ */ new Date(),
316
+ ...extraOrderFields ?? {}
317
+ });
318
+ } else {
319
+ logger.error(
320
+ `[AUDIT:MISSING_AUTH_CODE] orderId=${orderId} txId=${transactionId} ${auditResponseLabel ?? "debitResponse"}=${JSON.stringify(debitResponse)}`
321
+ );
322
+ batch.update(orderRef, {
323
+ status: "paid",
324
+ paymentTransactionId: transactionId,
325
+ missingAuthCodeFlagged: true,
326
+ missingAuthCodeLoggedAt: /* @__PURE__ */ new Date(),
327
+ emailPending: true,
328
+ ...installments ? { installments, installmentsType: installmentsType ?? 0 } : {},
329
+ chargeResponseAt: /* @__PURE__ */ new Date(),
330
+ updatedAt: /* @__PURE__ */ new Date(),
331
+ ...extraOrderFields ?? {}
332
+ });
333
+ }
334
+ const orderInfo = preReadOrder ?? ((await orderRef.get()).data() ?? {});
335
+ if (orderInfo.promotionId) {
336
+ const promoRef = db.collection("promotions").doc(orderInfo.promotionId);
337
+ batch.update(promoRef, {
338
+ currentUses: firestore.FieldValue.increment(1)
339
+ });
340
+ const usageRef = promoRef.collection("usages").doc();
341
+ batch.set(usageRef, {
342
+ userId: orderInfo.userId,
343
+ orderId,
344
+ discountApplied: orderInfo.discount || 0,
345
+ usedAt: /* @__PURE__ */ new Date()
346
+ });
347
+ }
348
+ await batch.commit();
349
+ if (deps.onPaymentSucceeded) {
350
+ try {
351
+ await deps.onPaymentSucceeded({ ...orderInfo, id: orderId });
352
+ } catch (err) {
353
+ logger.error(
354
+ `[charge] onPaymentSucceeded hook failed for order ${orderId}:`,
355
+ err
356
+ );
357
+ }
358
+ }
359
+ let emailSent = false;
360
+ if (hasValidAuthCode) {
361
+ const emailResult = await deps.email.sendPaymentConfirmation({
362
+ to: userEmail,
363
+ customerName: orderInfo.shippingAddress?.fullName || "",
364
+ orderId,
365
+ transactionId,
366
+ authorizationCode: rawAuthCode,
367
+ items: orderInfo.items || [],
368
+ subtotal: orderInfo.subtotal || amount,
369
+ discount: orderInfo.discount || void 0,
370
+ couponCode: orderInfo.couponCode,
371
+ vat: orderInfo.vat || vat,
372
+ total: amount,
373
+ ...includePostPurchaseNote !== false && orderInfo.postPurchaseNote ? { postPurchaseNote: orderInfo.postPurchaseNote } : {}
374
+ }).catch(() => ({ success: false }));
375
+ emailSent = emailResult.success;
376
+ if (emailSent) {
377
+ await orderRef.update({ emailSentAt: /* @__PURE__ */ new Date() });
378
+ }
379
+ }
380
+ if (deleteCardAfterPayment && token) {
381
+ deleteCard(token, userId).catch(
382
+ (err) => logger.error("[charge] Failed to delete card after payment:", err)
383
+ );
384
+ }
385
+ return {
386
+ transactionId,
387
+ authorizationCode: hasValidAuthCode ? rawAuthCode : null,
388
+ emailSent
389
+ };
390
+ }
391
+
392
+ // src/handlers/charge/error-messages.ts
193
393
  var NUVEI_ERROR_MESSAGES = {
194
394
  0: "Error del procesador de pagos. Intenta de nuevo.",
195
395
  // 1 = review/pending — handled separately, not shown as error
@@ -235,6 +435,141 @@ function getNuveiUserMessage(statusDetail, rawMessage) {
235
435
  }
236
436
  return "No se pudo procesar el pago. Verifica los datos de tu tarjeta o intenta con otra.";
237
437
  }
438
+
439
+ // src/handlers/charge/handle-3ds-results.ts
440
+ async function handleNonSuccessResult(deps, ctx) {
441
+ const { db, logger } = deps;
442
+ const { nuveiData, orderId, userEmail, amount, token, deleteCardAfterPayment } = ctx;
443
+ if (nuveiData.transaction?.status === "pending" && nuveiData.transaction?.status_detail === 1) {
444
+ await db.collection("orders").doc(orderId).update({
445
+ status: "processing",
446
+ paymentTransactionId: nuveiData.transaction.id || null,
447
+ chargeResponseAt: /* @__PURE__ */ new Date(),
448
+ updatedAt: /* @__PURE__ */ new Date()
449
+ });
450
+ return json({
451
+ review: true,
452
+ orderId,
453
+ transactionId: nuveiData.transaction.id
454
+ });
455
+ }
456
+ const persistDeleteOnPaid = deleteCardAfterPayment ? { deleteCardAfterPayment: true, paymentToken: token } : {};
457
+ if (nuveiData.transaction?.status_detail === 35) {
458
+ const threeDSData = nuveiData["3ds"];
459
+ const hiddenIframeHtml = threeDSData?.browser_response?.hidden_iframe || "";
460
+ await db.collection("orders").doc(orderId).update({
461
+ status: "3ds-pending",
462
+ isDeviceFingerprint: true,
463
+ nuveiTransactionId: nuveiData.transaction.id || null,
464
+ ...persistDeleteOnPaid,
465
+ updatedAt: /* @__PURE__ */ new Date()
466
+ });
467
+ return json({
468
+ challenge: true,
469
+ challengeHtml: hiddenIframeHtml,
470
+ isDeviceFingerprint: true,
471
+ orderId,
472
+ nuveiTransactionId: nuveiData.transaction.id,
473
+ statusDetail: 35
474
+ });
475
+ }
476
+ if (nuveiData.transaction?.status_detail === 31) {
477
+ await db.collection("orders").doc(orderId).update({
478
+ status: "otp-pending",
479
+ nuveiTransactionId: nuveiData.transaction.id || null,
480
+ ...persistDeleteOnPaid,
481
+ updatedAt: /* @__PURE__ */ new Date()
482
+ });
483
+ return json({
484
+ otpRequired: true,
485
+ orderId,
486
+ nuveiTransactionId: nuveiData.transaction.id,
487
+ statusDetail: 31
488
+ });
489
+ }
490
+ if (nuveiData.transaction?.status_detail === 36 || nuveiData.transaction?.status_detail === 37) {
491
+ const threeDSData = nuveiData["3ds"];
492
+ const challengeHtml = threeDSData?.browser_response?.challenge_request || threeDSData?.browser_response?.hidden_iframe || "";
493
+ const rawDebit = nuveiData;
494
+ const debitCres = rawDebit["3ds"]?.authentication?.["cres"] || rawDebit["3ds"]?.browser_response?.["cres"] || rawDebit["3ds"]?.["cres"] || rawDebit.transaction?.["cres"] || rawDebit["cres"] || rawDebit["value"] || null;
495
+ logger.log(
496
+ `[charge] status ${nuveiData.transaction.status_detail} \u2014 debitCres captured: ${debitCres ? "YES (len=" + debitCres.length + ")" : "NO"}`
497
+ );
498
+ if (challengeHtml) {
499
+ await db.collection("orders").doc(orderId).update({
500
+ status: "3ds-pending",
501
+ nuveiTransactionId: nuveiData.transaction?.id || null,
502
+ ...debitCres ? { threeDSCres: debitCres } : {},
503
+ ...persistDeleteOnPaid,
504
+ updatedAt: /* @__PURE__ */ new Date()
505
+ });
506
+ return json({
507
+ challenge: true,
508
+ challengeHtml,
509
+ isDeviceFingerprint: false,
510
+ orderId,
511
+ nuveiTransactionId: nuveiData.transaction?.id,
512
+ statusDetail: nuveiData.transaction?.status_detail
513
+ });
514
+ }
515
+ }
516
+ const threeDSStatus = nuveiData["3ds"]?.authentication?.status;
517
+ if (threeDSStatus && THREEDS_AUTH_MESSAGES[threeDSStatus]) {
518
+ const threeDSErrorMsg = THREEDS_AUTH_MESSAGES[threeDSStatus];
519
+ const currentOrder2 = await db.collection("orders").doc(orderId).get();
520
+ const currentOrderData2 = currentOrder2.data() ?? {};
521
+ if (currentOrderData2.status === "pending") {
522
+ await db.collection("orders").doc(orderId).update({
523
+ status: "failed",
524
+ chargeResponseAt: /* @__PURE__ */ new Date(),
525
+ updatedAt: /* @__PURE__ */ new Date()
526
+ });
527
+ }
528
+ if (userEmail) {
529
+ const retryUrl = deps.getRetryUrl?.({ ...currentOrderData2, id: orderId });
530
+ deps.email.sendPaymentFailed({
531
+ to: userEmail,
532
+ customerName: currentOrderData2.shippingAddress?.fullName || "",
533
+ orderId,
534
+ errorMessage: threeDSErrorMsg,
535
+ items: currentOrderData2.items || [],
536
+ total: currentOrderData2.total || amount,
537
+ ...retryUrl ? { retryUrl } : {}
538
+ }).catch(() => {
539
+ });
540
+ }
541
+ return json({ error: threeDSErrorMsg }, { status: 400 });
542
+ }
543
+ const failedErrorMsg = getNuveiUserMessage(
544
+ nuveiData.transaction?.status_detail,
545
+ nuveiData.transaction?.message || nuveiData.error?.description
546
+ );
547
+ const currentOrder = await db.collection("orders").doc(orderId).get();
548
+ const currentOrderData = currentOrder.data() ?? {};
549
+ if (currentOrderData.status === "pending") {
550
+ await db.collection("orders").doc(orderId).update({
551
+ status: "failed",
552
+ chargeResponseAt: /* @__PURE__ */ new Date(),
553
+ updatedAt: /* @__PURE__ */ new Date()
554
+ });
555
+ }
556
+ if (userEmail) {
557
+ const retryUrl = deps.getRetryUrl?.({ ...currentOrderData, id: orderId });
558
+ deps.email.sendPaymentFailed({
559
+ to: userEmail,
560
+ customerName: currentOrderData.shippingAddress?.fullName || "",
561
+ orderId,
562
+ errorMessage: failedErrorMsg,
563
+ items: currentOrderData.items || [],
564
+ total: currentOrderData.total || amount,
565
+ ...retryUrl ? { retryUrl } : {}
566
+ }).catch(() => {
567
+ });
568
+ }
569
+ return json({ error: failedErrorMsg }, { status: 400 });
570
+ }
571
+
572
+ // src/handlers/charge.ts
238
573
  function createChargeHandler(deps) {
239
574
  const logger = deps.logger ?? console;
240
575
  const { db, auth } = deps.firebase;
@@ -290,6 +625,7 @@ function createChargeHandler(deps) {
290
625
  orderId,
291
626
  amount,
292
627
  vat,
628
+ taxableAmount,
293
629
  description,
294
630
  userId,
295
631
  userEmail,
@@ -338,89 +674,14 @@ function createChargeHandler(deps) {
338
674
  }
339
675
  }
340
676
  if (runStandardValidation) {
341
- const orderItems = orderData.items ?? [];
342
- let verifiedSubtotal = 0;
343
- for (const item of orderItems) {
344
- const productDoc = await db.collection("products").doc(item.productId).get();
345
- if (!productDoc.exists || !productDoc.data()?.isActive) {
346
- return json(
347
- { error: `El producto "${item.name}" ya no est\xE1 disponible` },
348
- { status: 409 }
349
- );
350
- }
351
- const productData = productDoc.data();
352
- const isDigital = productData.isDigital === true;
353
- const stock = productData.stock ?? 0;
354
- if (!isDigital && stock < item.quantity) {
355
- return json(
356
- {
357
- error: `Stock insuficiente para "${item.name}" (disponible: ${stock})`
358
- },
359
- { status: 409 }
360
- );
361
- }
362
- const display = deps.getPriceDisplay({
363
- price: productData.price ?? 0,
364
- autoDiscounts: productData.autoDiscounts
365
- });
366
- const expectedItemSubtotal = display.finalSubtotal;
367
- if (Math.abs(expectedItemSubtotal - item.price) > 0.02) {
368
- return json(
369
- {
370
- error: `El precio de "${item.name}" cambi\xF3. Recarg\xE1 la p\xE1gina para ver el precio actualizado.`
371
- },
372
- { status: 409 }
373
- );
374
- }
375
- verifiedSubtotal += expectedItemSubtotal * item.quantity;
376
- }
377
- let verifiedDiscount = 0;
378
- const orderDiscount = orderData.discount ?? 0;
379
- const orderPromotionId = orderData.promotionId;
380
- if (orderPromotionId && orderDiscount > 0) {
381
- const promoDoc = await db.collection("promotions").doc(orderPromotionId).get();
382
- if (!promoDoc.exists || !promoDoc.data()?.isActive) {
383
- return json(
384
- { error: "El cupon aplicado ya no es valido. Remuevelo y vuelve a intentar." },
385
- { status: 409 }
386
- );
387
- }
388
- const promo = promoDoc.data();
389
- const now = /* @__PURE__ */ new Date();
390
- const validFrom = promo.rules.validFrom.toDate ? promo.rules.validFrom.toDate() : new Date(promo.rules.validFrom);
391
- const validUntil = promo.rules.validUntil.toDate ? promo.rules.validUntil.toDate() : new Date(promo.rules.validUntil);
392
- if (now < validFrom || now > validUntil) {
393
- return json(
394
- { error: "El cupon aplicado ha expirado. Remuevelo y vuelve a intentar." },
395
- { status: 409 }
396
- );
397
- }
398
- if (promo.rules.maxTotalUses && promo.currentUses >= promo.rules.maxTotalUses) {
399
- return json(
400
- { error: "El cupon aplicado ya alcanzo su limite de usos." },
401
- { status: 409 }
402
- );
403
- }
404
- if (promo.type === "percentage") {
405
- verifiedDiscount = verifiedSubtotal * (promo.value / 100);
406
- if (promo.maxDiscountAmount) {
407
- verifiedDiscount = Math.min(verifiedDiscount, promo.maxDiscountAmount);
408
- }
409
- } else if (promo.type === "fixed_amount") {
410
- verifiedDiscount = Math.min(promo.value, verifiedSubtotal);
411
- }
412
- verifiedDiscount = Math.round(verifiedDiscount * 100) / 100;
413
- }
414
- const verifiedDiscountedSubtotal = Math.max(0, verifiedSubtotal - verifiedDiscount);
415
- const verifiedVat = Math.round(verifiedDiscountedSubtotal * 0.15 * 100) / 100;
416
- const verifiedTotal = Math.round((verifiedDiscountedSubtotal + verifiedVat) * 100) / 100;
417
- if (verifiedTotal !== amount) {
418
- return json(
419
- {
420
- error: "El monto no coincide con los precios actuales. Actualiza tu carrito."
421
- },
422
- { status: 409 }
423
- );
677
+ const validationError = await validateStandardOrder({
678
+ db,
679
+ getPriceDisplay: deps.getPriceDisplay,
680
+ orderData,
681
+ amount
682
+ });
683
+ if (validationError) {
684
+ return validationError;
424
685
  }
425
686
  }
426
687
  const functionsBase = deps.cloudFunctionsBaseUrl ?? process.env.CLOUD_FUNCTIONS_BASE_URL;
@@ -432,7 +693,7 @@ function createChargeHandler(deps) {
432
693
  }
433
694
  const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "127.0.0.1";
434
695
  const enrichedBrowserInfo = browserInfo ? { ...browserInfo, ip_address: clientIp } : void 0;
435
- const discountedSubtotalForTax = amount - (vat ?? 0);
696
+ const discountedSubtotalForTax = taxableAmount ?? amount - (vat ?? 0);
436
697
  const nuveiData = await debitWithToken({
437
698
  userId,
438
699
  userEmail,
@@ -446,7 +707,6 @@ function createChargeHandler(deps) {
446
707
  ...installments ? { installments, installmentsType: installmentsType ?? 0 } : {},
447
708
  ...enrichedBrowserInfo && termUrl ? { browserInfo: enrichedBrowserInfo, termUrl } : {}
448
709
  });
449
- logger.log("[charge] Nuvei debit response FULL:", JSON.stringify(nuveiData));
450
710
  logger.log(
451
711
  "[charge] Nuvei debit response summary:",
452
712
  JSON.stringify({
@@ -458,221 +718,52 @@ function createChargeHandler(deps) {
458
718
  })
459
719
  );
460
720
  if (nuveiData.transaction && nuveiData.transaction.status === "success" && nuveiData.transaction.status_detail === 3) {
461
- const rawAuthCode = nuveiData.transaction.authorization_code;
462
- const hasValidAuthCode = typeof rawAuthCode === "string" && rawAuthCode.trim().length > 0 && rawAuthCode !== "null";
463
- const batch = db.batch();
464
- const orderRef = db.collection("orders").doc(orderId);
465
- if (hasValidAuthCode) {
466
- batch.update(orderRef, {
467
- status: "paid",
468
- paymentTransactionId: nuveiData.transaction.id,
469
- authorizationCode: rawAuthCode,
470
- ...installments ? { installments, installmentsType: installmentsType ?? 0 } : {},
471
- chargeResponseAt: /* @__PURE__ */ new Date(),
472
- updatedAt: /* @__PURE__ */ new Date()
473
- });
474
- } else {
475
- logger.error(
476
- `[AUDIT:MISSING_AUTH_CODE] orderId=${orderId} txId=${nuveiData.transaction.id} debitResponse=${JSON.stringify(nuveiData)}`
477
- );
478
- batch.update(orderRef, {
479
- status: "paid",
480
- paymentTransactionId: nuveiData.transaction.id,
481
- missingAuthCodeFlagged: true,
482
- missingAuthCodeLoggedAt: /* @__PURE__ */ new Date(),
483
- emailPending: true,
484
- ...installments ? { installments, installmentsType: installmentsType ?? 0 } : {},
485
- chargeResponseAt: /* @__PURE__ */ new Date(),
486
- updatedAt: /* @__PURE__ */ new Date()
487
- });
488
- }
489
- const orderSnap = await orderRef.get();
490
- const orderInfo = orderSnap.data() ?? {};
491
- if (orderInfo.promotionId) {
492
- const promoRef = db.collection("promotions").doc(orderInfo.promotionId);
493
- batch.update(promoRef, {
494
- currentUses: firestore.FieldValue.increment(1)
495
- });
496
- const usageRef = promoRef.collection("usages").doc();
497
- batch.set(usageRef, {
498
- userId: orderInfo.userId,
499
- orderId,
500
- discountApplied: orderInfo.discount || 0,
501
- usedAt: /* @__PURE__ */ new Date()
502
- });
503
- }
504
- await batch.commit();
505
- if (deps.onPaymentSucceeded) {
506
- try {
507
- await deps.onPaymentSucceeded({ ...orderInfo, id: orderId });
508
- } catch (err) {
509
- logger.error(
510
- `[charge] onPaymentSucceeded hook failed for order ${orderId}:`,
511
- err
512
- );
513
- }
514
- }
515
- let emailSent = false;
516
- if (hasValidAuthCode) {
517
- const emailResult = await deps.email.sendPaymentConfirmation({
518
- to: userEmail,
519
- customerName: orderInfo.shippingAddress?.fullName || "",
721
+ const finalizeResult = await finalizePaidOrder(
722
+ {
723
+ db,
724
+ email: deps.email,
725
+ onPaymentSucceeded: deps.onPaymentSucceeded,
726
+ logger
727
+ },
728
+ {
520
729
  orderId,
521
730
  transactionId: nuveiData.transaction.id,
522
- authorizationCode: rawAuthCode,
523
- items: orderInfo.items || [],
524
- subtotal: orderInfo.subtotal || amount,
525
- discount: orderInfo.discount || void 0,
526
- couponCode: orderInfo.couponCode,
527
- vat: orderInfo.vat || vat,
528
- total: amount,
529
- ...orderInfo.postPurchaseNote ? { postPurchaseNote: orderInfo.postPurchaseNote } : {}
530
- }).catch(() => ({ success: false }));
531
- emailSent = emailResult.success;
532
- if (emailSent) {
533
- await orderRef.update({ emailSentAt: /* @__PURE__ */ new Date() });
731
+ rawAuthCode: nuveiData.transaction.authorization_code,
732
+ debitResponse: nuveiData,
733
+ installments,
734
+ installmentsType,
735
+ userId,
736
+ userEmail,
737
+ vat,
738
+ amount,
739
+ token,
740
+ deleteCardAfterPayment: body.deleteCardAfterPayment
534
741
  }
535
- }
536
- if (body.deleteCardAfterPayment && token) {
537
- deleteCard(token, userId).catch(
538
- (err) => logger.error("[charge] Failed to delete card after payment:", err)
539
- );
540
- }
742
+ );
541
743
  return json({
542
744
  success: true,
543
- transactionId: nuveiData.transaction.id,
544
- authorizationCode: hasValidAuthCode ? rawAuthCode : null,
545
- orderId,
546
- emailSent
547
- });
548
- }
549
- if (nuveiData.transaction?.status === "pending" && nuveiData.transaction?.status_detail === 1) {
550
- await db.collection("orders").doc(orderId).update({
551
- status: "processing",
552
- paymentTransactionId: nuveiData.transaction.id || null,
553
- chargeResponseAt: /* @__PURE__ */ new Date(),
554
- updatedAt: /* @__PURE__ */ new Date()
555
- });
556
- return json({
557
- review: true,
745
+ transactionId: finalizeResult.transactionId,
746
+ authorizationCode: finalizeResult.authorizationCode,
558
747
  orderId,
559
- transactionId: nuveiData.transaction.id
748
+ emailSent: finalizeResult.emailSent
560
749
  });
561
750
  }
562
- const persistDeleteOnPaid = body.deleteCardAfterPayment ? { deleteCardAfterPayment: true, paymentToken: token } : {};
563
- if (nuveiData.transaction?.status_detail === 35) {
564
- const threeDSData = nuveiData["3ds"];
565
- const hiddenIframeHtml = threeDSData?.browser_response?.hidden_iframe || "";
566
- await db.collection("orders").doc(orderId).update({
567
- status: "3ds-pending",
568
- isDeviceFingerprint: true,
569
- nuveiTransactionId: nuveiData.transaction.id || null,
570
- ...persistDeleteOnPaid,
571
- updatedAt: /* @__PURE__ */ new Date()
572
- });
573
- return json({
574
- challenge: true,
575
- challengeHtml: hiddenIframeHtml,
576
- isDeviceFingerprint: true,
577
- orderId,
578
- nuveiTransactionId: nuveiData.transaction.id,
579
- statusDetail: 35
580
- });
581
- }
582
- if (nuveiData.transaction?.status_detail === 31) {
583
- await db.collection("orders").doc(orderId).update({
584
- status: "otp-pending",
585
- nuveiTransactionId: nuveiData.transaction.id || null,
586
- ...persistDeleteOnPaid,
587
- updatedAt: /* @__PURE__ */ new Date()
588
- });
589
- return json({
590
- otpRequired: true,
751
+ return handleNonSuccessResult(
752
+ {
753
+ db,
754
+ email: deps.email,
755
+ getRetryUrl: deps.getRetryUrl,
756
+ logger
757
+ },
758
+ {
759
+ nuveiData,
591
760
  orderId,
592
- nuveiTransactionId: nuveiData.transaction.id,
593
- statusDetail: 31
594
- });
595
- }
596
- if (nuveiData.transaction?.status_detail === 36 || nuveiData.transaction?.status_detail === 37) {
597
- const threeDSData = nuveiData["3ds"];
598
- const challengeHtml = threeDSData?.browser_response?.challenge_request || threeDSData?.browser_response?.hidden_iframe || "";
599
- const rawDebit = nuveiData;
600
- const debitCres = rawDebit["3ds"]?.authentication?.["cres"] || rawDebit["3ds"]?.browser_response?.["cres"] || rawDebit["3ds"]?.["cres"] || rawDebit.transaction?.["cres"] || rawDebit["cres"] || rawDebit["value"] || null;
601
- logger.log(
602
- `[charge] status ${nuveiData.transaction.status_detail} \u2014 debitCres captured: ${debitCres ? "YES (len=" + debitCres.length + ")" : "NO"}`
603
- );
604
- if (challengeHtml) {
605
- await db.collection("orders").doc(orderId).update({
606
- status: "3ds-pending",
607
- nuveiTransactionId: nuveiData.transaction?.id || null,
608
- ...debitCres ? { threeDSCres: debitCres } : {},
609
- ...persistDeleteOnPaid,
610
- updatedAt: /* @__PURE__ */ new Date()
611
- });
612
- return json({
613
- challenge: true,
614
- challengeHtml,
615
- isDeviceFingerprint: false,
616
- orderId,
617
- nuveiTransactionId: nuveiData.transaction?.id,
618
- statusDetail: nuveiData.transaction?.status_detail
619
- });
620
- }
621
- }
622
- const threeDSStatus = nuveiData["3ds"]?.authentication?.status;
623
- if (threeDSStatus && THREEDS_AUTH_MESSAGES[threeDSStatus]) {
624
- const threeDSErrorMsg = THREEDS_AUTH_MESSAGES[threeDSStatus];
625
- const currentOrder2 = await db.collection("orders").doc(orderId).get();
626
- const currentOrderData2 = currentOrder2.data() ?? {};
627
- if (currentOrderData2.status === "pending") {
628
- await db.collection("orders").doc(orderId).update({
629
- status: "failed",
630
- chargeResponseAt: /* @__PURE__ */ new Date(),
631
- updatedAt: /* @__PURE__ */ new Date()
632
- });
633
- }
634
- if (userEmail) {
635
- const retryUrl = deps.getRetryUrl?.({ ...currentOrderData2, id: orderId });
636
- deps.email.sendPaymentFailed({
637
- to: userEmail,
638
- customerName: currentOrderData2.shippingAddress?.fullName || "",
639
- orderId,
640
- errorMessage: threeDSErrorMsg,
641
- items: currentOrderData2.items || [],
642
- total: currentOrderData2.total || amount,
643
- ...retryUrl ? { retryUrl } : {}
644
- }).catch(() => {
645
- });
761
+ userEmail,
762
+ amount,
763
+ token,
764
+ deleteCardAfterPayment: body.deleteCardAfterPayment
646
765
  }
647
- return json({ error: threeDSErrorMsg }, { status: 400 });
648
- }
649
- const failedErrorMsg = getNuveiUserMessage(
650
- nuveiData.transaction?.status_detail,
651
- nuveiData.transaction?.message || nuveiData.error?.description
652
766
  );
653
- const currentOrder = await db.collection("orders").doc(orderId).get();
654
- const currentOrderData = currentOrder.data() ?? {};
655
- if (currentOrderData.status === "pending") {
656
- await db.collection("orders").doc(orderId).update({
657
- status: "failed",
658
- chargeResponseAt: /* @__PURE__ */ new Date(),
659
- updatedAt: /* @__PURE__ */ new Date()
660
- });
661
- }
662
- if (userEmail) {
663
- const retryUrl = deps.getRetryUrl?.({ ...currentOrderData, id: orderId });
664
- deps.email.sendPaymentFailed({
665
- to: userEmail,
666
- customerName: currentOrderData.shippingAddress?.fullName || "",
667
- orderId,
668
- errorMessage: failedErrorMsg,
669
- items: currentOrderData.items || [],
670
- total: currentOrderData.total || amount,
671
- ...retryUrl ? { retryUrl } : {}
672
- }).catch(() => {
673
- });
674
- }
675
- return json({ error: failedErrorMsg }, { status: 400 });
676
767
  } catch (error) {
677
768
  logger.error("Payment charge error:", error);
678
769
  return json(
@@ -771,72 +862,28 @@ function createWebhookHandler(deps) {
771
862
  const vTxId = verifyResult.transaction?.id ?? raw["transaction_id"] ?? orderDataEarly.nuveiTransactionId ?? transaction.id;
772
863
  const verifySuccess = (vStatus === "success" || vStatus === 1) && vDetail === 3;
773
864
  if (verifySuccess) {
774
- const hasAuth = typeof vAuthCode === "string" && vAuthCode.trim().length > 0 && vAuthCode !== "null";
775
- const batch = db.batch();
776
- batch.update(orderRef, {
777
- status: "paid",
778
- paymentTransactionId: vTxId,
779
- ...hasAuth ? { authorizationCode: vAuthCode } : {
780
- missingAuthCodeFlagged: true,
781
- missingAuthCodeLoggedAt: /* @__PURE__ */ new Date(),
782
- emailPending: true
783
- },
784
- chargeResponseAt: /* @__PURE__ */ new Date(),
785
- updatedAt: /* @__PURE__ */ new Date(),
786
- threeDSCres: firestore.FieldValue.delete(),
787
- threeDSTransStatus: firestore.FieldValue.delete()
788
- });
789
- if (orderDataEarly.promotionId) {
790
- const promoRef = db.collection("promotions").doc(orderDataEarly.promotionId);
791
- batch.update(promoRef, {
792
- currentUses: firestore.FieldValue.increment(1)
793
- });
794
- const usageRef = promoRef.collection("usages").doc();
795
- batch.set(usageRef, {
796
- userId: orderDataEarly.userId,
797
- orderId,
798
- discountApplied: orderDataEarly.discount || 0,
799
- usedAt: /* @__PURE__ */ new Date()
800
- });
801
- }
802
- await batch.commit();
803
- if (hasAuth && orderDataEarly.userEmail) {
804
- await deps.email.sendPaymentConfirmation({
805
- to: orderDataEarly.userEmail,
806
- customerName: orderDataEarly.shippingAddress?.fullName || "",
865
+ await finalizePaidOrder(
866
+ { db, email: deps.email, onPaymentSucceeded: deps.onPaymentSucceeded, logger },
867
+ {
807
868
  orderId,
808
869
  transactionId: vTxId,
809
- authorizationCode: vAuthCode,
810
- items: orderDataEarly.items || [],
811
- subtotal: orderDataEarly.subtotal || orderDataEarly.total || 0,
812
- discount: orderDataEarly.discount || void 0,
813
- couponCode: orderDataEarly.couponCode,
814
- vat: orderDataEarly.vat || 0,
815
- total: orderDataEarly.total || 0
816
- }).catch(() => ({ success: false }));
817
- await orderRef.update({ emailSentAt: /* @__PURE__ */ new Date() });
818
- }
819
- if (orderDataEarly.deleteCardAfterPayment && orderDataEarly.paymentToken) {
820
- deleteCard(
821
- orderDataEarly.paymentToken,
822
- orderDataEarly.userId
823
- ).catch(
824
- (err) => logger.error(
825
- "[webhook] Failed to delete card after payment:",
826
- err
827
- )
828
- );
829
- }
830
- if (deps.onPaymentSucceeded) {
831
- try {
832
- await deps.onPaymentSucceeded({ ...orderDataEarly, id: orderId });
833
- } catch (err) {
834
- logger.error(
835
- `[webhook] onPaymentSucceeded hook failed for ${orderId}:`,
836
- err
837
- );
870
+ rawAuthCode: vAuthCode ?? null,
871
+ debitResponse: verifyResult,
872
+ auditResponseLabel: "verifyResponse",
873
+ userId: orderDataEarly.userId,
874
+ userEmail: orderDataEarly.userEmail || "",
875
+ vat: 0,
876
+ amount: orderDataEarly.total || 0,
877
+ token: orderDataEarly.paymentToken,
878
+ deleteCardAfterPayment: orderDataEarly.deleteCardAfterPayment,
879
+ preReadOrder: orderDataEarly,
880
+ includePostPurchaseNote: false,
881
+ extraOrderFields: {
882
+ threeDSCres: firestore.FieldValue.delete(),
883
+ threeDSTransStatus: firestore.FieldValue.delete()
884
+ }
838
885
  }
839
- }
886
+ );
840
887
  return json({ received: true, verifiedFromWebhook: true });
841
888
  }
842
889
  } catch (err) {
@@ -1212,94 +1259,39 @@ function create3dsCompleteHandler(deps) {
1212
1259
  return json({ stillPending: true });
1213
1260
  }
1214
1261
  if (isSuccess) {
1215
- const hasValidAuthCode = typeof txAuthCode === "string" && txAuthCode.trim().length > 0 && txAuthCode !== "null";
1216
- const batch = db.batch();
1217
- if (hasValidAuthCode) {
1218
- batch.update(orderRef, {
1219
- status: "paid",
1220
- paymentTransactionId: txId,
1221
- authorizationCode: txAuthCode,
1222
- chargeResponseAt: /* @__PURE__ */ new Date(),
1223
- updatedAt: /* @__PURE__ */ new Date(),
1224
- threeDSCres: firestore.FieldValue.delete(),
1225
- threeDSTransStatus: firestore.FieldValue.delete(),
1226
- isDeviceFingerprint: firestore.FieldValue.delete()
1227
- });
1228
- } else {
1229
- logger.error(
1230
- `[AUDIT:MISSING_AUTH_CODE] orderId=${orderId} txId=${txId} verifyResponse=${JSON.stringify(verifyResult)}`
1231
- );
1232
- batch.update(orderRef, {
1233
- status: "paid",
1234
- paymentTransactionId: txId,
1235
- missingAuthCodeFlagged: true,
1236
- missingAuthCodeLoggedAt: /* @__PURE__ */ new Date(),
1237
- emailPending: true,
1238
- chargeResponseAt: /* @__PURE__ */ new Date(),
1239
- updatedAt: /* @__PURE__ */ new Date(),
1240
- threeDSCres: firestore.FieldValue.delete(),
1241
- threeDSTransStatus: firestore.FieldValue.delete(),
1242
- isDeviceFingerprint: firestore.FieldValue.delete()
1243
- });
1244
- }
1245
- if (orderData.promotionId) {
1246
- const promoRef = db.collection("promotions").doc(orderData.promotionId);
1247
- batch.update(promoRef, {
1248
- currentUses: firestore.FieldValue.increment(1)
1249
- });
1250
- const usageRef = promoRef.collection("usages").doc();
1251
- batch.set(usageRef, {
1252
- userId: orderData.userId,
1253
- orderId,
1254
- discountApplied: orderData.discount || 0,
1255
- usedAt: /* @__PURE__ */ new Date()
1256
- });
1257
- }
1258
- await batch.commit();
1259
- if (deps.onPaymentSucceeded) {
1260
- try {
1261
- await deps.onPaymentSucceeded({ ...orderData, id: orderId });
1262
- } catch (err) {
1263
- logger.error(
1264
- `[3ds-complete] onPaymentSucceeded hook failed for ${orderId}:`,
1265
- err
1266
- );
1267
- }
1268
- }
1269
- let emailSent = false;
1270
- if (hasValidAuthCode) {
1271
- const userEmail2 = orderData.userEmail || decodedToken.email || "";
1272
- const emailResult = await deps.email.sendPaymentConfirmation({
1273
- to: userEmail2,
1274
- customerName: orderData.shippingAddress?.fullName || "",
1262
+ const finalizeResult = await finalizePaidOrder(
1263
+ {
1264
+ db,
1265
+ email: deps.email,
1266
+ onPaymentSucceeded: deps.onPaymentSucceeded,
1267
+ logger
1268
+ },
1269
+ {
1275
1270
  orderId,
1276
1271
  transactionId: txId,
1277
- authorizationCode: txAuthCode,
1278
- items: orderData.items || [],
1279
- subtotal: orderData.subtotal || orderData.total || 0,
1280
- discount: orderData.discount || void 0,
1281
- couponCode: orderData.couponCode,
1282
- vat: orderData.vat || 0,
1283
- total: orderData.total || 0
1284
- }).catch(() => ({ success: false }));
1285
- emailSent = emailResult.success;
1286
- if (emailSent) {
1287
- await orderRef.update({ emailSentAt: /* @__PURE__ */ new Date() });
1272
+ rawAuthCode: txAuthCode,
1273
+ debitResponse: verifyResult,
1274
+ auditResponseLabel: "verifyResponse",
1275
+ userId: orderData.userId,
1276
+ userEmail: orderData.userEmail || decodedToken.email || "",
1277
+ vat: 0,
1278
+ amount: orderData.total || 0,
1279
+ token: orderData.paymentToken,
1280
+ deleteCardAfterPayment: orderData.deleteCardAfterPayment,
1281
+ preReadOrder: orderData,
1282
+ includePostPurchaseNote: false,
1283
+ extraOrderFields: {
1284
+ threeDSCres: firestore.FieldValue.delete(),
1285
+ threeDSTransStatus: firestore.FieldValue.delete(),
1286
+ isDeviceFingerprint: firestore.FieldValue.delete()
1287
+ }
1288
1288
  }
1289
- }
1290
- if (orderData.deleteCardAfterPayment && orderData.paymentToken) {
1291
- deleteCard(
1292
- orderData.paymentToken,
1293
- orderData.userId
1294
- ).catch(
1295
- (err) => logger.error("[3ds-complete] Failed to delete card after payment:", err)
1296
- );
1297
- }
1289
+ );
1298
1290
  return json({
1299
1291
  success: true,
1300
- transactionId: txId,
1301
- authorizationCode: hasValidAuthCode ? txAuthCode : null,
1302
- emailSent,
1292
+ transactionId: finalizeResult.transactionId,
1293
+ authorizationCode: finalizeResult.authorizationCode,
1294
+ emailSent: finalizeResult.emailSent,
1303
1295
  orderId
1304
1296
  });
1305
1297
  }