@blamejs/blamejs-shop 0.4.26 → 0.4.28
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/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +13 -0
- package/lib/admin.js +499 -54
- package/lib/asset-manifest.json +3 -3
- package/lib/checkout.js +263 -19
- package/lib/order.js +69 -3
- package/lib/payment.js +80 -5
- package/lib/quotes.js +216 -82
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +40 -0
- package/lib/storefront.js +21 -2
- package/package.json +1 -1
package/lib/admin.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
var pricing = require("./pricing");
|
|
34
|
+
var paymentModule = require("./payment"); // _decimalToMinor — normalize a PayPal refund response's decimal amount to minor units
|
|
34
35
|
var collectionsModule = require("./collections");
|
|
35
36
|
var quantityDiscountsModule = require("./quantity-discounts");
|
|
36
37
|
var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
|
|
@@ -396,6 +397,21 @@ function _dollarsToMinor(value, label, currency) {
|
|
|
396
397
|
return Number(minor);
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
// Render an integer minor-unit amount back into the major-unit decimal
|
|
401
|
+
// string a money form field expects ("1999" → "19.99"), honouring the
|
|
402
|
+
// currency's exponent the same way _dollarsToMinor does on the way in.
|
|
403
|
+
// Returns "" for a NULL / malformed amount so an unpriced field renders
|
|
404
|
+
// empty rather than crashing the form. The Money toString is
|
|
405
|
+
// "<decimal> <CUR>"; the field wants just the decimal.
|
|
406
|
+
function _minorToMajorInput(minor, currency) {
|
|
407
|
+
if (minor == null) return "";
|
|
408
|
+
var n = Number(minor);
|
|
409
|
+
if (!Number.isSafeInteger(n) || n < 0) return "";
|
|
410
|
+
var cur = typeof currency === "string" && /^[A-Z]{3}$/.test(currency) ? currency : "USD";
|
|
411
|
+
try { return b.money.fromMinorUnits(BigInt(n), cur).toString().split(" ")[0]; }
|
|
412
|
+
catch (_e) { return ""; }
|
|
413
|
+
}
|
|
414
|
+
|
|
399
415
|
// Strict non-negative integer for a form field (money minor units,
|
|
400
416
|
// dimensions). Refuses "", floats, and parseInt's loose-prefix "12abc"
|
|
401
417
|
// → 12 — the /^\d+$/ test is anchored so the whole string must be
|
|
@@ -828,7 +844,8 @@ function mount(router, deps) {
|
|
|
828
844
|
var catalog = deps.catalog;
|
|
829
845
|
var order = deps.order;
|
|
830
846
|
var cart = deps.cart || null; // abandoned-cart visibility console (/admin/carts) disabled when absent
|
|
831
|
-
var payment = deps.payment || null; //
|
|
847
|
+
var payment = deps.payment || null; // Stripe handle — Stripe-paid orders refund through this
|
|
848
|
+
var paypal = deps.paypal || null; // PayPal handle — PayPal-paid orders refund through this; refund endpoints disabled when BOTH are absent
|
|
832
849
|
var mailer = deps.mailer || null; // transactional email factory (lib/email.js) — resend-confirmation disabled when absent
|
|
833
850
|
var _checkout = deps.checkout || null; // reserved — future webhook handler wiring
|
|
834
851
|
var r2 = deps.r2_bridge || null; // media-upload endpoint disabled when absent
|
|
@@ -867,7 +884,8 @@ function mount(router, deps) {
|
|
|
867
884
|
var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
|
|
868
885
|
var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
|
|
869
886
|
var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
|
|
870
|
-
var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
|
|
887
|
+
var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/reprice/withdraw/convert) disabled when absent
|
|
888
|
+
var convertQuoteToOrder = deps.convertQuoteToOrder || null; // quote → pending-order converter (server.js composition); the console convert action disabled when absent
|
|
871
889
|
var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
|
|
872
890
|
var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
|
|
873
891
|
// Read-only activity log at /admin/audit. Defaults ON — the framework
|
|
@@ -2228,7 +2246,7 @@ function mount(router, deps) {
|
|
|
2228
2246
|
// a read failure leaves the panel to treat the order as un-refunded
|
|
2229
2247
|
// (the route re-validates the cap server-side before moving money).
|
|
2230
2248
|
var refundedMinor = 0;
|
|
2231
|
-
if (
|
|
2249
|
+
if (o.payment_intent_id && _refundHandleFor(o)) {
|
|
2232
2250
|
try { refundedMinor = await order.refundedTotalMinor(o.id); }
|
|
2233
2251
|
catch (_re) { refundedMinor = 0; }
|
|
2234
2252
|
}
|
|
@@ -2237,9 +2255,12 @@ function mount(router, deps) {
|
|
|
2237
2255
|
nav_available: navAvailable,
|
|
2238
2256
|
order: o,
|
|
2239
2257
|
transitions: order.transitionsFrom(o.status),
|
|
2240
|
-
// Refund moves money, so the console only offers it when
|
|
2241
|
-
//
|
|
2242
|
-
|
|
2258
|
+
// Refund moves money, so the console only offers it when the order
|
|
2259
|
+
// has a captured intent AND the provider that captured it is wired —
|
|
2260
|
+
// a Stripe handle can't refund a PayPal-paid order (and vice versa),
|
|
2261
|
+
// so the gate routes by the order's provider, not by "some payment
|
|
2262
|
+
// handle exists".
|
|
2263
|
+
can_refund: !!(o.payment_intent_id && _refundHandleFor(o)),
|
|
2243
2264
|
// Partial-refund panel inputs. `refunded_minor` is the running
|
|
2244
2265
|
// total already refunded; `refundable_minor` is what's left of the
|
|
2245
2266
|
// order's grand total. The panel renders a decimal-amount form
|
|
@@ -3992,27 +4013,162 @@ function mount(router, deps) {
|
|
|
3992
4013
|
}
|
|
3993
4014
|
|
|
3994
4015
|
// ---- refunds --------------------------------------------------------
|
|
4016
|
+
//
|
|
4017
|
+
// PROVIDER ROUTING. An order's payment_intent_id is provider-opaque: the
|
|
4018
|
+
// Stripe flow stores a `pi_...` PaymentIntent id, the PayPal flow stores
|
|
4019
|
+
// the PayPal order id. A refund must dial the provider that captured the
|
|
4020
|
+
// charge — sending a PayPal order id into Stripe's refund API (or vice
|
|
4021
|
+
// versa) is a guaranteed upstream 4xx with the operator left reading a
|
|
4022
|
+
// misleading provider error. Every refund surface below (full, partial,
|
|
4023
|
+
// RMA) resolves the order's provider first and routes through
|
|
4024
|
+
// `_issueProviderRefund`, which normalizes the two providers' shapes:
|
|
4025
|
+
//
|
|
4026
|
+
// stripe → payment.refund({ payment_intent, amount_minor?, reason })
|
|
4027
|
+
// paypal → paypal.refund({ capture_id, amount_minor?, currency })
|
|
4028
|
+
//
|
|
4029
|
+
// The PayPal capture id (the refund target — NOT the order id) resolves
|
|
4030
|
+
// from the persisted orders.paypal_capture_id column, falling back to the
|
|
4031
|
+
// mark_paid transition metadata for orders captured before the column
|
|
4032
|
+
// existed, then to a remote getOrder read — and heals the column so the
|
|
4033
|
+
// recovery runs once per order.
|
|
4034
|
+
|
|
4035
|
+
// Which provider captured this order's charge. The persisted column is
|
|
4036
|
+
// authoritative; legacy rows (placed before the column existed) fall back
|
|
4037
|
+
// to the payment_intent_id shape — a Stripe PaymentIntent id always
|
|
4038
|
+
// carries the `pi_` prefix, a PayPal order id never does. Returns
|
|
4039
|
+
// "stripe" | "paypal" | null (no provider charge — gift-card / loyalty
|
|
4040
|
+
// fully-covered orders).
|
|
4041
|
+
function _orderPaymentProvider(o) {
|
|
4042
|
+
if (!o) return null;
|
|
4043
|
+
if (o.payment_provider === "paypal" || o.payment_provider === "stripe") return o.payment_provider;
|
|
4044
|
+
if (!o.payment_intent_id) return null;
|
|
4045
|
+
return /^pi_/.test(o.payment_intent_id) ? "stripe" : "paypal";
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
// The wired adapter that can refund this order, or null when the order's
|
|
4049
|
+
// provider isn't configured. This is what the console's refund affordances
|
|
4050
|
+
// gate on — provider REALITY, not merely "some payment handle exists"
|
|
4051
|
+
// (offering a Stripe-backed Refund button on a PayPal order moves no
|
|
4052
|
+
// money and 502s).
|
|
4053
|
+
function _refundHandleFor(o) {
|
|
4054
|
+
var provider = _orderPaymentProvider(o);
|
|
4055
|
+
if (provider === "stripe") return payment;
|
|
4056
|
+
if (provider === "paypal") return paypal;
|
|
4057
|
+
return null;
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
// Resolve the PayPal CAPTURE id a refund runs against. Local resolution
|
|
4061
|
+
// first (column, then transition-metadata recovery — order.paypalCaptureId
|
|
4062
|
+
// heals the column), then a remote getOrder read for pre-existing orders
|
|
4063
|
+
// whose capture id never reached the local ledger. Throws a coded
|
|
4064
|
+
// TypeError (clean 422, nothing dialed for the refund) when no capture
|
|
4065
|
+
// can be found — refunding the ORDER id instead would 404 at PayPal.
|
|
4066
|
+
async function _paypalCaptureIdFor(o) {
|
|
4067
|
+
var local = await order.paypalCaptureId(o.id);
|
|
4068
|
+
if (local) return local;
|
|
4069
|
+
try {
|
|
4070
|
+
var remote = await paypal.getOrder(o.payment_intent_id);
|
|
4071
|
+
var recovered = remote.purchase_units[0].payments.captures[0].id;
|
|
4072
|
+
if (typeof recovered === "string" && recovered.length) {
|
|
4073
|
+
try { await order.setPaypalCapture(o.id, recovered); }
|
|
4074
|
+
catch (_e) { /* drop-silent — healing the column is best-effort; the refund proceeds on the recovered id */ }
|
|
4075
|
+
return recovered;
|
|
4076
|
+
}
|
|
4077
|
+
} catch (_e2) { /* fall through to the coded refusal below */ }
|
|
4078
|
+
var missing = new TypeError("This PayPal order has no recorded capture to refund against — the payment may not have been captured.");
|
|
4079
|
+
missing._refundCode = "no-paypal-capture";
|
|
4080
|
+
throw missing;
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
// Issue the provider refund for an order, routed by provider, normalized
|
|
4084
|
+
// to `{ provider, id, amount_minor, raw }`. `opts2.amount_minor` absent →
|
|
4085
|
+
// a FULL refund of the charge's remaining balance (both providers
|
|
4086
|
+
// implement that natively). `opts2.idempotency_key` is REQUIRED by the
|
|
4087
|
+
// callers' double-refund discipline: Stripe dedupes on Idempotency-Key;
|
|
4088
|
+
// the PayPal adapter folds it into the PayPal-Request-Id, so two distinct
|
|
4089
|
+
// partial slices on the SAME capture carry distinct request ids (PayPal
|
|
4090
|
+
// silently REPLAYS the first refund when the id repeats — it never
|
|
4091
|
+
// executes a second one) while a retry of the same slice stays
|
|
4092
|
+
// deduplicated.
|
|
4093
|
+
async function _issueProviderRefund(o, opts2) {
|
|
4094
|
+
var provider = _orderPaymentProvider(o);
|
|
4095
|
+
var handle = _refundHandleFor(o);
|
|
4096
|
+
if (!handle) {
|
|
4097
|
+
var unwired = new TypeError(provider === "paypal"
|
|
4098
|
+
? "This order was paid through PayPal, but PayPal credentials are not configured — set PAYPAL_CLIENT_ID / PAYPAL_SECRET to refund it."
|
|
4099
|
+
: "No payment provider is configured for this order's charge.");
|
|
4100
|
+
unwired._refundCode = "provider-not-configured";
|
|
4101
|
+
throw unwired;
|
|
4102
|
+
}
|
|
4103
|
+
var raw, refundedMinor = null;
|
|
4104
|
+
if (provider === "paypal") {
|
|
4105
|
+
var captureId = await _paypalCaptureIdFor(o);
|
|
4106
|
+
var ppInput = { capture_id: captureId };
|
|
4107
|
+
if (opts2.amount_minor != null) {
|
|
4108
|
+
ppInput.amount_minor = opts2.amount_minor;
|
|
4109
|
+
// A PayPal partial refund names its amount in the CAPTURE currency —
|
|
4110
|
+
// the order's charge currency.
|
|
4111
|
+
ppInput.currency = String(o.currency || "").toUpperCase();
|
|
4112
|
+
}
|
|
4113
|
+
raw = await handle.refund(ppInput, opts2.idempotency_key);
|
|
4114
|
+
// The refund resource echoes its amount as a decimal string; parse it
|
|
4115
|
+
// exactly, falling back to the requested amount when the response
|
|
4116
|
+
// omits it. Stays null on an unparseable full-refund response — the
|
|
4117
|
+
// ledger row then records no amount rather than a guessed one.
|
|
4118
|
+
if (raw && raw.amount && typeof raw.amount.value === "string") {
|
|
4119
|
+
try {
|
|
4120
|
+
refundedMinor = paymentModule._decimalToMinor(
|
|
4121
|
+
raw.amount.value, String(raw.amount.currency_code || o.currency || "").toUpperCase());
|
|
4122
|
+
} catch (_e) { refundedMinor = null; }
|
|
4123
|
+
}
|
|
4124
|
+
if (refundedMinor == null && opts2.amount_minor != null) refundedMinor = opts2.amount_minor;
|
|
4125
|
+
} else {
|
|
4126
|
+
raw = await handle.refund({
|
|
4127
|
+
payment_intent: o.payment_intent_id,
|
|
4128
|
+
amount_minor: opts2.amount_minor != null ? opts2.amount_minor : undefined,
|
|
4129
|
+
reason: opts2.reason || undefined,
|
|
4130
|
+
metadata: opts2.metadata || undefined,
|
|
4131
|
+
}, opts2.idempotency_key);
|
|
4132
|
+
refundedMinor = Number.isInteger(raw && raw.amount) ? raw.amount
|
|
4133
|
+
: (opts2.amount_minor != null ? opts2.amount_minor : null);
|
|
4134
|
+
}
|
|
4135
|
+
return { provider: provider, id: raw && raw.id, amount_minor: refundedMinor, raw: raw };
|
|
4136
|
+
}
|
|
3995
4137
|
|
|
3996
|
-
|
|
4138
|
+
// Ledger metadata for a provider refund: the provider's refund id under
|
|
4139
|
+
// its provider-named key plus the refunded amount. The PayPal key
|
|
4140
|
+
// (`paypal_refund_id`) is ALSO the dedupe identity the webhook mirror
|
|
4141
|
+
// checks — PayPal echoes every refund back as a PAYMENT.CAPTURE.REFUNDED
|
|
4142
|
+
// event whose resource.id is this refund id, and the mirror skips a row
|
|
4143
|
+
// it already finds in the ledger, so an admin-issued refund is never
|
|
4144
|
+
// double-applied when its own webhook arrives.
|
|
4145
|
+
function _refundLedgerMeta(result, extra) {
|
|
4146
|
+
var meta = Object.assign({}, extra || {});
|
|
4147
|
+
meta[result.provider === "paypal" ? "paypal_refund_id" : "stripe_refund_id"] = result.id;
|
|
4148
|
+
if (Number.isInteger(result.amount_minor) && result.amount_minor > 0) meta.amount_minor = result.amount_minor;
|
|
4149
|
+
return meta;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
if (payment || paypal) {
|
|
3997
4153
|
// Issue the actual payment-provider refund, then advance the order
|
|
3998
4154
|
// FSM. Shared by the JSON API and the browser console so a console
|
|
3999
4155
|
// "Refund" moves the money first (never a bare state change — that
|
|
4000
4156
|
// would mark an order refunded with the customer never paid back).
|
|
4001
4157
|
async function _refundOrder(o, body) {
|
|
4002
4158
|
var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || b.uuid.v7());
|
|
4003
|
-
var
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
}
|
|
4159
|
+
var result = await _issueProviderRefund(o, {
|
|
4160
|
+
amount_minor: body.amount_minor || undefined,
|
|
4161
|
+
reason: body.reason || undefined,
|
|
4162
|
+
metadata: { order_id: o.id },
|
|
4163
|
+
idempotency_key: refundIdempotencyKey,
|
|
4164
|
+
});
|
|
4009
4165
|
try {
|
|
4010
4166
|
await order.transition(o.id, "refund", {
|
|
4011
4167
|
reason: "admin:refund:" + (body.reason || "requested_by_customer"),
|
|
4012
|
-
metadata:
|
|
4168
|
+
metadata: _refundLedgerMeta(result),
|
|
4013
4169
|
});
|
|
4014
4170
|
} catch (_e) { /* refund succeeded at the provider; transition refusal logged, surfaced via re-fetch */ }
|
|
4015
|
-
return { refund:
|
|
4171
|
+
return { refund: result.raw, order: await order.get(o.id) };
|
|
4016
4172
|
}
|
|
4017
4173
|
|
|
4018
4174
|
// Browser confirmation interstitial for the full refund — it moves
|
|
@@ -4028,7 +4184,7 @@ function mount(router, deps) {
|
|
|
4028
4184
|
var o;
|
|
4029
4185
|
try { o = await order.get(id); }
|
|
4030
4186
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
4031
|
-
if (!o || !o.payment_intent_id) {
|
|
4187
|
+
if (!o || !o.payment_intent_id || !_refundHandleFor(o)) {
|
|
4032
4188
|
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
4033
4189
|
}
|
|
4034
4190
|
var amount = pricing.format(o.grand_total_minor, o.currency);
|
|
@@ -4053,8 +4209,14 @@ function mount(router, deps) {
|
|
|
4053
4209
|
try {
|
|
4054
4210
|
result = await _refundOrder(o, req.body || {});
|
|
4055
4211
|
} catch (e) {
|
|
4212
|
+
// A coded refusal (the order's provider isn't configured / the
|
|
4213
|
+
// PayPal capture can't be resolved) is a clean 422 — nothing was
|
|
4214
|
+
// dialed, the operator gets the actionable message.
|
|
4215
|
+
if (e instanceof TypeError && e._refundCode) {
|
|
4216
|
+
return _problem(res, 422, e._refundCode, e.message);
|
|
4217
|
+
}
|
|
4056
4218
|
// allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason (e.g. "charge already refunded"), an operator-actionable upstream message, not a server/storage internal.
|
|
4057
|
-
return _problem(res, 502, "
|
|
4219
|
+
return _problem(res, 502, _orderPaymentProvider(o) + "-refund-failed", (e && e.message) || String(e));
|
|
4058
4220
|
}
|
|
4059
4221
|
_json(res, 200, result);
|
|
4060
4222
|
return { id: o.id };
|
|
@@ -4067,7 +4229,7 @@ function mount(router, deps) {
|
|
|
4067
4229
|
var o;
|
|
4068
4230
|
try { o = await order.get(id); }
|
|
4069
4231
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
4070
|
-
if (!o || !o.payment_intent_id) {
|
|
4232
|
+
if (!o || !o.payment_intent_id || !_refundHandleFor(o)) {
|
|
4071
4233
|
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
4072
4234
|
}
|
|
4073
4235
|
try {
|
|
@@ -4135,15 +4297,19 @@ function mount(router, deps) {
|
|
|
4135
4297
|
}
|
|
4136
4298
|
var clearsBalance = (minor === remainingMinor);
|
|
4137
4299
|
// Idempotency key folds in the refunded-total seen, so two submits of
|
|
4138
|
-
// the same slice (a double-click, a retry) reuse one provider refund
|
|
4300
|
+
// the same slice (a double-click, a retry) reuse one provider refund —
|
|
4301
|
+
// and, equally load-bearing on the PayPal side, two DIFFERENT slices
|
|
4302
|
+
// on the same capture carry DIFFERENT keys: the adapter folds this key
|
|
4303
|
+
// into the PayPal-Request-Id, and PayPal silently replays the first
|
|
4304
|
+
// refund (it never executes a second) when the request id repeats.
|
|
4139
4305
|
var idemKey = "refund:" + o.id + ":partial:" + alreadyMinor + ":" + minor;
|
|
4140
|
-
var
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
}
|
|
4146
|
-
var refundedMinor = Number(
|
|
4306
|
+
var result = await _issueProviderRefund(o, {
|
|
4307
|
+
amount_minor: minor,
|
|
4308
|
+
reason: "requested_by_customer",
|
|
4309
|
+
metadata: { order_id: o.id, partial: !clearsBalance },
|
|
4310
|
+
idempotency_key: idemKey,
|
|
4311
|
+
});
|
|
4312
|
+
var refundedMinor = Number.isInteger(result.amount_minor) && result.amount_minor > 0 ? result.amount_minor : minor;
|
|
4147
4313
|
if (clearsBalance) {
|
|
4148
4314
|
// The slice clears the remaining balance — drive the terminal FSM
|
|
4149
4315
|
// edge so the order moves to `refunded` (and the gift-card / loyalty
|
|
@@ -4153,7 +4319,7 @@ function mount(router, deps) {
|
|
|
4153
4319
|
try {
|
|
4154
4320
|
await order.transition(o.id, "refund", {
|
|
4155
4321
|
reason: "admin:refund:partial-final",
|
|
4156
|
-
metadata: {
|
|
4322
|
+
metadata: _refundLedgerMeta(result, { amount_minor: refundedMinor, partial: true }),
|
|
4157
4323
|
});
|
|
4158
4324
|
} catch (_te) { /* provider refund persisted; FSM refusal surfaced via re-fetch */ }
|
|
4159
4325
|
} else {
|
|
@@ -4162,10 +4328,10 @@ function mount(router, deps) {
|
|
|
4162
4328
|
await order.recordPartialRefund(o.id, {
|
|
4163
4329
|
amount_minor: refundedMinor,
|
|
4164
4330
|
reason: "admin:refund:partial",
|
|
4165
|
-
metadata: {
|
|
4331
|
+
metadata: _refundLedgerMeta(result, { amount_minor: refundedMinor }),
|
|
4166
4332
|
});
|
|
4167
4333
|
}
|
|
4168
|
-
return { refund:
|
|
4334
|
+
return { refund: result.raw, amount_minor: refundedMinor, cleared: clearsBalance };
|
|
4169
4335
|
}
|
|
4170
4336
|
|
|
4171
4337
|
router.post("/admin/orders/:id/refund/partial", _pageOrApi(false,
|
|
@@ -4179,11 +4345,12 @@ function mount(router, deps) {
|
|
|
4179
4345
|
result = await _partialRefund(o, body.amount);
|
|
4180
4346
|
} catch (e) {
|
|
4181
4347
|
if (e instanceof TypeError) {
|
|
4182
|
-
var status = e._refundCode === "over-refund" || e._refundCode === "nothing-remaining"
|
|
4348
|
+
var status = e._refundCode === "over-refund" || e._refundCode === "nothing-remaining" ||
|
|
4349
|
+
e._refundCode === "provider-not-configured" || e._refundCode === "no-paypal-capture" ? 422 : 400;
|
|
4183
4350
|
return _problem(res, status, e._refundCode || "bad-request", e.message);
|
|
4184
4351
|
}
|
|
4185
4352
|
// allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason, an operator-actionable upstream message, not a server/storage internal.
|
|
4186
|
-
return _problem(res, 502, "
|
|
4353
|
+
return _problem(res, 502, _orderPaymentProvider(o) + "-refund-failed", (e && e.message) || String(e));
|
|
4187
4354
|
}
|
|
4188
4355
|
_json(res, 200, result);
|
|
4189
4356
|
return { id: o.id };
|
|
@@ -4193,7 +4360,7 @@ function mount(router, deps) {
|
|
|
4193
4360
|
var o;
|
|
4194
4361
|
try { o = await order.get(id); }
|
|
4195
4362
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
4196
|
-
if (!o || !o.payment_intent_id) {
|
|
4363
|
+
if (!o || !o.payment_intent_id || !_refundHandleFor(o)) {
|
|
4197
4364
|
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
4198
4365
|
}
|
|
4199
4366
|
var enc = encodeURIComponent(id);
|
|
@@ -4694,10 +4861,13 @@ function mount(router, deps) {
|
|
|
4694
4861
|
// degrades to "record-only" rather than throwing.
|
|
4695
4862
|
async function _rmaProviderContext(rma) {
|
|
4696
4863
|
var ctx = { order: null, canProviderRefund: false };
|
|
4697
|
-
if (!payment || !rma || !rma.order_id) return ctx;
|
|
4864
|
+
if ((!payment && !paypal) || !rma || !rma.order_id) return ctx;
|
|
4698
4865
|
try { ctx.order = await order.get(rma.order_id); }
|
|
4699
4866
|
catch (_e) { ctx.order = null; }
|
|
4700
|
-
|
|
4867
|
+
// Provider reality, not mere handle presence: the linked order must
|
|
4868
|
+
// carry a captured intent AND the provider that captured it must be
|
|
4869
|
+
// the one that's wired (see _refundHandleFor).
|
|
4870
|
+
ctx.canProviderRefund = !!(ctx.order && ctx.order.payment_intent_id && _refundHandleFor(ctx.order));
|
|
4701
4871
|
return ctx;
|
|
4702
4872
|
}
|
|
4703
4873
|
|
|
@@ -4996,12 +5166,16 @@ function mount(router, deps) {
|
|
|
4996
5166
|
var idem = "rma-refund:" + rma.id;
|
|
4997
5167
|
var refund;
|
|
4998
5168
|
try {
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5169
|
+
// Routed by the linked order's provider (Stripe payment_intent vs
|
|
5170
|
+
// PayPal capture) — see _issueProviderRefund. The deterministic key
|
|
5171
|
+
// keeps a retry of the SAME RMA refund deduplicated at either
|
|
5172
|
+
// provider (Stripe Idempotency-Key / PayPal-Request-Id).
|
|
5173
|
+
refund = (await _issueProviderRefund(order2, {
|
|
5174
|
+
amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
|
|
5175
|
+
reason: "requested_by_customer",
|
|
5176
|
+
metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
|
|
5177
|
+
idempotency_key: idem,
|
|
5178
|
+
})).raw;
|
|
5005
5179
|
} catch (e) {
|
|
5006
5180
|
// Provider call failed — release the claim so the operator can retry
|
|
5007
5181
|
// a transient failure. A release failure can't be recovered here, so
|
|
@@ -8112,13 +8286,16 @@ function mount(router, deps) {
|
|
|
8112
8286
|
|
|
8113
8287
|
// ---- quotes (B2B request-for-quote negotiation) ---------------------
|
|
8114
8288
|
// The operator side of the RFQ lifecycle. The list is the response queue
|
|
8115
|
-
// (oldest-waiting requests first) plus
|
|
8116
|
-
//
|
|
8117
|
-
//
|
|
8118
|
-
//
|
|
8119
|
-
//
|
|
8120
|
-
//
|
|
8121
|
-
//
|
|
8289
|
+
// (oldest-waiting requests first) plus per-status views (the expired
|
|
8290
|
+
// filter shows what the expiry cron transitioned) and a per-customer
|
|
8291
|
+
// view; the detail screen shows the requested lines + the customer
|
|
8292
|
+
// message, and — for a still-requested quote — a per-line pricing form
|
|
8293
|
+
// that responds with a priced quote + validity window. A responded quote
|
|
8294
|
+
// can be REPRICED (revised offer, fresh window — the customer's existing
|
|
8295
|
+
// link shows the new pricing) or CONVERTED to a pending order against a
|
|
8296
|
+
// recorded out-of-band approval; an operator can withdraw a quote that
|
|
8297
|
+
// hasn't been accepted. Content-negotiated like the other consoles
|
|
8298
|
+
// (bearer → JSON, browser → HTML).
|
|
8122
8299
|
if (quotes) {
|
|
8123
8300
|
// Build the respondToQuote line_prices array from the per-line
|
|
8124
8301
|
// `price_<sku>` dollar fields the detail form posts, converting each to
|
|
@@ -8139,28 +8316,46 @@ function mount(router, deps) {
|
|
|
8139
8316
|
return out;
|
|
8140
8317
|
}
|
|
8141
8318
|
|
|
8319
|
+
// Status filter for the list — a defensive request-shape reader: an
|
|
8320
|
+
// unknown / absent value falls back to the default response queue
|
|
8321
|
+
// rather than erroring a bookmarked link. `expired` is the one the
|
|
8322
|
+
// chips surface (what the expiry cron transitioned); any lifecycle
|
|
8323
|
+
// status is accepted so tooling can read the others.
|
|
8324
|
+
function _quoteStatusFilter(url) {
|
|
8325
|
+
var s = url && url.searchParams.get("status");
|
|
8326
|
+
return (s && quotes.QUOTE_STATUSES.indexOf(s) !== -1) ? s : null;
|
|
8327
|
+
}
|
|
8328
|
+
|
|
8142
8329
|
router.get("/admin/quotes", _pageOrApi(true,
|
|
8143
8330
|
R(async function (req, res) {
|
|
8144
8331
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
8145
8332
|
var cid = url && url.searchParams.get("customer_id");
|
|
8333
|
+
var sf = _quoteStatusFilter(url);
|
|
8146
8334
|
var rows = cid
|
|
8147
8335
|
? await quotes.quotesForCustomer(cid, { limit: 200 })
|
|
8148
|
-
:
|
|
8336
|
+
: sf
|
|
8337
|
+
? await quotes.listByStatus({ status: sf, limit: 200 })
|
|
8338
|
+
: await quotes.pendingResponse({ limit: 200 });
|
|
8149
8339
|
_json(res, 200, { rows: rows });
|
|
8150
8340
|
}),
|
|
8151
8341
|
async function (req, res) {
|
|
8152
8342
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
8153
8343
|
var cid = url && url.searchParams.get("customer_id");
|
|
8344
|
+
var sf = _quoteStatusFilter(url);
|
|
8154
8345
|
var rows = [];
|
|
8155
8346
|
try {
|
|
8156
8347
|
rows = cid
|
|
8157
8348
|
? await quotes.quotesForCustomer(cid, { limit: 200 })
|
|
8158
|
-
:
|
|
8349
|
+
: sf
|
|
8350
|
+
? await quotes.listByStatus({ status: sf, limit: 200 })
|
|
8351
|
+
: await quotes.pendingResponse({ limit: 200 });
|
|
8159
8352
|
} catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
8160
8353
|
_sendHtml(res, 200, renderAdminQuotes({
|
|
8161
8354
|
shop_name: deps.shop_name, nav_available: navAvailable, quotes: rows,
|
|
8162
8355
|
customer_filter: cid,
|
|
8356
|
+
status_filter: sf,
|
|
8163
8357
|
responded: url && url.searchParams.get("responded"),
|
|
8358
|
+
repriced: url && url.searchParams.get("repriced"),
|
|
8164
8359
|
withdrawn: url && url.searchParams.get("withdrawn"),
|
|
8165
8360
|
converted: url && url.searchParams.get("converted"),
|
|
8166
8361
|
notice: (url && url.searchParams.get("err"))
|
|
@@ -8189,6 +8384,8 @@ function mount(router, deps) {
|
|
|
8189
8384
|
}));
|
|
8190
8385
|
_sendHtml(res, 200, renderAdminQuoteDetail({
|
|
8191
8386
|
shop_name: deps.shop_name, nav_available: navAvailable, quote: row,
|
|
8387
|
+
can_convert: !!convertQuoteToOrder,
|
|
8388
|
+
repriced: url && url.searchParams.get("repriced"),
|
|
8192
8389
|
notice: (url && url.searchParams.get("err"))
|
|
8193
8390
|
? "That action couldn't be completed for the quote." : null,
|
|
8194
8391
|
}));
|
|
@@ -8254,6 +8451,190 @@ function mount(router, deps) {
|
|
|
8254
8451
|
},
|
|
8255
8452
|
));
|
|
8256
8453
|
|
|
8454
|
+
// Reprice: revise a still-responded quote the customer hasn't settled —
|
|
8455
|
+
// improved line prices, fresh shipping / tax / validity, an updated
|
|
8456
|
+
// note. Same payload contract as respond (the browser form posts dollar
|
|
8457
|
+
// amounts + validity-in-days; the bearer JSON contract takes minor units
|
|
8458
|
+
// + an absolute valid_until). The FSM's responded -> responded reprice
|
|
8459
|
+
// edge gates it, surfacing the same statuses the other quote actions
|
|
8460
|
+
// use: wrong state → 409, unknown quote → 404, bad shape → 400. The
|
|
8461
|
+
// quote-responded email is NOT re-fired here: the notifier rotates the
|
|
8462
|
+
// customer's view token, and the reprice contract is that the link the
|
|
8463
|
+
// customer already holds keeps working and shows the new pricing.
|
|
8464
|
+
router.post("/admin/quotes/:id/reprice", _pageOrApi(false,
|
|
8465
|
+
W("quote.reprice", async function (req, res) {
|
|
8466
|
+
var row;
|
|
8467
|
+
try { row = await quotes.repriceQuote(Object.assign({}, req.body || {}, { quote_id: req.params.id })); }
|
|
8468
|
+
catch (e) {
|
|
8469
|
+
if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
|
|
8470
|
+
if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
|
|
8471
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
8472
|
+
throw e;
|
|
8473
|
+
}
|
|
8474
|
+
_json(res, 200, row);
|
|
8475
|
+
return { id: row.id };
|
|
8476
|
+
}),
|
|
8477
|
+
async function (req, res) {
|
|
8478
|
+
var id = req.params.id;
|
|
8479
|
+
var enc = encodeURIComponent(id);
|
|
8480
|
+
var body = req.body || {};
|
|
8481
|
+
var current = null;
|
|
8482
|
+
try { current = await quotes.getQuote(id); } catch (_e) { current = null; }
|
|
8483
|
+
if (!current) return _redirect(res, "/admin/quotes?err=1");
|
|
8484
|
+
try {
|
|
8485
|
+
var validDays = _strictNonNegIntField(body.valid_days, "valid_days");
|
|
8486
|
+
if (validDays <= 0) throw new TypeError("admin: valid_days must be at least 1");
|
|
8487
|
+
var quoteCurrency = typeof body.currency === "string" && body.currency
|
|
8488
|
+
? body.currency.toUpperCase() : (current.currency || "USD");
|
|
8489
|
+
await quotes.repriceQuote({
|
|
8490
|
+
quote_id: id,
|
|
8491
|
+
line_prices: _quoteLinePricesFromForm(body, current.lines, quoteCurrency),
|
|
8492
|
+
shipping_minor: body.shipping == null || body.shipping === "" ? 0 : _dollarsToMinor(body.shipping, "shipping", quoteCurrency),
|
|
8493
|
+
tax_minor: body.tax == null || body.tax === "" ? 0 : _dollarsToMinor(body.tax, "tax", quoteCurrency),
|
|
8494
|
+
valid_until: Date.now() + b.constants.TIME.days(validDays),
|
|
8495
|
+
currency: quoteCurrency,
|
|
8496
|
+
operator_notes: body.operator_notes || null,
|
|
8497
|
+
});
|
|
8498
|
+
} catch (e) {
|
|
8499
|
+
if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
|
|
8500
|
+
var msg = _safeNotice(e, "quote.reprice");
|
|
8501
|
+
var fresh = await quotes.getQuote(id);
|
|
8502
|
+
return _sendHtml(res, msg.status, renderAdminQuoteDetail({
|
|
8503
|
+
shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
|
|
8504
|
+
can_convert: !!convertQuoteToOrder,
|
|
8505
|
+
notice: msg.message.replace(/^(quotes|admin)[.:]\s*/, ""),
|
|
8506
|
+
}));
|
|
8507
|
+
}
|
|
8508
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.reprice", outcome: "success", metadata: { quote_id: id } });
|
|
8509
|
+
_redirect(res, "/admin/quotes/" + enc + "?repriced=1");
|
|
8510
|
+
},
|
|
8511
|
+
));
|
|
8512
|
+
|
|
8513
|
+
// Convert to order without the customer's online acceptance — the
|
|
8514
|
+
// verbal-approval case (a phone / email yes the operator is recording).
|
|
8515
|
+
// Requires a reason naming that approval; the reason is captured on the
|
|
8516
|
+
// quote's acceptance attribution AND the chained operator audit log. A
|
|
8517
|
+
// responded quote is first accepted through the same customerAccept verb
|
|
8518
|
+
// the storefront uses — so the accept-time expiry guard still refuses a
|
|
8519
|
+
// stale price — then converted through the shared server.js composition
|
|
8520
|
+
// (inventory holds first; a hold/order failure releases the convert
|
|
8521
|
+
// claim back to accepted for a retry). An already-accepted quote (e.g.
|
|
8522
|
+
// the customer accepted online but conversion wasn't possible then)
|
|
8523
|
+
// skips straight to the conversion.
|
|
8524
|
+
if (convertQuoteToOrder) {
|
|
8525
|
+
// Shared by the bearer-JSON and browser-form branches. Throws coded
|
|
8526
|
+
// errors the branches map to their surfaces.
|
|
8527
|
+
var _operatorConvertQuote = async function (req, id, reasonRaw) {
|
|
8528
|
+
if (typeof reasonRaw !== "string" || !reasonRaw.trim().length) {
|
|
8529
|
+
throw new TypeError("admin: a reason is required to convert a quote on the customer's behalf");
|
|
8530
|
+
}
|
|
8531
|
+
var reason = reasonRaw.trim();
|
|
8532
|
+
if (reason.length > 200) {
|
|
8533
|
+
throw new TypeError("admin: reason must be <= 200 characters");
|
|
8534
|
+
}
|
|
8535
|
+
var current = await quotes.getQuote(id); // TypeError on a malformed id → 400
|
|
8536
|
+
if (!current) {
|
|
8537
|
+
var miss = new Error("quote " + id + " not found");
|
|
8538
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
8539
|
+
throw miss;
|
|
8540
|
+
}
|
|
8541
|
+
if (current.status === "responded") {
|
|
8542
|
+
await quotes.customerAccept({
|
|
8543
|
+
quote_id: id,
|
|
8544
|
+
accepted_by_customer: "operator-recorded: " + reason,
|
|
8545
|
+
});
|
|
8546
|
+
}
|
|
8547
|
+
// Any other non-accepted state falls through to the converter,
|
|
8548
|
+
// whose own FSM claim refuses it — one error shape per state class.
|
|
8549
|
+
var convertedQuote = await convertQuoteToOrder(id);
|
|
8550
|
+
if (!convertedQuote || !convertedQuote.converted_order_id) {
|
|
8551
|
+
// The only null path after a successful accept: no resolvable
|
|
8552
|
+
// ship-to (the customer has no default shipping address) — or the
|
|
8553
|
+
// quote wasn't accepted (wrong state).
|
|
8554
|
+
var blocked = new Error("the quote could not be converted — it must be accepted and " +
|
|
8555
|
+
"the customer needs a default shipping address on file");
|
|
8556
|
+
blocked.code = "QUOTE_NOT_CONVERTIBLE";
|
|
8557
|
+
throw blocked;
|
|
8558
|
+
}
|
|
8559
|
+
// Chained operator-audit row: WHO converted, WHY, and the order it
|
|
8560
|
+
// minted. Drop-silent — a recording failure must never unwind the
|
|
8561
|
+
// conversion the operator just watched succeed.
|
|
8562
|
+
if (operatorAuditLog && typeof operatorAuditLog.record === "function") {
|
|
8563
|
+
try {
|
|
8564
|
+
await operatorAuditLog.record({
|
|
8565
|
+
actor_type: "operator",
|
|
8566
|
+
actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
|
|
8567
|
+
action: "quote.convert_to_order",
|
|
8568
|
+
resource_kind: "quote",
|
|
8569
|
+
resource_id: id,
|
|
8570
|
+
before: { status: current.status },
|
|
8571
|
+
after: { reason: reason, order_id: convertedQuote.converted_order_id },
|
|
8572
|
+
});
|
|
8573
|
+
} catch (_e) { /* drop-silent */ }
|
|
8574
|
+
}
|
|
8575
|
+
return convertedQuote;
|
|
8576
|
+
};
|
|
8577
|
+
|
|
8578
|
+
router.post("/admin/quotes/:id/convert-to-order", _pageOrApi(false,
|
|
8579
|
+
W("quote.convert", async function (req, res) {
|
|
8580
|
+
var row;
|
|
8581
|
+
try { row = await _operatorConvertQuote(req, req.params.id, req.body && req.body.reason); }
|
|
8582
|
+
catch (e) {
|
|
8583
|
+
if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
|
|
8584
|
+
if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
|
|
8585
|
+
if (e && e.code === "QUOTE_EXPIRED") return _problem(res, 409, "quote-expired", e.message);
|
|
8586
|
+
if (e && e.code === "QUOTE_INSUFFICIENT_STOCK") return _problem(res, 409, "quote-insufficient-stock", e.message);
|
|
8587
|
+
if (e && e.code === "QUOTE_NOT_CONVERTIBLE") return _problem(res, 409, "quote-not-convertible", e.message);
|
|
8588
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
8589
|
+
throw e;
|
|
8590
|
+
}
|
|
8591
|
+
_json(res, 200, row);
|
|
8592
|
+
return { id: row.id };
|
|
8593
|
+
}),
|
|
8594
|
+
async function (req, res) {
|
|
8595
|
+
var id = req.params.id;
|
|
8596
|
+
try {
|
|
8597
|
+
await _operatorConvertQuote(req, id, req.body && req.body.reason);
|
|
8598
|
+
} catch (e) {
|
|
8599
|
+
// Per-code operator-safe banner copy (the coded refusals carry no
|
|
8600
|
+
// secrets, but the banner uses authored copy rather than echoing
|
|
8601
|
+
// the thrown text — same no-leak guarantee as _safeNotice, with
|
|
8602
|
+
// a more actionable message per refusal). Anything un-coded
|
|
8603
|
+
// routes through _safeNotice's classifier.
|
|
8604
|
+
var codedCopy = {
|
|
8605
|
+
QUOTE_TRANSITION_REFUSED: "The quote is no longer in a state that can be converted.",
|
|
8606
|
+
QUOTE_EXPIRED: "The quote's validity window has passed — reprice it before converting.",
|
|
8607
|
+
QUOTE_INSUFFICIENT_STOCK: "Not enough stock is available to fulfil the quote.",
|
|
8608
|
+
QUOTE_NOT_CONVERTIBLE: "The quote couldn't be converted — it must be accepted and the customer needs a default shipping address on file.",
|
|
8609
|
+
QUOTE_NOT_FOUND: "Quote not found.",
|
|
8610
|
+
};
|
|
8611
|
+
var codedNotice = e && e.code ? codedCopy[e.code] : null;
|
|
8612
|
+
if (!(e instanceof TypeError) && !codedNotice) throw e;
|
|
8613
|
+
var status, noticeText;
|
|
8614
|
+
if (codedNotice) {
|
|
8615
|
+
status = e.code === "QUOTE_NOT_FOUND" ? 404 : 409;
|
|
8616
|
+
noticeText = codedNotice;
|
|
8617
|
+
} else {
|
|
8618
|
+
// TypeError (validation) — _safeNotice surfaces its operator-
|
|
8619
|
+
// safe message verbatim and never records it as a 5xx.
|
|
8620
|
+
var msg = _safeNotice(e, "quote.convert");
|
|
8621
|
+
status = msg.status;
|
|
8622
|
+
noticeText = msg.message.replace(/^(quotes|admin)[.:]\s*/, "");
|
|
8623
|
+
}
|
|
8624
|
+
var fresh = null;
|
|
8625
|
+
try { fresh = await quotes.getQuote(id); } catch (_e2) { fresh = null; }
|
|
8626
|
+
return _sendHtml(res, status, renderAdminQuoteDetail({
|
|
8627
|
+
shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
|
|
8628
|
+
can_convert: true,
|
|
8629
|
+
notice: noticeText,
|
|
8630
|
+
}));
|
|
8631
|
+
}
|
|
8632
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.convert", outcome: "success", metadata: { quote_id: id } });
|
|
8633
|
+
_redirect(res, "/admin/quotes?converted=1");
|
|
8634
|
+
},
|
|
8635
|
+
));
|
|
8636
|
+
}
|
|
8637
|
+
|
|
8257
8638
|
// Withdraw: cancel a quote that hasn't been accepted yet (requested or
|
|
8258
8639
|
// responded). Accepted / terminal quotes refuse — the FSM gate is the
|
|
8259
8640
|
// single source of truth, surfaced as a 409.
|
|
@@ -21667,14 +22048,21 @@ function renderAdminQuotes(opts) {
|
|
|
21667
22048
|
opts = opts || {};
|
|
21668
22049
|
var rows = opts.quotes || [];
|
|
21669
22050
|
var responded = opts.responded ? "<div class=\"banner banner--ok\">Quote sent to the customer.</div>" : "";
|
|
22051
|
+
var repriced = opts.repriced ? "<div class=\"banner banner--ok\">Quote repriced — the customer's existing link shows the new pricing.</div>" : "";
|
|
21670
22052
|
var withdrawn = opts.withdrawn ? "<div class=\"banner banner--ok\">Quote withdrawn.</div>" : "";
|
|
21671
22053
|
var converted = opts.converted ? "<div class=\"banner banner--ok\">Quote converted to an order.</div>" : "";
|
|
21672
22054
|
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
21673
22055
|
|
|
21674
22056
|
var cf = opts.customer_filter;
|
|
21675
|
-
var
|
|
22057
|
+
var sf = opts.status_filter;
|
|
22058
|
+
var heading = cf ? "Quotes for this customer"
|
|
22059
|
+
: sf === "expired" ? "Expired quotes"
|
|
22060
|
+
: sf ? "Quotes — " + sf
|
|
22061
|
+
: "Quotes awaiting a response";
|
|
21676
22062
|
var chips = "<div class=\"order-filters\">" +
|
|
21677
|
-
"<a class=\"chip" + (cf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
|
|
22063
|
+
"<a class=\"chip" + (cf == null && sf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
|
|
22064
|
+
"<a class=\"chip" + (sf === "expired" ? " chip--on" : "") + "\" href=\"/admin/quotes?status=expired\">Expired</a>" +
|
|
22065
|
+
(sf && sf !== "expired" ? "<a class=\"chip chip--on\" href=\"/admin/quotes?status=" + _htmlEscape(encodeURIComponent(sf)) + "\">" + _htmlEscape(sf) + "</a>" : "") +
|
|
21678
22066
|
(cf ? "<a class=\"chip chip--on\" href=\"/admin/quotes?customer_id=" + _htmlEscape(encodeURIComponent(cf)) + "\">This customer</a>" : "") +
|
|
21679
22067
|
"</div>";
|
|
21680
22068
|
|
|
@@ -21696,9 +22084,11 @@ function renderAdminQuotes(opts) {
|
|
|
21696
22084
|
|
|
21697
22085
|
var table = rows.length
|
|
21698
22086
|
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Quote</th><th scope=\"col\">Customer</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Lines</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
|
|
21699
|
-
: "<p class=\"empty\">" + (cf ? "No quotes for this customer."
|
|
22087
|
+
: "<p class=\"empty\">" + (cf ? "No quotes for this customer."
|
|
22088
|
+
: sf ? "No " + _htmlEscape(sf) + " quotes."
|
|
22089
|
+
: "No quotes are waiting for a response.") + "</p>";
|
|
21700
22090
|
|
|
21701
|
-
var bodyHtml = "<section><h2>Quotes</h2>" + responded + withdrawn + converted + notice +
|
|
22091
|
+
var bodyHtml = "<section><h2>Quotes</h2>" + responded + repriced + withdrawn + converted + notice +
|
|
21702
22092
|
"<p class=\"meta\">Request-for-quote negotiations. The response queue lists the requests waiting on you, oldest first — open one to price its lines and send the customer a quote.</p>" +
|
|
21703
22093
|
chips + "<h3 class=\"subhead\">" + _htmlEscape(heading) + "</h3>" + table + "</section>";
|
|
21704
22094
|
return _renderAdminShell(opts.shop_name, "Quotes", bodyHtml, "quotes", opts.nav_available);
|
|
@@ -21713,6 +22103,8 @@ function renderAdminQuoteDetail(opts) {
|
|
|
21713
22103
|
return _renderAdminShell(opts.shop_name, "Quote", nf, "quotes", opts.nav_available);
|
|
21714
22104
|
}
|
|
21715
22105
|
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
22106
|
+
var repricedBanner = opts.repriced
|
|
22107
|
+
? "<div class=\"banner banner--ok\">Quote repriced — the customer's existing link shows the new pricing.</div>" : "";
|
|
21716
22108
|
var enc = _htmlEscape(encodeURIComponent(q.id));
|
|
21717
22109
|
var currency = q.currency || "USD";
|
|
21718
22110
|
|
|
@@ -21725,6 +22117,8 @@ function renderAdminQuoteDetail(opts) {
|
|
|
21725
22117
|
(q.payment_terms ? "<p class=\"meta\">Payment terms: " + _htmlEscape(q.payment_terms) + "</p>" : "") +
|
|
21726
22118
|
(q.message ? "<p class=\"meta\">Customer message: <q>" + _htmlEscape(q.message) + "</q></p>" : "") +
|
|
21727
22119
|
(q.valid_until ? "<p class=\"meta\">Valid until: " + _htmlEscape(new Date(Number(q.valid_until)).toISOString()) + "</p>" : "") +
|
|
22120
|
+
(q.response_version != null && Number(q.response_version) > 1
|
|
22121
|
+
? "<p class=\"meta\">Pricing revision: " + _htmlEscape(String(q.response_version)) + "</p>" : "") +
|
|
21728
22122
|
(q.total_minor != null ? "<p class=\"meta\">Quoted total: <strong>" + _htmlEscape(pricing.format(q.total_minor, currency)) + "</strong></p>" : "") +
|
|
21729
22123
|
(q.converted_order_id ? "<p class=\"meta\">Converted to order: <a href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(q.converted_order_id)) + "\"><code class=\"order-id\">" + _htmlEscape(String(q.converted_order_id).slice(0, 8)) + "</code></a></p>" : "") +
|
|
21730
22124
|
"</div>";
|
|
@@ -21775,6 +22169,57 @@ function renderAdminQuoteDetail(opts) {
|
|
|
21775
22169
|
"</div>";
|
|
21776
22170
|
}
|
|
21777
22171
|
|
|
22172
|
+
// Reprice form — only for a still-responded quote (the customer hasn't
|
|
22173
|
+
// settled it). Same fields as the respond form, prefilled with the
|
|
22174
|
+
// current pricing so the operator edits an offer rather than retyping
|
|
22175
|
+
// it. Posting re-runs the full pricing math server-side and bumps the
|
|
22176
|
+
// revision counter; the customer's existing link shows the new pricing.
|
|
22177
|
+
var repriceForm = "";
|
|
22178
|
+
if (q.status === "responded") {
|
|
22179
|
+
var repriceFields = (q.lines || []).map(function (l) {
|
|
22180
|
+
return _setupField(l.sku + " — unit price (" + currency + ")", "price_" + l.sku,
|
|
22181
|
+
_minorToMajorInput(l.unit_price_minor, l.currency || currency), "text",
|
|
22182
|
+
"Quantity " + l.quantity + ".", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\" required");
|
|
22183
|
+
}).join("");
|
|
22184
|
+
repriceForm =
|
|
22185
|
+
"<div class=\"panel mt mw-40\">" +
|
|
22186
|
+
"<h3 class=\"subhead\">Reprice this quote</h3>" +
|
|
22187
|
+
"<p class=\"meta\">Revise the offer before the customer settles it — new unit prices, shipping, tax, and a fresh validity window. The link the customer already has keeps working and shows the new pricing.</p>" +
|
|
22188
|
+
"<form method=\"post\" action=\"/admin/quotes/" + enc + "/reprice\">" +
|
|
22189
|
+
repriceFields +
|
|
22190
|
+
_setupField("Shipping (" + currency + ")", "shipping", _minorToMajorInput(q.shipping_minor, currency), "text", "Optional. Leave blank for free shipping.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
|
|
22191
|
+
_setupField("Tax (" + currency + ")", "tax", _minorToMajorInput(q.tax_minor, currency), "text", "Optional.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
|
|
22192
|
+
_setupField("Valid for (days)", "valid_days", "14", "number", "How many days the customer has to accept the revised quote.", " min=\"1\" max=\"365\" required") +
|
|
22193
|
+
_setupField("Currency", "currency", currency, "text", "ISO-4217, e.g. USD.", " maxlength=\"3\" pattern=\"[A-Za-z]{3}\"") +
|
|
22194
|
+
"<label class=\"form-field\"><span>Note to the customer</span><textarea name=\"operator_notes\" maxlength=\"4000\" rows=\"3\">" + _htmlEscape(q.operator_notes || "") + "</textarea></label>" +
|
|
22195
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Send revised quote</button></div>" +
|
|
22196
|
+
"</form>" +
|
|
22197
|
+
"</div>";
|
|
22198
|
+
}
|
|
22199
|
+
|
|
22200
|
+
// Convert to order — the verbal-approval path. Rendered for a responded
|
|
22201
|
+
// quote (the operator records the customer's out-of-band yes) and for an
|
|
22202
|
+
// accepted one (the customer accepted online but conversion wasn't
|
|
22203
|
+
// possible then — e.g. no address yet). Requires a reason; the reason
|
|
22204
|
+
// lands on the acceptance attribution and the operator audit log. Only
|
|
22205
|
+
// rendered when the conversion composition is wired.
|
|
22206
|
+
var convertForm = "";
|
|
22207
|
+
if (opts.can_convert && (q.status === "responded" || q.status === "accepted")) {
|
|
22208
|
+
convertForm =
|
|
22209
|
+
"<div class=\"panel mt mw-40\">" +
|
|
22210
|
+
"<h3 class=\"subhead\">Convert to order</h3>" +
|
|
22211
|
+
"<p class=\"meta\">" + (q.status === "responded"
|
|
22212
|
+
? "Records the customer's out-of-band approval (a phone or email yes) and lands this quote as a pending order at the quoted prices, reserving the stock. An expired quote refuses — reprice it first."
|
|
22213
|
+
: "The customer accepted this quote; convert it into a pending order at the accepted prices.") + "</p>" +
|
|
22214
|
+
"<form method=\"post\" action=\"/admin/quotes/" + enc + "/convert-to-order\">" +
|
|
22215
|
+
_setupField("Reason / approval record", "reason", "", "text",
|
|
22216
|
+
"Required. Who approved and how — e.g. “verbal approval, J. Doe, phone 2026-06-10”.",
|
|
22217
|
+
" maxlength=\"200\" required") +
|
|
22218
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Convert to order</button></div>" +
|
|
22219
|
+
"</form>" +
|
|
22220
|
+
"</div>";
|
|
22221
|
+
}
|
|
22222
|
+
|
|
21778
22223
|
// Withdraw — available while the quote hasn't been accepted (requested or
|
|
21779
22224
|
// responded). The FSM refuses it for accepted/terminal quotes; we only
|
|
21780
22225
|
// render the button when it would succeed.
|
|
@@ -21792,7 +22237,7 @@ function renderAdminQuoteDetail(opts) {
|
|
|
21792
22237
|
"</div>";
|
|
21793
22238
|
|
|
21794
22239
|
var bodyHtml = "<section><h2>Quote " + _htmlEscape(String(q.id).slice(0, 8)) + "</h2>" +
|
|
21795
|
-
notice + summary + linesPanel + respondForm + actions + "</section>";
|
|
22240
|
+
notice + repricedBanner + summary + linesPanel + respondForm + repriceForm + convertForm + actions + "</section>";
|
|
21796
22241
|
return _renderAdminShell(opts.shop_name, "Quote " + String(q.id).slice(0, 8), bodyHtml, "quotes", opts.nav_available);
|
|
21797
22242
|
}
|
|
21798
22243
|
|