@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/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; // refund endpoints disabled when absent
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 (payment && o.payment_intent_id) {
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 a payment
2241
- // provider is wired AND the order has a captured intent to refund.
2242
- can_refund: !!(payment && o.payment_intent_id),
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
- if (payment) {
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 refund = await payment.refund({
4004
- payment_intent: o.payment_intent_id,
4005
- amount_minor: body.amount_minor || undefined,
4006
- reason: body.reason || undefined,
4007
- metadata: { order_id: o.id },
4008
- }, refundIdempotencyKey);
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: { stripe_refund_id: refund.id, amount_minor: refund.amount },
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: refund, order: await order.get(o.id) };
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, "stripe-refund-failed", (e && e.message) || String(e));
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 refund = await payment.refund({
4141
- payment_intent: o.payment_intent_id,
4142
- amount_minor: minor,
4143
- reason: "requested_by_customer",
4144
- metadata: { order_id: o.id, partial: !clearsBalance },
4145
- }, idemKey);
4146
- var refundedMinor = Number(refund.amount) || minor;
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: { stripe_refund_id: refund.id, amount_minor: refundedMinor, partial: true },
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: { stripe_refund_id: refund.id },
4331
+ metadata: _refundLedgerMeta(result, { amount_minor: refundedMinor }),
4166
4332
  });
4167
4333
  }
4168
- return { refund: refund, amount_minor: refundedMinor, cleared: clearsBalance };
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" ? 422 : 400;
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, "stripe-refund-failed", (e && e.message) || String(e));
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
- ctx.canProviderRefund = !!(ctx.order && ctx.order.payment_intent_id);
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
- refund = await payment.refund({
5000
- payment_intent: order2.payment_intent_id,
5001
- amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
5002
- reason: "requested_by_customer",
5003
- metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
5004
- }, idem);
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 a recent-activity view; the detail
8116
- // screen shows the requested lines + the customer message, and for a
8117
- // still-requested quote a per-line pricing form that responds with a
8118
- // priced quote + validity window. Responded/accepted quotes show the
8119
- // quoted totals; an operator can withdraw a quote that hasn't been
8120
- // accepted, or convert an accepted one into a pending order. Content-
8121
- // negotiated like the other consoles (bearer JSON, browser HTML).
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
- : await quotes.pendingResponse({ limit: 200 });
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
- : await quotes.pendingResponse({ limit: 200 });
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 heading = cf ? "Quotes for this customer" : "Quotes awaiting a response";
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." : "No quotes are waiting for a response.") + "</p>";
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