vonpay-medusa 1.0.1 → 1.0.3

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.
@@ -132,7 +132,11 @@ const REASON_COPY = {
132
132
  },
133
133
  upsell_refund_failed: {
134
134
  label: "Late offer charge — refund failed",
135
- what: "The buyer's bank approved an offer after the offer had ended, so nothing was added and the plugin tried to refund the charge in full, but the refund did not go through. Refund the payment in Vonpay by the order id. Do not add the item."
135
+ what: "The buyer was charged for an offer whose item was NOT added to the order — the bank approved it after the offer had ended, the item could not be added, or an earlier declined attempt was approved after all — so the plugin tried to refund that charge in full, but the refund did not go through (refused, or not settled within 48 hours). Refund the payment in Vonpay by the order id. Do not add the item."
136
+ },
137
+ upsell_refund_unclear: {
138
+ label: "Late offer charge — refund unclear",
139
+ what: "The buyer was charged for an offer whose item was NOT added to the order, and the plugin's automatic refund got an unclear answer: Vonpay reported an error, then recorded that refund as failed, but the money may have gone back anyway. The plugin will not send another refund. First check the payment in Vonpay by the order id: refund it there ONLY if no refund went through. Do not add the item."
136
140
  },
137
141
  upsell_refund_pending: {
138
142
  label: "Late offer charge — refund in progress",
@@ -129,7 +129,11 @@ const REASON_COPY = {
129
129
  },
130
130
  upsell_refund_failed: {
131
131
  label: "Late offer charge — refund failed",
132
- what: "The buyer's bank approved an offer after the offer had ended, so nothing was added and the plugin tried to refund the charge in full, but the refund did not go through. Refund the payment in Vonpay by the order id. Do not add the item."
132
+ what: "The buyer was charged for an offer whose item was NOT added to the order — the bank approved it after the offer had ended, the item could not be added, or an earlier declined attempt was approved after all — so the plugin tried to refund that charge in full, but the refund did not go through (refused, or not settled within 48 hours). Refund the payment in Vonpay by the order id. Do not add the item."
133
+ },
134
+ upsell_refund_unclear: {
135
+ label: "Late offer charge — refund unclear",
136
+ what: "The buyer was charged for an offer whose item was NOT added to the order, and the plugin's automatic refund got an unclear answer: Vonpay reported an error, then recorded that refund as failed, but the money may have gone back anyway. The plugin will not send another refund. First check the payment in Vonpay by the order id: refund it there ONLY if no refund went through. Do not add the item."
133
137
  },
134
138
  upsell_refund_pending: {
135
139
  label: "Late offer charge — refund in progress",
@@ -14,5 +14,5 @@ async function POST(req, res) {
14
14
  }
15
15
  if ((0, upsell_routes_1.busy)(res, orderId))
16
16
  return;
17
- res.json(await found.provider.acceptUpsell({ providerId: found.id, orderId, variantId: typeof body.variant_id === "string" ? body.variant_id : "", expectedMinor: body.expected_amount_minor, token, customerId: (0, upsell_routes_1.customerFrom)(req), buyerIp: (0, upsell_routes_1.shopperIpFrom)(req) }));
17
+ res.json(await found.provider.acceptUpsell({ providerId: found.id, orderId, variantId: typeof body.variant_id === "string" ? body.variant_id : "", expectedMinor: body.expected_amount_minor, token, customerId: (0, upsell_routes_1.customerFrom)(req), buyerIp: (0, upsell_routes_1.shopperIpForRoute)(req, found.provider.shopperIpSecret()) }));
18
18
  }
@@ -13,5 +13,5 @@ async function POST(req, res) {
13
13
  }
14
14
  if ((0, upsell_routes_1.busy)(res, orderId))
15
15
  return;
16
- res.json(await found.provider.resumeUpsell({ providerId: found.id, orderId, token, customerId: (0, upsell_routes_1.customerFrom)(req), buyerIp: (0, upsell_routes_1.shopperIpFrom)(req) }));
16
+ res.json(await found.provider.resumeUpsell({ providerId: found.id, orderId, token, customerId: (0, upsell_routes_1.customerFrom)(req), buyerIp: (0, upsell_routes_1.shopperIpForRoute)(req, found.provider.shopperIpSecret()) }));
17
17
  }
