vonpay-medusa 1.0.1 → 1.0.2

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,7 @@ 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
136
  },
137
137
  upsell_refund_pending: {
138
138
  label: "Late offer charge — refund in progress",
@@ -1164,17 +1164,17 @@ const routeModule = {
1164
1164
  const menuItemModule = {
1165
1165
  menuItems: [
1166
1166
  {
1167
- label: config$1.label,
1168
- icon: config$1.icon,
1169
- path: "/vonpay-holds",
1167
+ label: config.label,
1168
+ icon: config.icon,
1169
+ path: "/vonpay-subscriptions",
1170
1170
  nested: "/orders",
1171
1171
  rank: void 0,
1172
1172
  translationNs: void 0
1173
1173
  },
1174
1174
  {
1175
- label: config.label,
1176
- icon: config.icon,
1177
- path: "/vonpay-subscriptions",
1175
+ label: config$1.label,
1176
+ icon: config$1.icon,
1177
+ path: "/vonpay-holds",
1178
1178
  nested: "/orders",
1179
1179
  rank: void 0,
1180
1180
  translationNs: void 0
@@ -129,7 +129,7 @@ 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
133
  },
134
134
  upsell_refund_pending: {
135
135
  label: "Late offer charge — refund in progress",
@@ -1161,17 +1161,17 @@ const routeModule = {
1161
1161
  const menuItemModule = {
1162
1162
  menuItems: [
1163
1163
  {
1164
- label: config$1.label,
1165
- icon: config$1.icon,
1166
- path: "/vonpay-holds",
1164
+ label: config.label,
1165
+ icon: config.icon,
1166
+ path: "/vonpay-subscriptions",
1167
1167
  nested: "/orders",
1168
1168
  rank: void 0,
1169
1169
  translationNs: void 0
1170
1170
  },
1171
1171
  {
1172
- label: config.label,
1173
- icon: config.icon,
1174
- path: "/vonpay-subscriptions",
1172
+ label: config$1.label,
1173
+ icon: config$1.icon,
1174
+ path: "/vonpay-holds",
1175
1175
  nested: "/orders",
1176
1176
  rank: void 0,
1177
1177
  translationNs: void 0
@@ -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" : "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,40 @@ 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 key = `vpm_upsell_late_rf_${pi}${step === 0 ? "" : `_s${step}`}`;
952
956
  try {
953
- const refund = await this.vonpay((c) => c.refunds.create({ paymentIntent: pi, amount: amountMinor, currency }, { idempotencyKey: `vpm_upsell_late_rf_${pi}` }));
957
+ const refund = await this.vonpay((c) => c.refunds.create({ paymentIntent: pi, amount: amountMinor, currency }, { idempotencyKey: key }));
958
+ const replay = refund.idempotent === true;
954
959
  const status = String(refund.status);
955
960
  if (REFUND_CONFIRMED.has(status))
956
961
  return "refunded";
957
- if (REFUND_DEFINITELY_NOT_MADE.has(status))
962
+ if (REFUND_DEFINITELY_NOT_MADE.has(status)) {
963
+ if (replay) {
964
+ this.log("info", "refund_transport", { ...ids, outcome: "key_spent" });
965
+ return "unsettled";
966
+ }
958
967
  return "failed";
968
+ }
959
969
  this.log("warn", "refund_transport", { ...ids, outcome: "unrecognised_status" });
960
970
  return "retry";
961
971
  }
962
972
  catch (err) {
963
973
  const reading = readRefundError(err);
964
- if (reading.kind === "nothing_left")
965
- return "refunded";
966
- if (reading.kind === "refused" || reading.kind === "key_conflict") {
974
+ if (reading.kind === "refused" && reading.medusa === "refund_before_settlement") {
975
+ this.log("info", "refund_transport", { ...ids, outcome: "before_settlement" });
976
+ return "unsettled";
977
+ }
978
+ if (reading.kind === "nothing_left" || reading.kind === "refused" || reading.kind === "key_conflict") {
967
979
  this.log("warn", "refund_transport", { ...ids, outcome: "refused", ...errorFields(err) });
968
980
  return "failed";
969
981
  }
@@ -1615,6 +1627,10 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
1615
1627
  const wrote = await this.writeOfferRecord(c.id, record);
1616
1628
  return wrote ? { ok: true, record: wrote } : { ok: false, why: "record_not_written" };
1617
1629
  }
1630
+ shopperIpSecret() {
1631
+ const v = this.options_.shopperIpSecret?.trim();
1632
+ return v ? v : undefined;
1633
+ }
1618
1634
  upsellsEnabled() {
1619
1635
  return !!this.options_.upsells;
1620
1636
  }
@@ -1664,7 +1680,8 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
1664
1680
  const done = { v: 1, order_id: record.order_id, revoked, at: new Date().toISOString() };
1665
1681
  if (!(await this.writeOfferDone(collectionId, done)))
1666
1682
  return { outcome: "retry", why: "record" };
1667
- this.log("info", "upsell_cleanup_done", { ...ids, revoked: revoked.length, guest: record.buyer_ref === null });
1683
+ const unrecorded = record.buyer_ref === null && !record.vp_payment_method_id;
1684
+ this.log(unrecorded ? "warn" : "info", "upsell_cleanup_done", { ...ids, revoked: revoked.length, guest: record.buyer_ref === null, ...(unrecorded ? { card_recorded: false } : {}) });
1668
1685
  return { outcome: "done", revoked };
1669
1686
  }
1670
1687
  async writeOfferDone(collectionId, value) {
@@ -4023,7 +4040,12 @@ class VonpayProviderService extends utils_1.AbstractPaymentProvider {
4023
4040
  return { action: utils_1.PaymentActions.SUCCESSFUL, data: { session_id: medusaSessionId, amount: (0, money_1.fromMinorUnits)(s.amount, s.currency) }, changedAt };
4024
4041
  }
4025
4042
  if ((0, reading_1.definitivelyNoCharge)(s)) {
4026
- const rec = await this.medusaSession(medusaSessionId, ["payment_collection_id"]);
4043
+ const found = await this.medusaSessionLookup(medusaSessionId, ["payment_collection_id"], { missingExpected: true });
4044
+ if (found.missing) {
4045
+ this.log("info", "webhook_not_actionable", { ...evt, vp_session_id: vpSessionId, why: "session_gone" });
4046
+ return { action: utils_1.PaymentActions.NOT_SUPPORTED, why: "session_gone" };
4047
+ }
4048
+ const rec = found.rec;
4027
4049
  if (await this.closedInHeldList(rec, vpSessionId))
4028
4050
  return notActionable("closed");
4029
4051
  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;
@@ -558,6 +559,7 @@ function upsellJobOwns(r) {
558
559
  r.state === "stopped" ||
559
560
  r.state === "refunding" ||
560
561
  upsellAwaitsRecheck(r) ||
562
+ priorOwed(r).length > 0 ||
561
563
  (r.state === "held" && r.hold_reason !== null && exports.UPSELL_JOB_HOLD_REASONS.has(r.hold_reason)));
562
564
  }
563
565
  function upsellAwaitsRecheck(r) {
@@ -565,6 +567,9 @@ function upsellAwaitsRecheck(r) {
565
567
  }
566
568
  exports.UPSELL_RELEASE_REASONS = ["out_of_stock", "amount_changed", "order_busy", "edit_failed", "hold_lost", "abandoned", "session_unusable", "declined"];
567
569
  exports.UPSELL_MAX_ATTEMPTS = 3;
570
+ function priorOwed(r) {
571
+ return (r.prior_intents ?? []).filter((p) => p.stuck === undefined);
572
+ }
568
573
  function upsellDescriptorOf(r) {
569
574
  return typeof r.descriptor_key === "string" && typeof r.descriptor_value === "string" ? { [r.descriptor_key]: r.descriptor_value } : {};
570
575
  }
@@ -606,6 +611,20 @@ function isUpsellRecord(v) {
606
611
  }
607
612
  if (r.rechecked === true && (wallet || !["declined", "skipped", "refunding", "refunded"].includes(r.state)))
608
613
  return false;
614
+ if (r.prior_intents !== undefined) {
615
+ const list = r.prior_intents;
616
+ if (wallet || !Array.isArray(list) || list.length < 1 || list.length > exports.UPSELL_MAX_ATTEMPTS - 1)
617
+ return false;
618
+ for (const p of list) {
619
+ const x = p;
620
+ if (!x || typeof x !== "object" || !isVpPaymentIntentIdShape(x.id) || !Number.isInteger(x.amount_minor) || x.amount_minor < 1)
621
+ return false;
622
+ if (typeof x.currency !== "string" || !CURRENCY.test(x.currency) || typeof x.at !== "string" || !Number.isFinite(Date.parse(x.at)))
623
+ return false;
624
+ if (!(x.stuck === undefined || x.stuck === "refund_failed" || x.stuck === "amount_mismatch"))
625
+ return false;
626
+ }
627
+ }
609
628
  if (exports.UPSELL_STOP_STATES.has(r.state) && ((wallet && r.state === "stopped") || r.vp_payment_intent_id === null || r.amount_minor === null))
610
629
  return false;
611
630
  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)
@@ -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);
@@ -975,9 +992,7 @@ async function afterAction(ctx, r) {
975
992
  return onAnswer(ctx, r, pi);
976
993
  }
977
994
  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;
995
+ return (0, reading_1.isoPlus)(pi.nextAction?.issuedAt, exports.UPSELL_BANK_LINK_MS);
981
996
  }
982
997
  async function refundLate(ctx, r0, extra = {}) {
983
998
  const { host } = ctx;
@@ -991,18 +1006,34 @@ async function refundLate(ctx, r0, extra = {}) {
991
1006
  if (!r)
992
1007
  return { outcome: "unknown" };
993
1008
  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);
1009
+ const stuck = r0.state === "refunding" && host.now() - Date.parse(r0.at) >= 60 * 60_000;
1010
+ if (!undone) {
1011
+ host.log(stuck ? "error" : "warn", "upsell_late_refund", { ...ids, result: "edit_open" });
1012
+ return answer(false);
1013
+ }
1014
+ const now = await facts(host, ctx.f.id);
1015
+ if (!now)
1016
+ return answer(false);
1017
+ if (now.variant_ids.includes(r.variant_id)) {
1018
+ host.log("error", "upsell_late_refund", { ...ids, result: "item_on_order" });
1019
+ await advance(ctx, r, { state: "held", hold_reason: "upsell_paid_unrecorded", decline_kind: null, rechecked: undefined });
1020
+ return { outcome: "unknown" };
1021
+ }
1022
+ const res = await host.refundLate(r.vp_payment_intent_id, r.amount_minor, r.currency, lateRefundStep(host.now(), r0.state === "refunding" ? r0.at : null));
995
1023
  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" })));
1024
+ host.log("info", "upsell_late_refund", { ...ids, result: "refunded" });
1025
+ return answer(!!(await advance(ctx, r, { state: "refunded" })));
998
1026
  }
999
- if (res === "failed") {
1000
- host.log("error", "upsell_late_refund", { ...ids, result: "failed" });
1027
+ const waitedOut = res === "unsettled" && r0.state === "refunding" && host.now() - Date.parse(r0.at) >= exports.UPSELL_UNSETTLED_REFUND_MS;
1028
+ if (res === "failed" || waitedOut) {
1029
+ host.log("error", "upsell_late_refund", { ...ids, result: res === "failed" ? "failed" : "not_settled" });
1001
1030
  await advance(ctx, r, { state: "held", hold_reason: "upsell_refund_failed", decline_kind: null, rechecked: undefined });
1002
1031
  }
1003
1032
  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" });
1033
+ if (res === "unsettled")
1034
+ host.log("info", "upsell_late_refund", { ...ids, result: "not_settled_yet" });
1035
+ else
1036
+ host.log(stuck ? "error" : "warn", "upsell_late_refund", { ...ids, result: stuck ? "still_pending" : "retry" });
1006
1037
  }
1007
1038
  return answer(false);
1008
1039
  }
@@ -1027,6 +1058,69 @@ async function readStoppedIntent(ctx, r) {
1027
1058
  }
1028
1059
  return { kind: "open", declineKind: null, pi };
1029
1060
  }
1061
+ async function recheckPrior(ctx, r) {
1062
+ const { host } = ctx;
1063
+ const all = r.prior_intents ?? [];
1064
+ const next = [];
1065
+ let changed = false;
1066
+ for (const p of all) {
1067
+ if (p.stuck !== undefined) {
1068
+ next.push(p);
1069
+ continue;
1070
+ }
1071
+ const age = host.now() - Date.parse(p.at);
1072
+ if (age < exports.UPSELL_RECHECK_AFTER_MS) {
1073
+ next.push(p);
1074
+ continue;
1075
+ }
1076
+ const ids = { ...ctx.ids, vp_payment_intent_id: p.id, amount_minor: p.amount_minor, prior: true };
1077
+ const stuck = (why) => {
1078
+ next.push({ ...p, stuck: why });
1079
+ changed = true;
1080
+ };
1081
+ const pi = await readIntent(host, p.id);
1082
+ if (!pi) {
1083
+ next.push(p);
1084
+ continue;
1085
+ }
1086
+ if (pi.status === "failed" || pi.status === "voided") {
1087
+ host.log("info", "upsell_rechecked", { ...ids, result: "ended" });
1088
+ changed = true;
1089
+ continue;
1090
+ }
1091
+ if (pi.status !== "succeeded" && pi.status !== "captured") {
1092
+ next.push(p);
1093
+ continue;
1094
+ }
1095
+ if (pi.amount !== p.amount_minor || typeof pi.currency !== "string" || pi.currency.toUpperCase() !== p.currency) {
1096
+ host.log("error", "upsell_outcome_unknown", { ...ids, why: "prior_charge_amount_mismatch" });
1097
+ stuck("amount_mismatch");
1098
+ continue;
1099
+ }
1100
+ host.log("warn", "upsell_charged_after_stop", ids);
1101
+ const res = await host.refundLate(p.id, p.amount_minor, p.currency, lateRefundStep(host.now(), p.at));
1102
+ const waitedOut = res === "unsettled" && age >= exports.UPSELL_UNSETTLED_REFUND_MS;
1103
+ if (res === "refunded") {
1104
+ host.log("info", "upsell_late_refund", { ...ids, result: "refunded" });
1105
+ changed = true;
1106
+ }
1107
+ else if (res === "failed" || waitedOut) {
1108
+ host.log("error", "upsell_late_refund", { ...ids, result: res === "failed" ? "failed" : "not_settled" });
1109
+ stuck("refund_failed");
1110
+ }
1111
+ else {
1112
+ host.log(res === "unsettled" ? "info" : "warn", "upsell_late_refund", { ...ids, result: res === "unsettled" ? "not_settled_yet" : "retry" });
1113
+ next.push(p);
1114
+ }
1115
+ }
1116
+ if (!changed)
1117
+ return r;
1118
+ const { prior_intents: _old, ...rest } = r;
1119
+ const value = next.length > 0 ? { ...rest, prior_intents: next } : rest;
1120
+ if (!(0, session_data_1.isUpsellRecord)(value))
1121
+ return r;
1122
+ return (await host.writeUpsell(ctx.collectionId, value, { expect: "at", at: r.at })) ? value : r;
1123
+ }
1030
1124
  function lateSettled(out) {
1031
1125
  return out.outcome === "refunded" && !out.pending ? "refunded" : "waiting";
1032
1126
  }
@@ -1054,12 +1148,17 @@ async function settleUpsell(host, input) {
1054
1148
  const read = (0, session_data_1.readUpsellRecord)(f.collections.find((c) => c.id === found.collectionId)?.metadata, input.acceptanceId);
1055
1149
  if (read.kind !== "record")
1056
1150
  return "none";
1057
- const r = read.r;
1151
+ let r = read.r;
1058
1152
  const age = host.now() - Date.parse(r.at);
1059
- const stop = offerStopped(host, f, found.offer);
1153
+ const stop = offerStopped(host, f);
1060
1154
  if (age < exports.UPSELL_SETTLE.afterMs && !stop)
1061
1155
  return "waiting";
1062
1156
  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 };
1157
+ if ((0, session_data_1.priorOwed)(r).length > 0 && !session_data_1.UPSELL_IN_FLIGHT_STATES.has(r.state)) {
1158
+ r = await recheckPrior(ctx, r);
1159
+ if (!(0, session_data_1.upsellJobOwns)(r))
1160
+ return "none";
1161
+ }
1063
1162
  if (r.state === "refunding")
1064
1163
  return lateSettled(await refundLate(ctx, r));
1065
1164
  if (r.pay === "wallet" && r.state !== "charged")
@@ -1150,7 +1249,7 @@ async function add(ctx, charged) {
1150
1249
  const fresh = await facts(ctx.host, ctx.f.id);
1151
1250
  if (!fresh)
1152
1251
  return { outcome: "unknown" };
1153
- if (offerStopped(ctx.host, fresh, ctx.offer) && !fresh.variant_ids.includes(r.variant_id))
1252
+ if (offerStopped(ctx.host, fresh) && !fresh.variant_ids.includes(r.variant_id))
1154
1253
  return refundLate(ctx, r);
1155
1254
  }
1156
1255
  let lastErr;
@@ -1178,8 +1277,12 @@ async function add(ctx, charged) {
1178
1277
  }
1179
1278
  }
1180
1279
  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) });
1280
+ if (r.state !== "charged")
1281
+ return { outcome: "unknown" };
1282
+ const fresh = await facts(ctx.host, ctx.f.id);
1283
+ if (fresh && r.upsell_collection_id === null && !fresh.variant_ids.includes(r.variant_id))
1284
+ return refundLate(ctx, r);
1285
+ await advance(ctx, r, { state: "held", hold_reason: paidHoldReason(fresh, r) });
1183
1286
  return { outcome: "unknown" };
1184
1287
  }
1185
1288
  function addedInMedusa(f, r) {
@@ -1689,7 +1792,7 @@ async function walletEditAndTake(ctx, r) {
1689
1792
  let f = await facts(host, ctx.f.id);
1690
1793
  if (!f)
1691
1794
  return { outcome: "processing" };
1692
- if (offerStopped(host, f, ctx.offer)) {
1795
+ if (offerStopped(host, f)) {
1693
1796
  host.log("info", "upsell_stopped", { ...ctx.ids, vp_payment_intent_id: r.vp_payment_intent_id, from: r.state });
1694
1797
  await cancelOwnEdit(ctx, r);
1695
1798
  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.2",
4
4
  "description": "Vonpay payment provider for Medusa v2",
5
5
  "author": "Von Payments",
6
6
  "license": "MIT",
@@ -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
+ }