@pandait.tech/payment-nuvei 1.0.1 → 1.1.1

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