@@ -16,6 +16,7 @@ exports.VONPAY_ERRORS = {
16
16
  options_mode_key_mismatch: "vonpay_options_mode_key_mismatch: mode does not match the secret key's mode",
17
17
  options_webhook_secret_invalid: "vonpay_options_webhook_secret_invalid: webhookSecret, when set, must be a string",
18
18
  options_webhook_secret_is_api_key: "vonpay_options_webhook_secret_is_api_key: webhookSecret must be the webhook signing secret, not a Vonpay API key",
19
+ options_shopper_ip_secret_invalid: "vonpay_options_shopper_ip_secret_invalid: shopperIpSecret, when set, must be its own random value of at least 32 characters (not a Vonpay key or the webhook secret)",
19
20
  options_storefront_url_invalid: "vonpay_options_storefront_url_invalid: storefrontUrl must be an https origin (http only for localhost in test mode), with no query or fragment",
20
21
  options_api_base_url_not_allowed: "vonpay_options_api_base_url_not_allowed: apiBaseUrl is a test-only option and accepts only the Vonpay staging host or a local address",
21
22
  options_hold_recheck_invalid: "vonpay_options_hold_recheck_invalid: holdRecheck must be true or false",
@@ -16,6 +16,7 @@ exports.readDescriptorConfig = readDescriptorConfig;
16
16
  exports.ruleKey = ruleKey;
17
17
  exports.sourcesInWords = sourcesInWords;
18
18
  exports.fallbackInWords = fallbackInWords;
19
+ const admin_holds_1 = require("./admin-holds");
19
20
  const errors_1 = require("../../lib/errors");
20
21
  const descriptor_rules_1 = require("./descriptor-rules");
21
22
  Object.defineProperty(exports, "DESCRIPTOR_VALUE_MAX", { enumerable: true, get: function () { return descriptor_rules_1.DESCRIPTOR_VALUE_MAX; } });
@@ -141,7 +142,7 @@ async function boundedDescriptorMetadata(option, readers, ms, skipped = () => un
141
142
  if (!option)
142
143
  return {};
143
144
  try {
144
- return await bounded(ms, () => descriptorMetadata(option, readers, skipped));
145
+ return await (0, admin_holds_1.within)(ms, () => descriptorMetadata(option, readers, skipped));
145
146
  }
146
147
  catch {
147
148
  skipped({ field: null, why: "deadline" });
@@ -151,28 +152,13 @@ async function boundedDescriptorMetadata(option, readers, ms, skipped = () => un
151
152
  function fieldsOf(option) {
152
153
  return new Set(option ? sourcesOf(option).filter((s) => typeof s === "string") : []);
153
154
  }
154
- async function bounded(ms, work) {
155
- let timer;
156
- try {
157
- return await Promise.race([
158
- work(),
159
- new Promise((_, reject) => {
160
- timer = setTimeout(() => reject(new Error("descriptor_read_deadline")), ms);
161
- }),
162
- ]);
163
- }
164
- finally {
165
- if (timer)
166
- clearTimeout(timer);
167
- }
168
- }
169
155
  async function readStoreName(query, ms) {
170
- const { data } = await bounded(ms, () => query.graph({ entity: "store", fields: ["id", "name"] }));
156
+ const { data } = await (0, admin_holds_1.within)(ms, () => query.graph({ entity: "store", fields: ["id", "name"] }));
171
157
  const rows = data.filter((r) => !!r);
172
158
  return rows.length === 1 ? (0, descriptor_rules_1.plainValue)(rows[0].name) : undefined;
173
159
  }
174
160
  async function readOrderDescriptorFacts(query, orderId, ms) {
175
- const { data } = await bounded(ms, () => query.graph({
161
+ const { data } = await (0, admin_holds_1.within)(ms, () => query.graph({
176
162
  entity: "order",
177
163
  fields: ["id", "display_id", "metadata", "items.id", "items.product_title", "items.title", "items.created_at"],
178
164
  filters: { id: orderId },
@@ -173,6 +173,23 @@ function upsellsFromCollection(c, now = Date.now()) {
173
173
  : !atBank && (r.state === "authorized" || r.state === "closing") && stale
174
174
  ? "upsell_hold_unsettled"
175
175
  : undefined;
176
+ for (const p of r?.prior_intents ?? []) {
177
+ if (p.stuck === undefined)
178
+ continue;
179
+ out.push({
180
+ vp_session_id: null,
181
+ medusa_payment_session_id: null,
182
+ payment_collection_id: c.id,
183
+ reason: p.stuck === "amount_mismatch" ? "amount_mismatch" : p.stuck === "refund_unclear" ? "upsell_refund_unclear" : "upsell_refund_failed",
184
+ source: "upsell",
185
+ held_at: isoOrNull(p.at),
186
+ order_id: medusaId(r.order_id),
187
+ payment_id: null,
188
+ retry_until: null,
189
+ status: "open",
190
+ resolution: null,
191
+ });
192
+ }
176
193
  if (!reason)
177
194
  continue;
178
195
  out.push({
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UPSELL_VARIANTS_MAX = exports.SAVED_CARDS_TERMS_MAX = exports.SAVED_CARDS_TERMS_MIN = void 0;
3
+ exports.UPSELL_VARIANTS_MAX = exports.SAVED_CARDS_TERMS_MAX = exports.SAVED_CARDS_TERMS_MIN = exports.SHOPPER_IP_SECRET_DISTINCT_MIN = exports.SHOPPER_IP_SECRET_MIN = void 0;
4
4
  exports.validateVonpayOptions = validateVonpayOptions;
5
5
  exports.successUrlFor = successUrlFor;
6
6
  exports.apiOrigin = apiOrigin;
7
7
  exports.webhookSecretOf = webhookSecretOf;
8
8
  const errors_1 = require("../../lib/errors");
9
9
  const descriptor_1 = require("./descriptor");
10
+ exports.SHOPPER_IP_SECRET_MIN = 32;
11
+ exports.SHOPPER_IP_SECRET_DISTINCT_MIN = 10;
10
12
  exports.SAVED_CARDS_TERMS_MIN = 20;
11
13
  exports.SAVED_CARDS_TERMS_MAX = 1_000;
12
14
  const VARIANT_ID = /^variant_[A-Za-z0-9]{1,64}$/;
@@ -86,6 +88,14 @@ function validateVonpayOptions(options) {
86
88
  }
87
89
  }
88
90
  (0, descriptor_1.validateStatementDescriptor)(o.statementDescriptor);
91
+ if (o.shopperIpSecret !== undefined && o.shopperIpSecret !== null && o.shopperIpSecret !== "") {
92
+ const v = o.shopperIpSecret;
93
+ const t = typeof v === "string" ? v.trim() : "";
94
+ const same = (x) => typeof x === "string" && x.trim() === t;
95
+ if (t.length < exports.SHOPPER_IP_SECRET_MIN || /^vp_(sk|pk)_/.test(t) || same(o.secretKey) || same(o.webhookSecret) || new Set(t).size < exports.SHOPPER_IP_SECRET_DISTINCT_MIN) {
96
+ throw (0, errors_1.vonpayError)("options_shopper_ip_secret_invalid");
97
+ }
98
+ }
89
99
  if (o.apiBaseUrl !== undefined) {
90
100
  if (o.mode !== "test")
91
101
  throw (0, errors_1.vonpayError)("options_api_base_url_not_allowed");
@@ -23,6 +23,7 @@ exports.decide = decide;
23
23
  exports.buyerDeclineOf = buyerDeclineOf;
24
24
  exports.retryAfterMsOf = retryAfterMsOf;
25
25
  exports.bankCheckUntilOf = bankCheckUntilOf;
26
+ exports.isoPlus = isoPlus;
26
27
  exports.shadowVerdict = shadowVerdict;
27
28
  const session_data_1 = require("./session-data");
28
29
  const KNOWN_NO_MONEY_PAYMENT = new Set(["unpaid", "cancelled"]);
@@ -192,8 +193,11 @@ function bankCheckUntilOf(s) {
192
193
  return null;
193
194
  if (s.outcome !== undefined && s.outcome !== null && s.outcome !== "in_flight")
194
195
  return null;
195
- const from = typeof s.updatedAt === "string" ? Date.parse(s.updatedAt) : NaN;
196
- return Number.isFinite(from) ? new Date(from + session_data_1.BANK_CHECK_MS).toISOString() : null;
196
+ return isoPlus(s.updatedAt, session_data_1.BANK_CHECK_MS);
197
+ }
198
+ function isoPlus(value, ms) {
199
+ const at = typeof value === "string" ? Date.parse(value) : NaN;
200
+ return Number.isFinite(at) ? new Date(at + ms).toISOString() : null;
197
201
  }
198
202
  exports.VERDICTS = ["open", "reopened", "in_flight", "declined", "authorized", "charged_once", "closed_no_charge", "needs_review"];
199
203
  function shadowVerdict(s, ours) {
@@ -256,7 +256,7 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
256
256
  async medusaSession(id, fields) {
257
257
  return (await this.medusaSessionLookup(id, fields)).rec;
258
258
  }
259
- async medusaSessionLookup(id, fields) {
259
+ async medusaSessionLookup(id, fields, opts = {}) {
260
260
  const svc = this.resolveOptional("paymentSessionService");
261
261
  try {
262
262
  if (!svc || typeof svc.retrieve !== "function")
@@ -265,7 +265,8 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
265
265
  }
266
266
  catch (err) {
267
267
  const missing = isNotFound(err);
268
- this.log(missing ? "warn" : "error", "medusa_record_unreadable", { record: "payment_session", medusa_session_id: safe(id), missing, ...errorFields(err) });
268
+ if (!(missing && opts.missingExpected))
269
+ this.log(missing ? "warn" : "error", "medusa_record_unreadable", { record: "payment_session", medusa_session_id: safe(id), missing, ...errorFields(err) });
269
270
  return { missing };
270
271
  }
271
272
  }
@@ -941,29 +942,68 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
941
942
  }),
942
943
  now: () => Date.now(),
943
944
  upsellAdded: (r, providerId) => this.announceUpsellAdded(providerId, r.order_id, r.acceptance_id),
944
- refundLate: (pi, amountMinor, currency) => this.refundLateUpsell(pi, amountMinor, currency),
945
+ refundLate: (pi, amountMinor, currency, step) => this.refundLateUpsell(pi, amountMinor, currency, step),
945
946
  statementDescriptor: () => this.options_.statementDescriptor,
946
947
  };
947
948
  }
948
- async refundLateUpsell(pi, amountMinor, currency) {
949
+ async refundLateUpsell(pi, amountMinor, currency, step) {
949
950
  if (!(0, session_data_2.isVpPaymentIntentIdShape)(pi) || (0, session_data_2.vpPaymentIntentMode)(pi) !== this.mode() || !Number.isInteger(amountMinor) || amountMinor < 1)
950
951
  return "failed";
951
- const ids = { vp_payment_intent_id: pi };
952
+ if (!Number.isInteger(step) || step < 0)
953
+ return "retry";
954
+ const ids = { vp_payment_intent_id: pi, step };
955
+ const ours = `vpm_upsell_late_rf_${pi}`;
956
+ if (step > 0) {
957
+ try {
958
+ const list = await this.vonpay((c) => c.refunds.list({ paymentIntent: pi }, { limit: 100 }));
959
+ const rows = Array.isArray(list.data) ? list.data : [];
960
+ const done = typeof list.amountRefunded === "number" ? list.amountRefunded : NaN;
961
+ if (!Number.isFinite(done))
962
+ return "unread";
963
+ const mine = (r) => typeof r.idempotencyKey === "string" && r.idempotencyKey.startsWith(ours);
964
+ if (rows.some((r) => mine(r) && REFUND_CONFIRMED.has(String(r.status)))) {
965
+ this.log("info", "refund_transport", { ...ids, outcome: "already_refunded" });
966
+ return "refunded";
967
+ }
968
+ if (done > 0) {
969
+ this.log("warn", "refund_transport", { ...ids, outcome: "refunded_elsewhere" });
970
+ return "failed";
971
+ }
972
+ if (list.refundable === false) {
973
+ this.log("info", "refund_transport", { ...ids, outcome: "not_refundable_yet" });
974
+ return "unsettled";
975
+ }
976
+ }
977
+ catch (err) {
978
+ const reading = readRefundError(err);
979
+ this.log("warn", "refund_transport", { ...ids, outcome: "list_unreadable", ...errorFields(err) });
980
+ return reading.kind === "unknown" ? "unread" : "failed";
981
+ }
982
+ }
983
+ const key = `vpm_upsell_late_rf_${pi}${step === 0 ? "" : `_s${step}`}`;
952
984
  try {
953
- const refund = await this.vonpay((c) => c.refunds.create({ paymentIntent: pi, amount: amountMinor, currency }, { idempotencyKey: `vpm_upsell_late_rf_${pi}` }));
985
+ const refund = await this.vonpay((c) => c.refunds.create({ paymentIntent: pi, amount: amountMinor, currency }, { idempotencyKey: key }));
986
+ const replay = refund.idempotent === true;
954
987
  const status = String(refund.status);
955
988
  if (REFUND_CONFIRMED.has(status))
956
989
  return "refunded";
957
- if (REFUND_DEFINITELY_NOT_MADE.has(status))
990
+ if (REFUND_DEFINITELY_NOT_MADE.has(status)) {
991
+ if (replay) {
992
+ this.log("info", "refund_transport", { ...ids, outcome: "key_spent" });
993
+ return "spent";
994
+ }
958
995
  return "failed";
996
+ }
959
997
  this.log("warn", "refund_transport", { ...ids, outcome: "unrecognised_status" });
960
998
  return "retry";
961
999
  }
962
1000
  catch (err) {
963
1001
  const reading = readRefundError(err);
964
- if (reading.kind === "nothing_left")
965
- return "refunded";
966
- if (reading.kind === "refused" || reading.kind === "key_conflict") {
1002
+ if (reading.kind === "refused" && reading.medusa === "refund_before_settlement") {
1003
+ this.log("info", "refund_transport", { ...ids, outcome: "before_settlement" });
1004
+ return "unsettled";
1005
+ }
1006
+ if (reading.kind === "nothing_left" || reading.kind === "refused" || reading.kind === "key_conflict") {
967
1007
  this.log("warn", "refund_transport", { ...ids, outcome: "refused", ...errorFields(err) });
968
1008
  return "failed";
969
1009
  }
@@ -1615,6 +1655,10 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
1615
1655
  const wrote = await this.writeOfferRecord(c.id, record);
1616
1656
  return wrote ? { ok: true, record: wrote } : { ok: false, why: "record_not_written" };
1617
1657
  }
1658
+ shopperIpSecret() {
1659
+ const v = this.options_.shopperIpSecret?.trim();
1660
+ return v ? v : undefined;
1661
+ }
1618
1662
  upsellsEnabled() {
1619
1663
  return !!this.options_.upsells;
1620
1664
  }
@@ -1664,7 +1708,8 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
1664
1708
  const done = { v: 1, order_id: record.order_id, revoked, at: new Date().toISOString() };
1665
1709
  if (!(await this.writeOfferDone(collectionId, done)))
1666
1710
  return { outcome: "retry", why: "record" };
1667
- this.log("info", "upsell_cleanup_done", { ...ids, revoked: revoked.length, guest: record.buyer_ref === null });
1711
+ const unrecorded = record.buyer_ref === null && !record.vp_payment_method_id;
1712
+ this.log(unrecorded ? "warn" : "info", "upsell_cleanup_done", { ...ids, revoked: revoked.length, guest: record.buyer_ref === null, ...(unrecorded ? { card_recorded: false } : {}) });
1668
1713
  return { outcome: "done", revoked };
1669
1714
  }
1670
1715
  async writeOfferDone(collectionId, value) {
@@ -1917,7 +1962,7 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
1917
1962
  return { sent: true, delivered: out.delivered === true, response_status: typeof out.responseStatus === "number" ? out.responseStatus : null };
1918
1963
  }
1919
1964
  catch (err) {
1920
- if (err instanceof checkout_node_1.VonPayError && err.status === 502)
1965
+ if (err instanceof checkout_node_1.VonPayError && (err.code === "webhook_test_delivery_failed" || err.status === 502))
1921
1966
  return { sent: true, delivered: false, response_status: null, unreachable: true };
1922
1967
  return { sent: false, why: "failed" };
1923
1968
  }
@@ -3386,7 +3431,8 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
3386
3431
  };
3387
3432
  let updated;
3388
3433
  try {
3389
- updated = await this.vonpay((c) => c.sessions.update(vpSessionId, { amount: amountMinor, currency }));
3434
+ const meta = await descriptor();
3435
+ updated = await this.vonpay((c) => c.sessions.update(vpSessionId, { amount: amountMinor, currency, ...(Object.keys(meta).length > 0 ? { metadata: meta } : {}) }));
3390
3436
  }
3391
3437
  catch (err) {
3392
3438
  if (!(err instanceof checkout_node_1.SessionNotModifiableError))
@@ -4023,7 +4069,12 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
4023
4069
  return { action: utils_1.PaymentActions.SUCCESSFUL, data: { session_id: medusaSessionId, amount: (0, money_1.fromMinorUnits)(s.amount, s.currency) }, changedAt };
4024
4070
  }
4025
4071
  if ((0, reading_1.definitivelyNoCharge)(s)) {
4026
- const rec = await this.medusaSession(medusaSessionId, ["payment_collection_id"]);
4072
+ const found = await this.medusaSessionLookup(medusaSessionId, ["payment_collection_id"], { missingExpected: true });
4073
+ if (found.missing) {
4074
+ this.log("info", "webhook_not_actionable", { ...evt, vp_session_id: vpSessionId, why: "session_gone" });
4075
+ return { action: utils_1.PaymentActions.NOT_SUPPORTED, why: "session_gone" };
4076
+ }
4077
+ const rec = found.rec;
4027
4078
  if (await this.closedInHeldList(rec, vpSessionId))
4028
4079
  return notActionable("closed");
4029
4080
  return { action: utils_1.PaymentActions.FAILED, data: { session_id: medusaSessionId, amount: (0, money_1.fromMinorUnits)(s.amount, s.currency) } };
@@ -49,6 +49,7 @@ exports.offerCleanupDone = offerCleanupDone;
49
49
  exports.offerOrderIdsOf = offerOrderIdsOf;
50
50
  exports.upsellJobOwns = upsellJobOwns;
51
51
  exports.upsellAwaitsRecheck = upsellAwaitsRecheck;
52
+ exports.priorOwed = priorOwed;
52
53
  exports.upsellDescriptorOf = upsellDescriptorOf;
53
54
  exports.acceptanceIdOf = acceptanceIdOf;
54
55
  exports.isUpsellRecord = isUpsellRecord;
@@ -94,6 +95,7 @@ const HOLD_REASONS = [
94
95
  "upsell_hold_unsettled",
95
96
  "upsell_bank_check_open",
96
97
  "upsell_refund_failed",
98
+ "upsell_refund_unclear",
97
99
  "upsell_refund_pending",
98
100
  "upsell_refunded_late",
99
101
  ];
@@ -546,6 +548,7 @@ exports.UPSELL_HOLD_REASONS = new Set([
546
548
  "upsell_hold_not_released",
547
549
  "upsell_take_unconfirmed",
548
550
  "upsell_refund_failed",
551
+ "upsell_refund_unclear",
549
552
  ]);
550
553
  exports.SEALED_HOLD_SESSION = /^[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{24,400}$/;
551
554
  exports.UPSELL_WALLET_ONLY_STATES = new Set(["tap", "authorized", "taking", "released"]);
@@ -558,6 +561,7 @@ function upsellJobOwns(r) {
558
561
  r.state === "stopped" ||
559
562
  r.state === "refunding" ||
560
563
  upsellAwaitsRecheck(r) ||
564
+ priorOwed(r).length > 0 ||
561
565
  (r.state === "held" && r.hold_reason !== null && exports.UPSELL_JOB_HOLD_REASONS.has(r.hold_reason)));
562
566
  }
563
567
  function upsellAwaitsRecheck(r) {
@@ -565,6 +569,9 @@ function upsellAwaitsRecheck(r) {
565
569
  }
566
570
  exports.UPSELL_RELEASE_REASONS = ["out_of_stock", "amount_changed", "order_busy", "edit_failed", "hold_lost", "abandoned", "session_unusable", "declined"];
567
571
  exports.UPSELL_MAX_ATTEMPTS = 3;
572
+ function priorOwed(r) {
573
+ return (r.prior_intents ?? []).filter((p) => p.stuck === undefined);
574
+ }
568
575
  function upsellDescriptorOf(r) {
569
576
  return typeof r.descriptor_key === "string" && typeof r.descriptor_value === "string" ? { [r.descriptor_key]: r.descriptor_value } : {};
570
577
  }
@@ -606,6 +613,24 @@ function isUpsellRecord(v) {
606
613
  }
607
614
  if (r.rechecked === true && (wallet || !["declined", "skipped", "refunding", "refunded"].includes(r.state)))
608
615
  return false;
616
+ if (r.refund_unseen_step !== undefined && (r.state !== "refunding" || !Number.isInteger(r.refund_unseen_step) || r.refund_unseen_step < 0))
617
+ return false;
618
+ if (r.prior_intents !== undefined) {
619
+ const list = r.prior_intents;
620
+ if (wallet || !Array.isArray(list) || list.length < 1 || list.length > exports.UPSELL_MAX_ATTEMPTS - 1)
621
+ return false;
622
+ for (const p of list) {
623
+ const x = p;
624
+ if (!x || typeof x !== "object" || !isVpPaymentIntentIdShape(x.id) || !Number.isInteger(x.amount_minor) || x.amount_minor < 1)
625
+ return false;
626
+ if (typeof x.currency !== "string" || !CURRENCY.test(x.currency) || typeof x.at !== "string" || !Number.isFinite(Date.parse(x.at)))
627
+ return false;
628
+ if (!(x.stuck === undefined || x.stuck === "refund_failed" || x.stuck === "amount_mismatch" || x.stuck === "refund_unclear"))
629
+ return false;
630
+ if (!(x.unseen_step === undefined || (Number.isInteger(x.unseen_step) && x.unseen_step >= 0)))
631
+ return false;
632
+ }
633
+ }
609
634
  if (exports.UPSELL_STOP_STATES.has(r.state) && ((wallet && r.state === "stopped") || r.vp_payment_intent_id === null || r.amount_minor === null))
610
635
  return false;
611
636
  if (!wallet && (r.vp_session_sealed !== undefined || r.release_reason !== undefined))
@@ -6,6 +6,7 @@ exports.requestHost = requestHost;
6
6
  exports.hostOf = hostOf;
7
7
  exports.infrastructureItems = infrastructureItems;
8
8
  exports.upsellEmailItem = upsellEmailItem;
9
+ exports.shopperIpItem = shopperIpItem;
9
10
  exports.webhookPathFor = webhookPathFor;
10
11
  exports.runSetupCheck = runSetupCheck;
11
12
  exports.regionsItem = regionsItem;
@@ -134,6 +135,16 @@ function upsellEmailItem(cfg) {
134
135
  fix: 'Add a Medusa notification provider with channels ["email"] (for example SendGrid or Resend) in medusa-config, or set upsells.email: false if your store sends its own.',
135
136
  };
136
137
  }
138
+ function shopperIpItem(secretSet) {
139
+ return secretSet
140
+ ? { id: "shopper_ip", status: "pass", detail: "shopperIpSecret is set: offer charges carry the shopper's address when the storefront signs it with the same value." }
141
+ : {
142
+ id: "shopper_ip",
143
+ status: "warn",
144
+ detail: "One-click upsells are on, but shopperIpSecret is not set, so offer charges carry no shopper address for the processor's fraud checks.",
145
+ fix: "Set VONPAY_SHOPPER_IP_SECRET (a long random value, e.g. openssl rand -hex 32) to the SAME value in the Medusa server's and the storefront's environment, and VONPAY_SHOPPER_IP_HEADER in the storefront's.",
146
+ };
147
+ }
137
148
  function webhookPathFor(providerId) {
138
149
  return `/hooks/payment/${providerId.replace(/^pp_/, "")}`;
139
150
  }
@@ -177,6 +188,9 @@ async function runSetupCheck(scope, settings = {}) {
177
188
  items.push(...list);
178
189
  if (providers.some(({ provider: p }) => p.upsellEmailEnabled?.() === true))
179
190
  items.push(upsellEmailItem(cfg));
191
+ const upselling = providers.filter(({ provider: p }) => p.upsellsEnabled?.() === true);
192
+ if (upselling.length > 0)
193
+ items.push(shopperIpItem(upselling.every(({ provider: p }) => !!p.shopperIpSecret?.())));
180
194
  items.push(...infra);
181
195
  const unverified = items.filter((i) => i.status === "not_checked").length;
182
196
  return { ok: !items.some((i) => i.status === "fail"), complete: unverified === 0, unverified, items };
@@ -29,6 +29,11 @@ function readShipOverride(meta, orderId) {
29
29
  }
30
30
  const PAID_NOT_ADDED = new Set(["upsell_paid_not_added", "upsell_paid_unsettled", "upsell_outcome_unknown", "upsell_not_settled", "amount_mismatch", "upsell_take_unconfirmed"]);
31
31
  const ADDING_PAST_STOP = new Set(["charged", "taking"]);
32
+ function noOfferPossible(offer, acted) {
33
+ if (offer.kind === "card" && !offer.vp_payment_method_id)
34
+ return true;
35
+ return acted.length >= upsell_accept_1.OFFERS_SHOWN_PER_ORDER && !acted.some(session_data_1.upsellMayRetry);
36
+ }
32
37
  function shipHoldOf(f, now) {
33
38
  const overridden = f.collections.some((c) => readShipOverride(c.metadata, f.id) !== undefined);
34
39
  if (f.status === "canceled" || f.status === "archived" || f.fulfilled)
@@ -41,10 +46,12 @@ function shipHoldOf(f, now) {
41
46
  const reads = f.collections.flatMap((c) => (0, session_data_1.upsellRecordsOf)(c.metadata).map((x) => x.read));
42
47
  if (reads.some((x) => x.kind === "record" && x.r.state === "held" && x.r.hold_reason !== null && PAID_NOT_ADDED.has(x.r.hold_reason)))
43
48
  return { hold: "paid", until: null, overridden };
44
- const stopped = now >= (0, upsell_token_1.offerStopMs)(offer.r.at);
49
+ const stopped = now >= (0, upsell_token_1.orderOfferStopMs)(f.created_ms, now);
45
50
  const busy = reads.some((x) => x.kind === "unrecognised" || (x.kind === "record" && (stopped ? ADDING_PAST_STOP.has(x.r.state) : session_data_1.UPSELL_IN_FLIGHT_STATES.has(x.r.state))));
46
51
  if (busy)
47
52
  return { hold: "in_flight", until: null, overridden };
53
+ if (noOfferPossible(offer.r, reads.flatMap((x) => (x.kind === "record" ? [x.r] : []))))
54
+ return { hold: "none", until: null, overridden };
48
55
  const end = Math.min(f.created_ms, now) + upsell_token_1.OFFER_WINDOW_MS;
49
56
  return end > now ? { hold: "open", until: new Date(end).toISOString(), overridden } : { hold: "none", until: null, overridden };
50
57
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.upsellHoldKey = exports.UPSELL_HOLD_EXPIRES_S = exports.UPSELL_SESSION_MARKER = exports.UPSELL_SETTLE = exports.UPSELL_RECHECK_AFTER_MS = exports.OFFER_PENDING_MS = exports.OFFERS_SHOWN_PER_ORDER = exports.upsellIdempotencyKey = exports.RESUME_ADDED_MS = exports.UPSELL_BANK_LINK_MS = exports.UPSELL_WORKFLOWS = void 0;
3
+ exports.upsellHoldKey = exports.UPSELL_HOLD_EXPIRES_S = exports.UPSELL_SESSION_MARKER = exports.UPSELL_SETTLE = exports.UPSELL_RECHECK_AFTER_MS = exports.OFFER_PENDING_MS = exports.OFFERS_SHOWN_PER_ORDER = exports.upsellIdempotencyKey = exports.RESUME_ADDED_MS = exports.UPSELL_LATE_REFUND_KEY_MS = exports.UPSELL_UNSETTLED_REFUND_MS = exports.UPSELL_BANK_LINK_MS = exports.UPSELL_WORKFLOWS = void 0;
4
+ exports.lateRefundStep = lateRefundStep;
4
5
  exports.readUpsellOrderFacts = readUpsellOrderFacts;
5
6
  exports.offerOf = offerOf;
6
7
  exports.offerStopped = offerStopped;
@@ -47,6 +48,12 @@ exports.UPSELL_WORKFLOWS = Object.freeze({
47
48
  });
48
49
  const OPEN_COLLECTION = new Set(["not_paid", "awaiting", "authorized", "partially_authorized"]);
49
50
  exports.UPSELL_BANK_LINK_MS = 30 * 60_000;
51
+ exports.UPSELL_UNSETTLED_REFUND_MS = 48 * 60 * 60_000;
52
+ exports.UPSELL_LATE_REFUND_KEY_MS = 2 * 60 * 60_000;
53
+ function lateRefundStep(nowMs, since) {
54
+ const at = since === null ? NaN : Date.parse(since);
55
+ return Number.isFinite(at) ? Math.max(0, ((nowMs - at) / exports.UPSELL_LATE_REFUND_KEY_MS) | 0) : 0;
56
+ }
50
57
  exports.RESUME_ADDED_MS = 2 * 60_000;
51
58
  async function readUpsellOrderFacts(query, orderId, ms, unreadable = () => { }) {
52
59
  const { data } = await (0, admin_holds_1.within)(ms, () => query.graph({
@@ -134,8 +141,9 @@ function offerOf(f) {
134
141
  }
135
142
  const upsellIdempotencyKey = (orderId, acceptanceId, attempt) => `vpm_upsell_${orderId}_${acceptanceId}_${attempt}`;
136
143
  exports.upsellIdempotencyKey = upsellIdempotencyKey;
137
- function offerStopped(host, f, offer) {
138
- return f.fulfilled || host.now() >= (0, upsell_token_1.offerStopMs)(offer.at);
144
+ function offerStopped(host, f) {
145
+ const now = host.now();
146
+ return f.fulfilled || f.status === "canceled" || f.status === "archived" || now >= (0, upsell_token_1.orderOfferStopMs)(f.created_ms, now);
139
147
  }
140
148
  function passBindsOrder(host, f, offer, token, asker) {
141
149
  if (!f.cart_id)
@@ -495,7 +503,7 @@ async function latestOutcome(host, input, orderId, latest) {
495
503
  case "released":
496
504
  return { outcome: "released", why: latest.release_reason ?? "abandoned", retryable: (0, session_data_1.upsellMayRetry)(latest) };
497
505
  case "held":
498
- return latest.hold_reason === "upsell_refund_failed" ? { outcome: "refunded", amount_minor: latest.amount_minor, currency: latest.currency, pending: true } : { outcome: "unknown" };
506
+ return latest.hold_reason === "upsell_refund_failed" || latest.hold_reason === "upsell_refund_unclear" ? { outcome: "refunded", amount_minor: latest.amount_minor, currency: latest.currency, pending: true } : { outcome: "unknown" };
499
507
  case "stopped":
500
508
  return { outcome: "declined", retryable: false, decline_kind: "bank_check_unfinished" };
501
509
  case "refunding":
@@ -505,7 +513,7 @@ async function latestOutcome(host, input, orderId, latest) {
505
513
  default: {
506
514
  const f = await facts(host, orderId);
507
515
  const found = f ? offerOf(f) : undefined;
508
- if (f && found && offerStopped(host, f, found.offer)) {
516
+ if (f && found && offerStopped(host, f)) {
509
517
  await settleUpsell(host, { providerId: input.providerId, orderId, acceptanceId: latest.acceptance_id });
510
518
  const now = await facts(host, orderId);
511
519
  const read = now ? (0, session_data_1.readUpsellRecord)(now.collections.find((c) => c.id === found.collectionId)?.metadata, latest.acceptance_id) : undefined;
@@ -701,7 +709,7 @@ async function acceptLocked(host, providerId, orderId, variantId, expected, toke
701
709
  case "declined":
702
710
  if (!(0, session_data_1.upsellMayRetry)(r))
703
711
  return refuse("offer_closed", "attempts_exhausted");
704
- return newAttempt(ctx, expected, r.attempt + 1, r.at, r.order_change_id);
712
+ return newAttempt(ctx, expected, r.attempt + 1, r);
705
713
  default:
706
714
  return { outcome: "unknown" };
707
715
  }
@@ -711,10 +719,12 @@ async function acceptLocked(host, providerId, orderId, variantId, expected, toke
711
719
  return refuse("offer_closed", "offers_per_order");
712
720
  if (f.variant_ids.includes(variantId))
713
721
  return refuse("offer_unavailable", "already_in_order");
714
- return newAttempt(ctx, expected, 1, null, null);
722
+ return newAttempt(ctx, expected, 1, null);
715
723
  }
716
724
  const WORKFLOWS_PER_ATTEMPT = 6;
717
- async function newAttempt(ctx, expected, attempt, priorAt, priorChangeId) {
725
+ async function newAttempt(ctx, expected, attempt, prior) {
726
+ const priorAt = prior?.at ?? null;
727
+ const priorChangeId = prior?.order_change_id ?? null;
718
728
  const { host, offer, ids } = ctx;
719
729
  const refuse = (code, why, level = "warn") => {
720
730
  host.log(level, "upsell_refused", { ...ids, why });
@@ -770,12 +780,19 @@ async function newAttempt(ctx, expected, attempt, priorAt, priorChangeId) {
770
780
  host.log("warn", "upsell_refused", { ...ids, why: "amount_changed", amount_minor: amountMinor, shown_minor: expected });
771
781
  return amountMinor >= 1 ? { outcome: "amount_changed", amount_minor: amountMinor, currency: f.currency } : { outcome: "refused", code: "offer_unavailable" };
772
782
  }
783
+ const owed = [
784
+ ...(prior?.prior_intents ?? []),
785
+ ...(prior && (0, session_data_1.upsellAwaitsRecheck)(prior) && prior.amount_minor !== null && prior.currency !== null
786
+ ? [{ id: prior.vp_payment_intent_id, amount_minor: prior.amount_minor, currency: prior.currency, at: prior.at }]
787
+ : []),
788
+ ];
773
789
  const sending = {
774
790
  ...blank(f.id, ctx.variantId, ctx.acc, "sending", attempt, host.now()),
775
791
  order_change_id: changeId,
776
792
  amount_minor: amountMinor,
777
793
  currency: f.currency,
778
794
  ...(await upsellDescriptor(ctx, ctx.variantId)),
795
+ ...(owed.length > 0 ? { prior_intents: owed } : {}),
779
796
  };
780
797
  if (!(await host.writeUpsell(ctx.collectionId, sending, priorAt === null ? { expect: "none" } : { expect: "at", at: priorAt }))) {
781
798
  await cancelEdit(ctx, changeId);
@@ -891,6 +908,8 @@ async function advance(ctx, from, next) {
891
908
  for (const k of Object.keys(value))
892
909
  if (value[k] === undefined)
893
910
  delete value[k];
911
+ if (value.state !== "refunding")
912
+ delete value.refund_unseen_step;
894
913
  if (!(0, session_data_1.isUpsellRecord)(value)) {
895
914
  ctx.host.log("error", "upsell_record_failed", { ...ctx.ids, state: next.state, why: "invalid_record", vp_payment_intent_id: from.vp_payment_intent_id });
896
915
  return undefined;
@@ -975,9 +994,7 @@ async function afterAction(ctx, r) {
975
994
  return onAnswer(ctx, r, pi);
976
995
  }
977
996
  function linkUntil(pi) {
978
- const issued = pi.nextAction?.issuedAt;
979
- const at = typeof issued === "string" ? Date.parse(issued) : NaN;
980
- return Number.isFinite(at) ? new Date(at + exports.UPSELL_BANK_LINK_MS).toISOString() : null;
997
+ return (0, reading_1.isoPlus)(pi.nextAction?.issuedAt, exports.UPSELL_BANK_LINK_MS);
981
998
  }
982
999
  async function refundLate(ctx, r0, extra = {}) {
983
1000
  const { host } = ctx;
@@ -991,21 +1008,66 @@ async function refundLate(ctx, r0, extra = {}) {
991
1008
  if (!r)
992
1009
  return { outcome: "unknown" };
993
1010
  const answer = (confirmed) => ({ outcome: "refunded", amount_minor: r.amount_minor, currency: r.currency, ...(confirmed ? {} : { pending: true }) });
994
- const res = await host.refundLate(r.vp_payment_intent_id, r.amount_minor, r.currency);
1011
+ const stuck = r0.state === "refunding" && host.now() - Date.parse(r0.at) >= 60 * 60_000;
1012
+ if (!undone) {
1013
+ host.log(stuck ? "error" : "warn", "upsell_late_refund", { ...ids, result: "edit_open" });
1014
+ return answer(false);
1015
+ }
1016
+ const now = await facts(host, ctx.f.id);
1017
+ if (!now)
1018
+ return answer(false);
1019
+ if (now.variant_ids.includes(r.variant_id)) {
1020
+ host.log("error", "upsell_late_refund", { ...ids, result: "item_on_order" });
1021
+ await advance(ctx, r, { state: "held", hold_reason: "upsell_paid_unrecorded", decline_kind: null, rechecked: undefined });
1022
+ return { outcome: "unknown" };
1023
+ }
1024
+ if (!refundFitsLock(ctx)) {
1025
+ host.log("warn", "upsell_late_refund", { ...ids, result: "lock_time" });
1026
+ return answer(false);
1027
+ }
1028
+ const step = r.refund_unseen_step ?? lateRefundStep(host.now(), r0.state === "refunding" ? r0.at : null);
1029
+ const res = await host.refundLate(r.vp_payment_intent_id, r.amount_minor, r.currency, step);
995
1030
  if (res === "refunded") {
996
- host.log("info", "upsell_late_refund", { ...ids, result: "refunded", edit_undone: undone });
997
- return answer(undone && !!(await advance(ctx, r, { state: "refunded" })));
1031
+ host.log("info", "upsell_late_refund", { ...ids, result: "refunded" });
1032
+ return answer(!!(await advance(ctx, r, { state: "refunded" })));
998
1033
  }
999
- if (res === "failed") {
1000
- host.log("error", "upsell_late_refund", { ...ids, result: "failed" });
1001
- await advance(ctx, r, { state: "held", hold_reason: "upsell_refund_failed", decline_kind: null, rechecked: undefined });
1034
+ if (res === "spent" && r.refund_unseen_step !== undefined) {
1035
+ host.log("error", "upsell_late_refund", { ...ids, result: "unclear", step });
1036
+ await advance(ctx, r, { state: "held", hold_reason: "upsell_refund_unclear", decline_kind: null, rechecked: undefined, refund_unseen_step: undefined });
1037
+ return answer(false);
1038
+ }
1039
+ const waiting = res === "unsettled" || res === "spent";
1040
+ const waitedOut = waiting && r0.state === "refunding" && host.now() - Date.parse(r0.at) >= exports.UPSELL_UNSETTLED_REFUND_MS;
1041
+ if (res === "failed" || waitedOut) {
1042
+ host.log("error", "upsell_late_refund", { ...ids, result: res === "failed" ? "failed" : "not_settled" });
1043
+ await advance(ctx, r, { state: "held", hold_reason: r.refund_unseen_step !== undefined ? "upsell_refund_unclear" : "upsell_refund_failed", decline_kind: null, rechecked: undefined, refund_unseen_step: undefined });
1044
+ }
1045
+ else if (waiting) {
1046
+ host.log("info", "upsell_late_refund", { ...ids, result: "not_settled_yet" });
1047
+ }
1048
+ else if (res === "unread") {
1049
+ host.log(stuck ? "error" : "warn", "upsell_late_refund", { ...ids, result: "list_unreadable", step });
1002
1050
  }
1003
1051
  else {
1004
- const stuck = ctx.host.now() - Date.parse(r0.at) >= 60 * 60_000 && r0.state === "refunding";
1005
- host.log(stuck ? "error" : "warn", "upsell_late_refund", { ...ids, result: stuck ? "still_pending" : "retry" });
1052
+ if (r.refund_unseen_step === undefined)
1053
+ await writeSameAt(ctx, r, { refund_unseen_step: step });
1054
+ host.log(stuck ? "error" : "warn", "upsell_late_refund", { ...ids, result: stuck ? "still_pending" : "retry", step });
1006
1055
  }
1007
1056
  return answer(false);
1008
1057
  }
1058
+ function refundFitsLock(ctx) {
1059
+ const t = ctx.host.timing();
1060
+ return ctx.lockRemainingMs() >= 2 * t.vonpayMs + t.sendMarginMs;
1061
+ }
1062
+ async function writeSameAt(ctx, r, patch) {
1063
+ const value = { ...r, ...patch };
1064
+ for (const k of Object.keys(value))
1065
+ if (value[k] === undefined)
1066
+ delete value[k];
1067
+ if (!(0, session_data_1.isUpsellRecord)(value))
1068
+ return r;
1069
+ return (await ctx.host.writeUpsell(ctx.collectionId, value, { expect: "at", at: r.at })) ? value : r;
1070
+ }
1009
1071
  async function stopOpenCheck(ctx, r) {
1010
1072
  if (!(await cancelOwnEdit(ctx, r)))
1011
1073
  return "waiting";
@@ -1021,12 +1083,96 @@ async function readStoppedIntent(ctx, r) {
1021
1083
  return { kind: "mismatch", declineKind: null, pi };
1022
1084
  return { kind: "charged", declineKind: null, pi };
1023
1085
  }
1024
- if (pi.status === "failed" || pi.status === "voided") {
1086
+ if (pi.status === "failed" || pi.status === "voided" || pi.status === "cancelled") {
1025
1087
  const code = pi.declineCode;
1026
1088
  return { kind: "ended", declineKind: code === "authentication_not_completed" ? "bank_check_unfinished" : null, pi };
1027
1089
  }
1028
1090
  return { kind: "open", declineKind: null, pi };
1029
1091
  }
1092
+ async function recheckPrior(ctx, r) {
1093
+ const { host } = ctx;
1094
+ const all = r.prior_intents ?? [];
1095
+ const next = [];
1096
+ let changed = false;
1097
+ for (const p of all) {
1098
+ if (p.stuck !== undefined) {
1099
+ next.push(p);
1100
+ continue;
1101
+ }
1102
+ const age = host.now() - Date.parse(p.at);
1103
+ if (age < exports.UPSELL_RECHECK_AFTER_MS) {
1104
+ next.push(p);
1105
+ continue;
1106
+ }
1107
+ const ids = { ...ctx.ids, vp_payment_intent_id: p.id, amount_minor: p.amount_minor, prior: true };
1108
+ const stuck = (why) => {
1109
+ next.push({ ...p, stuck: why });
1110
+ changed = true;
1111
+ };
1112
+ const pi = await readIntent(host, p.id);
1113
+ if (!pi) {
1114
+ next.push(p);
1115
+ continue;
1116
+ }
1117
+ if (pi.status === "failed" || pi.status === "voided" || pi.status === "cancelled") {
1118
+ host.log("info", "upsell_rechecked", { ...ids, result: "ended" });
1119
+ changed = true;
1120
+ continue;
1121
+ }
1122
+ if (pi.status !== "succeeded" && pi.status !== "captured") {
1123
+ next.push(p);
1124
+ continue;
1125
+ }
1126
+ if (pi.amount !== p.amount_minor || typeof pi.currency !== "string" || pi.currency.toUpperCase() !== p.currency) {
1127
+ host.log("error", "upsell_outcome_unknown", { ...ids, why: "prior_charge_amount_mismatch" });
1128
+ stuck("amount_mismatch");
1129
+ continue;
1130
+ }
1131
+ if (!refundFitsLock(ctx)) {
1132
+ host.log("warn", "upsell_late_refund", { ...ids, result: "lock_time" });
1133
+ next.push(p);
1134
+ continue;
1135
+ }
1136
+ host.log("warn", "upsell_charged_after_stop", ids);
1137
+ const step = p.unseen_step ?? lateRefundStep(host.now(), p.at);
1138
+ const res = await host.refundLate(p.id, p.amount_minor, p.currency, step);
1139
+ const waiting = res === "unsettled" || (res === "spent" && p.unseen_step === undefined);
1140
+ const waitedOut = waiting && age >= exports.UPSELL_UNSETTLED_REFUND_MS;
1141
+ if (res === "refunded") {
1142
+ host.log("info", "upsell_late_refund", { ...ids, result: "refunded" });
1143
+ changed = true;
1144
+ }
1145
+ else if (res === "spent" && p.unseen_step !== undefined) {
1146
+ host.log("error", "upsell_late_refund", { ...ids, result: "unclear", step });
1147
+ stuck("refund_unclear");
1148
+ }
1149
+ else if (res === "failed" || waitedOut) {
1150
+ host.log("error", "upsell_late_refund", { ...ids, result: res === "failed" ? "failed" : "not_settled" });
1151
+ stuck(p.unseen_step !== undefined ? "refund_unclear" : "refund_failed");
1152
+ }
1153
+ else if (waiting) {
1154
+ host.log("info", "upsell_late_refund", { ...ids, result: "not_settled_yet" });
1155
+ next.push(p);
1156
+ }
1157
+ else if (res === "unread") {
1158
+ host.log("warn", "upsell_late_refund", { ...ids, result: "list_unreadable", step });
1159
+ next.push(p);
1160
+ }
1161
+ else {
1162
+ host.log("warn", "upsell_late_refund", { ...ids, result: "retry", step });
1163
+ if (p.unseen_step === undefined)
1164
+ changed = true;
1165
+ next.push({ ...p, unseen_step: step });
1166
+ }
1167
+ }
1168
+ if (!changed)
1169
+ return r;
1170
+ const { prior_intents: _old, ...rest } = r;
1171
+ const value = next.length > 0 ? { ...rest, prior_intents: next } : rest;
1172
+ if (!(0, session_data_1.isUpsellRecord)(value))
1173
+ return r;
1174
+ return (await host.writeUpsell(ctx.collectionId, value, { expect: "at", at: r.at })) ? value : r;
1175
+ }
1030
1176
  function lateSettled(out) {
1031
1177
  return out.outcome === "refunded" && !out.pending ? "refunded" : "waiting";
1032
1178
  }
@@ -1054,12 +1200,17 @@ async function settleUpsell(host, input) {
1054
1200
  const read = (0, session_data_1.readUpsellRecord)(f.collections.find((c) => c.id === found.collectionId)?.metadata, input.acceptanceId);
1055
1201
  if (read.kind !== "record")
1056
1202
  return "none";
1057
- const r = read.r;
1203
+ let r = read.r;
1058
1204
  const age = host.now() - Date.parse(r.at);
1059
- const stop = offerStopped(host, f, found.offer);
1205
+ const stop = offerStopped(host, f);
1060
1206
  if (age < exports.UPSELL_SETTLE.afterMs && !stop)
1061
1207
  return "waiting";
1062
1208
  const ctx = { host, providerId: input.providerId, f, offer: found.offer, collectionId: found.collectionId, acc: r.acceptance_id, variantId: r.variant_id, ids: { order_id: orderId, variant_id: r.variant_id, step: "settle" }, lockRemainingMs: lock.lockRemainingMs };
1209
+ if ((0, session_data_1.priorOwed)(r).length > 0 && !session_data_1.UPSELL_IN_FLIGHT_STATES.has(r.state)) {
1210
+ r = await recheckPrior(ctx, r);
1211
+ if (!(0, session_data_1.upsellJobOwns)(r))
1212
+ return "none";
1213
+ }
1063
1214
  if (r.state === "refunding")
1064
1215
  return lateSettled(await refundLate(ctx, r));
1065
1216
  if (r.pay === "wallet" && r.state !== "charged")
@@ -1150,7 +1301,7 @@ async function add(ctx, charged) {
1150
1301
  const fresh = await facts(ctx.host, ctx.f.id);
1151
1302
  if (!fresh)
1152
1303
  return { outcome: "unknown" };
1153
- if (offerStopped(ctx.host, fresh, ctx.offer) && !fresh.variant_ids.includes(r.variant_id))
1304
+ if (offerStopped(ctx.host, fresh) && !fresh.variant_ids.includes(r.variant_id))
1154
1305
  return refundLate(ctx, r);
1155
1306
  }
1156
1307
  let lastErr;
@@ -1178,8 +1329,12 @@ async function add(ctx, charged) {
1178
1329
  }
1179
1330
  }
1180
1331
  ctx.host.log("error", "upsell_add_failed", { ...ctx.ids, step: "gave_up", vp_payment_intent_id: r.vp_payment_intent_id, error: errName(lastErr) });
1181
- if (r.state === "charged")
1182
- await advance(ctx, r, { state: "held", hold_reason: paidHoldReason(await facts(ctx.host, ctx.f.id), r) });
1332
+ if (r.state !== "charged")
1333
+ return { outcome: "unknown" };
1334
+ const fresh = await facts(ctx.host, ctx.f.id);
1335
+ if (fresh && r.upsell_collection_id === null && !fresh.variant_ids.includes(r.variant_id))
1336
+ return refundLate(ctx, r);
1337
+ await advance(ctx, r, { state: "held", hold_reason: paidHoldReason(fresh, r) });
1183
1338
  return { outcome: "unknown" };
1184
1339
  }
1185
1340
  function addedInMedusa(f, r) {
@@ -1689,7 +1844,7 @@ async function walletEditAndTake(ctx, r) {
1689
1844
  let f = await facts(host, ctx.f.id);
1690
1845
  if (!f)
1691
1846
  return { outcome: "processing" };
1692
- if (offerStopped(host, f, ctx.offer)) {
1847
+ if (offerStopped(host, f)) {
1693
1848
  host.log("info", "upsell_stopped", { ...ctx.ids, vp_payment_intent_id: r.vp_payment_intent_id, from: r.state });
1694
1849
  await cancelOwnEdit(ctx, r);
1695
1850
  return walletRelease(ctx, r, "abandoned");
@@ -32,12 +32,12 @@ exports.UPSELL_CLEANUP_JOB_NAME = "vonpay-upsell-cleanup";
32
32
  function isCleanupProvider(p) {
33
33
  const o = p;
34
34
  const ctor = p?.constructor;
35
- return !!o && typeof o === "object" && ctor?.identifier === "vonpay" && typeof o.adminMode === "function" && typeof o.upsellsEnabled === "function" && typeof o.cleanupOfferCards === "function" && typeof o.settleUpsellAcceptance === "function";
35
+ return !!o && typeof o === "object" && ctor?.identifier === "vonpay" && typeof o.adminMode === "function" && typeof o.cleanupOfferCards === "function" && typeof o.settleUpsellAcceptance === "function";
36
36
  }
37
37
  const processRunning = { active: false };
38
38
  async function cleanupUpsellCards(scope, settings = {}) {
39
39
  const log = (0, admin_holds_1.jobLogger)(scope);
40
- const run = { ran: false, settled: { added: 0, declined: 0, released: 0, held: 0, waiting: 0, stopped: 0, refunded: 0 }, scanned: 0, due: 0, done: 0, revoked: 0, waiting: 0, failed: 0, scan_complete: true };
40
+ const run = { ran: false, settled: { added: 0, declined: 0, released: 0, held: 0, waiting: 0, stopped: 0, refunded: 0 }, scanned: 0, scanned_oldest: 0, due: 0, done: 0, revoked: 0, waiting: 0, failed: 0, scan_complete: true };
41
41
  const running = settings.running ?? processRunning;
42
42
  if (running.active)
43
43
  return run;
@@ -51,7 +51,7 @@ async function cleanupUpsellCards(scope, settings = {}) {
51
51
  const byMode = new Map();
52
52
  const idByMode = new Map();
53
53
  for (const e of await (0, admin_holds_1.within)(limits.callMs, () => (0, admin_holds_1.vonpayProviderEntries)(scope, null))) {
54
- if (isCleanupProvider(e.provider) && e.provider.upsellsEnabled()) {
54
+ if (isCleanupProvider(e.provider)) {
55
55
  byMode.set(e.provider.adminMode(), e.provider);
56
56
  idByMode.set(e.provider.adminMode(), e.id);
57
57
  }
@@ -94,6 +94,29 @@ async function cleanupUpsellCards(scope, settings = {}) {
94
94
  });
95
95
  run.scanned = scan.scanned;
96
96
  run.scan_complete = scan.complete;
97
+ if (!scan.complete && !outOfTime()) {
98
+ const seen = new Set(inFlight.map((a) => a.acceptanceId));
99
+ const oldest = [];
100
+ const back = await (0, scan_1.pagedScan)({
101
+ budget: limits.maxScanned,
102
+ batch: limits.scanBatch,
103
+ outOfTime,
104
+ read: (skip, take) => (0, admin_holds_1.within)(limits.callMs, () => pm.listPaymentCollections({ id: { $gte: `pay_col_${(0, recovery_1.ulidTimePrefix)(started - limits.lookbackMs)}` }, metadata: { $ne: null } }, { select: ["id", "metadata"], skip, take, order: { id: "ASC" } })),
105
+ each: (c) => {
106
+ for (const { read } of (0, session_data_1.upsellRecordsOf)(c.metadata ?? undefined)) {
107
+ if (read.kind !== "record" || seen.has(read.r.acceptance_id) || !(0, session_data_1.upsellJobOwns)(read.r) || !jobDue(read.r, started - Date.parse(read.r.at)))
108
+ continue;
109
+ const offer = (0, session_data_1.readOfferRecord)(c.metadata ?? undefined, read.r.order_id);
110
+ if (offer.kind !== "record")
111
+ continue;
112
+ seen.add(read.r.acceptance_id);
113
+ oldest.push({ orderId: read.r.order_id, acceptanceId: read.r.acceptance_id, mode: offer.r.mode });
114
+ }
115
+ },
116
+ });
117
+ run.scanned_oldest = back.scanned;
118
+ inFlight.unshift(...oldest);
119
+ }
97
120
  for (const a of inFlight) {
98
121
  if (outOfTime())
99
122
  break;
@@ -139,7 +162,9 @@ async function cleanupUpsellCards(scope, settings = {}) {
139
162
  else
140
163
  run.failed++;
141
164
  }
142
- log("info", "upsell_cleanup_run", { settled_added: run.settled.added, settled_declined: run.settled.declined, settled_released: run.settled.released, settled_held: run.settled.held, settled_waiting: run.settled.waiting, settled_stopped: run.settled.stopped, settled_refunded: run.settled.refunded, scanned: run.scanned, due: run.due, done: run.done, revoked: run.revoked, waiting: run.waiting, failed: run.failed, scan_complete: run.scan_complete });
165
+ if (!run.scan_complete)
166
+ log("error", "upsell_cleanup_scan_incomplete", { scanned: run.scanned, budget: limits.maxScanned });
167
+ log("info", "upsell_cleanup_run", { settled_added: run.settled.added, settled_declined: run.settled.declined, settled_released: run.settled.released, settled_held: run.settled.held, settled_waiting: run.settled.waiting, settled_stopped: run.settled.stopped, settled_refunded: run.settled.refunded, scanned: run.scanned, scanned_oldest: run.scanned_oldest, due: run.due, done: run.done, revoked: run.revoked, waiting: run.waiting, failed: run.failed, scan_complete: run.scan_complete });
143
168
  }
144
169
  catch (err) {
145
170
  log("error", "upsell_cleanup_failed", { why: "run", error: (0, admin_holds_1.errName)(err) });
@@ -1,26 +1,71 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.rateLimitPass = exports.rateLimitUpsell = exports.UPSELL_RATE_KEYS_MAX = exports.PASS_CALLS_PER_MINUTE = exports.UPSELL_CALLS_PER_MINUTE = exports.SHOPPER_IP_HEADER = exports.CART_ID = exports.ORDER_ID = exports.OFFER_TOKEN_HEADER = void 0;
3
+ exports.rateLimitPass = exports.rateLimitUpsell = exports.UPSELL_RATE_KEYS_MAX = exports.PASS_CALLS_PER_MINUTE = exports.UPSELL_CALLS_PER_MINUTE = exports.SHOPPER_IP_REJECT_LOG_MS = exports.SHOPPER_IP_SIG_MAX_AGE_S = exports.SHOPPER_IP_SIG_HEADER = exports.SHOPPER_IP_HEADER = exports.CART_ID = exports.ORDER_ID = exports.OFFER_TOKEN_HEADER = void 0;
4
4
  exports.shopperIpFrom = shopperIpFrom;
5
+ exports.shopperIpCheck = shopperIpCheck;
6
+ exports.shopperIpForRoute = shopperIpForRoute;
5
7
  exports.upsellProviders = upsellProviders;
6
8
  exports.customerFrom = customerFrom;
7
9
  exports.offerTokenFrom = offerTokenFrom;
8
10
  exports.createLimiter = createLimiter;
9
11
  exports.providerForToken = providerForToken;
10
12
  exports.busy = busy;
13
+ const node_crypto_1 = require("node:crypto");
11
14
  const admin_holds_1 = require("./admin-holds");
15
+ const options_1 = require("./options");
12
16
  exports.OFFER_TOKEN_HEADER = "x-vonpay-offer-token";
13
17
  exports.ORDER_ID = /^order_[0-9A-Z]{26}$/;
14
18
  exports.CART_ID = /^cart_[0-9A-Z]{26}$/;
15
19
  exports.SHOPPER_IP_HEADER = "x-vonpay-shopper-ip";
16
- function shopperIpFrom(req) {
17
- const h = req.headers?.[exports.SHOPPER_IP_HEADER];
20
+ exports.SHOPPER_IP_SIG_HEADER = "x-vonpay-shopper-ip-sig";
21
+ exports.SHOPPER_IP_SIG_MAX_AGE_S = 300;
22
+ const SHOPPER_IP_LABEL = "vonpay-shopper-ip/v1";
23
+ const SHOPPER_IP_SIG = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{43})$/;
24
+ function oneHeader(req, name) {
25
+ const h = req.headers?.[name];
18
26
  const v = Array.isArray(h) ? (h.length === 1 ? h[0] : undefined) : h;
19
- return typeof v === "string" && v.length > 0 && v.length <= 45 ? v : null;
27
+ return typeof v === "string" ? v : undefined;
28
+ }
29
+ function shopperIpFrom(req, secret, nowMs = Date.now()) {
30
+ return shopperIpCheck(req, secret, nowMs).ip;
31
+ }
32
+ function shopperIpCheck(req, secret, nowMs = Date.now()) {
33
+ const ip = oneHeader(req, exports.SHOPPER_IP_HEADER);
34
+ const rawSig = oneHeader(req, exports.SHOPPER_IP_SIG_HEADER);
35
+ if (ip === undefined && rawSig === undefined)
36
+ return { ip: null, why: "none" };
37
+ if (!secret || secret.length < options_1.SHOPPER_IP_SECRET_MIN)
38
+ return { ip: null, why: "no_secret" };
39
+ if (rawSig === undefined)
40
+ return { ip: null, why: "unsigned" };
41
+ const pass = offerTokenFrom(req);
42
+ const sig = SHOPPER_IP_SIG.exec(rawSig);
43
+ if (!ip || ip.length > 45 || !pass || !sig)
44
+ return { ip: null, why: "malformed" };
45
+ const ts = Number(sig[1]);
46
+ if (Math.abs(nowMs / 1000 - ts) > exports.SHOPPER_IP_SIG_MAX_AGE_S)
47
+ return { ip: null, why: "stale" };
48
+ const passHash = (0, node_crypto_1.createHash)("sha256").update(pass).digest("hex");
49
+ const want = (0, node_crypto_1.createHmac)("sha256", secret).update(`${SHOPPER_IP_LABEL}\n${ip}\n${ts}\n${passHash}`).digest();
50
+ const got = Buffer.from(sig[2], "base64url");
51
+ return got.length === want.length && (0, node_crypto_1.timingSafeEqual)(got, want) ? { ip } : { ip: null, why: "mismatch" };
52
+ }
53
+ exports.SHOPPER_IP_REJECT_LOG_MS = 10 * 60_000;
54
+ const lastRejectLog = new Map();
55
+ function shopperIpForRoute(req, secret, nowMs = Date.now()) {
56
+ const r = shopperIpCheck(req, secret, nowMs);
57
+ if (r.ip === null && r.why !== "none") {
58
+ const last = lastRejectLog.get(r.why);
59
+ if (last === undefined || nowMs - last >= exports.SHOPPER_IP_REJECT_LOG_MS) {
60
+ lastRejectLog.set(r.why, nowMs);
61
+ (0, admin_holds_1.jobLogger)(req.scope)("warn", "shopper_ip_sig_rejected", { why: r.why });
62
+ }
63
+ }
64
+ return r.ip;
20
65
  }
21
66
  function isUpsellProvider(p) {
22
67
  const ctor = p?.constructor;
23
- return !!p && typeof p === "object" && ctor?.identifier === "vonpay" && typeof p.acceptUpsell === "function" && typeof p.prepareUpsellWallet === "function";
68
+ return !!p && typeof p === "object" && ctor?.identifier === "vonpay" && typeof p.acceptUpsell === "function" && typeof p.prepareUpsellWallet === "function" && typeof p.shopperIpSecret === "function";
24
69
  }
25
70
  async function upsellProviders(scope) {
26
71
  try {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OFFER_PASS_TTL_MS = exports.OFFER_CONTINUE_GRACE_MS = exports.OFFER_WINDOW_MS = void 0;
4
4
  exports.offerStopMs = offerStopMs;
5
+ exports.orderOfferStopMs = orderOfferStopMs;
5
6
  exports.mintOfferPass = mintOfferPass;
6
7
  exports.checkOfferPass = checkOfferPass;
7
8
  exports.sealHoldSession = sealHoldSession;
@@ -13,6 +14,9 @@ exports.OFFER_CONTINUE_GRACE_MS = 30 * 60_000;
13
14
  function offerStopMs(offerAt) {
14
15
  return Date.parse(offerAt) + exports.OFFER_WINDOW_MS + exports.OFFER_CONTINUE_GRACE_MS;
15
16
  }
17
+ function orderOfferStopMs(orderCreatedMs, nowMs) {
18
+ return Math.min(orderCreatedMs, nowMs) + exports.OFFER_WINDOW_MS + exports.OFFER_CONTINUE_GRACE_MS;
19
+ }
16
20
  exports.OFFER_PASS_TTL_MS = 3 * 60 * 60_000;
17
21
  const VERSION = "v2";
18
22
  const LABEL = (mode) => `vonpay-medusa/upsell-pass/${VERSION}/${mode}`;
package/README.md CHANGED
@@ -74,6 +74,7 @@ module.exports = defineConfig({
74
74
  mode: process.env.VONPAY_MODE, // "test" or "live"
75
75
  storefrontUrl: process.env.VONPAY_STOREFRONT_URL,
76
76
  webhookSecret: process.env.VONPAY_WEBHOOK_SECRET, // empty until you register the endpoint
77
+ shopperIpSecret: process.env.VONPAY_SHOPPER_IP_SECRET, // optional: see "The shopper's address on offer charges"
77
78
  },
78
79
  },
79
80
  ],
@@ -97,6 +98,7 @@ Set these in the server's `.env` (never in `medusa-config.ts` or any file you co
97
98
  | `VONPAY_MODE` | yes | `test` or `live`. Must match the key. |
98
99
  | `VONPAY_STOREFRONT_URL` | yes | Your storefront's address, such as `https://shop.example.com`. Must be `https`; `http://localhost` is allowed with test keys. Shoppers come back here from their bank's 3-D Secure check. |
99
100
  | `VONPAY_WEBHOOK_SECRET` | after setup | The signing secret Vonpay shows when you register your notification endpoint. Leave it empty for the first start (see [Payment notifications](#payment-notifications)). |
101
+ | `VONPAY_SHOPPER_IP_SECRET` | optional | With one-click offers: a long random value of your own (`openssl rand -hex 32`), the SAME value in the storefront's environment. It proves the shopper's address on offer charges is real (see [`upsells`](#upsells-default-off), "The shopper's address on offer charges"). Never your Vonpay key or the webhook secret. |
100
102
  | `MEDUSA_BACKEND_URL` | recommended | Your Medusa server's public address, such as `https://api.example.com`. Lets the setup check confirm the notification endpoint is this store's. |
101
103
  | `VONPAY_UPDATE_CHECK` | no | `off` turns off the update notice (for a server with no outbound internet). |
102
104
 
@@ -493,9 +495,14 @@ writes it from the `VONPAY_SHOPPER_IP_HEADER` environment variable) to the reque
493
495
  sets to the visitor's own address, one value it writes itself: `x-real-ip` on Vercel,
494
496
  `cf-connecting-ip` behind Cloudflare. It must be a header your host overwrites on every request; a header a
495
497
  visitor can send through unchanged lets them choose the address you report. Do not use `x-forwarded-for`
496
- (its first entry can be typed by anyone; a header holding a list is ignored). Only a public address is sent; private,
497
- local or malformed values are dropped. Left unset, offers charge as before, without an address. The
498
- address is never logged or stored by the plugin.
498
+ (its first entry can be typed by anyone; a header holding a list is ignored). Then set the same random value as
499
+ `VONPAY_SHOPPER_IP_SECRET` in BOTH the storefront's environment (the setup command passes it as `shopperIpSecret`;
500
+ server-only, never `NEXT_PUBLIC_`) and the Medusa server's (option `shopperIpSecret`): the storefront signs the address
501
+ with it, and the store ignores any address that is not signed, so someone calling your store directly cannot choose
502
+ it. Only a public address is sent; private, local or malformed values are dropped. Either setting left unset, offer
503
+ charges carry no address (the setup check WARNs; the store logs `shopper_ip_sig_rejected` when an address arrives that
504
+ it cannot believe). **Upgrading from 1.0.1:** 1.0.1 sent the address unsigned; from 1.0.2 it is used only once
505
+ `VONPAY_SHOPPER_IP_SECRET` is set on both sides. The address is never logged or stored by the plugin.
499
506
 
500
507
  Guest shoppers get offers too, by default: the checkout names them by the email they type, and their
501
508
  card is kept for that purchase's offers only, then removed once the offer ends. A guest's card is never
@@ -21,7 +21,8 @@ import { createVonpayStorefrontServer } from "vonpay-medusa/storefront/server"
21
21
 
22
22
  // Every function below is a public endpoint: none takes an argument — the cart comes from the cookie.
23
23
  // VONPAY_SHOPPER_IP_HEADER: the header this storefront's HOSTING sets to the shopper's own address (e.g. x-real-ip on
24
- // Vercel, cf-connecting-ip behind Cloudflare); unset ⇒ offer charges carry no shopper address.
24
+ // Vercel, cf-connecting-ip behind Cloudflare); VONPAY_SHOPPER_IP_SECRET: the secret that signs it, the SAME value as the
25
+ // store's (server-only, never NEXT_PUBLIC_). Either unset ⇒ offer charges carry no shopper address.
25
26
  const vonpay = createVonpayStorefrontServer({
26
27
  sdk,
27
28
  getAuthHeaders,
@@ -30,6 +31,7 @@ const vonpay = createVonpayStorefrontServer({
30
31
  removeCartId,
31
32
  initiatePaymentSession,
32
33
  shopperIpHeader: process.env.VONPAY_SHOPPER_IP_HEADER?.trim() || undefined,
34
+ shopperIpSecret: process.env.VONPAY_SHOPPER_IP_SECRET?.trim() || undefined,
33
35
  })
34
36
 
35
37
  export async function completeVonpayCheckout() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vonpay-medusa",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Vonpay payment provider for Medusa v2",
5
5
  "author": "Von Payments",
6
6
  "license": "MIT",
@@ -60,6 +60,7 @@
60
60
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p tests/contract/tsconfig.json && tsc --noEmit -p src/admin/tsconfig.json && tsc --noEmit -p tests/integration/tsconfig.json && tsc --noEmit -p tests/admin/tsconfig.json",
61
61
  "check:money": "node scripts/ci/check-money-arithmetic.mjs src && node scripts/ci/check-money-arithmetic.mjs --self-test",
62
62
  "check:release-install": "node scripts/ci/check-release-install.mjs && node scripts/ci/check-release-install.mjs --self-test",
63
+ "check:ci-scope": "node scripts/ci/check-ci-scope.mjs && node scripts/ci/check-ci-scope.mjs --self-test",
63
64
  "check:harness-isolation": "node scripts/ci/check-harness-isolation.mjs src apps && node scripts/ci/check-harness-isolation.mjs --self-test",
64
65
  "check:pack": "node scripts/ci/check-pack.mjs && node scripts/ci/check-pack.mjs --self-test",
65
66
  "check:pan-canary": "node scripts/ci/check-pan-canary.mjs && node scripts/ci/check-pan-canary.mjs --self-test",
@@ -183,7 +184,7 @@
183
184
  }
184
185
  },
185
186
  "dependencies": {
186
- "@vonpay/checkout-node": "^3.11.0",
187
+ "@vonpay/checkout-node": "^4.2.0",
187
188
  "bignumber.js": "^9.3.1"
188
189
  },
189
190
  "bin": {
@@ -0,0 +1,17 @@
1
+ export async function withDeadline<T>(ms: number, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
2
+ const ask = new AbortController()
3
+ let timer: ReturnType<typeof setTimeout> | undefined
4
+ try {
5
+ return await Promise.race([
6
+ run(ask.signal),
7
+ new Promise<never>((_, reject) => {
8
+ timer = setTimeout(() => {
9
+ ask.abort()
10
+ reject(Object.assign(new Error("timeout"), { status: "timeout" as const }))
11
+ }, ms)
12
+ }),
13
+ ])
14
+ } finally {
15
+ if (timer) clearTimeout(timer)
16
+ }
17
+ }
@@ -5,7 +5,9 @@ import { cookies, headers as requestHeaders } from "next/headers"
5
5
  import { type CompletionResult, type VonpaySessionView, buyerReferenceOf, vonpaySession } from "./outcome"
6
6
  import { STUCK_REPORT_GLOBAL_MAX, STUCK_REPORT_WINDOW_MS, stuckReportLimiter } from "./report-limit"
7
7
  import { SAVED_CARDS_COPY, SAVED_CARD_ID, readSavedCards, type VonpayRemoveCardResult, type VonpaySavedCardsResult } from "./saved-cards"
8
+ import { SHOPPER_IP_SECRET_MIN, SHOPPER_IP_SIG_HEADER, signShopperIp } from "./shopper-ip"
8
9
  import { sleep } from "./sleep"
10
+ import { withDeadline } from "./deadline"
9
11
  import { readOfferAnswer, readOutcome, readWalletSession, type VonpayOffer, type VonpayOfferOutcome, type VonpayWalletSession } from "./upsell"
10
12
 
11
13
  export interface VonpayStorefrontServerDeps {
@@ -19,6 +21,7 @@ export interface VonpayStorefrontServerDeps {
19
21
  removeCartId(): Promise<void>
20
22
  initiatePaymentSession(cart: HttpTypes.StoreCart, data: { provider_id: string }): Promise<unknown>
21
23
  shopperIpHeader?: string
24
+ shopperIpSecret?: string
22
25
  }
23
26
 
24
27
  export interface VonpayStorefrontServer {
@@ -50,6 +53,7 @@ export const SHOPPER_IP_HEADER = "x-vonpay-shopper-ip"
50
53
  export function createVonpayStorefrontServer(deps: VonpayStorefrontServerDeps): VonpayStorefrontServer {
51
54
  const { sdk, getCacheTag, getCartId, removeCartId, initiatePaymentSession } = deps
52
55
  const getAuthHeaders = async () => ({ ...((await deps.getAuthHeaders()) as Record<string, string>) })
56
+ let warnedIpSecret = false
53
57
 
54
58
  async function completeVonpayCheckout(): Promise<CompletionResult> {
55
59
  const cartId = await getCartId()
@@ -200,21 +204,15 @@ async function readSavedStatus(cartId: string, headers: Record<string, string>):
200
204
 
201
205
 
202
206
  async function requestOfferPass(cartId: string, headers: Record<string, string>): Promise<string | null> {
203
- let timer: ReturnType<typeof setTimeout> | undefined
204
207
  try {
205
- const body = await Promise.race([
206
- sdk.client.fetch<{ token?: unknown }>("/store/vonpay/upsells/pass", { method: "POST", headers, body: { cart_id: cartId }, cache: "no-store" }),
207
- new Promise<never>((_, reject) => {
208
- timer = setTimeout(() => reject(Object.assign(new Error("timeout"), { status: "timeout" })), OFFER_PASS_REQUEST_MS)
209
- }),
210
- ])
208
+ const body = await withDeadline(OFFER_PASS_REQUEST_MS, (signal) =>
209
+ sdk.client.fetch<{ token?: unknown }>("/store/vonpay/upsells/pass", { method: "POST", headers, body: { cart_id: cartId }, cache: "no-store", signal })
210
+ )
211
211
  return typeof body?.token === "string" && body.token.length > 0 && body.token.length <= 2048 ? body.token : null
212
212
  } catch (err) {
213
213
  const status = (err as { status?: unknown } | null)?.status
214
214
  if (status !== 404) console.warn(`vonpay offer: pass request failed (${typeof status === "number" || status === "timeout" ? status : "error"})`)
215
215
  return null
216
- } finally {
217
- if (timer) clearTimeout(timer)
218
216
  }
219
217
  }
220
218
 
@@ -243,7 +241,17 @@ async function readSavedStatus(cartId: string, headers: Record<string, string>):
243
241
  }
244
242
  if (!pass) return null
245
243
  const ip = await shopperIp()
246
- return { ...(await getAuthHeaders()), "x-vonpay-offer-token": pass, ...(ip ? { [SHOPPER_IP_HEADER]: ip } : {}) }
244
+ const secret = shopperIpSecret()
245
+ const signed: Record<string, string> = ip && secret ? { [SHOPPER_IP_HEADER]: ip, [SHOPPER_IP_SIG_HEADER]: signShopperIp(secret, ip, pass) } : {}
246
+ return { ...(await getAuthHeaders()), "x-vonpay-offer-token": pass, ...signed }
247
+ }
248
+
249
+ function shopperIpSecret(): string | null {
250
+ const v = deps.shopperIpSecret?.trim()
251
+ if (v && v.length >= SHOPPER_IP_SECRET_MIN) return v
252
+ if (deps.shopperIpHeader && !warnedIpSecret) console.warn("vonpay: shopperIpHeader is set but VONPAY_SHOPPER_IP_SECRET is missing or shorter than 32 characters — offer charges carry no shopper address")
253
+ warnedIpSecret = true
254
+ return null
247
255
  }
248
256
 
249
257
  async function shopperIp(): Promise<string | null> {
@@ -269,25 +277,11 @@ async function readSavedStatus(cartId: string, headers: Record<string, string>):
269
277
  const path = `/store/vonpay/upsells/${encodeURIComponent(orderId)}/offer`
270
278
  for (let i = 0; ; i++) {
271
279
  let answer: { offer: VonpayOffer | null; pending: boolean; resume: boolean }
272
- const ask = new AbortController()
273
- let timer: ReturnType<typeof setTimeout> | undefined
274
280
  try {
275
- answer = readOfferAnswer(
276
- await Promise.race([
277
- sdk.client.fetch<unknown>(path, { method: "GET", headers, cache: "no-store", signal: ask.signal }),
278
- new Promise<never>((_, reject) => {
279
- timer = setTimeout(() => {
280
- ask.abort()
281
- reject(new Error("timeout"))
282
- }, OFFER_READ_REQUEST_MS)
283
- }),
284
- ])
285
- )
281
+ answer = readOfferAnswer(await withDeadline(OFFER_READ_REQUEST_MS, (signal) => sdk.client.fetch<unknown>(path, { method: "GET", headers, cache: "no-store", signal })))
286
282
  } catch {
287
283
  console.warn("vonpay offer: the offer could not be read")
288
284
  return none
289
- } finally {
290
- if (timer) clearTimeout(timer)
291
285
  }
292
286
  if (answer.resume || answer.offer || !answer.pending || i >= OFFER_PENDING_WAITS_MS.length) return { offer: answer.offer, resume: answer.resume }
293
287
  await sleep(OFFER_PENDING_WAITS_MS[i])
@@ -339,7 +333,7 @@ async function readSavedStatus(cartId: string, headers: Record<string, string>):
339
333
  if (typeof orderId !== "string" || !ORDER_ID.test(orderId) || typeof variantId !== "string" || !VARIANT_ID.test(variantId)) return { ok: false }
340
334
  const headers = await offerHeaders()
341
335
  if (!headers) return { ok: false }
342
- let pending = false
336
+ let ended = false
343
337
  try {
344
338
  const body = await sdk.client.fetch<{ ok?: unknown; pending_bank_check?: unknown }>(`/store/vonpay/upsells/${encodeURIComponent(orderId)}/skip`, {
345
339
  method: "POST",
@@ -347,12 +341,13 @@ async function readSavedStatus(cartId: string, headers: Record<string, string>):
347
341
  body: { variant_id: variantId },
348
342
  cache: "no-store",
349
343
  })
350
- pending = body?.ok === true && body?.pending_bank_check === true
344
+ const pending = body?.ok === true && body?.pending_bank_check === true
345
+ ended = body?.ok === true && !pending
351
346
  return { ok: body?.ok === true, ...(pending ? { pendingBankCheck: true as const } : {}) }
352
347
  } catch {
353
348
  return { ok: false }
354
349
  } finally {
355
- if (!pending) await endUpsellOffer()
350
+ if (ended) await endUpsellOffer()
356
351
  }
357
352
  }
358
353
 
@@ -0,0 +1,12 @@
1
+ import { createHash, createHmac } from "node:crypto"
2
+
3
+ export const SHOPPER_IP_SIG_HEADER = "x-vonpay-shopper-ip-sig"
4
+ export const SHOPPER_IP_SECRET_MIN = 32
5
+ const LABEL = "vonpay-shopper-ip/v1"
6
+
7
+ export function signShopperIp(secret: string, ip: string, pass: string, nowMs: number = Date.now()): string {
8
+ const ts = Math.floor(nowMs / 1000)
9
+ const passHash = createHash("sha256").update(pass).digest("hex")
10
+ const mac = createHmac("sha256", secret).update(`${LABEL}\n${ip}\n${ts}\n${passHash}`).digest("base64url")
11
+ return `v1.${ts}.${mac}`
12
+ }