vonpay-medusa 1.0.0 → 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.
- package/.medusa/server/src/admin/index.js +7 -7
- package/.medusa/server/src/admin/index.mjs +7 -7
- 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 +34 -12
- package/.medusa/server/src/providers/vonpay/session-data.js +19 -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 +126 -23
- 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 +1 -1
- 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,7 @@ 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
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
|
|
1168
|
-
icon: config
|
|
1169
|
-
path: "/vonpay-
|
|
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-
|
|
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
|
|
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
|
|
1165
|
-
icon: config
|
|
1166
|
-
path: "/vonpay-
|
|
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-
|
|
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.
|
|
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" : "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,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
|
-
|
|
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:
|
|
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 === "
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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)
|
|
@@ -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);
|
|
@@ -975,9 +992,7 @@ async function afterAction(ctx, r) {
|
|
|
975
992
|
return onAnswer(ctx, r, pi);
|
|
976
993
|
}
|
|
977
994
|
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;
|
|
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
|
|
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"
|
|
997
|
-
return answer(
|
|
1024
|
+
host.log("info", "upsell_late_refund", { ...ids, result: "refunded" });
|
|
1025
|
+
return answer(!!(await advance(ctx, r, { state: "refunded" })));
|
|
998
1026
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1151
|
+
let r = read.r;
|
|
1058
1152
|
const age = host.now() - Date.parse(r.at);
|
|
1059
|
-
const stop = offerStopped(host, f
|
|
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
|
|
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
|
|
1182
|
-
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
+
}
|