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.
- package/.medusa/server/src/admin/index.js +5 -1
- package/.medusa/server/src/admin/index.mjs +5 -1
- package/.medusa/server/src/api/store/vonpay/upsells/[order]/accept/route.js +1 -1
- package/.medusa/server/src/api/store/vonpay/upsells/[order]/resume/route.js +1 -1
- package/.medusa/server/src/lib/errors.js +1 -0
- package/.medusa/server/src/providers/vonpay/descriptor.js +4 -18
- package/.medusa/server/src/providers/vonpay/held-list.js +17 -0
- package/.medusa/server/src/providers/vonpay/options.js +11 -1
- package/.medusa/server/src/providers/vonpay/reading.js +6 -2
- package/.medusa/server/src/providers/vonpay/service.js +65 -14
- package/.medusa/server/src/providers/vonpay/session-data.js +25 -0
- package/.medusa/server/src/providers/vonpay/setup-check.js +14 -0
- package/.medusa/server/src/providers/vonpay/ship-hold.js +8 -1
- package/.medusa/server/src/providers/vonpay/upsell-accept.js +181 -26
- package/.medusa/server/src/providers/vonpay/upsell-cleanup.js +29 -4
- package/.medusa/server/src/providers/vonpay/upsell-routes.js +50 -5
- package/.medusa/server/src/providers/vonpay/upsell-token.js +4 -0
- package/README.md +10 -3
- package/bin/vonpay-medusa.cjs +3 -1
- package/package.json +3 -2
- package/storefront/src/deadline.ts +17 -0
- package/storefront/src/server.ts +23 -28
- package/storefront/src/shopper-ip.ts +12 -0
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 === "
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
138
|
-
|
|
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
|
|
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
|
|
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
|
|
722
|
+
return newAttempt(ctx, expected, 1, null);
|
|
715
723
|
}
|
|
716
724
|
const WORKFLOWS_PER_ATTEMPT = 6;
|
|
717
|
-
async function newAttempt(ctx, expected, attempt,
|
|
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
|
-
|
|
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
|
|
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"
|
|
997
|
-
return answer(
|
|
1031
|
+
host.log("info", "upsell_late_refund", { ...ids, result: "refunded" });
|
|
1032
|
+
return answer(!!(await advance(ctx, r, { state: "refunded" })));
|
|
998
1033
|
}
|
|
999
|
-
if (res === "
|
|
1000
|
-
host.log("error", "upsell_late_refund", { ...ids, result: "
|
|
1001
|
-
await advance(ctx, r, { state: "held", hold_reason: "
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1203
|
+
let r = read.r;
|
|
1058
1204
|
const age = host.now() - Date.parse(r.at);
|
|
1059
|
-
const stop = offerStopped(host, f
|
|
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
|
|
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
|
|
1182
|
-
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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"
|
|
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).
|
|
497
|
-
|
|
498
|
-
|
|
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
|
package/bin/vonpay-medusa.cjs
CHANGED
|
@@ -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);
|
|
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.
|
|
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": "^
|
|
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
|
+
}
|
package/storefront/src/server.ts
CHANGED
|
@@ -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
|
|
206
|
-
sdk.client.fetch<{ token?: unknown }>("/store/vonpay/upsells/pass", { method: "POST", headers, body: { cart_id: cartId }, cache: "no-store" })
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
+
}
|